Skip to content

Commit 53dd572

Browse files
authored
Fix REST API compatibility headers when overriding Accept or Content-Type (#207)
## Summary Elasticsearch's REST API compatibility mode requires `Accept` and `Content-Type` to be in sync — either both carry the `;compatible-with=N` annotation or neither does. When a caller overrode `IRequestConfiguration.Accept` (or `ContentType`) the override flowed through `BoundConfiguration` verbatim while the other header still carried the annotation from the default, producing a mismatch that the server rejects (see elastic/elasticsearch-net#8867). ## Approach - Add a `TransformContentType` virtual on `ProductRegistration` (default identity). - Override it in `ElasticsearchProductRegistration` to append `;compatible-with=N` to a supported vendor MIME type — `application/vnd.elasticsearch+(json|x-ndjson|vnd.mapbox-vector-tile)` — only when the parameter is not already present. Results are cached per-instance. - `BoundConfiguration` runs both `Accept` and `ContentType` through the hook, so the default and override paths share the same logic. - `DefaultContentType` always returns the bare vendor MIME (`application/vnd.elasticsearch+json`); the version suffix is appended by the transform when a client major version is known. ## Design notes (intentionally different from `elasticsearch-py`) - Plain MIME types like `application/json` are **not** rewritten to vendor form. If the caller supplies a plain MIME, that intent is respected; keeping `Accept` and `Content-Type` in sync is the caller's responsibility. - Consuming clients are expected to emit the vendor MIME (`application/vnd.elasticsearch+json`) without `;compatible-with=`. The transport adds the version suffix. - A value that already contains `compatible-with=` is treated as user-controlled and left alone (including for explicit major-version overrides like `compatible-with=9`). - Multi-value `Accept` lists are handled — every vendor MIME entry independently gains the suffix.
1 parent 2fd2c1e commit 53dd572

4 files changed

Lines changed: 212 additions & 9 deletions

File tree

src/Elastic.Transport/Components/Pipeline/BoundConfiguration.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ public BoundConfiguration(ITransportConfiguration global, IRequestConfiguration?
4848
DisablePings = global.DisablePings ?? !global.NodePool.SupportsPinging;
4949
HttpPipeliningEnabled = local?.HttpPipeliningEnabled ?? global.HttpPipeliningEnabled ?? true;
5050
HttpCompression = global.EnableHttpCompression ?? local?.EnableHttpCompression ?? true;
51-
ContentType = local?.ContentType ?? global.Accept ?? DefaultContentType;
52-
Accept = local?.Accept ?? global.Accept ?? DefaultContentType;
51+
ContentType = global.ProductRegistration.TransformContentType(local?.ContentType ?? global.Accept ?? DefaultContentType)!;
52+
Accept = global.ProductRegistration.TransformContentType(local?.Accept ?? global.Accept ?? DefaultContentType)!;
5353
ThrowExceptions = local?.ThrowExceptions ?? global.ThrowExceptions ?? false;
5454
RequestTimeout = local?.RequestTimeout ?? global.RequestTimeout ?? RequestConfiguration.DefaultRequestTimeout;
5555
RequestMetaData = local?.RequestMetaData;

src/Elastic.Transport/Products/Elasticsearch/ElasticsearchProductRegistration.cs

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
// See the LICENSE file in the project root for more information
44

55
using System;
6+
using System.Collections.Concurrent;
67
using System.Collections.Generic;
78
using System.Collections.ObjectModel;
89
using System.Diagnostics;
910
using System.IO;
1011
using System.Linq;
1112
using System.Reflection;
13+
using System.Text.RegularExpressions;
1214
using System.Threading;
1315
using System.Threading.Tasks;
1416
using Elastic.Transport.Diagnostics;
@@ -20,12 +22,24 @@ namespace Elastic.Transport.Products.Elasticsearch;
2022
/// for Elasticsearch so that <see cref="RequestPipeline"/> knows how to ping and sniff if we setup
2123
/// <see cref="ITransport{TConfiguration}"/> to talk to Elasticsearch
2224
/// </summary>
23-
public class ElasticsearchProductRegistration : ProductRegistration
25+
public partial class ElasticsearchProductRegistration : ProductRegistration
2426
{
2527
internal const string XFoundHandlingClusterHeader = "X-Found-Handling-Cluster";
2628
internal const string XFoundHandlingInstanceHeader = "X-Found-Handling-Instance";
2729

30+
#if NET7_0_OR_GREATER
31+
[GeneratedRegex(@"application/vnd\.elasticsearch\+(json|x-ndjson|vnd\.mapbox-vector-tile)", RegexOptions.IgnoreCase)]
32+
private static partial Regex VendorMimeRegex();
33+
34+
private static readonly Regex _vendorMimeRegex = VendorMimeRegex();
35+
#else
36+
private static readonly Regex _vendorMimeRegex = new(
37+
@"application/vnd\.elasticsearch\+(json|x-ndjson|vnd\.mapbox-vector-tile)",
38+
RegexOptions.Compiled | RegexOptions.IgnoreCase);
39+
#endif
40+
2841
private readonly int? _clientMajorVersion;
42+
private readonly ConcurrentDictionary<string, string>? _contentTypeCache;
2943

3044
private static string? _clusterName;
3145
private static readonly string[] _all = [XFoundHandlingClusterHeader, XFoundHandlingInstanceHeader];
@@ -56,7 +70,10 @@ public ElasticsearchProductRegistration(Type markerType) : this()
5670
// Only set this if we have a version.
5771
// If we don't have a version we won't apply the vendor-based REST API compatibility Accept header.
5872
if (clientVersionInfo.Major > 0)
73+
{
5974
_clientMajorVersion = clientVersionInfo.Major;
75+
_contentTypeCache = new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
76+
}
6077

6178
ProductAssemblyVersion = markerType.Assembly
6279
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
@@ -85,7 +102,43 @@ public ElasticsearchProductRegistration(Type markerType) : this()
85102
public override MetaHeaderProvider MetaHeaderProvider { get; }
86103

87104
/// <inheritdoc cref="ProductRegistration.DefaultContentType"/>
88-
public override string? DefaultContentType => _clientMajorVersion.HasValue ? $"application/vnd.elasticsearch+json;compatible-with={_clientMajorVersion.Value}" : null;
105+
/// <remarks>
106+
/// Always returns the bare vendor MIME type. The <c>;compatible-with=N</c>
107+
/// suffix is appended by <see cref="TransformContentType"/> when the value is
108+
/// bound onto a request — but only when a client major version is known, so
109+
/// the suffix is omitted for the parameterless registration.
110+
/// </remarks>
111+
public override string? DefaultContentType => "application/vnd.elasticsearch+json";
112+
113+
/// <inheritdoc cref="ProductRegistration.TransformContentType"/>
114+
/// <remarks>
115+
/// Appends <c>;compatible-with=N</c> to a supported vendor MIME type
116+
/// (<c>application/vnd.elasticsearch+json</c>, <c>+x-ndjson</c>, or
117+
/// <c>+vnd.mapbox-vector-tile</c>) when the parameter is not already present.
118+
/// Plain MIME types like <c>application/json</c> are returned unchanged so the
119+
/// caller stays in control of the value they explicitly provided.
120+
/// </remarks>
121+
public override string? TransformContentType(string? contentType)
122+
{
123+
if (string.IsNullOrEmpty(contentType) || !_clientMajorVersion.HasValue)
124+
return contentType;
125+
126+
return _contentTypeCache!.GetOrAdd(contentType!, AppendCompatibleWithAnnotation);
127+
}
128+
129+
private string AppendCompatibleWithAnnotation(string input)
130+
{
131+
// If a compatible-with parameter is already present anywhere in the
132+
// value, treat it as user-controlled and leave it alone.
133+
if (input.IndexOf("compatible-with=", StringComparison.OrdinalIgnoreCase) >= 0)
134+
return input;
135+
136+
// If no supported vendor MIME type is present, nothing to do.
137+
if (!_vendorMimeRegex.IsMatch(input))
138+
return input;
139+
140+
return _vendorMimeRegex.Replace(input, $"$0;compatible-with={_clientMajorVersion!.Value}");
141+
}
89142

90143
/// <summary> Exposes the path used for sniffing in Elasticsearch </summary>
91144
public const string SniffPath = "_nodes/http,settings";

src/Elastic.Transport/Products/ProductRegistration.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ public abstract class ProductRegistration
2929
/// </summary>
3030
public abstract string? DefaultContentType { get; }
3131

32+
/// <summary>
33+
/// Allows the product registration to rewrite the MIME type used in the
34+
/// <c>Accept</c> and <c>Content-Type</c> request headers. The default
35+
/// implementation returns the input unchanged. Products such as Elasticsearch
36+
/// override this to append a REST API compatibility annotation
37+
/// (<c>;compatible-with=N</c>) to supported vendor MIME types when missing.
38+
/// </summary>
39+
public virtual string? TransformContentType(string? contentType) => contentType;
40+
3241
/// <summary>
3342
/// The name of the current product utilizing <see cref="ITransport{TConfiguration}"/>
3443
/// <para>This name makes its way into the transport diagnostics sources and the default user agent string</para>

tests/Elastic.Transport.IntegrationTests/Http/ApiCompatibilityHeaderTests.cs

Lines changed: 146 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// See the LICENSE file in the project root for more information
44

55
using System.Linq;
6+
using System.Net.Http;
67
using System.Threading.Tasks;
78
using Elastic.Transport.IntegrationTests.Plumbing;
89
using Elastic.Transport.IntegrationTests.Plumbing.Stubs;
@@ -15,6 +16,8 @@ namespace Elastic.Transport.IntegrationTests.Http;
1516

1617
public class ApiCompatibilityHeaderTests(TestServerFixture instance) : AssemblyServerTestsBase(instance)
1718
{
19+
private const string DefaultVendorMime = "application/vnd.elasticsearch+json;compatible-with=8";
20+
1821
[Fact]
1922
public async Task AddsExpectedVendorInformationForRestApiCompaitbility()
2023
{
@@ -25,18 +28,156 @@ public async Task AddsExpectedVendorInformationForRestApiCompaitbility()
2528
_ = parameter.Name.Should().Be("compatible-with");
2629
_ = parameter.Value.Should().Be("8");
2730

28-
var acceptValues = responseMessage.RequestMessage.Headers.GetValues("Accept");
29-
_ = acceptValues.Single().Replace(" ", "").Should().Be("application/vnd.elasticsearch+json;compatible-with=8");
31+
AssertHeader(responseMessage, "Accept", DefaultVendorMime);
32+
AssertContentTypeHeader(responseMessage, DefaultVendorMime);
33+
});
34+
35+
await SendAsync(requestInvoker);
36+
}
37+
38+
[Fact]
39+
public async Task OverridingAcceptWithVendorMimeAppendsCompatibleWith()
40+
{
41+
var requestInvoker = new TrackingRequestInvoker(responseMessage =>
42+
{
43+
AssertHeader(responseMessage, "Accept", DefaultVendorMime);
44+
AssertContentTypeHeader(responseMessage, DefaultVendorMime);
45+
});
46+
47+
await SendAsync(requestInvoker, new RequestConfiguration { Accept = "application/vnd.elasticsearch+json" });
48+
}
49+
50+
[Fact]
51+
public async Task OverridingAcceptWithExplicitCompatibleWithIsRespected()
52+
{
53+
var requestInvoker = new TrackingRequestInvoker(responseMessage =>
54+
{
55+
AssertHeader(responseMessage, "Accept", "application/vnd.elasticsearch+json;compatible-with=9");
56+
AssertContentTypeHeader(responseMessage, DefaultVendorMime);
57+
});
58+
59+
await SendAsync(requestInvoker, new RequestConfiguration { Accept = "application/vnd.elasticsearch+json;compatible-with=9" });
60+
}
61+
62+
[Fact]
63+
public async Task OverridingAcceptWithPlainJsonIsLeftUnchanged()
64+
{
65+
var requestInvoker = new TrackingRequestInvoker(responseMessage =>
66+
{
67+
AssertHeader(responseMessage, "Accept", "application/json");
68+
AssertContentTypeHeader(responseMessage, DefaultVendorMime);
69+
});
70+
71+
await SendAsync(requestInvoker, new RequestConfiguration { Accept = "application/json" });
72+
}
73+
74+
[Fact]
75+
public async Task OverridingAcceptWithTextPlainIsLeftUnchanged()
76+
{
77+
var requestInvoker = new TrackingRequestInvoker(responseMessage =>
78+
{
79+
AssertHeader(responseMessage, "Accept", "text/plain");
80+
AssertContentTypeHeader(responseMessage, DefaultVendorMime);
81+
});
82+
83+
await SendAsync(requestInvoker, new RequestConfiguration { Accept = "text/plain" });
84+
}
85+
86+
[Fact]
87+
public async Task OverridingContentTypeWithVendorMimeAppendsCompatibleWith()
88+
{
89+
var requestInvoker = new TrackingRequestInvoker(responseMessage =>
90+
{
91+
AssertHeader(responseMessage, "Accept", DefaultVendorMime);
92+
AssertContentTypeHeader(responseMessage, "application/vnd.elasticsearch+x-ndjson;compatible-with=8");
93+
});
94+
95+
await SendAsync(requestInvoker, new RequestConfiguration { ContentType = "application/vnd.elasticsearch+x-ndjson" });
96+
}
97+
98+
[Fact]
99+
public async Task WithoutClientMajorVersionDefaultsToBareVendorMime()
100+
{
101+
var requestInvoker = new TrackingRequestInvoker(responseMessage =>
102+
{
103+
AssertHeader(responseMessage, "Accept", "application/vnd.elasticsearch+json");
104+
AssertContentTypeHeader(responseMessage, "application/vnd.elasticsearch+json");
105+
});
106+
107+
await SendWithDefaultRegistrationAsync(requestInvoker, requestConfiguration: null);
108+
}
109+
110+
[Fact]
111+
public async Task WithoutClientMajorVersionHeaderOverridesAreNotTransformed()
112+
{
113+
var requestInvoker = new TrackingRequestInvoker(responseMessage =>
114+
{
115+
AssertHeader(responseMessage, "Accept", "application/vnd.elasticsearch+json");
116+
AssertContentTypeHeader(responseMessage, "application/vnd.elasticsearch+json");
117+
});
118+
119+
await SendWithDefaultRegistrationAsync(
120+
requestInvoker,
121+
new RequestConfiguration { Accept = "application/vnd.elasticsearch+json", ContentType = "application/vnd.elasticsearch+json" });
122+
}
123+
124+
private async Task SendWithDefaultRegistrationAsync(TrackingRequestInvoker requestInvoker, IRequestConfiguration requestConfiguration)
125+
{
126+
var nodePool = new SingleNodePool(Server.Uri);
127+
var config = new TransportConfiguration(nodePool, requestInvoker, productRegistration: ElasticsearchProductRegistration.Default);
128+
var transport = new DistributedTransport(config);
129+
130+
_ = await transport.RequestAsync<StringResponse>(
131+
new EndpointPath(HttpMethod.POST, "/metaheader"),
132+
PostData.String("{}"),
133+
default,
134+
requestConfiguration,
135+
TestContext.Current.CancellationToken);
136+
}
30137

31-
var contentTypeValues = responseMessage.RequestMessage.Content.Headers.GetValues("Content-Type");
32-
_ = contentTypeValues.Single().Replace(" ", "").Should().Be("application/vnd.elasticsearch+json;compatible-with=8");
138+
[Fact]
139+
public async Task MultiValueAcceptListAppendsCompatibleWithToEachVendorEntry()
140+
{
141+
var requestInvoker = new TrackingRequestInvoker(responseMessage =>
142+
{
143+
AssertHeader(
144+
responseMessage,
145+
"Accept",
146+
"application/vnd.elasticsearch+json;compatible-with=8,application/vnd.elasticsearch+x-ndjson;compatible-with=8");
147+
AssertContentTypeHeader(responseMessage, DefaultVendorMime);
33148
});
34149

150+
await SendAsync(
151+
requestInvoker,
152+
new RequestConfiguration { Accept = "application/vnd.elasticsearch+json,application/vnd.elasticsearch+x-ndjson" });
153+
}
154+
155+
private Task SendAsync(TrackingRequestInvoker requestInvoker) => SendAsync(requestInvoker, null);
156+
157+
private async Task SendAsync(TrackingRequestInvoker requestInvoker, IRequestConfiguration requestConfiguration)
158+
{
35159
var nodePool = new SingleNodePool(Server.Uri);
36160
var config = new TransportConfiguration(nodePool, requestInvoker, productRegistration: new ElasticsearchProductRegistration(typeof(Clients.Elasticsearch.ElasticsearchClient)));
37161
var transport = new DistributedTransport(config);
38162

39-
var response = await transport.PostAsync<StringResponse>("/metaheader", PostData.String("{}"), cancellationToken: TestContext.Current.CancellationToken);
163+
_ = await transport.RequestAsync<StringResponse>(
164+
new EndpointPath(HttpMethod.POST, "/metaheader"),
165+
PostData.String("{}"),
166+
default,
167+
requestConfiguration,
168+
TestContext.Current.CancellationToken);
169+
}
170+
171+
private static void AssertHeader(System.Net.Http.HttpResponseMessage responseMessage, string headerName, string expected)
172+
{
173+
var values = responseMessage.RequestMessage.Headers.GetValues(headerName);
174+
_ = string.Join(",", values).Replace(" ", "").Should().Be(expected);
175+
}
176+
177+
private static void AssertContentTypeHeader(System.Net.Http.HttpResponseMessage responseMessage, string expected)
178+
{
179+
var values = responseMessage.RequestMessage.Content.Headers.GetValues("Content-Type");
180+
_ = string.Join(",", values).Replace(" ", "").Should().Be(expected);
40181
}
41182
}
42183

0 commit comments

Comments
 (0)