Repository: joke2k/django-environ
Pull request #463 (2023-04-11 21:54:36)

Added support for secure Elasticsearch connections
Added support for `elasticsearchs`, `elasticsearch2s`, `elasticsearch5s` and `elasticsearch7s` schemes.
Which will translate into `https://server` server URL instead of `http://server`.
This fix is required, since AWS OpenSearch Service now forces HTTPS in many cases.


Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4738a07..f964a68 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -24,7 +24,7 @@ jobs:
 
     steps:
       - name: Checkout code
-        uses: actions/checkout@v3.5.0
+        uses: actions/checkout@v3.5.2
 
       - name: Set up Python 3.10
         uses: actions/setup-python@v4.5.0
@@ -64,7 +64,7 @@ jobs:
 
     steps:
       - name: Checkout code
-        uses: actions/checkout@v3.5.0
+        uses: actions/checkout@v3.5.2
 
       - name: Set up Python 3.10
         uses: actions/setup-python@v4.5.0
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 150373e..e77660f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -66,7 +66,7 @@ jobs:
 
     steps:
       - name: Checkout code
-        uses: actions/checkout@v3.5.0
+        uses: actions/checkout@v3.5.2
         with:
           fetch-depth: 5
 
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 4a560a7..a9b4c0f 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -45,7 +45,7 @@ jobs:
 
     steps:
     - name: Checkout repository
-      uses: actions/checkout@v3.5.0
+      uses: actions/checkout@v3.5.2
 
     # Initializes the CodeQL tools for scanning.
     - name: Initialize CodeQL
diff --git a/.github/workflows/cs.yml b/.github/workflows/cs.yml
index 640b5f9..5bec0e5 100644
--- a/.github/workflows/cs.yml
+++ b/.github/workflows/cs.yml
@@ -25,7 +25,7 @@ jobs:
 
     steps:
       - name: Checkout code
-        uses: actions/checkout@v3.5.0
+        uses: actions/checkout@v3.5.2
 
       - name: Set up Python 3.10
         uses: actions/setup-python@v4.5.0
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index a58efbb..e455c2f 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -26,7 +26,7 @@ jobs:
 
     steps:
       - name: Checkout code
-        uses: actions/checkout@v3.5.0
+        uses: actions/checkout@v3.5.2
 
       - name: Set up Python 3.10
         uses: actions/setup-python@v4.5.0
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 5167ddd..682b107 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -7,6 +7,11 @@ and this project adheres to `Semantic Versioning <https://semver.org/spec/v2.0.0
 
 `v0.11.0`_ - 00-Unreleased-2023
 -------------------------------
+Added
++++++
+- Added support for secure Elasticsearch connections
+  `#463 <https://github.com/joke2k/django-environ/pull/463>`_.
+
 Changed
 +++++++
 - Used ``mssql-django`` as engine for SQL Server
diff --git a/docs/types.rst b/docs/types.rst
index 3fcdcbb..c099bf9 100644
--- a/docs/types.rst
+++ b/docs/types.rst
@@ -156,10 +156,10 @@ For more detailed example see ":ref:`complex_dict_format`".
 
 :py:meth:`~.environ.Env.search_url` supports the following URL schemas:
 
-* Elasticsearch: ``elasticsearch://``
-* Elasticsearch2: ``elasticsearch2://``
-* Elasticsearch5: ``elasticsearch5://``
-* Elasticsearch7: ``elasticsearch7://``
+* Elasticsearch: ``elasticsearch://`` (http) or ``elasticsearchs://`` (https)
+* Elasticsearch2: ``elasticsearch2://`` (http) or ``elasticsearch2s://`` (https)
+* Elasticsearch5: ``elasticsearch5://`` (http) or ``elasticsearch5s://`` (https)
+* Elasticsearch7: ``elasticsearch7://`` (http) or ``elasticsearch7s://`` (https)
 * Solr: ``solr://``
 * Whoosh: ``whoosh://``
 * Xapian: ``xapian://``
diff --git a/environ/environ.py b/environ/environ.py
index cbbf107..f72f5a3 100644
--- a/environ/environ.py
+++ b/environ/environ.py
@@ -113,7 +113,6 @@ class Env:
     URL_CLASS = ParseResult
 
     POSTGRES_FAMILY = ['postgres', 'postgresql', 'psql', 'pgsql', 'postgis']
-    ELASTICSEARCH_FAMILY = ['elasticsearch' + x for x in ['', '2', '5', '7']]
 
     DEFAULT_DATABASE_ENV = 'DATABASE_URL'
     DB_SCHEMES = {
@@ -191,6 +190,9 @@ class Env:
         "xapian": "haystack.backends.xapian_backend.XapianEngine",
         "simple": "haystack.backends.simple_backend.SimpleEngine",
     }
+    ELASTICSEARCH_FAMILY = [scheme + s for scheme in SEARCH_SCHEMES
+                            if scheme.startswith("elasticsearch")
+                            for s in ('', 's')]
     CLOUDSQL = 'cloudsql'
 
     def __init__(self, **scheme):
@@ -760,9 +762,15 @@ class Env:
         path = url.path[1:]
         path = unquote_plus(path.split('?', 2)[0])
 
-        if url.scheme not in cls.SEARCH_SCHEMES:
+        scheme = url.scheme
+        secure = False
+        # elasticsearch supports secure schemes, similar to http -> https
+        if scheme in cls.ELASTICSEARCH_FAMILY and scheme.endswith('s'):
+            scheme = scheme[:-1]
+            secure = True
+        if scheme not in cls.SEARCH_SCHEMES:
             raise ImproperlyConfigured(f'Invalid search schema {url.scheme}')
-        config["ENGINE"] = cls.SEARCH_SCHEMES[url.scheme]
+        config["ENGINE"] = cls.SEARCH_SCHEMES[scheme]
 
         # check commons params
         params = {}
@@ -811,7 +819,7 @@ class Env:
                 index = split[0]
 
             config['URL'] = urlunparse(
-                ('http',) + url[1:2] + (path,) + ('', '', '')
+                ('https' if secure else 'http', url[1], path, '', '', '')
             )
             if 'TIMEOUT' in params:
                 config['TIMEOUT'] = cls.parse_value(params['TIMEOUT'][0], int)


Test patch:
diff --git a/tests/test_search.py b/tests/test_search.py
index 0992bf9..a6d8f06 100644
--- a/tests/test_search.py
+++ b/tests/test_search.py
@@ -33,25 +33,45 @@ def test_solr_multicore_parsing(solr_url):
 
 
 @pytest.mark.parametrize(
-    'url,engine',
+    'url,engine,scheme',
     [
         ('elasticsearch://127.0.0.1:9200/index',
-         'elasticsearch_backend.ElasticsearchSearchEngine'),
+         'elasticsearch_backend.ElasticsearchSearchEngine',
+         'http',),
+        ('elasticsearchs://127.0.0.1:9200/index',
+         'elasticsearch_backend.ElasticsearchSearchEngine',
+         'https',),
         ('elasticsearch2://127.0.0.1:9200/index',
-         'elasticsearch2_backend.Elasticsearch2SearchEngine'),
+         'elasticsearch2_backend.Elasticsearch2SearchEngine',
+         'http',),
+        ('elasticsearch2s://127.0.0.1:9200/index',
+         'elasticsearch2_backend.Elasticsearch2SearchEngine',
+         'https',),
         ('elasticsearch5://127.0.0.1:9200/index',
-         'elasticsearch5_backend.Elasticsearch5SearchEngine'),
+         'elasticsearch5_backend.Elasticsearch5SearchEngine',
+         'http'),
+        ('elasticsearch5s://127.0.0.1:9200/index',
+         'elasticsearch5_backend.Elasticsearch5SearchEngine',
+         'https'),
         ('elasticsearch7://127.0.0.1:9200/index',
-         'elasticsearch7_backend.Elasticsearch7SearchEngine'),
+         'elasticsearch7_backend.Elasticsearch7SearchEngine',
+         'http'),
+        ('elasticsearch7s://127.0.0.1:9200/index',
+         'elasticsearch7_backend.Elasticsearch7SearchEngine',
+         'https'),
     ],
     ids=[
         'elasticsearch',
+        'elasticsearchs',
         'elasticsearch2',
+        'elasticsearch2s',
         'elasticsearch5',
+        'elasticsearch5s',
         'elasticsearch7',
+        'elasticsearch7s',
     ]
 )
-def test_elasticsearch_parsing(url, engine):
+def test_elasticsearch_parsing(url, engine, scheme):
     """Ensure all supported Elasticsearch engines are recognized."""
     timeout = 360
     url = '{}?TIMEOUT={}'.format(url, timeout)
@@ -63,6 +83,7 @@ def test_elasticsearch_parsing(url, engine):
     assert 'TIMEOUT' in url.keys()
     assert url['TIMEOUT'] == timeout
     assert 'PATH' not in url
+    assert url["URL"].startswith(scheme + ":")
 
 
 @pytest.mark.parametrize('storage', ['file', 'ram'])