Repository: deepset-ai/haystack

Issue:
Update `MetadataRouter` to also support routing of `ByteStreams` based on metadata
**Is your feature request related to a problem? Please describe.**
@ju-gu has a need for the [`MetadataRouter`](https://docs.haystack.deepset.ai/docs/metadatarouter) to be able to route `ByteStream` objects based on `ByteStream.meta`.

This is needed to be able to route loaded files to different converters based on a separate metadata field. In this case the client has two classes of PDFs where one type needs to be routed to a OCR converter like Azure and the other to a normal PDF converter like PyPDF. This is why the FileRouter which routes on mimetype doesn't work for this use case. 

**Describe the solution you'd like**
- Update the `MetadataRouter`'s run method to also work with `ByteStream` objects. 
- We will have to think about the best way to set the output type of `MetadataRouter` in the init method. If we update it to `Union[List[ByteStream], List[Document]]` this could cause existing pipelines to fail since connecting  this output type to a component which expects `List[Document]` as input would now fail. So it may be best to add a new init param called `output_type` like in `OutputAdapter` with a default value of `List[Document]` to allow users to specify which `output_type` the component should have. 

**Additional context**
We can reach out to @ju-gu for more context. 



Discussion:

Patch:
diff --git a/haystack/components/routers/metadata_router.py b/haystack/components/routers/metadata_router.py
index c5aba90aae..9e6eabf552 100644
--- a/haystack/components/routers/metadata_router.py
+++ b/haystack/components/routers/metadata_router.py
@@ -2,21 +2,26 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
+from typing import Any, Union
 
-from haystack import Document, component
+from haystack import Document, component, default_from_dict, default_to_dict
+from haystack.dataclasses import ByteStream
+from haystack.utils import deserialize_type, serialize_type
 from haystack.utils.filters import document_matches_filter
 
 
 @component
 class MetadataRouter:
     """
-    Routes documents to different connections based on their metadata fields.
+    Routes documents or byte streams to different connections based on their metadata fields.
 
     Specify the routing rules in the `init` method.
-    If a document does not match any of the rules, it's routed to a connection named "unmatched".
+    If a document or byte stream does not match any of the rules, it's routed to a connection named "unmatched".
 
-    ### Usage example
 
+    ### Usage examples
+
+    **Routing Documents by metadata:**
     ```python
     from haystack import Document
     from haystack.components.routers import MetadataRouter
@@ -27,18 +32,36 @@ class MetadataRouter:
     router = MetadataRouter(rules={"en": {"field": "meta.language", "operator": "==", "value": "en"}})
 
     print(router.run(documents=docs))
-
     # {'en': [Document(id=..., content: 'Paris is the capital of France.', meta: {'language': 'en'})],
     # 'unmatched': [Document(id=..., content: 'Berlin ist die Haupststadt von Deutschland.', meta: {'language': 'de'})]}
     ```
+
+    **Routing ByteStreams by metadata:**
+    ```python
+    from haystack.dataclasses import ByteStream
+    from haystack.components.routers import MetadataRouter
+
+    streams = [
+        ByteStream.from_string("Hello world", meta={"language": "en"}),
+        ByteStream.from_string("Bonjour le monde", meta={"language": "fr"})
+    ]
+
+    router = MetadataRouter(
+        rules={"english": {"field": "meta.language", "operator": "==", "value": "en"}},
+        output_type=list[ByteStream]
+    )
+
+    result = router.run(documents=streams)
+    # {'english': [ByteStream(...)], 'unmatched': [ByteStream(...)]}
+    ```
     """
 
-    def __init__(self, rules: dict[str, dict]):
+    def __init__(self, rules: dict[str, dict], output_type: type = list[Document]) -> None:
         """
         Initializes the MetadataRouter component.
 
-        :param rules: A dictionary defining how to route documents to output connections based on their metadata.
-            Keys are output connection names, and values are dictionaries of
+        :param rules: A dictionary defining how to route documents or byte streams to output connections based on their
+            metadata. Keys are output connection names, and values are dictionaries of
             [filtering expressions](https://docs.haystack.deepset.ai/docs/metadata-filtering) in Haystack.
             For example:
             ```python
@@ -73,38 +96,66 @@ def __init__(self, rules: dict[str, dict]):
             },
             }
             ```
+            :param output_type: The type of the output produced. Lists of Documents or ByteStreams can be specified.
         """
         self.rules = rules
+        self.output_type = output_type
         for rule in self.rules.values():
             if "operator" not in rule:
                 raise ValueError(
                     "Invalid filter syntax. See https://docs.haystack.deepset.ai/docs/metadata-filtering for details."
                 )
-        component.set_output_types(self, unmatched=list[Document], **dict.fromkeys(rules, list[Document]))
+        component.set_output_types(self, unmatched=self.output_type, **dict.fromkeys(rules, self.output_type))
 
-    def run(self, documents: list[Document]):
+    def run(self, documents: Union[list[Document], list[ByteStream]]):
         """
-        Routes the documents.
+        Routes documents or byte streams to different connections based on their metadata fields.
 
-        If a document does not match any of the rules, it's routed to a connection named "unmatched".
+        If a document or byte stream does not match any of the rules, it's routed to a connection named "unmatched".
 
-        :param documents: A list of documents to route.
+        :param documents: A list of `Document` or `ByteStream` objects to be routed based on their metadata.
 
         :returns: A dictionary where the keys are the names of the output connections (including `"unmatched"`)
-            and the values are lists of routed documents.
+            and the values are lists of `Document` or `ByteStream` objects that matched the corresponding rules.
         """
-        unmatched_documents = []
-        output: dict[str, list[Document]] = {edge: [] for edge in self.rules}
 
-        for document in documents:
-            cur_document_matched = False
+        unmatched: Union[list[Document], list[ByteStream]] = []
+        output: dict[str, Union[list[Document], list[ByteStream]]] = {edge: [] for edge in self.rules}
+
+        for doc_or_bytestream in documents:
+            current_obj_matched = False
             for edge, rule in self.rules.items():
-                if document_matches_filter(rule, document):
-                    output[edge].append(document)
-                    cur_document_matched = True
+                if document_matches_filter(filters=rule, document=doc_or_bytestream):
+                    # we need to ignore the arg-type here because the underlying
+                    # filter methods use type Union[Document, ByteStream]
+                    output[edge].append(doc_or_bytestream)  # type: ignore[arg-type]
+                    current_obj_matched = True
 
-            if not cur_document_matched:
-                unmatched_documents.append(document)
+            if not current_obj_matched:
+                unmatched.append(doc_or_bytestream)  # type: ignore[arg-type]
 
-        output["unmatched"] = unmatched_documents
+        output["unmatched"] = unmatched
         return output
+
+    def to_dict(self) -> dict[str, Any]:
+        """
+        Serialize this component to a dictionary.
+
+        :returns:
+            The serialized component as a dictionary.
+        """
+        return default_to_dict(self, rules=self.rules, output_type=serialize_type(self.output_type))
+
+    @classmethod
+    def from_dict(cls, data: dict[str, Any]) -> "MetadataRouter":
+        """
+        Deserialize this component from a dictionary.
+
+        :param data:
+            The dictionary representation of this component.
+        :returns:
+            The deserialized component instance.
+        """
+        init_params = data.get("init_parameters", {})
+        init_params["output_type"] = deserialize_type(init_params["output_type"])
+        return default_from_dict(cls, data)
diff --git a/haystack/utils/filters.py b/haystack/utils/filters.py
index faf4734d35..fb7f45b2d5 100644
--- a/haystack/utils/filters.py
+++ b/haystack/utils/filters.py
@@ -4,11 +4,11 @@
 
 from dataclasses import fields
 from datetime import datetime
-from typing import Any, Optional
+from typing import Any, Optional, Union
 
 import dateutil.parser
 
-from haystack.dataclasses import Document
+from haystack.dataclasses import ByteStream, Document
 from haystack.errors import FilterError
 
 
@@ -21,57 +21,57 @@ def raise_on_invalid_filter_syntax(filters: Optional[dict[str, Any]] = None) ->
         raise FilterError(msg)
 
 
-def document_matches_filter(filters: dict[str, Any], document: Document) -> bool:
+def document_matches_filter(filters: dict[str, Any], document: Union[Document, ByteStream]) -> bool:
     """
-    Return whether `filters` match the Document.
+    Return whether `filters` match the Document or the ByteStream.
 
     For a detailed specification of the filters, refer to the
     `DocumentStore.filter_documents()` protocol documentation.
     """
     if "field" in filters:
-        return _comparison_condition(filters, document)
-    return _logic_condition(filters, document)
+        return _comparison_condition(condition=filters, document=document)
+    return _logic_condition(condition=filters, document=document)
 
 
-def _and(document: Document, conditions: list[dict[str, Any]]) -> bool:
-    return all(_comparison_condition(condition, document) for condition in conditions)
+def _and(document: Union[Document, ByteStream], conditions: list[dict[str, Any]]) -> bool:
+    return all(_comparison_condition(condition=condition, document=document) for condition in conditions)
 
 
-def _or(document: Document, conditions: list[dict[str, Any]]) -> bool:
-    return any(_comparison_condition(condition, document) for condition in conditions)
+def _or(document: Union[Document, ByteStream], conditions: list[dict[str, Any]]) -> bool:
+    return any(_comparison_condition(condition=condition, document=document) for condition in conditions)
 
 
-def _not(document: Document, conditions: list[dict[str, Any]]) -> bool:
-    return not _and(document, conditions)
+def _not(document: Union[Document, ByteStream], conditions: list[dict[str, Any]]) -> bool:
+    return not _and(document=document, conditions=conditions)
 
 
 LOGICAL_OPERATORS = {"NOT": _not, "OR": _or, "AND": _and}
 
 
-def _equal(document_value: Any, filter_value: Any) -> bool:
-    return document_value == filter_value
+def _equal(value: Any, filter_value: Any) -> bool:
+    return value == filter_value
 
 
-def _not_equal(document_value: Any, filter_value: Any) -> bool:
-    return not _equal(document_value=document_value, filter_value=filter_value)
+def _not_equal(value: Any, filter_value: Any) -> bool:
+    return not _equal(value=value, filter_value=filter_value)
 
 
-def _greater_than(document_value: Any, filter_value: Any) -> bool:
-    if document_value is None or filter_value is None:
+def _greater_than(value: Any, filter_value: Any) -> bool:
+    if value is None or filter_value is None:
         # We can't compare None values reliably using operators '>', '>=', '<', '<='
         return False
 
-    if isinstance(document_value, str) or isinstance(filter_value, str):
+    if isinstance(value, str) or isinstance(filter_value, str):
         try:
-            document_value = _parse_date(document_value)
+            value = _parse_date(value)
             filter_value = _parse_date(filter_value)
-            document_value, filter_value = _ensure_both_dates_naive_or_aware(document_value, filter_value)
+            value, filter_value = _ensure_both_dates_naive_or_aware(value, filter_value)
         except FilterError as exc:
             raise exc
     if isinstance(filter_value, list):
         msg = f"Filter value can't be of type {type(filter_value)} using operators '>', '>=', '<', '<='"
         raise FilterError(msg)
-    return document_value > filter_value
+    return value > filter_value
 
 
 def _parse_date(value):
@@ -107,43 +107,41 @@ def _ensure_both_dates_naive_or_aware(date1: datetime, date2: datetime) -> tuple
     return date1, date2
 
 
-def _greater_than_equal(document_value: Any, filter_value: Any) -> bool:
-    if document_value is None or filter_value is None:
+def _greater_than_equal(value: Any, filter_value: Any) -> bool:
+    if value is None or filter_value is None:
         # We can't compare None values reliably using operators '>', '>=', '<', '<='
         return False
 
-    return _equal(document_value=document_value, filter_value=filter_value) or _greater_than(
-        document_value=document_value, filter_value=filter_value
-    )
+    return _equal(value=value, filter_value=filter_value) or _greater_than(value=value, filter_value=filter_value)
 
 
-def _less_than(document_value: Any, filter_value: Any) -> bool:
-    if document_value is None or filter_value is None:
+def _less_than(value: Any, filter_value: Any) -> bool:
+    if value is None or filter_value is None:
         # We can't compare None values reliably using operators '>', '>=', '<', '<='
         return False
 
-    return not _greater_than_equal(document_value=document_value, filter_value=filter_value)
+    return not _greater_than_equal(value=value, filter_value=filter_value)
 
 
-def _less_than_equal(document_value: Any, filter_value: Any) -> bool:
-    if document_value is None or filter_value is None:
+def _less_than_equal(value: Any, filter_value: Any) -> bool:
+    if value is None or filter_value is None:
         # We can't compare None values reliably using operators '>', '>=', '<', '<='
         return False
 
-    return not _greater_than(document_value=document_value, filter_value=filter_value)
+    return not _greater_than(value=value, filter_value=filter_value)
 
 
-def _in(document_value: Any, filter_value: Any) -> bool:
+def _in(value: Any, filter_value: Any) -> bool:
     if not isinstance(filter_value, list):
         msg = (
             f"Filter value must be a `list` when using operator 'in' or 'not in', received type '{type(filter_value)}'"
         )
         raise FilterError(msg)
-    return any(_equal(e, document_value) for e in filter_value)
+    return any(_equal(e, value) for e in filter_value)
 
 
-def _not_in(document_value: Any, filter_value: Any) -> bool:
-    return not _in(document_value=document_value, filter_value=filter_value)
+def _not_in(value: Any, filter_value: Any) -> bool:
+    return not _in(value=value, filter_value=filter_value)
 
 
 COMPARISON_OPERATORS = {
@@ -158,7 +156,7 @@ def _not_in(document_value: Any, filter_value: Any) -> bool:
 }
 
 
-def _logic_condition(condition: dict[str, Any], document: Document) -> bool:
+def _logic_condition(condition: dict[str, Any], document: Union[Document, ByteStream]) -> bool:
     if "operator" not in condition:
         msg = f"'operator' key missing in {condition}"
         raise FilterError(msg)
@@ -167,14 +165,14 @@ def _logic_condition(condition: dict[str, Any], document: Document) -> bool:
         raise FilterError(msg)
     operator: str = condition["operator"]
     conditions: list[dict[str, Any]] = condition["conditions"]
-    return LOGICAL_OPERATORS[operator](document, conditions)
+    return LOGICAL_OPERATORS[operator](document=document, conditions=conditions)
 
 
-def _comparison_condition(condition: dict[str, Any], document: Document) -> bool:
+def _comparison_condition(condition: dict[str, Any], document: Union[Document, ByteStream]) -> bool:
     if "field" not in condition:
         # 'field' key is only found in comparison dictionaries.
         # We assume this is a logic dictionary since it's not present.
-        return _logic_condition(condition, document)
+        return _logic_condition(condition=condition, document=document)
     field: str = condition["field"]
 
     if "operator" not in condition:
@@ -207,4 +205,4 @@ def _comparison_condition(condition: dict[str, Any], document: Document) -> bool
         document_value = getattr(document, field)
     operator: str = condition["operator"]
     filter_value: Any = condition["value"]
-    return COMPARISON_OPERATORS[operator](filter_value=filter_value, document_value=document_value)
+    return COMPARISON_OPERATORS[operator](filter_value=filter_value, value=document_value)


Test patch:
diff --git a/releasenotes/notes/add-bytestream-support-metadata-router-1ee5149745894f42.yaml b/releasenotes/notes/add-bytestream-support-metadata-router-1ee5149745894f42.yaml
new file mode 100644
index 0000000000..d17601e6f5
--- /dev/null
+++ b/releasenotes/notes/add-bytestream-support-metadata-router-1ee5149745894f42.yaml
@@ -0,0 +1,4 @@
+---
+features:
+  - Add support for ByteStream objects in MetadataRouter.
+    It can now be used to route `list[Documents]` or `list[ByteStream]` based on metadata.
diff --git a/test/components/routers/test_metadata_router.py b/test/components/routers/test_metadata_router.py
index 140a261aac..e2ae154759 100644
--- a/test/components/routers/test_metadata_router.py
+++ b/test/components/routers/test_metadata_router.py
@@ -2,10 +2,16 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
+from typing import Union
+
 import pytest
 
-from haystack import Document
+from haystack import Pipeline
+from haystack.components.classifiers import DocumentLanguageClassifier
 from haystack.components.routers.metadata_router import MetadataRouter
+from haystack.components.writers import DocumentWriter
+from haystack.dataclasses import ByteStream, Document
+from haystack.document_stores.in_memory import InMemoryDocumentStore
 
 
 class TestMetadataRouter:
@@ -37,6 +43,33 @@ def test_run(self):
         assert output["edge_2"][0].meta["created_at"] == "2023-05-01"
         assert output["unmatched"][0].meta["created_at"] == "2023-08-01"
 
+    def test_run_with_byte_stream(self):
+        byt1 = ByteStream.from_string(text="What is this", meta={"language": "en"})
+        byt2 = ByteStream.from_string(text="Berlin ist die Haupststadt von Deutschland.", meta={"language": "de"})
+        docs = [byt1, byt2]
+        router = MetadataRouter(
+            rules={"en": {"field": "meta.language", "operator": "==", "value": "en"}}, output_type=list[ByteStream]
+        )
+        output = router.run(documents=docs)
+        assert output["en"][0].data == byt1.data
+        assert output["unmatched"][0].data == byt2.data
+
+    def test_run_with_mixed_documents_and_byte_streams(self):
+        byt1 = ByteStream.from_string(text="What is this", meta={"language": "en"})
+        byt2 = ByteStream.from_string(text="Berlin ist die Haupststadt von Deutschland.", meta={"language": "de"})
+        doc1 = Document(content="What is this", meta={"language": "en"})
+        doc2 = Document(content="Berlin ist die Haupststadt von Deutschland.", meta={"language": "de"})
+        docs = [byt1, byt2, doc1, doc2]
+        router = MetadataRouter(
+            rules={"en": {"field": "meta.language", "operator": "==", "value": "en"}},
+            output_type=list[Union[Document, ByteStream]],
+        )
+        output = router.run(documents=docs)
+        assert output["en"][0].data == byt1.data
+        assert output["en"][1].content == "What is this"
+        assert output["unmatched"][0].data == byt2.data
+        assert output["unmatched"][1].content == "Berlin ist die Haupststadt von Deutschland."
+
     def test_run_wrong_filter(self):
         rules = {
             "edge_1": {"field": "meta.created_at", "operator": ">=", "value": "2023-01-01"},
@@ -63,3 +96,98 @@ def test_run_datetime_with_timezone(self):
         assert output["edge_1"][0].meta["created_at"] == "2025-02-03T12:45:46.435816Z"
         assert output["edge_1"][1].meta["created_at"] == "2025-02-01T12:45:46.435816Z"
         assert output["unmatched"][0].meta["created_at"] == "2025-01-03T12:45:46.435816Z"
+
+    def test_to_dict(self):
+        rules = {
+            "edge_1": {
+                "operator": "AND",
+                "conditions": [{"field": "meta.created_at", "operator": ">=", "value": "2025-02-01"}],
+            }
+        }
+        router = MetadataRouter(rules=rules)
+        expected_dict = {
+            "type": "haystack.components.routers.metadata_router.MetadataRouter",
+            "init_parameters": {"rules": rules, "output_type": "list[haystack.dataclasses.document.Document]"},
+        }
+        assert router.to_dict() == expected_dict
+
+    def test_to_dict_with_parameters(self):
+        rules = {
+            "edge_1": {
+                "operator": "AND",
+                "conditions": [{"field": "meta.created_at", "operator": ">=", "value": "2025-02-01"}],
+            }
+        }
+        router = MetadataRouter(rules=rules, output_type=list[Union[ByteStream, Document]])
+        expected_dict = {
+            "type": "haystack.components.routers.metadata_router.MetadataRouter",
+            "init_parameters": {
+                "rules": rules,
+                "output_type": "list[typing.Union[haystack.dataclasses.byte_stream.ByteStream, "
+                "haystack.dataclasses.document.Document]]",
+            },
+        }
+        assert router.to_dict() == expected_dict
+
+    def test_from_dict(self):
+        router_dict = {
+            "type": "haystack.components.routers.metadata_router.MetadataRouter",
+            "init_parameters": {
+                "rules": {
+                    "edge_1": {
+                        "operator": "AND",
+                        "conditions": [{"field": "meta.created_at", "operator": ">=", "value": "2025-02-01"}],
+                    }
+                },
+                "output_type": "list[haystack.dataclasses.document.Document]",
+            },
+        }
+        router = MetadataRouter.from_dict(router_dict)
+        assert router.rules == {
+            "edge_1": {
+                "operator": "AND",
+                "conditions": [{"field": "meta.created_at", "operator": ">=", "value": "2025-02-01"}],
+            }
+        }
+        assert router.output_type == list[Document]
+
+    def test_from_dict_with_parameters(self):
+        router_dict = {
+            "type": "haystack.components.routers.metadata_router.MetadataRouter",
+            "init_parameters": {
+                "rules": {
+                    "edge_1": {
+                        "operator": "AND",
+                        "conditions": [{"field": "meta.created_at", "operator": ">=", "value": "2025-02-01"}],
+                    }
+                },
+                "output_type": "list[typing.Union[haystack.dataclasses.document.Document, "
+                "haystack.dataclasses.byte_stream.ByteStream]]",
+            },
+        }
+        router = MetadataRouter.from_dict(router_dict)
+        assert router.rules == {
+            "edge_1": {
+                "operator": "AND",
+                "conditions": [{"field": "meta.created_at", "operator": ">=", "value": "2025-02-01"}],
+            }
+        }
+        assert router.output_type == list[Union[ByteStream, Document]]
+
+    def test_metadata_router_in_pipeline(self):
+        document_store = InMemoryDocumentStore()
+        p = Pipeline()
+        docs = [
+            Document(content="Hello, welcome to the world of Haystack!", meta={"language": "en"}),
+            Document(content="Hallo, willkommen in der Welt von Haystack!", meta={"language": "de"}),
+        ]
+        p.add_component(instance=DocumentLanguageClassifier(), name="language_classifier")
+        p.add_component(
+            instance=MetadataRouter(rules={"en": {"field": "meta.language", "operator": "==", "value": "en"}}),
+            name="router",
+        )
+        p.add_component(instance=DocumentWriter(document_store=document_store), name="writer")
+        p.connect("language_classifier.documents", "router.documents")
+        p.connect("router.en", "writer.documents")
+        p.run({"language_classifier": {"documents": docs}})
+        assert document_store.filter_documents() == [docs[0]]