[7.x] Add support for the HTTP API compatibility header

Co-authored-by: Seth Michael Larson <[email protected]>
This commit is contained in:
github-actions[bot]
2021-04-26 16:55:59 -05:00
committed by GitHub
co-authored by Seth Michael Larson
parent 5ab523b703
commit 2c5f1d1569
9 changed files with 84 additions and 18 deletions
+23 -1
View File
@@ -60,7 +60,29 @@ To create an `SSLContext` object you only need to use one of cafile, capath or c
* `capath` is the directory of a collection of CA's * `capath` is the directory of a collection of CA's
* `cadata` is either an ASCII string of one or more PEM-encoded certificates or a bytes-like object of DER-encoded certificates. * `cadata` is either an ASCII string of one or more PEM-encoded certificates or a bytes-like object of DER-encoded certificates.
Please note that the use of SSLContext is only available for Urllib3. Please note that the use of SSLContext is only available for urllib3.
.. autoclass:: Urllib3HttpConnection .. autoclass:: Urllib3HttpConnection
:members: :members:
API Compatibility HTTP Header
-----------------------------
The Python client can be configured to emit an HTTP header
``Accept: application/vnd.elasticsearch+json; compatible-with=7``
which signals to Elasticsearch that the client is requesting
``7.x`` version of request and response bodies. This allows for
upgrading from 7.x to 8.x version of Elasticsearch without upgrading
everything at once. Elasticsearch should be upgraded first after
the compatibility header is configured and clients should be upgraded
second.
.. code-block:: python
from elasticsearch import Elasticsearch
client = Elasticsearch("http://...", headers={"accept": "application/vnd.elasticsearch+json; compatible-with=7"})
If you'd like to have the client emit the header without configuring ``headers`` you
can use the environment variable ``ELASTIC_CLIENT_APIVERSIONING=1``.
+12 -4
View File
@@ -19,6 +19,7 @@ import binascii
import gzip import gzip
import io import io
import logging import logging
import os
import re import re
import warnings import warnings
from platform import python_version from platform import python_version
@@ -28,7 +29,7 @@ try:
except ImportError: except ImportError:
import json import json
from .. import __versionstr__ from .. import __version__, __versionstr__
from ..exceptions import ( from ..exceptions import (
HTTP_EXCEPTIONS, HTTP_EXCEPTIONS,
ElasticsearchWarning, ElasticsearchWarning,
@@ -121,6 +122,13 @@ class Connection(object):
if opaque_id: if opaque_id:
self.headers["x-opaque-id"] = opaque_id self.headers["x-opaque-id"] = opaque_id
if os.getenv("ELASTIC_CLIENT_APIVERSIONING") == "1":
self.headers.setdefault(
"accept",
"application/vnd.elasticsearch+json;compatible-with=%s"
% (str(__version__[0]),),
)
self.headers.setdefault("content-type", "application/json") self.headers.setdefault("content-type", "application/json")
self.headers.setdefault("user-agent", self._get_default_user_agent()) self.headers.setdefault("user-agent", self._get_default_user_agent())
@@ -248,7 +256,7 @@ class Connection(object):
def log_request_success( def log_request_success(
self, method, full_url, path, body, status_code, response, duration self, method, full_url, path, body, status_code, response, duration
): ):
""" Log a successful API call. """ """Log a successful API call."""
# TODO: optionally pass in params instead of full_url and do urlencode only when needed # TODO: optionally pass in params instead of full_url and do urlencode only when needed
# body has already been serialized to utf-8, deserialize it for logging # body has already been serialized to utf-8, deserialize it for logging
@@ -278,7 +286,7 @@ class Connection(object):
response=None, response=None,
exception=None, exception=None,
): ):
""" Log an unsuccessful API call. """ """Log an unsuccessful API call."""
# do not log 404s on HEAD requests # do not log 404s on HEAD requests
if method == "HEAD" and status_code == 404: if method == "HEAD" and status_code == 404:
return return
@@ -307,7 +315,7 @@ class Connection(object):
logger.debug("< %s", response) logger.debug("< %s", response)
def _raise_error(self, status_code, raw_data): def _raise_error(self, status_code, raw_data):
""" Locate appropriate exception and raise it. """ """Locate appropriate exception and raise it."""
error_message = raw_data error_message = raw_data
additional_info = None additional_info = None
try: try:
+8 -8
View File
@@ -68,7 +68,7 @@ class TransportError(ElasticsearchException):
@property @property
def error(self): def error(self):
""" A string error message. """ """A string error message."""
return self.args[1] return self.args[1]
@property @property
@@ -120,11 +120,11 @@ class ConnectionError(TransportError):
class SSLError(ConnectionError): class SSLError(ConnectionError):
""" Error raised when encountering SSL errors. """ """Error raised when encountering SSL errors."""
class ConnectionTimeout(ConnectionError): class ConnectionTimeout(ConnectionError):
""" A network timeout. Doesn't cause a node retry by default. """ """A network timeout. Doesn't cause a node retry by default."""
def __str__(self): def __str__(self):
return "ConnectionTimeout caused by - %s(%s)" % ( return "ConnectionTimeout caused by - %s(%s)" % (
@@ -134,23 +134,23 @@ class ConnectionTimeout(ConnectionError):
class NotFoundError(TransportError): class NotFoundError(TransportError):
""" Exception representing a 404 status code. """ """Exception representing a 404 status code."""
class ConflictError(TransportError): class ConflictError(TransportError):
""" Exception representing a 409 status code. """ """Exception representing a 409 status code."""
class RequestError(TransportError): class RequestError(TransportError):
""" Exception representing a 400 status code. """ """Exception representing a 400 status code."""
class AuthenticationException(TransportError): class AuthenticationException(TransportError):
""" Exception representing a 401 status code. """ """Exception representing a 401 status code."""
class AuthorizationException(TransportError): class AuthorizationException(TransportError):
""" Exception representing a 403 status code. """ """Exception representing a 403 status code."""
class ElasticsearchWarning(Warning): class ElasticsearchWarning(Warning):
+1 -1
View File
@@ -21,7 +21,7 @@ from ..exceptions import ElasticsearchException
class BulkIndexError(ElasticsearchException): class BulkIndexError(ElasticsearchException):
@property @property
def errors(self): def errors(self):
""" List of errors from execution of the last chunk. """ """List of errors from execution of the last chunk."""
return self.args[1] return self.args[1]
+6 -2
View File
@@ -154,8 +154,12 @@ class Deserializer(object):
if not mimetype: if not mimetype:
deserializer = self.default deserializer = self.default
else: else:
# split out charset # split out 'charset' and 'compatible-width' options
mimetype, _, _ = mimetype.partition(";") mimetype = mimetype.partition(";")[0].strip()
# Treat 'application/vnd.elasticsearch+json'
# as application/json for compatibility.
if mimetype == "application/vnd.elasticsearch+json":
mimetype = "application/json"
try: try:
deserializer = self.serializers[mimetype] deserializer = self.serializers[mimetype]
except KeyError: except KeyError:
@@ -75,7 +75,7 @@ class AsyncYamlRunner(YamlRunner):
await self.teardown() await self.teardown()
async def run_code(self, test): async def run_code(self, test):
""" Execute an instruction based on it's type. """ """Execute an instruction based on it's type."""
print(test) print(test)
for action in test: for action in test:
assert len(action) == 1 assert len(action) == 1
+21
View File
@@ -18,6 +18,7 @@
import gzip import gzip
import io import io
import os
import re import re
import ssl import ssl
import warnings import warnings
@@ -171,6 +172,26 @@ class TestBaseConnection(TestCase):
Connection(meta_header=1) Connection(meta_header=1)
assert str(e.value) == "meta_header must be of type bool" assert str(e.value) == "meta_header must be of type bool"
def test_compatibility_accept_header(self):
try:
conn = Connection()
assert "accept" not in conn.headers
os.environ["ELASTIC_CLIENT_APIVERSIONING"] = "0"
conn = Connection()
assert "accept" not in conn.headers
os.environ["ELASTIC_CLIENT_APIVERSIONING"] = "1"
conn = Connection()
assert (
conn.headers["accept"]
== "application/vnd.elasticsearch+json;compatible-with=8"
)
finally:
os.environ.pop("ELASTIC_CLIENT_APIVERSIONING")
class TestUrllib3Connection(TestCase): class TestUrllib3Connection(TestCase):
def _get_mock_connection(self, connection_params={}, response_body=b"{}"): def _get_mock_connection(self, connection_params={}, response_body=b"{}"):
+11
View File
@@ -185,6 +185,17 @@ class TestDeserializer(TestCase):
self.de.loads('{"some":"data"}', "text/plain; charset=whatever"), self.de.loads('{"some":"data"}', "text/plain; charset=whatever"),
) )
def test_deserialize_compatibility_header(self):
for content_type in (
"application/vnd.elasticsearch+json;compatible-with=7",
"application/vnd.elasticsearch+json; compatible-with=7",
"application/vnd.elasticsearch+json;compatible-with=8",
"application/vnd.elasticsearch+json; compatible-with=8",
):
self.assertEqual(
{"some": "data"}, self.de.loads('{"some":"data"}', content_type)
)
def test_raises_serialization_error_on_unknown_mimetype(self): def test_raises_serialization_error_on_unknown_mimetype(self):
self.assertRaises(SerializationError, self.de.loads, "{}", "text/html") self.assertRaises(SerializationError, self.de.loads, "{}", "text/html")
@@ -138,7 +138,7 @@ class YamlRunner:
self.teardown() self.teardown()
def run_code(self, test): def run_code(self, test):
""" Execute an instruction based on it's type. """ """Execute an instruction based on it's type."""
print(test) print(test)
for action in test: for action in test:
assert len(action) == 1 assert len(action) == 1