Skip to content

Commit 25e2962

Browse files
Introduce compatibility header (#5438)
* stage * update to latest libs from elastic/elasticsearch-net-abstractions and remove nightlies from nuget.config (cherry picked from commit 1e8b465) * Adds support for vendored mimetype headers Changes our default `Accept` and `Content-Type` request headers over to the new vendor specific headers that includes compatible with informations to start powering structurally versioned API's ``` Accept: application/vnd.elasticsearch+json;compatible-with=7 Content-Type: application/vnd.elasticsearch+json; compatible-with=7 ``` On 7.x Elasticsearch does not return the vendor type we send for `Accept` but is still hardwired to return: ``` content-type: application/json; charset=UTF-8 ``` We are therefor lenient in our Accept to Response ContentType validations. We do these assertions to prevent the client from deserializing HTML from possible proxies in the middle of the client and server. * Make sure compatible with is calculated from assembly version * Make sure InMemoryConnection still defaults to the default mimetype * update nuget.config * Make sure RequestDataContent does not use constructor to set mimetype since its too strict * try to isolate constant header usage and add tests * Randomly enable ApiVersioning in tests if we are testing against 7.12.0 and higher * make sure we can talk to 8.x cluster * Fix isvalid json mimetype check * Add space after ; because MediaType .ToString() does this too * Fix FixedResponseClient default content type * update StartsWith to ignore culture and case * Add support for configuring via env var Enabled when ELASTIC_CLIENT_APIVERSIONING is set to "true" or "1". Co-authored-by: Steve Gordon <sgordon@hotmail.co.uk>
1 parent 657a9bf commit 25e2962

27 files changed

+277
-76
lines changed

src/Elasticsearch.Net.VirtualizedCluster/Rules/RuleBase.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,14 @@ public TRule ReturnResponse<T>(T response)
6969
r = ms.ToArray();
7070
}
7171
Self.ReturnResponse = r;
72-
Self.ReturnContentType = RequestData.MimeType;
72+
Self.ReturnContentType = RequestData.DefaultJsonMimeType;
7373
return (TRule)this;
7474
}
7575

76-
public TRule ReturnByteResponse(byte[] response, string responseContentType = RequestData.MimeType)
76+
public TRule ReturnByteResponse(byte[] response, string responseContentType = null)
7777
{
7878
Self.ReturnResponse = response;
79-
Self.ReturnContentType = responseContentType;
79+
Self.ReturnContentType = responseContentType ?? RequestData.DefaultJsonMimeType;
8080
return (TRule)this;
8181
}
8282
}

src/Elasticsearch.Net/Configuration/ConnectionConfiguration.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ public ConnectionConfiguration(IConnectionPool connectionPool, IConnection conne
131131
public abstract class ConnectionConfiguration<T> : IConnectionConfigurationValues
132132
where T : ConnectionConfiguration<T>
133133
{
134+
internal const string ApiVersioningEnvironmentVariableName = "ELASTIC_CLIENT_APIVERSIONING";
135+
134136
private readonly IConnection _connection;
135137
private readonly IConnectionPool _connectionPool;
136138
private readonly NameValueCollection _headers = new NameValueCollection();
@@ -176,6 +178,7 @@ public abstract class ConnectionConfiguration<T> : IConnectionConfigurationValue
176178
//public static IMemoryStreamFactory Default { get; } = RecyclableMemoryStreamFactory.Default;
177179
public static IMemoryStreamFactory DefaultMemoryStreamFactory { get; } = Elasticsearch.Net.MemoryStreamFactory.Default;
178180
private bool _enableThreadPoolStats;
181+
private bool _enableApiVersioningHeader;
179182

180183
private string _userAgent = ConnectionConfiguration.DefaultUserAgent;
181184
private readonly Func<HttpMethod, int, bool> _statusCodeToResponseSuccess;
@@ -205,6 +208,14 @@ protected ConnectionConfiguration(IConnectionPool connectionPool, IConnection co
205208
_apiKeyAuthCredentials = cloudPool.ApiKeyCredentials;
206209
_enableHttpCompression = true;
207210
}
211+
212+
var apiVersioningEnabled = Environment.GetEnvironmentVariable(ApiVersioningEnvironmentVariableName);
213+
_enableApiVersioningHeader = string.IsNullOrEmpty(apiVersioningEnabled) switch
214+
{
215+
false when bool.TryParse(apiVersioningEnabled, out var isEnabled) => isEnabled,
216+
false when int.TryParse(apiVersioningEnabled, out var isEnabledValue) => isEnabledValue == 1,
217+
_ => _enableApiVersioningHeader
218+
};
208219
}
209220

210221
protected IElasticsearchSerializer UseThisRequestResponseSerializer { get; set; }
@@ -257,8 +268,9 @@ protected ConnectionConfiguration(IConnectionPool connectionPool, IConnection co
257268
bool IConnectionConfigurationValues.TransferEncodingChunked => _transferEncodingChunked;
258269
bool IConnectionConfigurationValues.EnableTcpStats => _enableTcpStats;
259270
bool IConnectionConfigurationValues.EnableThreadPoolStats => _enableThreadPoolStats;
260-
271+
261272
MetaHeaderProvider IConnectionConfigurationValues.MetaHeaderProvider { get; } = new MetaHeaderProvider();
273+
bool IConnectionConfigurationValues.EnableApiVersioningHeader => _enableApiVersioningHeader;
262274

263275
void IDisposable.Dispose() => DisposeManagedResources();
264276

@@ -342,7 +354,7 @@ public T SniffOnConnectionFault(bool sniffsOnConnectionFault = true) =>
342354
public T DisableAutomaticProxyDetection(bool disable = true) => Assign(disable, (a, v) => a._disableAutomaticProxyDetection = v);
343355

344356
/// <summary>
345-
/// Disables the meta header which is included on all requests by default. This header contains lightweight information
357+
/// Disables the meta header which is included on all requests by default. This header contains lightweight information
346358
/// about the client and runtime.
347359
/// </summary>
348360
public T DisableMetaHeader(bool disable = true) => Assign(disable, (a, v) => a._disableMetaHeader = v);
@@ -602,6 +614,9 @@ public T SkipDeserializationForStatusCodes(params int[] statusCodes) =>
602614
/// </summary>
603615
public T MemoryStreamFactory(IMemoryStreamFactory memoryStreamFactory) => Assign(memoryStreamFactory, (a, v) => a._memoryStreamFactory = v);
604616

617+
/// <inheritdoc cref="IConnectionConfigurationValues.EnableApiVersioningHeader"/>
618+
public T EnableApiVersioningHeader(bool enable = true) => Assign(enable, (a, v) => a._enableApiVersioningHeader = v);
619+
605620
public T EnableTcpStats(bool enableTcpStats = true) => Assign(enableTcpStats, (a, v) => a._enableTcpStats = v);
606621

607622
public T EnableThreadPoolStats(bool enableThreadPoolStats = true) => Assign(enableThreadPoolStats, (a, v) => a._enableThreadPoolStats = v);

src/Elasticsearch.Net/Configuration/IConnectionConfigurationValues.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,5 +280,11 @@ public interface IConnectionConfigurationValues : IDisposable
280280
/// Produces the client meta header for a request.
281281
/// </summary>
282282
MetaHeaderProvider MetaHeaderProvider { get; }
283+
284+
/// <summary>
285+
/// Enables the client to start sending the API versioning header. This allows a `7.x` client to talk to `8.x` Elasticsearch.
286+
/// <para> NOTE: You need at least Elasticsearch 7.11 and higher before you can enable this setting on the client</para>
287+
/// </summary>
288+
bool EnableApiVersioningHeader { get; }
283289
}
284290
}

src/Elasticsearch.Net/Connection/Content/RequestDataContent.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ internal class RequestDataContent : HttpContent
3030
public RequestDataContent(RequestData requestData)
3131
{
3232
_requestData = requestData;
33-
Headers.ContentType = new MediaTypeHeaderValue(requestData.RequestMimeType);
33+
Headers.ContentType = MediaTypeHeaderValue.Parse(requestData.RequestMimeType);
3434
if (requestData.HttpCompression)
3535
Headers.ContentEncoding.Add("gzip");
3636

@@ -50,7 +50,7 @@ Task OnStreamAvailable(RequestData data, Stream stream, HttpContent content, Tra
5050
public RequestDataContent(RequestData requestData, CancellationToken token)
5151
{
5252
_requestData = requestData;
53-
Headers.ContentType = new MediaTypeHeaderValue(requestData.RequestMimeType);
53+
Headers.ContentType = MediaTypeHeaderValue.Parse(requestData.RequestMimeType);
5454
if (requestData.HttpCompression)
5555
Headers.ContentEncoding.Add("gzip");
5656

src/Elasticsearch.Net/Connection/HttpConnection.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ public virtual TResponse Request<TResponse>(RequestData requestData)
9595

9696
requestData.MadeItToResponse = true;
9797
responseMessage.Headers.TryGetValues("Warning", out warnings);
98-
mimeType = responseMessage.Content.Headers.ContentType?.MediaType;
98+
mimeType = responseMessage.Content.Headers.ContentType?.ToString();
9999

100100
if (responseMessage.Content != null)
101101
{
@@ -162,7 +162,7 @@ public virtual async Task<TResponse> RequestAsync<TResponse>(RequestData request
162162
}
163163

164164
requestData.MadeItToResponse = true;
165-
mimeType = responseMessage.Content.Headers.ContentType?.MediaType;
165+
mimeType = responseMessage.Content.Headers.ContentType?.ToString();
166166
responseMessage.Headers.TryGetValues("Warning", out warnings);
167167

168168
if (responseMessage.Content != null)
@@ -322,7 +322,7 @@ protected virtual HttpRequestMessage CreateRequestMessage(RequestData requestDat
322322

323323
requestMessage.Headers.Connection.Clear();
324324
requestMessage.Headers.ConnectionClose = false;
325-
requestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(requestData.Accept));
325+
requestMessage.Headers.TryAddWithoutValidation("Accept", requestData.Accept);
326326

327327
if (!string.IsNullOrWhiteSpace(requestData.UserAgent))
328328
{
@@ -372,7 +372,7 @@ private static void SetContent(HttpRequestMessage message, RequestData requestDa
372372
if (requestData.HttpCompression)
373373
message.Content.Headers.ContentEncoding.Add("gzip");
374374

375-
message.Content.Headers.ContentType = new MediaTypeHeaderValue(requestData.RequestMimeType);
375+
message.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(requestData.RequestMimeType);
376376
}
377377
}
378378

@@ -409,7 +409,7 @@ private static async Task SetContentAsync(HttpRequestMessage message, RequestDat
409409
if (requestData.HttpCompression)
410410
message.Content.Headers.ContentEncoding.Add("gzip");
411411

412-
message.Content.Headers.ContentType = new MediaTypeHeaderValue(requestData.RequestMimeType);
412+
message.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(requestData.RequestMimeType);
413413
}
414414
}
415415

src/Elasticsearch.Net/Connection/InMemoryConnection.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,13 @@ public class InMemoryConnection : IConnection
2525
/// </summary>
2626
public InMemoryConnection() => _statusCode = 200;
2727

28-
public InMemoryConnection(byte[] responseBody, int statusCode = 200, Exception exception = null, string contentType = RequestData.MimeType)
28+
public InMemoryConnection(byte[] responseBody, int statusCode = 200, Exception exception = null, string contentType = null)
2929
{
30+
3031
_responseBody = responseBody;
3132
_statusCode = statusCode;
3233
_exception = exception;
33-
_contentType = contentType;
34+
_contentType = contentType ?? RequestData.DefaultJsonMimeType;
3435
}
3536

3637
public virtual TResponse Request<TResponse>(RequestData requestData)
@@ -65,7 +66,7 @@ protected TResponse ReturnConnectionStatus<TResponse>(RequestData requestData, b
6566

6667
var sc = statusCode ?? _statusCode;
6768
Stream s = body != null ? requestData.MemoryStreamFactory.Create(body) : requestData.MemoryStreamFactory.Create(EmptyBody);
68-
return ResponseBuilder.ToResponse<TResponse>(requestData, _exception, sc, null, s, contentType ?? _contentType ?? RequestData.MimeType);
69+
return ResponseBuilder.ToResponse<TResponse>(requestData, _exception, sc, null, s, contentType ?? _contentType ?? RequestData.DefaultJsonMimeType);
6970
}
7071

7172
protected async Task<TResponse> ReturnConnectionStatusAsync<TResponse>(RequestData requestData, CancellationToken cancellationToken,

src/Elasticsearch.Net/Connection/MetaData/ClientVersionInfo.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ internal sealed class ClientVersionInfo : VersionInfo
1515

1616
public static readonly ClientVersionInfo Empty = new ClientVersionInfo { Version = new Version(0, 0, 0), IsPrerelease = false };
1717

18+
public static readonly ClientVersionInfo LowLevelClientVersionInfo = Create<IElasticLowLevelClient>();
19+
1820
private ClientVersionInfo() { }
1921

2022
public static ClientVersionInfo Create<T>()

src/Elasticsearch.Net/Responses/ElasticsearchResponse.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ public virtual bool TryGetServerError(out ServerError serverError)
7474
{
7575
serverError = null;
7676
var bytes = ApiCall.ResponseBodyInBytes;
77-
if (bytes == null || ResponseMimeType != RequestData.MimeType)
77+
if (bytes == null || RequestData.IsJsonMimeType(ResponseMimeType))
7878
return false;
7979

8080
using(var stream = ConnectionConfiguration.MemoryStreamFactory.Create(bytes))

src/Elasticsearch.Net/Responses/Special/BytesResponse.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ public BytesResponse() { }
1313
public override bool TryGetServerError(out ServerError serverError)
1414
{
1515
serverError = null;
16-
if (Body == null || Body.Length == 0 || ResponseMimeType != RequestData.MimeType)
16+
if (Body == null || Body.Length == 0 || !RequestData.IsJsonMimeType(ResponseMimeType))
1717
return false;
1818

1919
using(var stream = ConnectionConfiguration.MemoryStreamFactory.Create(Body))

src/Elasticsearch.Net/Responses/Special/StringResponse.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public StringResponse() { }
1515
public override bool TryGetServerError(out ServerError serverError)
1616
{
1717
serverError = null;
18-
if (string.IsNullOrEmpty(Body) || ResponseMimeType != RequestData.MimeType)
18+
if (string.IsNullOrEmpty(Body) || !RequestData.IsJsonMimeType(ResponseMimeType))
1919
return false;
2020

2121
using(var stream = ConnectionConfiguration.MemoryStreamFactory.Create(Encoding.UTF8.GetBytes(Body)))

src/Elasticsearch.Net/Transport/Pipeline/RequestData.cs

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,18 @@ namespace Elasticsearch.Net
1313
{
1414
public class RequestData
1515
{
16-
public const string MimeType = "application/json";
17-
public const string MimeTypeTextPlain = "text/plain";
1816
public const string OpaqueIdHeader = "X-Opaque-Id";
1917
public const string RunAsSecurityHeader = "es-security-runas-user";
2018

19+
public const string MimeTypeTextPlain = "text/plain";
20+
private const string MimeTypeOld = "application/json";
21+
private static readonly string MimeType = "application/vnd.elasticsearch+json; compatible-with=" + ClientVersionInfo.LowLevelClientVersionInfo.Version.Major;
22+
23+
public static readonly string DefaultJsonMimeType =
24+
ClientVersionInfo.LowLevelClientVersionInfo.Version.Major >= 8 ? MimeType : MimeTypeOld;
25+
26+
public string JsonContentMimeType { get; }
27+
2128
public RequestData(HttpMethod method, string path, PostData data, IConnectionConfigurationValues global, IRequestParameters local,
2229
IMemoryStreamFactory memoryStreamFactory
2330
)
@@ -41,13 +48,15 @@ IMemoryStreamFactory memoryStreamFactory
4148
Method = method;
4249
PostData = data;
4350

51+
JsonContentMimeType = DefaultJsonBasedOnConfigurationSettings(ConnectionSettings);
52+
4453
if (data != null)
4554
data.DisableDirectStreaming = local?.DisableDirectStreaming ?? global.DisableDirectStreaming;
4655

4756
Pipelined = local?.EnableHttpPipelining ?? global.HttpPipeliningEnabled;
4857
HttpCompression = global.EnableHttpCompression;
49-
RequestMimeType = local?.ContentType ?? MimeType;
50-
Accept = local?.Accept ?? MimeType;
58+
RequestMimeType = local?.ContentType ?? JsonContentMimeType;
59+
Accept = local?.Accept ?? JsonContentMimeType;
5160

5261
if (global.Headers != null)
5362
Headers = new NameValueCollection(global.Headers);
@@ -151,6 +160,37 @@ IMemoryStreamFactory memoryStreamFactory
151160

152161
public override string ToString() => $"{Method.GetStringValue()} {_path}";
153162

163+
public static string DefaultJsonBasedOnConfigurationSettings(IConnectionConfigurationValues settings) =>
164+
settings.EnableApiVersioningHeader ? MimeType : MimeTypeOld;
165+
166+
167+
public static bool IsJsonMimeType(string mimeType) =>
168+
ValidResponseContentType(MimeType, mimeType) || ValidResponseContentType(MimeTypeOld, mimeType);
169+
170+
public static bool ValidResponseContentType(string acceptMimeType, string responseMimeType)
171+
{
172+
if (string.IsNullOrEmpty(acceptMimeType)) return false;
173+
if (string.IsNullOrEmpty(responseMimeType)) return false;
174+
175+
// we a startswith check because the response can return charset information
176+
// e.g: application/json; charset=UTF-8
177+
if (acceptMimeType == RequestData.MimeTypeOld)
178+
return responseMimeType.StartsWith(RequestData.MimeTypeOld, StringComparison.OrdinalIgnoreCase);
179+
180+
//vendored check
181+
if (acceptMimeType == RequestData.MimeType)
182+
// we check both vendored and nonvendored since on 7.x the response does not return a
183+
// vendored Content-Type header on the response
184+
return
185+
responseMimeType == RequestData.MimeType
186+
|| responseMimeType == RequestData.MimeTypeOld
187+
|| responseMimeType.StartsWith(RequestData.MimeTypeOld, StringComparison.OrdinalIgnoreCase)
188+
|| responseMimeType.StartsWith(RequestData.MimeType, StringComparison.OrdinalIgnoreCase);
189+
190+
return responseMimeType.StartsWith(acceptMimeType, StringComparison.OrdinalIgnoreCase);
191+
}
192+
193+
154194
// TODO This feels like its in the wrong place
155195
private string CreatePathWithQueryStrings(string path, IConnectionConfigurationValues global, IRequestParameters request)
156196
{

src/Elasticsearch.Net/Transport/Pipeline/RequestPipeline.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
// Licensed to Elasticsearch B.V under one or more agreements.
2-
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3-
// See the LICENSE file in the project root for more information
4-
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
55
using System;
66
using System.Collections.Generic;
77
using System.Diagnostics;
@@ -142,7 +142,7 @@ ElasticsearchClientException exception
142142
{
143143
//make sure we copy over the error body in case we disabled direct streaming.
144144
var s = callDetails?.ResponseBodyInBytes == null ? Stream.Null : _memoryStreamFactory.Create(callDetails.ResponseBodyInBytes);
145-
var m = callDetails?.ResponseMimeType ?? RequestData.MimeType;
145+
var m = callDetails?.ResponseMimeType ?? RequestData.DefaultJsonMimeType;
146146
response = ResponseBuilder.ToResponse<TResponse>(data, exception, callDetails?.HttpStatusCode, null, s, m);
147147
}
148148

src/Elasticsearch.Net/Transport/Pipeline/ResponseBuilder.cs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public static TResponse ToResponse<TResponse>(
2525
int? statusCode,
2626
IEnumerable<string> warnings,
2727
Stream responseStream,
28-
string mimeType = RequestData.MimeType
28+
string mimeType = null
2929
)
3030
where TResponse : class, IElasticsearchResponse, new()
3131
{
@@ -43,7 +43,7 @@ public static async Task<TResponse> ToResponseAsync<TResponse>(
4343
int? statusCode,
4444
IEnumerable<string> warnings,
4545
Stream responseStream,
46-
string mimeType = RequestData.MimeType,
46+
string mimeType = null,
4747
CancellationToken cancellationToken = default
4848
)
4949
where TResponse : class, IElasticsearchResponse, new()
@@ -70,8 +70,7 @@ private static ApiCallDetails Initialize(
7070
success = requestData.ConnectionSettings
7171
.StatusCodeToResponseSuccess(requestData.Method, statusCode.Value);
7272
}
73-
//mimeType can include charset information on .NET full framework
74-
if (!string.IsNullOrEmpty(mimeType) && !mimeType.StartsWith(requestData.Accept))
73+
if (!RequestData.ValidResponseContentType(requestData.Accept, mimeType))
7574
success = false;
7675

7776
var details = new ApiCallDetails
@@ -114,7 +113,7 @@ private static TResponse SetBody<TResponse>(ApiCallDetails details, RequestData
114113
if (requestData.CustomResponseBuilder != null)
115114
return requestData.CustomResponseBuilder.DeserializeResponse(serializer, details, responseStream) as TResponse;
116115

117-
return mimeType == null || !mimeType.StartsWith(requestData.Accept, StringComparison.Ordinal)
116+
return !RequestData.ValidResponseContentType(requestData.Accept, mimeType)
118117
? null
119118
: serializer.Deserialize<TResponse>(responseStream);
120119
}
@@ -146,7 +145,7 @@ private static async Task<TResponse> SetBodyAsync<TResponse>(
146145
if (requestData.CustomResponseBuilder != null)
147146
return await requestData.CustomResponseBuilder.DeserializeResponseAsync(serializer, details, responseStream, cancellationToken).ConfigureAwait(false) as TResponse;
148147

149-
return mimeType == null || !mimeType.StartsWith(requestData.Accept, StringComparison.Ordinal)
148+
return !RequestData.ValidResponseContentType(requestData.Accept, mimeType)
150149
? null
151150
: await serializer
152151
.DeserializeAsync<TResponse>(responseStream, cancellationToken)
@@ -170,7 +169,7 @@ private static bool SetSpecialTypes<TResponse>(string mimeType, byte[] bytes, IM
170169
else if (responseType == typeof(DynamicResponse))
171170
{
172171
//if not json store the result under "body"
173-
if (mimeType == null || !mimeType.StartsWith(RequestData.MimeType))
172+
if (!RequestData.IsJsonMimeType(mimeType))
174173
{
175174
var dictionary = new DynamicDictionary();
176175
dictionary["body"] = new DynamicValue(bytes.Utf8String());

0 commit comments

Comments
 (0)