Repository: apache/airflow

Issue:
Simplify Parsing of apprise_default Connection ID
### Apache Airflow Provider(s)

apprise

### Versions of Apache Airflow Providers

1.2.1

### Apache Airflow version

2.8.1

### Operating System

Ubuntu 20.04.6 LTS

### Deployment

Docker-Compose

### Deployment details

Added airflow connection environment variable for Apprise.

```shell
AIRFLOW_CONN_APPRISE_DEFAULT='{"extra": {"config": {"path": "https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7F", "tags": "pipeline"}}}'
```

### What happened

https://github.com/apache/airflow/blob/46470aba68e5ebeee24a03dc22d012a50ee287ad/airflow/providers/apprise/hooks/apprise.py#L49-L51

The way the return statement of this method parses the connection value raises the exception if quotes are not escaped in the serialized JSON string. Here are the examples, note the variations in the JSON string:

## When quotes are not escaped
```python
$ export AIRFLOW_CONN_APPRISE_DEFAULT='{"extra": {"config": "{"path": "https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7F", "tags": "alert"}"}}'

>>> from airflow.providers.apprise.hooks.apprise import AppriseHook
>>> hook = AppriseHook()
>>> conn = hook.get_connection('apprise_default')

json.decoder.JSONDecodeError: Expecting ',' delimiter: line 1 column 25 (char 24)
```

## When the value is a valid JSON string
```python
$ export AIRFLOW_CONN_APPRISE_DEFAULT='{"extra": {"config": {"path": "https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7F", "tags": "alert"}}}'

>>> import json
>>> from airflow.providers.apprise.hooks.apprise import AppriseHook
>>> hook = AppriseHook()
>>> conn = hook.get_connection('apprise_default')
>>> json.loads(conn.extra_dejson["config"])

TypeError: the JSON object must be str, bytes or bytearray, not dict
```

## Current way to do it
Escape quotes with backslash.
```python
$ export AIRFLOW_CONN_APPRISE_DEFAULT='{"extra": {"config": "{\"path\": \"https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7F\", \"tags\": \"alert\"}"}}'

>>> from airflow.providers.apprise.hooks.apprise import AppriseHook
>>> hook = AppriseHook()
>>> conn = hook.get_connection('apprise_default')
>>> json.loads(conn.extra_dejson["config"])

{"path": "https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7F", "tags": "alert"}
```

### What you think should happen instead

To simplify setting of connection ID through an environment variable as a valid JSON string, a slight change can be made to `AppriseHook.get_config_from_conn`.

```python
def get_config_from_conn(self):
    conn = self.get_connection(self.apprise_conn_id)
    return conn['config']
```

```python
$ export AIRFLOW_CONN_APPRISE_DEFAULT='{"extra": {"config": {"path": "https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7F", "tags": "alert"}}}'

>>> hook = AppriseHook()
>>> hook.get_config_from_conn()

{"path": "https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7F", "tags": "alert"}
```

### How to reproduce

```python
$ export AIRFLOW_CONN_APPRISE_DEFAULT='{"extra": {"config": {"path": "https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7F", "tags": "alert"}}}'

>>> hook = AppriseHook()
>>> hook.get_config_from_conn()

TypeError: the JSON object must be str, bytes or bytearray, not dict
```

### Anything else

_No response_

### Are you willing to submit PR?

- [X] Yes I am willing to submit a PR!


### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)
Simplify Parsing of apprise_default Connection ID
### Apache Airflow Provider(s)

apprise

### Versions of Apache Airflow Providers

1.2.1

### Apache Airflow version

2.8.1

### Operating System

Ubuntu 20.04.6 LTS

### Deployment

Docker-Compose

### Deployment details

Added airflow connection environment variable for Apprise.

```shell
AIRFLOW_CONN_APPRISE_DEFAULT='{"extra": {"config": {"path": "https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7F", "tags": "pipeline"}}}'
```

### What happened

https://github.com/apache/airflow/blob/46470aba68e5ebeee24a03dc22d012a50ee287ad/airflow/providers/apprise/hooks/apprise.py#L49-L51

The way the return statement of this method parses the connection value raises the exception if quotes are not escaped in the serialized JSON string. Here are the examples, note the variations in the JSON string:

## When quotes are not escaped
```python
$ export AIRFLOW_CONN_APPRISE_DEFAULT='{"extra": {"config": "{"path": "https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7F", "tags": "alert"}"}}'

>>> from airflow.providers.apprise.hooks.apprise import AppriseHook
>>> hook = AppriseHook()
>>> conn = hook.get_connection('apprise_default')

json.decoder.JSONDecodeError: Expecting ',' delimiter: line 1 column 25 (char 24)
```

## When the value is a valid JSON string
```python
$ export AIRFLOW_CONN_APPRISE_DEFAULT='{"extra": {"config": {"path": "https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7F", "tags": "alert"}}}'

>>> import json
>>> from airflow.providers.apprise.hooks.apprise import AppriseHook
>>> hook = AppriseHook()
>>> conn = hook.get_connection('apprise_default')
>>> json.loads(conn.extra_dejson["config"])

TypeError: the JSON object must be str, bytes or bytearray, not dict
```

## Current way to do it
Escape quotes with backslash.
```python
$ export AIRFLOW_CONN_APPRISE_DEFAULT='{"extra": {"config": "{\"path\": \"https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7F\", \"tags\": \"alert\"}"}}'

>>> from airflow.providers.apprise.hooks.apprise import AppriseHook
>>> hook = AppriseHook()
>>> conn = hook.get_connection('apprise_default')
>>> json.loads(conn.extra_dejson["config"])

{"path": "https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7F", "tags": "alert"}
```

### What you think should happen instead

To simplify setting of connection ID through an environment variable as a valid JSON string, a slight change can be made to `AppriseHook.get_config_from_conn`.

```python
def get_config_from_conn(self):
    conn = self.get_connection(self.apprise_conn_id)
    return conn['config']
```

```python
$ export AIRFLOW_CONN_APPRISE_DEFAULT='{"extra": {"config": {"path": "https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7F", "tags": "alert"}}}'

>>> hook = AppriseHook()
>>> hook.get_config_from_conn()

{"path": "https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7F", "tags": "alert"}
```

### How to reproduce

```python
$ export AIRFLOW_CONN_APPRISE_DEFAULT='{"extra": {"config": {"path": "https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7F", "tags": "alert"}}}'

>>> hook = AppriseHook()
>>> hook.get_config_from_conn()

TypeError: the JSON object must be str, bytes or bytearray, not dict
```

### Anything else

_No response_

### Are you willing to submit PR?

- [X] Yes I am willing to submit a PR!


### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)


Discussion:
Thanks for opening your first issue here! Be sure to follow the issue template! If you are willing to raise PR to address this issue please do so, no need to wait for approval.

Thanks for opening your first issue here! Be sure to follow the issue template! If you are willing to raise PR to address this issue please do so, no need to wait for approval.


Resolving patch:
diff --git a/airflow/providers/apprise/hooks/apprise.py b/airflow/providers/apprise/hooks/apprise.py
--- a/airflow/providers/apprise/hooks/apprise.py
+++ b/airflow/providers/apprise/hooks/apprise.py
@@ -48,7 +48,8 @@ def __init__(self, apprise_conn_id: str = default_conn_name) -> None:
 
     def get_config_from_conn(self):
         conn = self.get_connection(self.apprise_conn_id)
-        return json.loads(conn.extra_dejson["config"])
+        config = conn.extra_dejson["config"]
+        return json.loads(config) if isinstance(config, str) else config
 
     def set_config_from_conn(self, apprise_obj: apprise.Apprise):
         """Set config from connection to apprise object."""


Test patch:
diff --git a/tests/providers/apprise/hooks/test_apprise.py b/tests/providers/apprise/hooks/test_apprise.py
--- a/tests/providers/apprise/hooks/test_apprise.py
+++ b/tests/providers/apprise/hooks/test_apprise.py
@@ -20,6 +20,7 @@
 from unittest.mock import MagicMock, call, patch
 
 import apprise
+import pytest
 
 from airflow.models import Connection
 from airflow.providers.apprise.hooks.apprise import AppriseHook
@@ -30,27 +31,34 @@ class TestAppriseHook:
     Test for AppriseHook
     """
 
-    def test_get_config_from_conn(self):
-        config = '{"path": "http://some_path_that_dont_exist/", "tag": "alert"}'
+    @pytest.mark.parametrize(
+        "config",
+        [
+            {"path": "http://some_path_that_dont_exist/", "tag": "alert"},
+            '{"path": "http://some_path_that_dont_exist/", "tag": "alert"}',
+        ],
+    )
+    def test_get_config_from_conn(self, config):
+        extra = {"config": config}
         with patch.object(
             AppriseHook,
             "get_connection",
-            return_value=Connection(conn_type="apprise", extra={"config": config}),
+            return_value=Connection(conn_type="apprise", extra=extra),
         ):
             hook = AppriseHook()
-            assert hook.get_config_from_conn() == json.loads(config)
+            assert hook.get_config_from_conn() == (json.loads(config) if isinstance(config, str) else config)
 
     def test_set_config_from_conn_with_dict(self):
         """
         Test set_config_from_conn for dict config
         """
-        config = '{"path": "http://some_path_that_dont_exist/", "tag": "alert"}'
+        extra = {"config": {"path": "http://some_path_that_dont_exist/", "tag": "alert"}}
         apprise_obj = apprise.Apprise()
         apprise_obj.add = MagicMock()
         with patch.object(
             AppriseHook,
             "get_connection",
-            return_value=Connection(conn_type="apprise", extra={"config": config}),
+            return_value=Connection(conn_type="apprise", extra=extra),
         ):
             hook = AppriseHook()
             hook.set_config_from_conn(apprise_obj)
@@ -60,17 +68,19 @@ def test_set_config_from_conn_with_list(self):
         """
         Test set_config_from_conn for list of dict config
         """
-        config = (
-            '[{"path": "http://some_path_that_dont_exist/", "tag": "p0"},'
-            '{"path": "http://some_other_path_that_dont_exist/", "tag": "p1"}]'
-        )
+        extra = {
+            "config": [
+                {"path": "http://some_path_that_dont_exist/", "tag": "p0"},
+                {"path": "http://some_other_path_that_dont_exist/", "tag": "p1"},
+            ]
+        }
 
         apprise_obj = apprise.Apprise()
         apprise_obj.add = MagicMock()
         with patch.object(
             AppriseHook,
             "get_connection",
-            return_value=Connection(conn_type="apprise", extra={"config": config}),
+            return_value=Connection(conn_type="apprise", extra=extra),
         ):
             hook = AppriseHook()
             hook.set_config_from_conn(apprise_obj)