Repository: safe-global/safe-config-service

Issue:
Register CGW hook in order to invalidate its cache
A service hook should be implemented so that when Chain related data is updated, the CGW is notified about those changes and invalidates its cache


Discussion:
## Context

As for the CGW side of things, we technically already expose an endpoint through which chain information can be invalidated. Technically, this would invalidate anything `config` service related (the invalidation pattern only looks for the base URL of the given config service env)

## Resources

`POST /v1/flush/<token>` where `token` is the webhook token of the CGW instance

The body of the request in json:

```json
{
    "invalidate": "Chains"
}
```

Resolving patch:
diff --git a/src/chains/apps.py b/src/chains/apps.py
--- a/src/chains/apps.py
+++ b/src/chains/apps.py
@@ -4,3 +4,6 @@
 class AppsConfig(AppConfig):
     default_auto_field = "django.db.models.BigAutoField"
     name = "chains"
+
+    def ready(self):
+        import chains.signals  # noqa: F401
diff --git a/src/chains/signals.py b/src/chains/signals.py
new file mode 100644
--- /dev/null
+++ b/src/chains/signals.py
@@ -0,0 +1,42 @@
+import logging
+from functools import cache
+from urllib.parse import urljoin
+
+import requests
+from django.conf import settings
+from django.db.models.signals import post_delete, post_save
+from django.dispatch import receiver
+
+from .models import Chain
+
+logger = logging.getLogger(__name__)
+
+
+@cache
+def setup_session():
+    session = requests.Session()
+    adapter = requests.adapters.HTTPAdapter(max_retries=3)
+    session.mount("http://", adapter)
+    session.mount("https://", adapter)
+    return session
+
+
+@receiver(post_save, sender=Chain)
+@receiver(post_delete, sender=Chain)
+def on_chain_update(sender, **kwargs):
+    logger.info("Chain update. Triggering CGW webhook")
+    cgw_url = settings.CGW_URL
+    if cgw_url is None:
+        logger.error("CGW_URL is not set. Skipping hook call")
+        return
+    cgw_flush_token = settings.CGW_FLUSH_TOKEN
+    if cgw_flush_token is None:
+        logger.error("CGW_FLUSH_TOKEN is not set. Skipping hook call")
+        return
+
+    url = urljoin(cgw_url, f"/v1/flush/{cgw_flush_token}")
+    try:
+        post = setup_session().post(url, json={"invalidate": "Chains"})
+        post.raise_for_status()
+    except Exception as error:
+        logger.error(error)
diff --git a/src/config/settings.py b/src/config/settings.py
--- a/src/config/settings.py
+++ b/src/config/settings.py
@@ -190,3 +190,6 @@
 
 CORS_ALLOW_ALL_ORIGINS = True
 CORS_URLS_REGEX = r"^/api/.*$"
+
+CGW_URL = os.environ.get("CGW_URL")
+CGW_FLUSH_TOKEN = os.environ.get("CGW_FLUSH_TOKEN")


Test patch:
diff --git a/src/chains/tests/test_signals.py b/src/chains/tests/test_signals.py
new file mode 100644
--- /dev/null
+++ b/src/chains/tests/test_signals.py
@@ -0,0 +1,93 @@
+import responses
+from django.test import TestCase, override_settings
+
+from chains.tests.factories import ChainFactory
+
+
+@override_settings(
+    CGW_URL="http://127.0.0.1",
+    CGW_FLUSH_TOKEN="example-token",
+)
+class ChainNetworkHookTestCase(TestCase):
+    @responses.activate
+    def test_on_chain_update_hook_200(self):
+        responses.add(
+            responses.POST,
+            "http://127.0.0.1/v1/flush/example-token",
+            status=200,
+            match=[responses.json_params_matcher({"invalidate": "Chains"})],
+        )
+
+        ChainFactory.create()
+
+        assert len(responses.calls) == 1
+        assert responses.calls[0].request.body == b'{"invalidate": "Chains"}'
+        assert (
+            responses.calls[0].request.url == "http://127.0.0.1/v1/flush/example-token"
+        )
+
+    @responses.activate
+    def test_on_chain_update_hook_400(self):
+        responses.add(
+            responses.POST,
+            "http://127.0.0.1/v1/flush/example-token",
+            status=400,
+            match=[responses.json_params_matcher({"invalidate": "Chains"})],
+        )
+
+        ChainFactory.create()
+
+        assert len(responses.calls) == 1
+
+    @responses.activate
+    def test_on_chain_update_hook_500(self):
+        responses.add(
+            responses.POST,
+            "http://127.0.0.1/v1/flush/example-token",
+            status=500,
+            match=[responses.json_params_matcher({"invalidate": "Chains"})],
+        )
+
+        ChainFactory.create()
+
+        assert len(responses.calls) == 1
+
+    @responses.activate
+    def test_on_chain_delete_hook_call(self):
+        chain = ChainFactory.create()
+
+        chain.delete()
+
+        # 2 calls: one for creation and one for deletion
+        assert len(responses.calls) == 2
+
+    @responses.activate
+    def test_on_chain_update_hook_call(self):
+        chain = ChainFactory.create()
+
+        # Not updating using queryset because hooks are not triggered that way
+        chain.currency_name = "Ether"
+        chain.save()
+
+        # 2 calls: one for creation and one for updating
+        assert len(responses.calls) == 2
+
+    @override_settings(
+        CGW_URL=None,
+        CGW_FLUSH_TOKEN=None,
+    )
+    @responses.activate
+    def test_on_chain_update_with_no_cgw_set(self):
+        ChainFactory.create()
+
+        assert len(responses.calls) == 0
+
+    @override_settings(
+        CGW_URL="http://127.0.0.1",
+        CGW_FLUSH_TOKEN=None,
+    )
+    @responses.activate
+    def test_on_chain_update_with_no_flush_token_set(self):
+        ChainFactory.create()
+
+        assert len(responses.calls) == 0