Repository: DiamondLightSource/python-zocalo

Issue:
Configuration script needs to add bindings to support delayed messages exchanges
`RabbitMQAPI` in `zocalo.util.rabbitmq` needs extending to support bindings to allow the configuration of queues bound to exchanges other than the default exchange

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/DiamondLightSource/python-zocalo/pull/147?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=DiamondLightSource) Report
> Merging [#147](https://codecov.io/gh/DiamondLightSource/python-zocalo/pull/147?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=DiamondLightSource) (cced693) into [main](https://codecov.io/gh/DiamondLightSource/python-zocalo/commit/28aa56061e630b161b77a0eed2fc6783146b91da?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=DiamondLightSource) (28aa560) will **decrease** coverage by `0.30%`.
> The diff coverage is `80.48%`.

```diff
@@            Coverage Diff             @@
##             main     #147      +/-   ##
==========================================
- Coverage   65.03%   64.73%   -0.31%     
==========================================
  Files          20       20              
  Lines        1473     1534      +61     
  Branches      224      248      +24     
==========================================
+ Hits          958      993      +35     
- Misses        474      493      +19     
- Partials       41       48       +7     
```



Patch:
diff --git a/src/zocalo/util/rabbitmq.py b/src/zocalo/util/rabbitmq.py
index d132aac..7dbda08 100644
--- a/src/zocalo/util/rabbitmq.py
+++ b/src/zocalo/util/rabbitmq.py
@@ -12,7 +12,7 @@ from workflows.transport import pika_transport
 
 import zocalo.configuration
 
-logger = logging.getLogger("workflows.transport.pika_transport")
+logger = logging.getLogger("zocalo.util.rabbitmq")
 
 
 class MessageStats(BaseModel):
@@ -287,6 +287,37 @@ class NodeInfo(BaseModel):
     # binary	Detailed breakdown of the owners of binary memory. Only appears if ?binary=true is appended to the URL. Note that this can be an expensive query if there are many small binaries in the system.
 
 
+class DestinationType(enum.Enum):
+    QUEUE = "q"
+    EXCHANGE = "e"
+
+
+class BindingSpec(BaseModel):
+    source: str = Field(
+        ..., description="The name of the source exchange of the binding"
+    )
+    destination: str = Field(
+        ...,
+        description="The name of the end point of the binding (either an exchange or a queue)",
+    )
+    destination_type: DestinationType = Field(
+        ..., description="The type of the binding end point"
+    )
+    vhost: str = Field(
+        ..., description="Virtual host name with non-ASCII characters escaped as in C."
+    )
+    routing_key: str = Field("", description="Routing key attached to binding")
+    arguments: Optional[dict] = Field(None, description="Binding arguments")
+    properties_key: str = Field(
+        "",
+        description="Unique identifier composed of the bindings routing key and a hash of its arguments",
+    )
+
+
+class BindingInfo(BindingSpec):
+    pass
+
+
 class ExchangeType(enum.Enum):
     direct = "direct"
     topic = "topic"
@@ -624,11 +655,73 @@ class RabbitMQAPI:
             f"{self._url}/{endpoint}", auth=self._auth, params=params, json=json
         )
 
+    def post(
+        self,
+        endpoint: str,
+        data: Optional[Dict[str, Any]] = None,
+        json: Optional[Dict[str, Any]] = None,
+    ) -> requests.Response:
+        return requests.post(
+            f"{self._url}/{endpoint}", auth=self._auth, data=data, json=json
+        )
+
     def delete(self, endpoint: str, params: Dict[str, Any] = None) -> requests.Response:
         return requests.delete(
             f"{self._url}/{endpoint}", auth=self._auth, params=params
         )
 
+    def bindings(
+        self,
+        vhost: Optional[str] = None,
+        source: Optional[str] = None,
+        destination: Optional[str] = None,
+        destination_type: Optional[str] = None,
+    ) -> List[BindingInfo]:
+        endpoint = "bindings"
+        if vhost is not None:
+            endpoint = f"{endpoint}/{vhost}"
+        _check = {source, destination, destination_type}
+        if None in _check and len(_check) > 1:
+            raise ValueError(
+                "Either all of source, destination and destination_type must be specified, or none of them"
+            )
+        if destination_type is not None:
+            endpoint = f"{endpoint}/e/{source}/{destination_type}/{destination}"
+        response = self.get(endpoint)
+        return [BindingInfo(**bi) for bi in response.json()]
+
+    def binding_declare(self, binding: BindingSpec):
+        endpoint = f"bindings/{binding.vhost}/e/{binding.source}/{binding.destination_type.value}/{binding.destination}"
+        self.post(
+            endpoint,
+            json=binding.dict(
+                exclude_defaults=True,
+                exclude={"vhost", "source", "destination", "destination_type"},
+            ),
+        )
+
+    def bindings_delete(
+        self,
+        vhost: str,
+        source: str,
+        destination: str,
+        destination_type: str,
+        properties_key: Optional[str] = None,
+    ):
+        # If properties_key is not specified then all bindings between the specified
+        # source and destination are deleted
+        endpoint = f"bindings/{vhost}/e/{source}/{destination_type}/{destination}"
+        if properties_key is None:
+            props = [BindingInfo(**r).properties_key for r in self.get(endpoint).json()]
+        else:
+            props = [properties_key]
+        for prop in props:
+            resp = self.delete(f"{endpoint}/{prop}")
+            if resp.status_code == 404:
+                logger.error(f"404 not found when deleting {endpoint}/{prop}")
+            elif resp.status_code == 405:
+                logger.error(f"405 not allowed to delete {endpoint}/{prop}")
+
     def connections(
         self, name: Optional[str] = None
     ) -> Union[List[ConnectionInfo], ConnectionInfo]:


Test patch:
diff --git a/tests/util/test_rabbitmq.py b/tests/util/test_rabbitmq.py
index cfb23fd..04eb538 100644
--- a/tests/util/test_rabbitmq.py
+++ b/tests/util/test_rabbitmq.py
@@ -128,6 +128,90 @@ def test_api_queue_delete(requests_mock, rmqapi, queue_spec):
     assert history.url.endswith("/api/queues/zocalo/foo?if_unused=True&if_empty=True")
 
 
+def test_api_bindings(requests_mock, rmqapi):
+    binding = {
+        "source": "foo",
+        "destination": "bar",
+        "destination_type": "q",
+        "arguments": {},
+        "routing_key": "bar",
+        "properties_key": "bar",
+        "vhost": "zocalo",
+    }
+
+    requests_mock.get("/api/bindings", json=[binding])
+    assert rmqapi.bindings() == [rabbitmq.BindingInfo(**binding)]
+
+    requests_mock.get("/api/bindings/zocalo", json=[binding])
+    assert rmqapi.bindings(vhost="zocalo") == [rabbitmq.BindingInfo(**binding)]
+
+    requests_mock.get(
+        f"/api/bindings/zocalo/e/{binding['source']}/q/{binding['destination']}",
+        json=[binding],
+    )
+    assert (
+        rmqapi.bindings(
+            vhost="zocalo",
+            source=binding["source"],
+            destination=binding["destination"],
+            destination_type="q",
+        )
+        == [rabbitmq.BindingInfo(**binding)]
+    )
+
+
+@pytest.fixture
+def binding_spec():
+    return rabbitmq.BindingSpec(
+        source="foo",
+        destination="bar",
+        destination_type="q",
+        arguments={},
+        routing_key="bar",
+        vhost="zocalo",
+    )
+
+
+def test_api_binding_declare(requests_mock, rmqapi, binding_spec):
+    requests_mock.post("/api/bindings/zocalo/e/foo/q/bar")
+    rmqapi.binding_declare(binding=binding_spec)
+    assert requests_mock.call_count == 1
+    history = requests_mock.request_history[0]
+    assert history.method == "POST"
+    assert history.url.endswith("/api/bindings/zocalo/e/foo/q/bar")
+    assert history.json() == {
+        "arguments": binding_spec.arguments,
+        "routing_key": "bar",
+    }
+
+
+def test_api_bindings_delete(requests_mock, rmqapi, binding_spec):
+    binding = {
+        "source": "foo",
+        "destination": "bar",
+        "destination_type": "q",
+        "arguments": {},
+        "routing_key": "bar",
+        "properties_key": "bar",
+        "vhost": "zocalo",
+    }
+    requests_mock.get("/api/bindings/zocalo/e/foo/q/bar", json=[binding])
+    requests_mock.delete("/api/bindings/zocalo/e/foo/q/bar/bar")
+    rmqapi.bindings_delete(
+        vhost="zocalo",
+        source="foo",
+        destination="bar",
+        destination_type="q",
+    )
+    assert requests_mock.call_count == 2
+    history = requests_mock.request_history[0]
+    assert history.method == "GET"
+    assert history.url.endswith("/api/bindings/zocalo/e/foo/q/bar")
+    history = requests_mock.request_history[1]
+    assert history.method == "DELETE"
+    assert history.url.endswith("/api/bindings/zocalo/e/foo/q/bar/bar")
+
+
 def test_api_nodes(requests_mock, rmqapi):
     node = {
         "name": "rabbit@pooter123",