WIP: Add VCR-compatible YAML serializer
diff --git a/requests_cache/serializers/__init__.py b/requests_cache/serializers/__init__.py
index 72420b7..09a7434 100644
--- a/requests_cache/serializers/__init__.py
+++ b/requests_cache/serializers/__init__.py
@@ -13,6 +13,7 @@
     utf8_encoder,
     yaml_serializer,
 )
+from .vcr import vcr_serializer
 
 __all__ = [
     'SERIALIZERS',
diff --git a/requests_cache/serializers/vcr.py b/requests_cache/serializers/vcr.py
new file mode 100644
index 0000000..db979f5
--- /dev/null
+++ b/requests_cache/serializers/vcr.py
@@ -0,0 +1,118 @@
+# TODO: Maybe this should be a one-way conversion function instead of a full serializer?
+"""YAML serializer compatible with VCR-based libraries, including `vcrpy
+<https://github.com/kevin1024/vcrpy>`_ and `betamax <https://github.com/betamaxpy/betamax>`_.
+"""
+from os import makedirs
+from os.path import abspath, dirname, expanduser, join
+from typing import Any, Dict, Iterable
+from urllib.parse import urlparse
+
+from .. import __version__, get_placeholder_class
+from ..models import CachedHTTPResponse, CachedRequest, CachedResponse
+from .pipeline import SerializerPipeline, Stage
+from .preconf import yaml_preconf_stage
+
+
+def to_vcr_cassette(responses: Iterable[CachedResponse]) -> Dict:
+    """Convert responses to a VCR cassette"""
+    return {
+        'http_interactions': [to_vcr_episode(r) for r in responses],
+        'recorded_with': f'requests-cache {__version__}',
+    }
+
+
+def to_vcr_episode(response: CachedResponse) -> Dict:
+    """Convert a single response to a VCR-compatible response ("episode") dict"""
+    # Do most of the work with cattrs + default YAML conversions
+    response_dict = yaml_preconf_stage.dumps(response)
+
+    def _to_multidict(d):
+        return {k: [v] for k, v in d.items()}
+
+    # Translate requests.Response structure into VCR format
+    return {
+        'request': {
+            'body': response_dict['request']['body'],
+            'headers': _to_multidict(response_dict['request']['headers']),
+            'method': response_dict['request']['method'],
+            'uri': response_dict['request']['url'],
+        },
+        'response': {
+            'body': {'string': response_dict['_content'], 'encoding': response_dict['encoding']},
+            'headers': _to_multidict(response_dict['headers']),
+            'status': {'code': response_dict['status_code'], 'message': response_dict['reason']},
+            'url': response_dict['url'],
+        },
+        'recorded_at': response_dict['created_at'],
+    }
+
+
+def from_vcr_episode(cassette_dict: Dict):
+    data = cassette_dict.get('interactions') or cassette_dict.get('http_interactions') or cassette_dict
+    request = CachedRequest(
+        body=data['request']['body'],
+        headers=data['request']['headers'],
+        method=data['request']['method'],
+        url=data['request']['uri'],
+    )
+    raw_response = CachedHTTPResponse(body=data['response']['body'])  # type: ignore  # TODO: fix type hint
+    return CachedResponse(
+        content=data['response']['body']['string'],
+        encoding=data['response']['body'].get('encoding'),
+        headers=data['response']['headers'],
+        raw=raw_response,
+        reason=data['response']['status']['message'],
+        request=request,
+        status_code=data['response']['status']['code'],
+        url=data['response']['url'],
+    )
+
+
+try:
+    import yaml
+
+    vcr_stage = Stage(dumps=to_vcr_episode, loads=from_vcr_episode)
+    vcr_serializer = SerializerPipeline(
+        [
+            yaml_preconf_stage,
+            vcr_stage,
+            Stage(yaml, loads='safe_load', dumps='safe_dump'),
+        ]
+    )  #: VRC-compatible YAML serializer
+except ImportError as e:
+    yaml_serializer = get_placeholder_class(e)
+
+
+# Experimental
+# ------------
+
+
+def save_vcr_cassette(responses: Iterable[CachedResponse], path: str):
+    """Save responses as VCR-compatible YAML files"""
+    _write_cassette(to_vcr_cassette(responses), path)
+
+
+def save_vcr_cassettes_by_host(responses: Iterable[CachedResponse], cassette_dir: str):
+    """Save responses as VCR-compatible YAML files, with one cassette per request host"""
+
+    cassettes_by_host = _to_vcr_cassettes_by_host(responses)
+    for host, cassette in cassettes_by_host.items():
+        _write_cassette(cassette, join(cassette_dir, f'{host}.yml'))
+
+
+def _to_vcr_cassettes_by_host(responses: Iterable[CachedResponse]) -> Dict[str, Dict]:
+    responses_by_host: Dict[str, Any] = {}
+    for response in responses:
+        host = urlparse(response.request.url).netloc
+        responses_by_host.setdefault(host, [])
+        responses_by_host[host].append(response)
+    return {host: to_vcr_cassette(responses) for host, responses in responses_by_host.items()}
+
+
+def _write_cassette(cassette, path):
+    import yaml
+
+    path = abspath(expanduser(path))
+    makedirs(dirname(path), exist_ok=True)
+    with open(path, 'w') as f:
+        f.write(yaml.safe_dump(cassette))
diff --git a/tests/sample_data/vcr_betamax.yaml b/tests/sample_data/vcr_betamax.yaml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/sample_data/vcr_betamax.yaml
diff --git a/tests/sample_data/vcr_requests_cache.yaml b/tests/sample_data/vcr_requests_cache.yaml
new file mode 100644
index 0000000..a827b0e
--- /dev/null
+++ b/tests/sample_data/vcr_requests_cache.yaml
@@ -0,0 +1,46 @@
+http_interactions:
+- recorded_at: '2021-08-15T02:14:11.003635'
+  request:
+    body: !!binary |
+      Tm9uZQ==
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.26.0
+    method: GET
+    uri: https://httpbin.org/get
+  response:
+    body:
+      encoding: utf-8
+      string: !!binary |
+        ewogICJhcmdzIjoge30sIAogICJoZWFkZXJzIjogewogICAgIkFjY2VwdCI6ICIqLyoiLCAKICAg
+        ICJBY2NlcHQtRW5jb2RpbmciOiAiZ3ppcCwgZGVmbGF0ZSIsIAogICAgIkhvc3QiOiAiaHR0cGJp
+        bi5vcmciLCAKICAgICJVc2VyLUFnZW50IjogInB5dGhvbi1yZXF1ZXN0cy8yLjI2LjAiLCAKICAg
+        ICJYLUFtem4tVHJhY2UtSWQiOiAiUm9vdD0xLTYxMTg3ODcyLTM4YjE1NmZhMjliOTU3ODMzM2Nj
+        NmJmMCIKICB9LCAKICAib3JpZ2luIjogIjE3My4yMS4xMjYuMTQ1IiwgCiAgInVybCI6ICJodHRw
+        czovL2h0dHBiaW4ub3JnL2dldCIKfQo=
+    headers:
+      Access-Control-Allow-Credentials:
+      - 'true'
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '308'
+      Content-Type:
+      - application/json
+      Date:
+      - Sun, 15 Aug 2021 02:14:10 GMT
+      Server:
+      - gunicorn/19.9.0
+    status:
+      code: 200
+      reason: OK
+    url: https://httpbin.org/get
+recorded_with: requests-cache 0.8.0
diff --git a/tests/sample_data/vcr_vcrpy.yaml b/tests/sample_data/vcr_vcrpy.yaml
new file mode 100644
index 0000000..bcf8836
--- /dev/null
+++ b/tests/sample_data/vcr_vcrpy.yaml
@@ -0,0 +1,76 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.26.0
+    method: GET
+    uri: https://httpbin.org/get
+  response:
+    body:
+      string: "{\n  \"args\": {}, \n  \"headers\": {\n    \"Accept\": \"*/*\", \n
+        \   \"Accept-Encoding\": \"gzip, deflate\", \n    \"Host\": \"httpbin.org\",
+        \n    \"User-Agent\": \"python-requests/2.26.0\", \n    \"X-Amzn-Trace-Id\":
+        \"Root=1-61269390-110f184a5a3b52ef31278574\"\n  }, \n  \"origin\": \"173.21.126.145\",
+        \n  \"url\": \"https://httpbin.org/get\"\n}\n"
+    headers:
+      Access-Control-Allow-Credentials:
+      - 'true'
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '308'
+      Content-Type:
+      - application/json
+      Date:
+      - Wed, 25 Aug 2021 19:01:36 GMT
+      Server:
+      - gunicorn/19.9.0
+    status:
+      code: 200
+      message: OK
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.26.0
+    method: GET
+    uri: https://httpbin.org/ip
+  response:
+    body:
+      string: "{\n  \"origin\": \"173.21.126.145\"\n}\n"
+    headers:
+      Access-Control-Allow-Credentials:
+      - 'true'
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '33'
+      Content-Type:
+      - application/json
+      Date:
+      - Wed, 25 Aug 2021 19:09:56 GMT
+      Server:
+      - gunicorn/19.9.0
+      Foo:
+      - bar
+    status:
+      code: 200
+      message: OK
+version: 1