From deef2eabbcd0c17a02ab59c114d7e96c4fee574c Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Wed, 6 Nov 2024 17:07:11 +0000 Subject: [PATCH 1/3] Refactor response handling and update project structure Refactored response handling to improve clarity and consistency: - Renamed `DefaultMimeType` to `DefaultContentType`. - Removed `CustomResponseBuilder` parameter from `RequestData` constructor. - Introduced `HttpRequestInvoker` class and refactored constructors. - Updated `RequestCoreAsync` to use `contentType` instead of `mimeType`. - Added `ResponseFactory` property to `IRequestInvoker` interface. - Refactored `DefaultResponseBuilder` to implement `IResponseBuilder`. - Simplified response deserialization logic. Updated project structure and configurations: - Added new project `Elastic.Transport.Tests.Shared`. - Updated solution file and project references. - Enabled nullable reference types in several files. - Removed `StreamResponseTests.cs` and `ResponseBuilderDisposeTests.cs`. - Added new test classes for `ResponseFactory`, `DynamicResponseBuilder`, and `BytesResponseBuilder`. Enhanced error handling and documentation: - Removed `EmptyError` class. - Updated `ApiCallDetails` to use nullable reference types. - Improved method signatures and internal logic for consistency. - Added license information to several files. --- Elastic.Transport.sln | 7 + Playground/Program.cs | 2 +- .../Components/ExposingPipelineFactory.cs | 3 +- .../Components/VirtualClusterConnection.cs | 7 +- .../Components/VirtualizedCluster.cs | 6 +- .../ElasticsearchVirtualCluster.cs | 6 +- .../Rules/RuleBase.cs | 4 +- .../Pipeline/DefaultRequestPipeline.cs | 13 +- .../Pipeline/DefaultResponseBuilder.cs | 286 +----- .../Components/Pipeline/RequestData.cs | 31 +- .../DefaultRequestPipelineFactory.cs | 2 +- .../HttpRequestInvoker-FullFramework.cs | 27 + .../TransportClient/HttpRequestInvoker.cs | 57 +- .../HttpTransportClient-FullFramework.cs | 13 - .../TransportClient/HttpWebRequestInvoker.cs | 43 +- .../TransportClient/IRequestInvoker.cs | 8 +- .../TransportClient/InMemoryRequestInvoker.cs | 26 +- .../Configuration/ITransportConfiguration.cs | 11 +- .../Configuration/TransportConfiguration.cs | 30 +- .../TransportConfigurationDescriptor.cs | 78 +- src/Elastic.Transport/DistributedTransport.cs | 34 +- .../Elastic.Transport.csproj | 4 +- src/Elastic.Transport/ITransport.cs | 31 +- .../ITransportHttpMethodExtensions.cs | 41 +- .../Products/DefaultProductRegistration.cs | 6 +- .../ElasticsearchErrorExtensions.cs | 6 +- .../ElasticsearchProductRegistration.cs | 10 +- .../ElasticsearchResponseBuilder.cs | 94 +- .../Products/ProductRegistration.cs | 10 +- .../Responses/BufferedResponseHelpers.cs | 24 + .../Responses/CustomResponseBuilder.cs | 22 - .../Responses/DefaultResponseFactory.cs | 152 +++ .../Dynamic/DynamicResponseBuilder.cs | 96 ++ src/Elastic.Transport/Responses/EmptyError.cs | 16 - .../Responses/HttpDetails/ApiCallDetails.cs | 61 +- .../Responses/IResponseBuilder.cs | 49 + .../ResponseFactory.cs} | 44 +- .../Responses/Special/BytesResponseBuilder.cs | 42 + .../Responses/Special/StreamResponse.cs | 8 +- .../Special/StreamResponseBuilder.cs | 19 + .../Special/StringResponseBuilder.cs | 75 ++ .../Responses/Special/VoidResponseBuilder.cs | 19 + .../Responses/TypedResponseBuilder.cs | 32 + .../Elastic.Transport.IntegrationTests.csproj | 1 + .../Http/StreamResponseTests.cs | 208 ---- .../Http/TransferEncodingChunckedTests.cs | 6 +- .../Plumbing/Stubs/TrackingRequestInvoker.cs | 3 + .../Plumbing/TransportTestServer.cs | 2 +- .../Responses/SpecialisedResponseTests.cs | 907 ++++++++++++++++++ .../Elastic.Transport.Tests.Shared.csproj | 15 + .../TrackDisposeStream.cs | 26 + .../TrackingMemoryStreamFactory.cs | 33 + .../CodeStandards/NamingConventions.doc.cs | 9 +- .../Elastic.Transport.Tests.csproj | 3 +- .../OpenTelemetryTests.cs | 2 +- .../Plumbing/InMemoryConnectionFactory.cs | 2 +- .../ResponseBuilderDisposeTests.cs | 228 ----- .../ResponseFactoryDisposeTests.cs | 150 +++ .../Dynamic/DynamicResponseBuilderTests.cs | 56 ++ .../Special/BytesResponseBuilderTests.cs | 80 ++ .../Special/StreamResponseBuilderTests.cs | 77 ++ .../Special/StringResponseBuilderTests.cs | 158 +++ .../Special/VoidResponseBuilderTests.cs | 52 + tests/Elastic.Transport.Tests/UsageTests.cs | 2 +- 64 files changed, 2514 insertions(+), 1061 deletions(-) create mode 100644 src/Elastic.Transport/Components/TransportClient/HttpRequestInvoker-FullFramework.cs delete mode 100644 src/Elastic.Transport/Components/TransportClient/HttpTransportClient-FullFramework.cs create mode 100644 src/Elastic.Transport/Responses/BufferedResponseHelpers.cs delete mode 100644 src/Elastic.Transport/Responses/CustomResponseBuilder.cs create mode 100644 src/Elastic.Transport/Responses/DefaultResponseFactory.cs create mode 100644 src/Elastic.Transport/Responses/Dynamic/DynamicResponseBuilder.cs delete mode 100644 src/Elastic.Transport/Responses/EmptyError.cs create mode 100644 src/Elastic.Transport/Responses/IResponseBuilder.cs rename src/Elastic.Transport/{Components/Pipeline/ResponseBuilder.cs => Responses/ResponseFactory.cs} (78%) create mode 100644 src/Elastic.Transport/Responses/Special/BytesResponseBuilder.cs create mode 100644 src/Elastic.Transport/Responses/Special/StreamResponseBuilder.cs create mode 100644 src/Elastic.Transport/Responses/Special/StringResponseBuilder.cs create mode 100644 src/Elastic.Transport/Responses/Special/VoidResponseBuilder.cs create mode 100644 src/Elastic.Transport/Responses/TypedResponseBuilder.cs delete mode 100644 tests/Elastic.Transport.IntegrationTests/Http/StreamResponseTests.cs create mode 100644 tests/Elastic.Transport.IntegrationTests/Responses/SpecialisedResponseTests.cs create mode 100644 tests/Elastic.Transport.Tests.Shared/Elastic.Transport.Tests.Shared.csproj create mode 100644 tests/Elastic.Transport.Tests.Shared/TrackDisposeStream.cs create mode 100644 tests/Elastic.Transport.Tests.Shared/TrackingMemoryStreamFactory.cs delete mode 100644 tests/Elastic.Transport.Tests/ResponseBuilderDisposeTests.cs create mode 100644 tests/Elastic.Transport.Tests/ResponseFactoryDisposeTests.cs create mode 100644 tests/Elastic.Transport.Tests/Responses/Dynamic/DynamicResponseBuilderTests.cs create mode 100644 tests/Elastic.Transport.Tests/Responses/Special/BytesResponseBuilderTests.cs create mode 100644 tests/Elastic.Transport.Tests/Responses/Special/StreamResponseBuilderTests.cs create mode 100644 tests/Elastic.Transport.Tests/Responses/Special/StringResponseBuilderTests.cs create mode 100644 tests/Elastic.Transport.Tests/Responses/Special/VoidResponseBuilderTests.cs diff --git a/Elastic.Transport.sln b/Elastic.Transport.sln index 13b40be..0f0a972 100644 --- a/Elastic.Transport.sln +++ b/Elastic.Transport.sln @@ -44,6 +44,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Playground", "Playground\Pl EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elastic.Elasticsearch.IntegrationTests", "tests\Elastic.Elasticsearch.IntegrationTests\Elastic.Elasticsearch.IntegrationTests.csproj", "{317C118F-FA1E-499A-B7F2-DC932DE66CB8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Transport.Tests.Shared", "tests\Elastic.Transport.Tests.Shared\Elastic.Transport.Tests.Shared.csproj", "{13A2597D-F50C-4D7F-ADA9-716991C8E9DE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -86,6 +88,10 @@ Global {317C118F-FA1E-499A-B7F2-DC932DE66CB8}.Debug|Any CPU.Build.0 = Debug|Any CPU {317C118F-FA1E-499A-B7F2-DC932DE66CB8}.Release|Any CPU.ActiveCfg = Release|Any CPU {317C118F-FA1E-499A-B7F2-DC932DE66CB8}.Release|Any CPU.Build.0 = Release|Any CPU + {13A2597D-F50C-4D7F-ADA9-716991C8E9DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {13A2597D-F50C-4D7F-ADA9-716991C8E9DE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {13A2597D-F50C-4D7F-ADA9-716991C8E9DE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {13A2597D-F50C-4D7F-ADA9-716991C8E9DE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -100,6 +106,7 @@ Global {ED4E89BE-FBE9-4876-979C-63A0E3BC5419} = {BBB0AC81-F09D-4895-84E2-7E933D608E78} {5EE4DC72-B337-448B-802A-6158F4D90667} = {7610B796-BB3E-4CB2-8296-79BBFF6D23FC} {317C118F-FA1E-499A-B7F2-DC932DE66CB8} = {3582B07D-C2B0-49CC-B676-EAF806EB010E} + {13A2597D-F50C-4D7F-ADA9-716991C8E9DE} = {3582B07D-C2B0-49CC-B676-EAF806EB010E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7F60C4BB-6216-4E50-B1E4-9C38EB484843} diff --git a/Playground/Program.cs b/Playground/Program.cs index 6683b41..2ad437e 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -6,4 +6,4 @@ var registration = new ElasticsearchProductRegistration(typeof(Elastic.Clients.Elasticsearch.ElasticsearchClient)); -Console.WriteLine(registration.DefaultMimeType ?? "NOT SPECIFIED"); +Console.WriteLine(registration.DefaultContentType ?? "NOT SPECIFIED"); diff --git a/src/Elastic.Transport.VirtualizedCluster/Components/ExposingPipelineFactory.cs b/src/Elastic.Transport.VirtualizedCluster/Components/ExposingPipelineFactory.cs index b2601fe..05a746d 100644 --- a/src/Elastic.Transport.VirtualizedCluster/Components/ExposingPipelineFactory.cs +++ b/src/Elastic.Transport.VirtualizedCluster/Components/ExposingPipelineFactory.cs @@ -15,7 +15,7 @@ public ExposingPipelineFactory(TConfiguration configuration, DateTimeProvider da { DateTimeProvider = dateTimeProvider; Configuration = configuration; - Pipeline = Create(new RequestData(Configuration, null, null), DateTimeProvider); + Pipeline = Create(new RequestData(Configuration, null), DateTimeProvider); RequestHandler = new DistributedTransport(Configuration, this, DateTimeProvider); } @@ -28,3 +28,4 @@ public ExposingPipelineFactory(TConfiguration configuration, DateTimeProvider da public override RequestPipeline Create(RequestData requestData, DateTimeProvider dateTimeProvider) => new DefaultRequestPipeline(requestData, DateTimeProvider); } +#nullable restore diff --git a/src/Elastic.Transport.VirtualizedCluster/Components/VirtualClusterConnection.cs b/src/Elastic.Transport.VirtualizedCluster/Components/VirtualClusterConnection.cs index 873c12e..00d92f1 100644 --- a/src/Elastic.Transport.VirtualizedCluster/Components/VirtualClusterConnection.cs +++ b/src/Elastic.Transport.VirtualizedCluster/Components/VirtualClusterConnection.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime; using System.Threading; using System.Threading.Tasks; using Elastic.Transport.VirtualizedCluster.Products; @@ -101,6 +102,8 @@ private static object DefaultResponse } } + public ResponseFactory ResponseFactory => _inMemoryRequestInvoker.ResponseFactory; + private void UpdateCluster(VirtualCluster cluster) { lock (Lock) @@ -109,7 +112,6 @@ private void UpdateCluster(VirtualCluster cluster) _calls = cluster.Nodes.ToDictionary(n => n.Uri.Port, v => new State()); _productRegistration = cluster.ProductRegistration; } - } private bool IsSniffRequest(Endpoint endpoint) => _productRegistration.IsSniffRequest(endpoint); @@ -173,7 +175,7 @@ public TResponse Request(Endpoint endpoint, RequestData requestData, } catch (TheException e) { - return requestData.ConnectionSettings.ProductRegistration.ResponseBuilder.ToResponse(endpoint, requestData, postData, e, null, null, Stream.Null, null, -1, null, null); + return ResponseFactory.Create(endpoint, requestData, postData, e, null, null, Stream.Null, null, -1, null, null); } } @@ -326,3 +328,4 @@ private class State public int Successes; } } +#nullable restore diff --git a/src/Elastic.Transport.VirtualizedCluster/Components/VirtualizedCluster.cs b/src/Elastic.Transport.VirtualizedCluster/Components/VirtualizedCluster.cs index 20ddf3f..20074fe 100644 --- a/src/Elastic.Transport.VirtualizedCluster/Components/VirtualizedCluster.cs +++ b/src/Elastic.Transport.VirtualizedCluster/Components/VirtualizedCluster.cs @@ -32,8 +32,7 @@ internal VirtualizedCluster(TestableDateTimeProvider dateTimeProvider, Transport path: RootPath, postData: PostData.Serializable(new { }), openTelemetryData: default, - localConfiguration: r?.Invoke(new RequestConfigurationDescriptor()), - responseBuilder: null + localConfiguration: r?.Invoke(new RequestConfigurationDescriptor()) ); _asyncCall = async (t, r) => { @@ -43,14 +42,13 @@ internal VirtualizedCluster(TestableDateTimeProvider dateTimeProvider, Transport postData: PostData.Serializable(new { }), openTelemetryData: default, localConfiguration: r?.Invoke(new RequestConfigurationDescriptor()), - responseBuilder: null, CancellationToken.None ).ConfigureAwait(false); return res; }; } - public VirtualClusterRequestInvoker Connection => RequestHandler.Configuration.Connection as VirtualClusterRequestInvoker; + public VirtualClusterRequestInvoker Connection => RequestHandler.Configuration.RequestInvoker as VirtualClusterRequestInvoker; public NodePool ConnectionPool => RequestHandler.Configuration.NodePool; public ITransport RequestHandler => _exposingRequestPipeline?.RequestHandler; diff --git a/src/Elastic.Transport.VirtualizedCluster/Products/Elasticsearch/ElasticsearchVirtualCluster.cs b/src/Elastic.Transport.VirtualizedCluster/Products/Elasticsearch/ElasticsearchVirtualCluster.cs index 021c060..d41c635 100644 --- a/src/Elastic.Transport.VirtualizedCluster/Products/Elasticsearch/ElasticsearchVirtualCluster.cs +++ b/src/Elastic.Transport.VirtualizedCluster/Products/Elasticsearch/ElasticsearchVirtualCluster.cs @@ -66,7 +66,7 @@ public ElasticsearchVirtualCluster MasterEligible(params int[] ports) foreach (var node in InternalNodes.Where(n => !ports.Contains(n.Uri.Port))) { var currentFeatures = node.Features.Count == 0 ? ElasticsearchNodeFeatures.Default : node.Features; - node.Features = currentFeatures.Except(new[] { ElasticsearchNodeFeatures.MasterEligible }).ToList().AsReadOnly(); + node.Features = currentFeatures.Except([ElasticsearchNodeFeatures.MasterEligible]).ToList().AsReadOnly(); } return this; } @@ -77,7 +77,7 @@ public ElasticsearchVirtualCluster StoresNoData(params int[] ports) foreach (var node in InternalNodes.Where(n => ports.Contains(n.Uri.Port))) { var currentFeatures = node.Features.Count == 0 ? ElasticsearchNodeFeatures.Default : node.Features; - node.Features = currentFeatures.Except(new[] { ElasticsearchNodeFeatures.HoldsData }).ToList().AsReadOnly(); + node.Features = currentFeatures.Except([ElasticsearchNodeFeatures.HoldsData]).ToList().AsReadOnly(); } return this; } @@ -88,7 +88,7 @@ public VirtualCluster HttpDisabled(params int[] ports) foreach (var node in InternalNodes.Where(n => ports.Contains(n.Uri.Port))) { var currentFeatures = node.Features.Count == 0 ? ElasticsearchNodeFeatures.Default : node.Features; - node.Features = currentFeatures.Except(new[] { ElasticsearchNodeFeatures.HttpEnabled }).ToList().AsReadOnly(); + node.Features = currentFeatures.Except([ElasticsearchNodeFeatures.HttpEnabled]).ToList().AsReadOnly(); } return this; } diff --git a/src/Elastic.Transport.VirtualizedCluster/Rules/RuleBase.cs b/src/Elastic.Transport.VirtualizedCluster/Rules/RuleBase.cs index c9e15a9..3fe8331 100644 --- a/src/Elastic.Transport.VirtualizedCluster/Rules/RuleBase.cs +++ b/src/Elastic.Transport.VirtualizedCluster/Rules/RuleBase.cs @@ -80,11 +80,11 @@ public TRule ReturnResponse(T response) r = ms.ToArray(); } Self.ReturnResponse = r; - Self.ReturnContentType = RequestData.DefaultMimeType; + Self.ReturnContentType = RequestData.DefaultContentType; return (TRule)this; } - public TRule ReturnByteResponse(byte[] response, string responseContentType = RequestData.DefaultMimeType) + public TRule ReturnByteResponse(byte[] response, string responseContentType = RequestData.DefaultContentType) { Self.ReturnResponse = response; Self.ReturnContentType = responseContentType; diff --git a/src/Elastic.Transport/Components/Pipeline/DefaultRequestPipeline.cs b/src/Elastic.Transport/Components/Pipeline/DefaultRequestPipeline.cs index 19b7d64..4b405a1 100644 --- a/src/Elastic.Transport/Components/Pipeline/DefaultRequestPipeline.cs +++ b/src/Elastic.Transport/Components/Pipeline/DefaultRequestPipeline.cs @@ -25,8 +25,6 @@ public class DefaultRequestPipeline : RequestPipeline private readonly MemoryStreamFactory _memoryStreamFactory; private readonly Func _nodePredicate; private readonly ProductRegistration _productRegistration; - private readonly ResponseBuilder _responseBuilder; - private RequestConfiguration? _pingAndSniffRequestConfiguration; private List? _auditTrail; private readonly ITransportConfiguration _settings; @@ -36,13 +34,11 @@ internal DefaultRequestPipeline(RequestData requestData, DateTimeProvider dateTi { _requestData = requestData; _settings = requestData.ConnectionSettings; - _nodePool = requestData.ConnectionSettings.NodePool; - _requestInvoker = requestData.ConnectionSettings.Connection; + _requestInvoker = requestData.ConnectionSettings.RequestInvoker; _dateTimeProvider = dateTimeProvider; _memoryStreamFactory = requestData.MemoryStreamFactory; _productRegistration = requestData.ConnectionSettings.ProductRegistration; - _responseBuilder = _productRegistration.ResponseBuilder; _nodePredicate = requestData.ConnectionSettings.NodePredicate ?? _productRegistration.NodePredicate; StartedOn = dateTimeProvider.Now(); @@ -148,8 +144,8 @@ public override void BadResponse(ref TResponse response, ApiCallDetai { //make sure we copy over the error body in case we disabled direct streaming. var s = callDetails?.ResponseBodyInBytes == null ? Stream.Null : _memoryStreamFactory.Create(callDetails.ResponseBodyInBytes); - var m = callDetails?.ResponseMimeType ?? RequestData.DefaultMimeType; - response = _responseBuilder.ToResponse(endpoint, data, postData, exception, callDetails?.HttpStatusCode, null, s, m, callDetails?.ResponseBodyInBytes?.Length ?? -1, null, null); + var m = callDetails?.ResponseContentType ?? RequestData.DefaultContentType; + response = _requestInvoker.ResponseFactory.Create(endpoint, data, postData, exception, callDetails?.HttpStatusCode, null, s, m, callDetails?.ResponseBodyInBytes?.Length ?? -1, null, null); } response.ApiCallDetails.AuditTrail = AuditTrail; @@ -447,8 +443,9 @@ public async ValueTask SniffCoreAsync(bool isAsync, CancellationToken cancellati foreach (var node in SniffNodes) { var sniffEndpoint = _productRegistration.CreateSniffEndpoint(node, PingAndSniffRequestConfiguration, _settings); + //TODO remove - var requestData = new RequestData(_settings, null, null); + var requestData = new RequestData(_settings, null); using var audit = Audit(SniffSuccess, node); diff --git a/src/Elastic.Transport/Components/Pipeline/DefaultResponseBuilder.cs b/src/Elastic.Transport/Components/Pipeline/DefaultResponseBuilder.cs index 88c8225..ab5193a 100644 --- a/src/Elastic.Transport/Components/Pipeline/DefaultResponseBuilder.cs +++ b/src/Elastic.Transport/Components/Pipeline/DefaultResponseBuilder.cs @@ -3,318 +3,68 @@ // See the LICENSE file in the project root for more information using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Diagnostics; using System.IO; using System.Linq; -using System.Net.NetworkInformation; using System.Text.Json; using System.Threading; using System.Threading.Tasks; - using Elastic.Transport.Diagnostics; -using Elastic.Transport.Extensions; - -using static Elastic.Transport.ResponseBuilderDefaults; namespace Elastic.Transport; -internal static class ResponseBuilderDefaults -{ - public const int BufferSize = 81920; - - public static readonly Type[] SpecialTypes = - { - typeof(StringResponse), typeof(BytesResponse), typeof(VoidResponse), typeof(DynamicResponse), typeof(StreamResponse) - }; -} - /// -/// A helper class that deals with handling how a is transformed to the requested -/// implementation. This includes handling optionally buffering based on -/// . And handling short circuiting special responses -/// such as , and +/// Attempts to build a response for any type by deserializing the response stream. /// -internal class DefaultResponseBuilder : ResponseBuilder where TError : ErrorResponse, new() +internal sealed class DefaultResponseBuilder : IResponseBuilder { - private readonly bool _isEmptyError; - - public DefaultResponseBuilder() => _isEmptyError = typeof(TError) == typeof(EmptyError); - - /// - /// Create an instance of from - /// - public override TResponse ToResponse( - Endpoint endpoint, - RequestData requestData, - PostData postData, - Exception ex, - int? statusCode, - Dictionary> headers, - Stream responseStream, - string mimeType, - long contentLength, - IReadOnlyDictionary threadPoolStats, - IReadOnlyDictionary tcpStats - ) - { - responseStream.ThrowIfNull(nameof(responseStream)); - - var details = Initialize(endpoint, requestData, postData, ex, statusCode, headers, mimeType, threadPoolStats, tcpStats, contentLength); - - TResponse response = null; - - // Only attempt to set the body if the response may have content - if (MayHaveBody(statusCode, endpoint.Method, contentLength)) - response = SetBody(details, requestData, responseStream, mimeType); - - response ??= new TResponse(); - response.ApiCallDetails = details; - return response; - } - - /// - /// Create an instance of from - /// - public override async Task ToResponseAsync( - Endpoint endpoint, - RequestData requestData, - PostData postData, - Exception ex, - int? statusCode, - Dictionary> headers, - Stream responseStream, - string mimeType, - long contentLength, - IReadOnlyDictionary threadPoolStats, - IReadOnlyDictionary tcpStats, - CancellationToken cancellationToken = default - ) - { - responseStream.ThrowIfNull(nameof(responseStream)); - - var details = Initialize(endpoint, requestData, postData, ex, statusCode, headers, mimeType, threadPoolStats, tcpStats, contentLength); - - TResponse response = null; - - // Only attempt to set the body if the response may have content - if (MayHaveBody(statusCode, endpoint.Method, contentLength)) - response = await SetBodyAsync(details, requestData, responseStream, mimeType, - cancellationToken).ConfigureAwait(false); - - response ??= new TResponse(); - response.ApiCallDetails = details; - return response; - } - - - /// - /// - /// - /// - /// - /// - protected virtual bool RequiresErrorDeserialization(ApiCallDetails details, RequestData requestData) => false; - - /// - /// - /// - /// - /// - /// - /// - /// - protected virtual bool TryGetError(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, out TError? error) - { - if (!_isEmptyError) - { - error = null; - return false; - } + /// + bool IResponseBuilder.CanBuild() => true; - error = EmptyError.Instance as TError; - - return error is not null; - } - - /// - /// - /// - /// - /// - /// - protected virtual void SetErrorOnResponse(TResponse response, TError error) where TResponse : TransportResponse, new() { } - - private TResponse SetBody(ApiCallDetails details, RequestData requestData, - Stream responseStream, string mimeType) + /// + public TResponse Build(ApiCallDetails apiCallDetails, RequestData requestData, + Stream responseStream, string contentType, long contentLength) where TResponse : TransportResponse, new() => - SetBodyCoreAsync(false, details, requestData, responseStream, mimeType).EnsureCompleted(); + SetBodyCoreAsync(false, apiCallDetails, requestData, responseStream).EnsureCompleted(); - private Task SetBodyAsync( - ApiCallDetails details, RequestData requestData, Stream responseStream, string mimeType, + /// + public Task BuildAsync( + ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, string contentType, long contentLength, CancellationToken cancellationToken) where TResponse : TransportResponse, new() => - SetBodyCoreAsync(true, details, requestData, responseStream, mimeType, cancellationToken).AsTask(); + SetBodyCoreAsync(true, apiCallDetails, requestData, responseStream, cancellationToken).AsTask(); - private async ValueTask SetBodyCoreAsync(bool isAsync, - ApiCallDetails details, RequestData requestData, Stream responseStream, string mimeType, + private static async ValueTask SetBodyCoreAsync(bool isAsync, + ApiCallDetails details, RequestData requestData, Stream responseStream, CancellationToken cancellationToken = default) where TResponse : TransportResponse, new() { - byte[] bytes = null; - var disableDirectStreaming = requestData.DisableDirectStreaming; - var requiresErrorDeserialization = RequiresErrorDeserialization(details, requestData); - - var ownsStream = false; - - if (disableDirectStreaming || NeedsToEagerReadStream() || requiresErrorDeserialization) - { - var inMemoryStream = requestData.MemoryStreamFactory.Create(); - - if (isAsync) - await responseStream.CopyToAsync(inMemoryStream, BufferSize, cancellationToken).ConfigureAwait(false); - else - responseStream.CopyTo(inMemoryStream, BufferSize); - - bytes = SwapStreams(ref responseStream, ref inMemoryStream); - ownsStream = true; - details.ResponseBodyInBytes = bytes; - } - - if (TrySetSpecialType(mimeType, bytes, responseStream, requestData.MemoryStreamFactory, out var response)) - { - ConditionalDisposal(responseStream, ownsStream, response); - return response; - } + TResponse response = null; if (details.HttpStatusCode.HasValue && requestData.SkipDeserializationForStatusCodes.Contains(details.HttpStatusCode.Value)) { - ConditionalDisposal(responseStream, ownsStream, response); - return null; - } - - var serializer = requestData.ConnectionSettings.RequestResponseSerializer; - - if (requestData.CustomResponseBuilder != null) - { - var beforeTicks = Stopwatch.GetTimestamp(); - - if (isAsync) - response = await requestData.CustomResponseBuilder - .DeserializeResponseAsync(serializer, details, responseStream, cancellationToken) - .ConfigureAwait(false) as TResponse; - else - response = requestData.CustomResponseBuilder - .DeserializeResponse(serializer, details, responseStream) as TResponse; - - var deserializeResponseMs = (Stopwatch.GetTimestamp() - beforeTicks) / (Stopwatch.Frequency / 1000); - if (deserializeResponseMs > OpenTelemetry.MinimumMillisecondsToEmitTimingSpanAttribute && OpenTelemetry.CurrentSpanIsElasticTransportOwnedHasListenersAndAllDataRequested) - Activity.Current?.SetTag(OpenTelemetryAttributes.ElasticTransportDeserializeResponseMs, deserializeResponseMs); - - ConditionalDisposal(responseStream, ownsStream, response); return response; } - // TODO: Handle empty data in a nicer way as throwing exceptions has a cost we'd like to avoid! - // ie. check content-length (add to ApiCallDetails)? Content-length cannot be retrieved from a GZip content stream which is annoying. try { - if (requiresErrorDeserialization && TryGetError(details, requestData, responseStream, out var error) && error.HasError()) - { - response = new TResponse(); - SetErrorOnResponse(response, error); - ConditionalDisposal(responseStream, ownsStream, response); - return response; - } - - if (!ValidateResponseContentType(requestData.Accept, mimeType)) - { - ConditionalDisposal(responseStream, ownsStream, response); - return default; - } - var beforeTicks = Stopwatch.GetTimestamp(); if (isAsync) - response = await serializer.DeserializeAsync(responseStream, cancellationToken).ConfigureAwait(false); + response = await requestData.ConnectionSettings.RequestResponseSerializer.DeserializeAsync(responseStream, cancellationToken).ConfigureAwait(false); else - response = serializer.Deserialize(responseStream); + response = requestData.ConnectionSettings.RequestResponseSerializer.Deserialize(responseStream); var deserializeResponseMs = (Stopwatch.GetTimestamp() - beforeTicks) / (Stopwatch.Frequency / 1000); if (deserializeResponseMs > OpenTelemetry.MinimumMillisecondsToEmitTimingSpanAttribute && OpenTelemetry.CurrentSpanIsElasticTransportOwnedHasListenersAndAllDataRequested) Activity.Current?.SetTag(OpenTelemetryAttributes.ElasticTransportDeserializeResponseMs, deserializeResponseMs); - ConditionalDisposal(responseStream, ownsStream, response); return response; } catch (JsonException ex) when (ex.Message.Contains("The input does not contain any JSON tokens")) { - // Note the exception this handles is ONLY thrown after a check if the stream length is zero. - // When the length is zero, `default` is returned by Deserialize(Async) instead. - ConditionalDisposal(responseStream, ownsStream, response); - return default; - } - - static void ConditionalDisposal(Stream responseStream, bool ownsStream, TResponse response) - { - // We only dispose of the responseStream if we created it (i.e. it is a MemoryStream) we - // created via MemoryStreamFactory. - if (ownsStream && (response is null || !response.LeaveOpen)) - responseStream.Dispose(); - } - } - - private static bool TrySetSpecialType(string mimeType, byte[] bytes, Stream responseStream, - MemoryStreamFactory memoryStreamFactory, out TResponse response) - where TResponse : TransportResponse, new() - { - response = null; - var responseType = typeof(TResponse); - if (!SpecialTypes.Contains(responseType)) return false; - - if (responseType == typeof(StringResponse)) - response = new StringResponse(bytes.Utf8String()) as TResponse; - else if (responseType == typeof(StreamResponse)) - response = new StreamResponse(responseStream, mimeType) as TResponse; - else if (responseType == typeof(BytesResponse)) - response = new BytesResponse(bytes) as TResponse; - else if (responseType == typeof(VoidResponse)) - response = VoidResponse.Default as TResponse; - else if (responseType == typeof(DynamicResponse)) - { - //if not json store the result under "body" - if (mimeType == null || !mimeType.StartsWith(RequestData.DefaultMimeType)) - { - var dictionary = new DynamicDictionary - { - ["body"] = new DynamicValue(bytes.Utf8String()) - }; - response = new DynamicResponse(dictionary) as TResponse; - } - else - { - using var ms = memoryStreamFactory.Create(bytes); - var body = LowLevelRequestResponseSerializer.Instance.Deserialize(ms); - response = new DynamicResponse(body) as TResponse; - } + return response; } - - return response != null; - } - - private static bool NeedsToEagerReadStream() - where TResponse : TransportResponse, new() => - typeof(TResponse) == typeof(StringResponse) - || typeof(TResponse) == typeof(BytesResponse) - || typeof(TResponse) == typeof(DynamicResponse); - - private static byte[] SwapStreams(ref Stream responseStream, ref MemoryStream ms) - { - var bytes = ms.ToArray(); - responseStream = ms; - responseStream.Position = 0; - return bytes; } } diff --git a/src/Elastic.Transport/Components/Pipeline/RequestData.cs b/src/Elastic.Transport/Components/Pipeline/RequestData.cs index 6cb1b50..44792e7 100644 --- a/src/Elastic.Transport/Components/Pipeline/RequestData.cs +++ b/src/Elastic.Transport/Components/Pipeline/RequestData.cs @@ -5,10 +5,7 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; -using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -using Elastic.Transport.Diagnostics; -using Elastic.Transport.Diagnostics.Auditing; using Elastic.Transport.Extensions; namespace Elastic.Transport; @@ -25,18 +22,16 @@ public sealed record RequestData private const string OpaqueIdHeader = "X-Opaque-Id"; /// The default MIME type used for request and response payloads. - public const string DefaultMimeType = "application/json"; + public const string DefaultContentType = "application/json"; /// The security header used to run requests as a different user. public const string RunAsSecurityHeader = "es-security-runas-user"; /// - public RequestData(ITransportConfiguration global, IRequestConfiguration? local = null, CustomResponseBuilder? customResponseBuilder = null) + public RequestData(ITransportConfiguration global, IRequestConfiguration? local = null) { - CustomResponseBuilder = customResponseBuilder; ConnectionSettings = global; MemoryStreamFactory = global.MemoryStreamFactory; - SkipDeserializationForStatusCodes = global.SkipDeserializationForStatusCodes ?? []; DnsRefreshTimeout = global.DnsRefreshTimeout; MetaHeaderProvider = global.MetaHeaderProvider; @@ -47,21 +42,17 @@ public RequestData(ITransportConfiguration global, IRequestConfiguration? local UserAgent = global.UserAgent; KeepAliveInterval = (int)(global.KeepAliveInterval?.TotalMilliseconds ?? 2000); KeepAliveTime = (int)(global.KeepAliveTime?.TotalMilliseconds ?? 2000); - RunAs = local?.RunAs ?? global.RunAs; - DisableDirectStreaming = local?.DisableDirectStreaming ?? global.DisableDirectStreaming ?? false; - ForceNode = global.ForceNode ?? local?.ForceNode; MaxRetries = ForceNode != null ? 0 : Math.Min(global.MaxRetries.GetValueOrDefault(int.MaxValue), global.NodePool.MaxRetries); DisableSniff = global.DisableSniff ?? local?.DisableSniff ?? false; DisablePings = global.DisablePings ?? !global.NodePool.SupportsPinging; - HttpPipeliningEnabled = local?.HttpPipeliningEnabled ?? global.HttpPipeliningEnabled ?? true; HttpCompression = global.EnableHttpCompression ?? local?.EnableHttpCompression ?? true; - ContentType = local?.ContentType ?? global.Accept ?? DefaultMimeType; - Accept = local?.Accept ?? global.Accept ?? DefaultMimeType; + ContentType = local?.ContentType ?? global.Accept ?? DefaultContentType; + Accept = local?.Accept ?? global.Accept ?? DefaultContentType; ThrowExceptions = local?.ThrowExceptions ?? global.ThrowExceptions ?? false; RequestTimeout = local?.RequestTimeout ?? global.RequestTimeout ?? RequestConfiguration.DefaultRequestTimeout; RequestMetaData = local?.RequestMetaData?.Items ?? EmptyReadOnly.Dictionary; @@ -85,17 +76,16 @@ public RequestData(ITransportConfiguration global, IRequestConfiguration? local if (local?.Headers != null) { - Headers ??= new NameValueCollection(); + Headers ??= []; foreach (var key in local.Headers.AllKeys) Headers[key] = local.Headers[key]; } if (!string.IsNullOrEmpty(local?.OpaqueId)) { - Headers ??= new NameValueCollection(); + Headers ??= []; Headers.Add(OpaqueIdHeader, local.OpaqueId); } - } /// @@ -120,11 +110,8 @@ public RequestData(ITransportConfiguration global, IRequestConfiguration? local public UserAgent UserAgent { get; } /// public TimeSpan DnsRefreshTimeout { get; } - - /// public IReadOnlyDictionary RequestMetaData { get; } - /// public string Accept { get; } /// @@ -135,8 +122,6 @@ public RequestData(ITransportConfiguration global, IRequestConfiguration? local public X509CertificateCollection? ClientCertificates { get; } /// public ITransportConfiguration ConnectionSettings { get; } - /// - public CustomResponseBuilder? CustomResponseBuilder { get; } /// public HeadersList? ResponseHeadersToParse { get; } /// @@ -167,12 +152,10 @@ public RequestData(ITransportConfiguration global, IRequestConfiguration? local public bool EnableTcpStats { get; } /// public bool EnableThreadPoolStats { get; } - /// public int MaxRetries { get; } /// public bool DisableSniff { get; } /// - public bool DisablePings { get; } - + public bool DisablePings { get; } } diff --git a/src/Elastic.Transport/Components/Providers/DefaultRequestPipelineFactory.cs b/src/Elastic.Transport/Components/Providers/DefaultRequestPipelineFactory.cs index 0d137b3..f9be33b 100644 --- a/src/Elastic.Transport/Components/Providers/DefaultRequestPipelineFactory.cs +++ b/src/Elastic.Transport/Components/Providers/DefaultRequestPipelineFactory.cs @@ -13,5 +13,5 @@ internal sealed class DefaultRequestPipelineFactory : RequestPipelineFactory /// returns instances of /// public override RequestPipeline Create(RequestData requestData, DateTimeProvider dateTimeProvider) => - new DefaultRequestPipeline(requestData, dateTimeProvider); + new DefaultRequestPipeline(requestData, dateTimeProvider); } diff --git a/src/Elastic.Transport/Components/TransportClient/HttpRequestInvoker-FullFramework.cs b/src/Elastic.Transport/Components/TransportClient/HttpRequestInvoker-FullFramework.cs new file mode 100644 index 0000000..68c7d05 --- /dev/null +++ b/src/Elastic.Transport/Components/TransportClient/HttpRequestInvoker-FullFramework.cs @@ -0,0 +1,27 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +#if NETFRAMEWORK +using System.Net; + +namespace Elastic.Transport; + +/// The default implementation. Uses on the current .NET desktop framework. +public class HttpRequestInvoker : HttpWebRequestInvoker +{ + /// + /// Create a new instance of the . + /// + public HttpRequestInvoker() : base() { } + + /// + /// Create a new instance of the . + /// + /// The from which response builders can be loaded. + public HttpRequestInvoker(ITransportConfiguration transportConfiguration) : base(transportConfiguration) { } + + /// The default TransportClient implementation. Uses on the current .NET desktop framework. + internal HttpRequestInvoker(ResponseFactory responseFactory) : base(responseFactory) { } +} +#endif diff --git a/src/Elastic.Transport/Components/TransportClient/HttpRequestInvoker.cs b/src/Elastic.Transport/Components/TransportClient/HttpRequestInvoker.cs index 5593e1a..c6ec90c 100644 --- a/src/Elastic.Transport/Components/TransportClient/HttpRequestInvoker.cs +++ b/src/Elastic.Transport/Components/TransportClient/HttpRequestInvoker.cs @@ -22,7 +22,9 @@ namespace Elastic.Transport; -/// The default TransportClient implementation. Uses . +/// +/// The default implementation. Uses to make requests. +/// public class HttpRequestInvoker : IRequestInvoker { private static readonly string MissingConnectionLimitMethodError = @@ -32,18 +34,49 @@ public class HttpRequestInvoker : IRequestInvoker private string _expectedCertificateFingerprint; - /// - public HttpRequestInvoker() => HttpClientFactory = new RequestDataHttpClientFactory(r => CreateHttpClientHandler(r)); + /// + /// Create a new instance of the . + /// + public HttpRequestInvoker() : this(new TransportConfiguration()) { } + + /// + /// Create a new instance of the . + /// + /// The from which response builders can be loaded. + public HttpRequestInvoker(ITransportConfiguration transportConfiguration) : + this(new DefaultResponseFactory(transportConfiguration)) { } + + internal HttpRequestInvoker(ResponseFactory responseFactory) + { + ResponseFactory = responseFactory; + HttpClientFactory = new RequestDataHttpClientFactory(CreateHttpClientHandler); + } /// - /// Allows users to inject their own HttpMessageHandler, and optionally call our default implementation + /// Allows consumers to inject their own HttpMessageHandler, and optionally call our default implementation. /// - public HttpRequestInvoker(Func wrappingHandler) => + public HttpRequestInvoker(Func wrappingHandler) : + this(wrappingHandler, new DefaultResponseFactory(new TransportConfiguration())) { } + + /// + /// Allows consumers to inject their own HttpMessageHandler, and optionally call our default implementation. + /// + public HttpRequestInvoker(Func wrappingHandler, ITransportConfiguration transportConfiguration) : + this(wrappingHandler, new DefaultResponseFactory(transportConfiguration)) + { } + + internal HttpRequestInvoker(Func wrappingHandler, ResponseFactory responseFactory) + { + ResponseFactory = responseFactory; HttpClientFactory = new RequestDataHttpClientFactory(r => { var defaultHandler = CreateHttpClientHandler(r); return wrappingHandler(defaultHandler, r) ?? defaultHandler; }); + } + + /// + public ResponseFactory ResponseFactory { get; } /// public int InUseHandlers => HttpClientFactory.InUseHandlers; @@ -73,7 +106,7 @@ private async ValueTask RequestCoreAsync(bool isAsync, End int? statusCode = null; Stream responseStream = null; Exception ex = null; - string mimeType = null; + string contentType = null; long contentLength = -1; IDisposable receivedResponse = DiagnosticSources.SingletonDisposable; ReadOnlyDictionary tcpStats = null; @@ -121,7 +154,7 @@ private async ValueTask RequestCoreAsync(bool isAsync, End statusCode = (int)responseMessage.StatusCode; } - mimeType = responseMessage.Content.Headers.ContentType?.ToString(); + contentType = responseMessage.Content.Headers.ContentType?.ToString(); responseHeaders = ParseHeaders(requestData, responseMessage); if (responseMessage.Content != null) @@ -140,7 +173,7 @@ private async ValueTask RequestCoreAsync(bool isAsync, End #endif } - // We often won't have the content length as responses are GZip compressed and the HttpContent ditches this when AutomaticDecompression is enabled. + // We often won't have the content length as most responses are GZip compressed and the HttpContent ditches this when AutomaticDecompression is enabled. contentLength = responseMessage.Content.Headers.ContentLength ?? -1; } catch (TaskCanceledException e) @@ -157,12 +190,12 @@ private async ValueTask RequestCoreAsync(bool isAsync, End try { if (isAsync) - response = await requestData.ConnectionSettings.ProductRegistration.ResponseBuilder.ToResponseAsync - (endpoint, requestData, postData, ex, statusCode, responseHeaders, responseStream, mimeType, contentLength, threadPoolStats, tcpStats, cancellationToken) + response = await ResponseFactory.CreateAsync + (endpoint, requestData, postData, ex, statusCode, responseHeaders, responseStream, contentType, contentLength, threadPoolStats, tcpStats, cancellationToken) .ConfigureAwait(false); else - response = requestData.ConnectionSettings.ProductRegistration.ResponseBuilder.ToResponse - (endpoint, requestData, postData, ex, statusCode, responseHeaders, responseStream, mimeType, contentLength, threadPoolStats, tcpStats); + response = ResponseFactory.Create + (endpoint, requestData, postData, ex, statusCode, responseHeaders, responseStream, contentType, contentLength, threadPoolStats, tcpStats); // Unless indicated otherwise by the TransportResponse, we've now handled the response stream, so we can dispose of the HttpResponseMessage // to release the connection. In cases, where the derived response works directly on the stream, it can be left open and additional IDisposable diff --git a/src/Elastic.Transport/Components/TransportClient/HttpTransportClient-FullFramework.cs b/src/Elastic.Transport/Components/TransportClient/HttpTransportClient-FullFramework.cs deleted file mode 100644 index 06d8344..0000000 --- a/src/Elastic.Transport/Components/TransportClient/HttpTransportClient-FullFramework.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -#if NETFRAMEWORK -using System.Net; - -namespace Elastic.Transport -{ - /// The default TransportClient implementation. Uses on the current .NET desktop framework. - public class HttpRequestInvoker : HttpWebRequestInvoker { } -} -#endif diff --git a/src/Elastic.Transport/Components/TransportClient/HttpWebRequestInvoker.cs b/src/Elastic.Transport/Components/TransportClient/HttpWebRequestInvoker.cs index 6a10c5c..98eb2ff 100644 --- a/src/Elastic.Transport/Components/TransportClient/HttpWebRequestInvoker.cs +++ b/src/Elastic.Transport/Components/TransportClient/HttpWebRequestInvoker.cs @@ -42,8 +42,23 @@ static HttpWebRequestInvoker() if (!IsMono) HttpWebRequest.DefaultMaximumErrorResponseLength = -1; } - /// > - public HttpWebRequestInvoker() { } + /// + /// Create a new instance of the . + /// + public HttpWebRequestInvoker() : this(new TransportConfiguration()) { } + + /// + /// Create a new instance of the . + /// + /// The from which response builders can be loaded. + public HttpWebRequestInvoker(ITransportConfiguration transportConfiguration) : + this(new DefaultResponseFactory(transportConfiguration)) + { } + + internal HttpWebRequestInvoker(ResponseFactory responseFactory) => ResponseFactory = responseFactory; + + /// + public ResponseFactory ResponseFactory { get; } internal static bool IsMono { get; } = Type.GetType("Mono.Runtime") != null; @@ -66,7 +81,7 @@ private async ValueTask RequestCoreAsync(bool isAsync, End int? statusCode = null; Stream responseStream = null; Exception ex = null; - string mimeType = null; + string contentType = null; long contentLength = -1; IDisposable receivedResponse = DiagnosticSources.SingletonDisposable; ReadOnlyDictionary tcpStats = null; @@ -146,7 +161,7 @@ private async ValueTask RequestCoreAsync(bool isAsync, End receivedResponse = httpWebResponse; - HandleResponse(httpWebResponse, out statusCode, out responseStream, out mimeType); + HandleResponse(httpWebResponse, out statusCode, out responseStream, out contentType); responseHeaders = ParseHeaders(requestData, httpWebResponse, responseHeaders); contentLength = httpWebResponse.ContentLength; } @@ -155,7 +170,7 @@ private async ValueTask RequestCoreAsync(bool isAsync, End { ex = e; if (e.Response is HttpWebResponse httpWebResponse) - HandleResponse(httpWebResponse, out statusCode, out responseStream, out mimeType); + HandleResponse(httpWebResponse, out statusCode, out responseStream, out contentType); } finally { @@ -167,12 +182,12 @@ private async ValueTask RequestCoreAsync(bool isAsync, End TResponse response; if (isAsync) - response = await requestData.ConnectionSettings.ProductRegistration.ResponseBuilder.ToResponseAsync - (endpoint, requestData, postData, ex, statusCode, responseHeaders, responseStream, mimeType, contentLength, threadPoolStats, tcpStats, cancellationToken) + response = await ResponseFactory.CreateAsync + (endpoint, requestData, postData, ex, statusCode, responseHeaders, responseStream, contentType, contentLength, threadPoolStats, tcpStats, cancellationToken) .ConfigureAwait(false); else - response = requestData.ConnectionSettings.ProductRegistration.ResponseBuilder.ToResponse - (endpoint, requestData, postData, ex, statusCode, responseHeaders, responseStream, mimeType, contentLength, threadPoolStats, tcpStats); + response = ResponseFactory.Create + (endpoint, requestData, postData, ex, statusCode, responseHeaders, responseStream, contentType, contentLength, threadPoolStats, tcpStats); // Unless indicated otherwise by the TransportResponse, we've now handled the response stream, so we can dispose of the HttpResponseMessage // to release the connection. In cases, where the derived response works directly on the stream, it can be left open and additional IDisposable @@ -473,14 +488,14 @@ private static void TimeoutCallback(object state, bool timedOut) (state as WebRequest)?.Abort(); } - private static void HandleResponse(HttpWebResponse response, out int? statusCode, out Stream responseStream, out string mimeType) + private static void HandleResponse(HttpWebResponse response, out int? statusCode, out Stream responseStream, out string contentType) { statusCode = (int)response.StatusCode; responseStream = response.GetResponseStream(); - mimeType = response.ContentType; - // https://github.com/elastic/elasticsearch-net/issues/2311 - // if stream is null call dispose on response instead. - if (responseStream == null || responseStream == Stream.Null) response.Dispose(); + contentType = response.ContentType; + + if (responseStream == null || responseStream == Stream.Null) + response.Dispose(); } } diff --git a/src/Elastic.Transport/Components/TransportClient/IRequestInvoker.cs b/src/Elastic.Transport/Components/TransportClient/IRequestInvoker.cs index aeced28..a842a64 100644 --- a/src/Elastic.Transport/Components/TransportClient/IRequestInvoker.cs +++ b/src/Elastic.Transport/Components/TransportClient/IRequestInvoker.cs @@ -12,10 +12,15 @@ namespace Elastic.Transport; /// This interface abstracts the actual IO performs. /// holds a single instance of this class /// The instance to be used is provided to the constructor of implementations -/// Where its exposed under +/// Where its exposed under /// public interface IRequestInvoker : IDisposable { + /// + /// Exposes the used by the . + /// + public ResponseFactory ResponseFactory { get; } + /// /// Perform a request to the endpoint described by using its associated configuration. /// @@ -54,5 +59,4 @@ public Task RequestAsync(Endpoint endpoint, RequestData re /// public TResponse Request(Endpoint endpoint, RequestData requestData, PostData? postData) where TResponse : TransportResponse, new(); - } diff --git a/src/Elastic.Transport/Components/TransportClient/InMemoryRequestInvoker.cs b/src/Elastic.Transport/Components/TransportClient/InMemoryRequestInvoker.cs index 3154537..c487d35 100644 --- a/src/Elastic.Transport/Components/TransportClient/InMemoryRequestInvoker.cs +++ b/src/Elastic.Transport/Components/TransportClient/InMemoryRequestInvoker.cs @@ -9,6 +9,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using Elastic.Transport.Products; namespace Elastic.Transport; @@ -28,18 +29,32 @@ public class InMemoryRequestInvoker : IRequestInvoker /// Every request will succeed with this overload, note that it won't actually return mocked responses /// so using this overload might fail if you are using it to test high level bits that need to deserialize the response. /// - public InMemoryRequestInvoker() => _statusCode = 200; + public InMemoryRequestInvoker() : this(null) { } + + /// + public InMemoryRequestInvoker(ProductRegistration? productRegistration) + { + _statusCode = 200; + + productRegistration ??= DefaultProductRegistration.Default; + ResponseFactory = new DefaultResponseFactory(new TransportConfiguration(null, productRegistration)); + } /// - public InMemoryRequestInvoker(byte[] responseBody, int statusCode = 200, Exception? exception = null, string contentType = RequestData.DefaultMimeType, Dictionary> headers = null) + public InMemoryRequestInvoker(byte[] responseBody, int statusCode = 200, Exception? exception = null, string contentType = RequestData.DefaultContentType, Dictionary> headers = null) { _responseBody = responseBody; _statusCode = statusCode; _exception = exception; _contentType = contentType; _headers = headers; + + ResponseFactory = new DefaultResponseFactory(new TransportConfiguration(null, DefaultProductRegistration.Default)); } + /// + public ResponseFactory ResponseFactory { get; } + void IDisposable.Dispose() { } /// > @@ -86,8 +101,7 @@ public TResponse BuildResponse(Endpoint endpoint, RequestData request var sc = statusCode ?? _statusCode; Stream responseStream = body != null ? requestData.MemoryStreamFactory.Create(body) : requestData.MemoryStreamFactory.Create(EmptyBody); - return requestData.ConnectionSettings.ProductRegistration.ResponseBuilder - .ToResponse(endpoint, requestData, postData, _exception, sc, _headers, responseStream, contentType ?? _contentType ?? RequestData.DefaultMimeType, body?.Length ?? 0, null, null); + return ResponseFactory.Create(endpoint, requestData, postData, _exception, sc, _headers, responseStream, contentType ?? _contentType ?? RequestData.DefaultContentType, body?.Length ?? 0, null, null); } /// > @@ -116,8 +130,8 @@ public async Task BuildResponseAsync(Endpoint endpoint, Re Stream responseStream = body != null ? requestData.MemoryStreamFactory.Create(body) : requestData.MemoryStreamFactory.Create(EmptyBody); - return await requestData.ConnectionSettings.ProductRegistration.ResponseBuilder - .ToResponseAsync(endpoint, requestData, postData, _exception, sc, _headers, responseStream, contentType ?? _contentType, body?.Length ?? 0, null, null, cancellationToken) + return await ResponseFactory + .CreateAsync(endpoint, requestData, postData, _exception, sc, _headers, responseStream, contentType ?? _contentType, body?.Length ?? 0, null, null, cancellationToken) .ConfigureAwait(false); } } diff --git a/src/Elastic.Transport/Configuration/ITransportConfiguration.cs b/src/Elastic.Transport/Configuration/ITransportConfiguration.cs index 0cd334b..a35e21e 100644 --- a/src/Elastic.Transport/Configuration/ITransportConfiguration.cs +++ b/src/Elastic.Transport/Configuration/ITransportConfiguration.cs @@ -21,8 +21,10 @@ public interface ITransportConfiguration : IRequestConfiguration, IDisposable /// Provides a to transport implementations that need to limit access to a resource SemaphoreSlim BootstrapLock { get; } - /// The connection abstraction behind which all actual IO happens - IRequestInvoker Connection { get; } + /// + /// The abstraction behind which all actual IO happens. + /// + IRequestInvoker RequestInvoker { get; } /// /// Limits the number of concurrent connections that can be opened to an endpoint. Defaults to 80 (see @@ -201,4 +203,9 @@ public interface ITransportConfiguration : IRequestConfiguration, IDisposable /// about the client and runtime. /// bool DisableMetaHeader { get; } + + /// + /// Additional response builders to apply. + /// + IReadOnlyCollection ResponseBuilders { get; } } diff --git a/src/Elastic.Transport/Configuration/TransportConfiguration.cs b/src/Elastic.Transport/Configuration/TransportConfiguration.cs index db9c150..9ebd657 100644 --- a/src/Elastic.Transport/Configuration/TransportConfiguration.cs +++ b/src/Elastic.Transport/Configuration/TransportConfiguration.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Net.Security; -using System.Runtime.InteropServices; using System.Security.Cryptography.X509Certificates; using System.Threading; using Elastic.Transport.Products; @@ -43,7 +42,7 @@ public record TransportConfiguration : ITransportConfiguration #pragma warning disable 1570 /// /// The default concurrent connection limit for outgoing http requests. Defaults to 80 -#if !NETFRAMEWORK /// Except for implementations based on curl, which defaults to +#if !NETFRAMEWORK /// Except for implementations based on curl, which defaults to #endif /// #pragma warning restore 1570 @@ -51,7 +50,7 @@ public record TransportConfiguration : ITransportConfiguration public static readonly int DefaultConnectionLimit = UsingCurlHandler ? Environment.ProcessorCount : 80; /// - /// Creates a new instance of + /// Creates a new instance of /// /// The root of the Elastic stack product node we want to connect to. Defaults to http://localhost:9200 /// @@ -74,30 +73,28 @@ public TransportConfiguration(string cloudId, Base64ApiKey credentials, ProductR /// /// - /// + /// /// /// - public TransportConfiguration( + internal TransportConfiguration( NodePool nodePool, - IRequestInvoker? invoker = null, + IRequestInvoker? requestInvoker = null, Serializer? serializer = null, ProductRegistration? productRegistration = null ) { //non init properties NodePool = nodePool; + RequestInvoker = requestInvoker ?? new HttpRequestInvoker(this); ProductRegistration = productRegistration ?? DefaultProductRegistration.Default; - Connection = invoker ?? new HttpRequestInvoker(); - Accept = productRegistration?.DefaultMimeType; + Accept = productRegistration?.DefaultContentType; RequestResponseSerializer = serializer ?? new LowLevelRequestResponseSerializer(); - ConnectionLimit = DefaultConnectionLimit; DnsRefreshTimeout = DefaultDnsRefreshTimeout; MemoryStreamFactory = DefaultMemoryStreamFactory; SniffsOnConnectionFault = true; SniffsOnStartup = true; SniffInformationLifeSpan = TimeSpan.FromHours(1); - MetaHeaderProvider = productRegistration?.MetaHeaderProvider; UrlFormatter = new UrlFormatter(this); StatusCodeToResponseSuccess = ProductRegistration.HttpStatusCodeClassifier; @@ -115,8 +112,12 @@ public TransportConfiguration( /// Expert usage: Create a new transport configuration based of a previously configured instance public TransportConfiguration(ITransportConfiguration config) { +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(config); +#else if (config is null) throw new ArgumentNullException(nameof(config)); +#endif Accept = config.Accept; AllowedStatusCodes = config.AllowedStatusCodes; @@ -124,7 +125,6 @@ public TransportConfiguration(ITransportConfiguration config) BootstrapLock = config.BootstrapLock; CertificateFingerprint = config.CertificateFingerprint; ClientCertificates = config.ClientCertificates; - Connection = config.Connection; ConnectionLimit = config.ConnectionLimit; ContentType = config.ContentType; DeadTimeout = config.DeadTimeout; @@ -160,6 +160,7 @@ public TransportConfiguration(ITransportConfiguration config) ProxyPassword = config.ProxyPassword; ProxyUsername = config.ProxyUsername; QueryStringParameters = config.QueryStringParameters; + RequestInvoker = config.RequestInvoker; RequestMetaData = config.RequestMetaData; RequestResponseSerializer = config.RequestResponseSerializer; RequestTimeout = config.RequestTimeout; @@ -200,11 +201,10 @@ public virtual bool DebugMode /// public SemaphoreSlim BootstrapLock { get; } = new(1, 1); /// - public IRequestInvoker Connection { get; } + public IRequestInvoker RequestInvoker { get; } /// public Serializer RequestResponseSerializer { get; } - /// // ReSharper disable UnusedAutoPropertyAccessor.Global public string? Accept { get; } @@ -263,7 +263,7 @@ public virtual bool DebugMode public void Dispose() { NodePool.Dispose(); - Connection.Dispose(); + RequestInvoker.Dispose(); BootstrapLock.Dispose(); } @@ -322,6 +322,8 @@ public void Dispose() public MetaHeaderProvider? MetaHeaderProvider { get; init; } /// public bool DisableMetaHeader { get; init; } + /// + public IReadOnlyCollection ResponseBuilders { get; init; } = []; // ReSharper restore UnusedAutoPropertyAccessor.Global } diff --git a/src/Elastic.Transport/Configuration/TransportConfigurationDescriptor.cs b/src/Elastic.Transport/Configuration/TransportConfigurationDescriptor.cs index 73b2fca..4f361d2 100644 --- a/src/Elastic.Transport/Configuration/TransportConfigurationDescriptor.cs +++ b/src/Elastic.Transport/Configuration/TransportConfigurationDescriptor.cs @@ -19,7 +19,16 @@ namespace Elastic.Transport; /// /// Allows you to control how behaves and where/how it connects to Elastic Stack products /// -public class TransportConfigurationDescriptor : TransportConfigurationDescriptorBase +/// +/// +/// +/// +/// +public class TransportConfigurationDescriptor( + NodePool nodePool, + IRequestInvoker? invoker = null, + Serializer? serializer = null, + ProductRegistration? productRegistration = null) : TransportConfigurationDescriptorBase(nodePool, invoker, serializer, productRegistration) { /// /// Creates a new instance of @@ -42,19 +51,6 @@ public TransportConfigurationDescriptor(string cloudId, BasicAuthentication cred /// public TransportConfigurationDescriptor(string cloudId, Base64ApiKey credentials, ProductRegistration? productRegistration = null) : this(new CloudNodePool(cloudId, credentials), productRegistration: productRegistration) { } - - /// - /// - /// - /// - /// - public TransportConfigurationDescriptor( - NodePool nodePool, - IRequestInvoker? invoker = null, - Serializer? serializer = null, - ProductRegistration? productRegistration = null) - : base(nodePool, invoker, serializer, productRegistration) { } - } /// > @@ -73,26 +69,22 @@ public abstract class TransportConfigurationDescriptorBase : ITransportConfig protected TransportConfigurationDescriptorBase(NodePool nodePool, IRequestInvoker? requestInvoker, Serializer? requestResponseSerializer, ProductRegistration? productRegistration) { _nodePool = nodePool; + _requestInvoker = requestInvoker ?? new HttpRequestInvoker(this); _productRegistration = productRegistration ?? DefaultProductRegistration.Default; - _connection = requestInvoker ?? new HttpRequestInvoker(); - _accept = productRegistration?.DefaultMimeType; + _accept = productRegistration?.DefaultContentType; _bootstrapLock = new(1, 1); - _requestResponseSerializer = requestResponseSerializer ?? new LowLevelRequestResponseSerializer(); - _connectionLimit = TransportConfiguration.DefaultConnectionLimit; _dnsRefreshTimeout = TransportConfiguration.DefaultDnsRefreshTimeout; _memoryStreamFactory = TransportConfiguration.DefaultMemoryStreamFactory; _sniffsOnConnectionFault = true; _sniffsOnStartup = true; _sniffInformationLifeSpan = TimeSpan.FromHours(1); - _metaHeaderProvider = productRegistration?.MetaHeaderProvider; - _urlFormatter = new UrlFormatter(this); _statusCodeToResponseSuccess = _productRegistration.HttpStatusCodeClassifier; _userAgent = Transport.UserAgent.Create(_productRegistration.Name, _productRegistration.GetType()); - + if (nodePool is CloudNodePool cloudPool) { _authentication = cloudPool.AuthenticationHeader; @@ -103,23 +95,23 @@ protected TransportConfigurationDescriptorBase(NodePool nodePool, IRequestInvoke } private readonly SemaphoreSlim _bootstrapLock; - private readonly IRequestInvoker _connection; private readonly NodePool _nodePool; private readonly ProductRegistration _productRegistration; //TODO these are not exposed globally #pragma warning disable CS0649 // Field is never assigned to, and will always have its default value - private IReadOnlyCollection? _allowedStatusCodes; - private string? _contentType; - private bool? _disableSniff; - private Uri? _forceNode; - private string? _opaqueId; - private string? _runAs; - private RequestMetaData? _requestMetaData; + private readonly IReadOnlyCollection? _allowedStatusCodes; + private readonly string? _contentType; + private readonly bool? _disableSniff; + private readonly Uri? _forceNode; + private readonly string? _opaqueId; + private readonly string? _runAs; + private readonly RequestMetaData? _requestMetaData; + private readonly IRequestInvoker? _requestInvoker; #pragma warning restore CS0649 // Field is never assigned to, and will always have its default value private bool _prettyJson; - private string? _accept; + private readonly string? _accept; private AuthorizationHeader? _authentication; private X509CertificateCollection? _clientCertificates; private bool? _disableDirectStreaming; @@ -157,17 +149,18 @@ protected TransportConfigurationDescriptorBase(NodePool nodePool, IRequestInvoke private TimeSpan? _sniffInformationLifeSpan; private bool _sniffsOnConnectionFault; private bool _sniffsOnStartup; - private UrlFormatter _urlFormatter; + private readonly UrlFormatter _urlFormatter; private UserAgent _userAgent; - private Func _statusCodeToResponseSuccess; + private readonly Func _statusCodeToResponseSuccess; private TimeSpan _dnsRefreshTimeout; private bool _disableMetaHeader; - private MetaHeaderProvider? _metaHeaderProvider; + private readonly MetaHeaderProvider? _metaHeaderProvider; private HeadersList? _responseHeadersToParse; private bool? _parseAllHeaders; + private List? _responseBuilders; SemaphoreSlim ITransportConfiguration.BootstrapLock => _bootstrapLock; - IRequestInvoker ITransportConfiguration.Connection => _connection; + IRequestInvoker ITransportConfiguration.RequestInvoker => _requestInvoker; int ITransportConfiguration.ConnectionLimit => _connectionLimit; NodePool ITransportConfiguration.NodePool => _nodePool; ProductRegistration ITransportConfiguration.ProductRegistration => _productRegistration; @@ -196,7 +189,7 @@ protected TransportConfigurationDescriptorBase(NodePool nodePool, IRequestInvoke Func ITransportConfiguration.StatusCodeToResponseSuccess => _statusCodeToResponseSuccess; TimeSpan ITransportConfiguration.DnsRefreshTimeout => _dnsRefreshTimeout; bool ITransportConfiguration.PrettyJson => _prettyJson; - + IReadOnlyCollection ITransportConfiguration.ResponseBuilders => _responseBuilders ?? []; HeadersList? IRequestConfiguration.ResponseHeadersToParse => _responseHeadersToParse; string? IRequestConfiguration.RunAs => _runAs; @@ -227,7 +220,6 @@ protected TransportConfigurationDescriptorBase(NodePool nodePool, IRequestInvoke TimeSpan? IRequestConfiguration.PingTimeout => _pingTimeout; TimeSpan? IRequestConfiguration.RequestTimeout => _requestTimeout; - /// /// Allows more specialized implementations of to use their own /// request response serializer defaults @@ -296,14 +288,14 @@ public T SniffOnConnectionFault(bool sniffsOnConnectionFault = true) => // ReSharper disable once MemberCanBePrivate.Global public T GlobalQueryStringParameters(NameValueCollection queryStringParameters) => Assign(queryStringParameters, static (a, v) => { - a._queryStringParameters ??= new(); + a._queryStringParameters ??= []; a._queryStringParameters.Add(v); }); /// public T GlobalHeaders(NameValueCollection headers) => Assign(headers, static (a, v) => { - a._headers ??= new(); + a._headers ??= []; a._headers.Add(v); }); @@ -369,6 +361,13 @@ public T OnRequestDataCreated(Action handler) => /// Return true if you want the node to be used for API calls public T NodePredicate(Func predicate) => Assign(predicate, static (a, v) => a._nodePredicate = v); + /// + public T ResponseBuilder(IResponseBuilder responseBuilder) => Assign(responseBuilder, static (a, v) => + { + a._responseBuilders ??= []; + a._responseBuilders.Add(v); + }); + /// /// Turns on settings that aid in debugging like DisableDirectStreaming() and PrettyJson() /// so that the original request and response JSON can be inspected. It also always asks the server for the full stack trace on errors @@ -443,7 +442,7 @@ public T SkipDeserializationForStatusCodes(params int[] statusCodes) => protected virtual void DisposeManagedResources() { _nodePool.Dispose(); - _connection.Dispose(); + _requestInvoker?.Dispose(); _bootstrapLock.Dispose(); } @@ -458,5 +457,4 @@ protected T UpdateGlobalQueryString(string key, string value, bool enabled) } void IDisposable.Dispose() => DisposeManagedResources(); - } diff --git a/src/Elastic.Transport/DistributedTransport.cs b/src/Elastic.Transport/DistributedTransport.cs index c24dfc0..7e98e11 100644 --- a/src/Elastic.Transport/DistributedTransport.cs +++ b/src/Elastic.Transport/DistributedTransport.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using System.Reflection; using System.Threading; using System.Threading.Tasks; using Elastic.Transport.Diagnostics; @@ -68,21 +67,20 @@ public DistributedTransport( { configurationValues.ThrowIfNull(nameof(configurationValues)); configurationValues.NodePool.ThrowIfNull(nameof(configurationValues.NodePool)); - configurationValues.Connection.ThrowIfNull(nameof(configurationValues.Connection)); + configurationValues.RequestInvoker.ThrowIfNull(nameof(configurationValues.RequestInvoker)); configurationValues.RequestResponseSerializer.ThrowIfNull(nameof(configurationValues .RequestResponseSerializer)); _productRegistration = configurationValues.ProductRegistration; + Configuration = configurationValues; TransportRequestData = new RequestData(Configuration); - PipelineProvider = pipelineProvider ?? new DefaultRequestPipelineFactory(); + RequestPipelineFactory = pipelineProvider ?? new DefaultRequestPipelineFactory(); DateTimeProvider = dateTimeProvider ?? DefaultDateTimeProvider.Default; - MemoryStreamFactory = configurationValues.MemoryStreamFactory; } private DateTimeProvider DateTimeProvider { get; } - private MemoryStreamFactory MemoryStreamFactory { get; } - private RequestPipelineFactory PipelineProvider { get; } + private RequestPipelineFactory RequestPipelineFactory { get; } private RequestData TransportRequestData { get; } /// @@ -93,11 +91,9 @@ public TResponse Request( in EndpointPath path, PostData? data, in OpenTelemetryData openTelemetryData, - IRequestConfiguration? localConfiguration, - CustomResponseBuilder? responseBuilder - ) - where TResponse : TransportResponse, new() => - RequestCoreAsync(isAsync: false, path, data, openTelemetryData, localConfiguration, responseBuilder) + IRequestConfiguration? localConfiguration + ) where TResponse : TransportResponse, new() => + RequestCoreAsync(isAsync: false, path, data, openTelemetryData, localConfiguration) .EnsureCompleted(); /// @@ -106,11 +102,9 @@ public Task RequestAsync( PostData? data, in OpenTelemetryData openTelemetryData, IRequestConfiguration? localConfiguration, - CustomResponseBuilder? responseBuilder, CancellationToken cancellationToken = default - ) - where TResponse : TransportResponse, new() => - RequestCoreAsync(isAsync: true, path, data, openTelemetryData, localConfiguration, responseBuilder, cancellationToken) + ) where TResponse : TransportResponse, new() => + RequestCoreAsync(isAsync: true, path, data, openTelemetryData, localConfiguration, cancellationToken) .AsTask(); private async ValueTask RequestCoreAsync( @@ -119,10 +113,8 @@ private async ValueTask RequestCoreAsync( PostData? data, OpenTelemetryData openTelemetryData, IRequestConfiguration? localConfiguration, - CustomResponseBuilder? customResponseBuilder, CancellationToken cancellationToken = default - ) - where TResponse : TransportResponse, new() + ) where TResponse : TransportResponse, new() { Activity activity = null; @@ -135,13 +127,13 @@ private async ValueTask RequestCoreAsync( //unless per request configuration or custom response builder is provided we can reuse a request data //that is specific to this transport var requestData = - localConfiguration != null || customResponseBuilder != null - ? new RequestData(Configuration, localConfiguration, customResponseBuilder) + localConfiguration != null + ? new RequestData(Configuration, localConfiguration) : TransportRequestData; Configuration.OnRequestDataCreated?.Invoke(requestData); - using var pipeline = PipelineProvider.Create(requestData, DateTimeProvider); + using var pipeline = RequestPipelineFactory.Create(requestData, DateTimeProvider); if (isAsync) await pipeline.FirstPoolUsageAsync(Configuration.BootstrapLock, cancellationToken).ConfigureAwait(false); diff --git a/src/Elastic.Transport/Elastic.Transport.csproj b/src/Elastic.Transport/Elastic.Transport.csproj index 19ca9f7..0f1a546 100644 --- a/src/Elastic.Transport/Elastic.Transport.csproj +++ b/src/Elastic.Transport/Elastic.Transport.csproj @@ -19,13 +19,14 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive - + @@ -37,6 +38,7 @@ + diff --git a/src/Elastic.Transport/ITransport.cs b/src/Elastic.Transport/ITransport.cs index a813aef..5d92ebc 100644 --- a/src/Elastic.Transport/ITransport.cs +++ b/src/Elastic.Transport/ITransport.cs @@ -22,17 +22,14 @@ public interface ITransport /// The data to be included as the body of the HTTP request. /// Data to be used to control the OpenTelemetry instrumentation. /// Per request configuration - /// /// Allows callers to override completely how `TResponse` should be deserialized to a `TResponse` that implements instance. /// Expert setting only - /// /// The deserialized . public TResponse Request( in EndpointPath path, PostData? postData, in OpenTelemetryData openTelemetryData, - IRequestConfiguration? localConfiguration, - CustomResponseBuilder? responseBuilder + IRequestConfiguration? localConfiguration ) where TResponse : TransportResponse, new(); @@ -46,17 +43,14 @@ public TResponse Request( /// The cancellation token to use. /// Data to be used to control the OpenTelemetry instrumentation. /// Per request configuration - /// /// Allows callers to override completely how `TResponse` should be deserialized to a `TResponse` that implements instance. /// Expert setting only - /// /// The deserialized . public Task RequestAsync( in EndpointPath path, PostData? postData, in OpenTelemetryData openTelemetryData, IRequestConfiguration? localConfiguration, - CustomResponseBuilder? responseBuilder, CancellationToken cancellationToken = default ) where TResponse : TransportResponse, new(); @@ -80,31 +74,30 @@ public interface ITransport : ITransport /// public static class TransportExtensions { - /// > public static TResponse Request(this ITransport transport, in EndpointPath path) where TResponse : TransportResponse, new() - => transport.Request(path, null, default, null, null); + => transport.Request(path, null, default, null); /// > public static TResponse Request(this ITransport transport, in EndpointPath path, PostData? postData) where TResponse : TransportResponse, new() - => transport.Request(path, postData, default, null, null); + => transport.Request(path, postData, default, null); /// > public static TResponse Request(this ITransport transport, in EndpointPath path, PostData? postData, IRequestConfiguration configuration) where TResponse : TransportResponse, new() - => transport.Request(path, postData, default, configuration, null); + => transport.Request(path, postData, default, configuration); /// > public static TResponse Request(this ITransport transport, HttpMethod method, string path) where TResponse : TransportResponse, new() - => transport.Request(new EndpointPath(method, path), null, default, null, null); + => transport.Request(new EndpointPath(method, path), null, default, null); /// > public static TResponse Request(this ITransport transport, HttpMethod method, string path, PostData? postData) where TResponse : TransportResponse, new() - => transport.Request(new EndpointPath(method, path), postData, default, null, null); + => transport.Request(new EndpointPath(method, path), postData, default, null); /// > public static TResponse Request( @@ -114,17 +107,17 @@ public static TResponse Request( PostData? postData, IRequestConfiguration localConfiguration) where TResponse : TransportResponse, new() - => transport.Request(new EndpointPath(method, path), postData, default, localConfiguration, null); + => transport.Request(new EndpointPath(method, path), postData, default, localConfiguration); /// > public static Task RequestAsync(this ITransport transport, in EndpointPath path, CancellationToken cancellationToken = default) where TResponse : TransportResponse, new() - => transport.RequestAsync(path, null, default, null, null, cancellationToken); + => transport.RequestAsync(path, null, default, null, cancellationToken); /// > public static Task RequestAsync(this ITransport transport, in EndpointPath path, PostData? postData, CancellationToken cancellationToken = default) where TResponse : TransportResponse, new() - => transport.RequestAsync(path, postData, default, null, null, cancellationToken); + => transport.RequestAsync(path, postData, default, null, cancellationToken); /// > public static Task RequestAsync( @@ -133,7 +126,7 @@ public static Task RequestAsync( string path, CancellationToken cancellationToken = default) where TResponse : TransportResponse, new() - => transport.RequestAsync(new EndpointPath(method, path), null, default, null, null, cancellationToken); + => transport.RequestAsync(new EndpointPath(method, path), null, default, null, cancellationToken); /// > public static Task RequestAsync( @@ -143,7 +136,7 @@ public static Task RequestAsync( PostData? postData, CancellationToken cancellationToken = default) where TResponse : TransportResponse, new() - => transport.RequestAsync(new EndpointPath(method, path), postData, default, null, null, cancellationToken); + => transport.RequestAsync(new EndpointPath(method, path), postData, default, null, cancellationToken); /// > public static Task RequestAsync( @@ -154,5 +147,5 @@ public static Task RequestAsync( IRequestConfiguration localConfiguration, CancellationToken cancellationToken = default) where TResponse : TransportResponse, new() - => transport.RequestAsync(new EndpointPath(method, path), postData, default, localConfiguration, null, cancellationToken); + => transport.RequestAsync(new EndpointPath(method, path), postData, default, localConfiguration, cancellationToken); } diff --git a/src/Elastic.Transport/ITransportHttpMethodExtensions.cs b/src/Elastic.Transport/ITransportHttpMethodExtensions.cs index 26b86aa..ee4e656 100644 --- a/src/Elastic.Transport/ITransportHttpMethodExtensions.cs +++ b/src/Elastic.Transport/ITransportHttpMethodExtensions.cs @@ -19,99 +19,98 @@ private static EndpointPath ToEndpointPath(HttpMethod method, string path, Reque /// Perform a GET request public static TResponse Get(this ITransport transport, string path, RequestParameters parameters) where TResponse : TransportResponse, new() => - transport.Request(ToEndpointPath(GET, path, parameters, transport.Configuration), postData: null, openTelemetryData: default, null, null); + transport.Request(ToEndpointPath(GET, path, parameters, transport.Configuration), postData: null, openTelemetryData: default, null); /// Perform a GET request public static Task GetAsync(this ITransport transport, string path, RequestParameters parameters, CancellationToken cancellationToken = default) where TResponse : TransportResponse, new() => - transport.RequestAsync(ToEndpointPath(GET, path, parameters, transport.Configuration), postData: null, openTelemetryData: default, null, null, cancellationToken); + transport.RequestAsync(ToEndpointPath(GET, path, parameters, transport.Configuration), postData: null, openTelemetryData: default, null, cancellationToken); /// Perform a GET request public static TResponse Get(this ITransport transport, string pathAndQuery) where TResponse : TransportResponse, new() => - transport.Request(new EndpointPath(GET, pathAndQuery), postData: null, openTelemetryData: default, null, null); + transport.Request(new EndpointPath(GET, pathAndQuery), postData: null, openTelemetryData: default, null); /// Perform a GET request public static Task GetAsync(this ITransport transport, string pathAndQuery, CancellationToken cancellationToken = default) where TResponse : TransportResponse, new() => - transport.RequestAsync(new EndpointPath(GET, pathAndQuery), postData: null, openTelemetryData: default, null, null, cancellationToken); + transport.RequestAsync(new EndpointPath(GET, pathAndQuery), postData: null, openTelemetryData: default, null, cancellationToken); /// Perform a HEAD request public static VoidResponse Head(this ITransport transport, string path, RequestParameters parameters) - => transport.Request(ToEndpointPath(HEAD, path, parameters, transport.Configuration), postData: null, openTelemetryData: default, null, null); + => transport.Request(ToEndpointPath(HEAD, path, parameters, transport.Configuration), postData: null, openTelemetryData: default, null); /// Perform a HEAD request public static Task HeadAsync(this ITransport transport, string path, RequestParameters parameters, CancellationToken cancellationToken = default) - => transport.RequestAsync(ToEndpointPath(HEAD, path, parameters, transport.Configuration), postData: null, openTelemetryData: default, null, null, cancellationToken); + => transport.RequestAsync(ToEndpointPath(HEAD, path, parameters, transport.Configuration), postData: null, openTelemetryData: default, null, cancellationToken); /// Perform a HEAD request public static VoidResponse Head(this ITransport transport, string pathAndQuery) - => transport.Request(new EndpointPath(HEAD, pathAndQuery), postData: null, openTelemetryData: default, null, null); + => transport.Request(new EndpointPath(HEAD, pathAndQuery), postData: null, openTelemetryData: default, null); /// Perform a HEAD request public static Task HeadAsync(this ITransport transport, string pathAndQuery, CancellationToken cancellationToken = default) - => transport.RequestAsync(new EndpointPath(HEAD, pathAndQuery), postData: null, openTelemetryData: default, null, null, cancellationToken); + => transport.RequestAsync(new EndpointPath(HEAD, pathAndQuery), postData: null, openTelemetryData: default, null, cancellationToken); /// Perform a POST request public static TResponse Post(this ITransport transport, string path, PostData data, RequestParameters parameters) where TResponse : TransportResponse, new() => - transport.Request(ToEndpointPath(POST, path, parameters, transport.Configuration), data, openTelemetryData: default, null, null); + transport.Request(ToEndpointPath(POST, path, parameters, transport.Configuration), data, openTelemetryData: default, null); /// Perform a POST request public static Task PostAsync(this ITransport transport, string path, PostData data, RequestParameters parameters, CancellationToken cancellationToken = default) where TResponse : TransportResponse, new() => - transport.RequestAsync(ToEndpointPath(POST, path, parameters, transport.Configuration), data, openTelemetryData: default, null, null, cancellationToken); + transport.RequestAsync(ToEndpointPath(POST, path, parameters, transport.Configuration), data, openTelemetryData: default, null, cancellationToken); /// Perform a POST request public static TResponse Post(this ITransport transport, string pathAndQuery, PostData data) where TResponse : TransportResponse, new() => - transport.Request(new EndpointPath(POST, pathAndQuery), data, openTelemetryData: default, null, null); + transport.Request(new EndpointPath(POST, pathAndQuery), data, openTelemetryData: default, null); /// Perform a POST request public static Task PostAsync(this ITransport transport, string pathAndQuery, PostData data, CancellationToken cancellationToken = default) where TResponse : TransportResponse, new() => - transport.RequestAsync(new EndpointPath(POST, pathAndQuery), data, openTelemetryData: default, null, null, cancellationToken); + transport.RequestAsync(new EndpointPath(POST, pathAndQuery), data, openTelemetryData: default, null, cancellationToken); /// Perform a PUT request public static TResponse Put(this ITransport transport, string path, PostData data, RequestParameters parameters) where TResponse : TransportResponse, new() => - transport.Request(ToEndpointPath(PUT, path, parameters, transport.Configuration), data, openTelemetryData: default, null, null); + transport.Request(ToEndpointPath(PUT, path, parameters, transport.Configuration), data, openTelemetryData: default, null); /// Perform a PUT request public static Task PutAsync(this ITransport transport, string path, PostData data, RequestParameters parameters, CancellationToken cancellationToken = default) where TResponse : TransportResponse, new() => - transport.RequestAsync(ToEndpointPath(PUT, path, parameters, transport.Configuration), data, openTelemetryData: default, null, null, cancellationToken); + transport.RequestAsync(ToEndpointPath(PUT, path, parameters, transport.Configuration), data, openTelemetryData: default, null, cancellationToken); /// Perform a PUT request public static TResponse Put(this ITransport transport, string pathAndQuery, PostData data) where TResponse : TransportResponse, new() => - transport.Request(new EndpointPath(PUT, pathAndQuery), data, openTelemetryData: default, null, null); + transport.Request(new EndpointPath(PUT, pathAndQuery), data, openTelemetryData: default, null); /// Perform a PUT request public static Task PutAsync(this ITransport transport, string pathAndQuery, PostData data, CancellationToken cancellationToken = default) where TResponse : TransportResponse, new() => - transport.RequestAsync(new EndpointPath(PUT, pathAndQuery), data, openTelemetryData: default, null, null, cancellationToken); - + transport.RequestAsync(new EndpointPath(PUT, pathAndQuery), data, openTelemetryData: default, null, cancellationToken); /// Perform a DELETE request public static TResponse Delete(this ITransport transport, string path, RequestParameters parameters, PostData? data = null) where TResponse : TransportResponse, new() => - transport.Request(ToEndpointPath(DELETE, path, parameters, transport.Configuration), data, openTelemetryData: default, null, null); + transport.Request(ToEndpointPath(DELETE, path, parameters, transport.Configuration), data, openTelemetryData: default, null); /// Perform a DELETE request public static Task DeleteAsync(this ITransport transport, string path, RequestParameters parameters, PostData? data = null, CancellationToken cancellationToken = default) where TResponse : TransportResponse, new() => - transport.RequestAsync(ToEndpointPath(DELETE, path, parameters, transport.Configuration), data, openTelemetryData: default, null, null, cancellationToken); + transport.RequestAsync(ToEndpointPath(DELETE, path, parameters, transport.Configuration), data, openTelemetryData: default, null, cancellationToken); /// Perform a DELETE request public static TResponse Delete(this ITransport transport, string pathAndQuery, PostData? data = null) where TResponse : TransportResponse, new() => - transport.Request(new EndpointPath(DELETE, pathAndQuery), data, openTelemetryData: default, null, null); + transport.Request(new EndpointPath(DELETE, pathAndQuery), data, openTelemetryData: default, null); /// Perform a DELETE request public static Task DeleteAsync(this ITransport transport, string pathAndQuery, PostData? data = null, CancellationToken cancellationToken = default) where TResponse : TransportResponse, new() => - transport.RequestAsync(new EndpointPath(DELETE, pathAndQuery), data, openTelemetryData: default, null, null, cancellationToken); + transport.RequestAsync(new EndpointPath(DELETE, pathAndQuery), data, openTelemetryData: default, null, cancellationToken); } diff --git a/src/Elastic.Transport/Products/DefaultProductRegistration.cs b/src/Elastic.Transport/Products/DefaultProductRegistration.cs index 8db6de6..46d4d93 100644 --- a/src/Elastic.Transport/Products/DefaultProductRegistration.cs +++ b/src/Elastic.Transport/Products/DefaultProductRegistration.cs @@ -59,8 +59,8 @@ public DefaultProductRegistration() /// public override MetaHeaderProvider MetaHeaderProvider => _metaHeaderProvider; - /// - public override string? DefaultMimeType => null; + /// + public override string? DefaultContentType => null; /// public override string ProductAssemblyVersion { get; } @@ -104,7 +104,7 @@ public override TransportResponse Ping(IRequestInvoker requestInvoker, Endpoint throw new NotImplementedException(); /// - public override IReadOnlyCollection DefaultHeadersToParse() => Array.Empty(); + public override IReadOnlyCollection DefaultHeadersToParse() => []; /// public override Dictionary? ParseOpenTelemetryAttributesFromApiCallDetails(ApiCallDetails callDetails) => null; diff --git a/src/Elastic.Transport/Products/Elasticsearch/ElasticsearchErrorExtensions.cs b/src/Elastic.Transport/Products/Elasticsearch/ElasticsearchErrorExtensions.cs index fa94e63..963b2e1 100644 --- a/src/Elastic.Transport/Products/Elasticsearch/ElasticsearchErrorExtensions.cs +++ b/src/Elastic.Transport/Products/Elasticsearch/ElasticsearchErrorExtensions.cs @@ -15,7 +15,7 @@ public static class ElasticsearchErrorExtensions public static bool TryGetElasticsearchServerError(this StringResponse response, out ElasticsearchServerError serverError) { serverError = null; - if (string.IsNullOrEmpty(response.Body) || response.ApiCallDetails.ResponseMimeType != RequestData.DefaultMimeType) + if (string.IsNullOrEmpty(response.Body) || response.ApiCallDetails.ResponseContentType != RequestData.DefaultContentType) return false; var settings = response.ApiCallDetails.TransportConfiguration; @@ -27,7 +27,7 @@ public static bool TryGetElasticsearchServerError(this StringResponse response, public static bool TryGetElasticsearchServerError(this BytesResponse response, out ElasticsearchServerError serverError) { serverError = null; - if (response.Body == null || response.Body.Length == 0 || response.ApiCallDetails.ResponseMimeType != RequestData.DefaultMimeType) + if (response.Body == null || response.Body.Length == 0 || response.ApiCallDetails.ResponseContentType != RequestData.DefaultContentType) return false; var settings = response.ApiCallDetails.TransportConfiguration; @@ -43,7 +43,7 @@ public static bool TryGetElasticsearchServerError(this TransportResponse respons { serverError = null; var bytes = response.ApiCallDetails.ResponseBodyInBytes; - if (bytes == null || response.ApiCallDetails.ResponseMimeType != RequestData.DefaultMimeType) + if (bytes == null || response.ApiCallDetails.ResponseContentType != RequestData.DefaultContentType) return false; var settings = response.ApiCallDetails.TransportConfiguration; diff --git a/src/Elastic.Transport/Products/Elasticsearch/ElasticsearchProductRegistration.cs b/src/Elastic.Transport/Products/Elasticsearch/ElasticsearchProductRegistration.cs index fa9af27..1343ba5 100644 --- a/src/Elastic.Transport/Products/Elasticsearch/ElasticsearchProductRegistration.cs +++ b/src/Elastic.Transport/Products/Elasticsearch/ElasticsearchProductRegistration.cs @@ -80,11 +80,8 @@ public ElasticsearchProductRegistration(Type markerType) : this() /// public override MetaHeaderProvider MetaHeaderProvider => _metaHeaderProvider; - /// - public override ResponseBuilder ResponseBuilder => new ElasticsearchResponseBuilder(); - - /// - public override string? DefaultMimeType => _clientMajorVersion.HasValue ? $"application/vnd.elasticsearch+json;compatible-with={_clientMajorVersion.Value}" : null; + /// + public override string? DefaultContentType => _clientMajorVersion.HasValue ? $"application/vnd.elasticsearch+json;compatible-with={_clientMajorVersion.Value}" : null; /// Exposes the path used for sniffing in Elasticsearch public const string SniffPath = "_nodes/http,settings"; @@ -221,4 +218,7 @@ public override IReadOnlyCollection DefaultHeadersToParse() { [SemanticConventions.DbSystem] = "elasticsearch" }; + + /// + public override IReadOnlyCollection ResponseBuilders { get; } = [new ElasticsearchResponseBuilder()]; } diff --git a/src/Elastic.Transport/Products/Elasticsearch/ElasticsearchResponseBuilder.cs b/src/Elastic.Transport/Products/Elasticsearch/ElasticsearchResponseBuilder.cs index 77fc157..c1e5814 100644 --- a/src/Elastic.Transport/Products/Elasticsearch/ElasticsearchResponseBuilder.cs +++ b/src/Elastic.Transport/Products/Elasticsearch/ElasticsearchResponseBuilder.cs @@ -4,44 +4,108 @@ using System.Diagnostics; using System.IO; +using System.Linq; using System.Text.Json; +using Elastic.Transport.Diagnostics; +using System.Threading.Tasks; +using System.Threading; +using System; namespace Elastic.Transport.Products.Elasticsearch; -internal sealed class ElasticsearchResponseBuilder : DefaultResponseBuilder +internal sealed class ElasticsearchResponseBuilder : IResponseBuilder { - protected override void SetErrorOnResponse(TResponse response, ElasticsearchServerError error) + bool IResponseBuilder.CanBuild() => true; + + public TResponse Build(ApiCallDetails apiCallDetails, RequestData requestData, + Stream responseStream, string contentType, long contentLength) + where TResponse : TransportResponse, new() => + SetBodyCoreAsync(false, apiCallDetails, requestData, responseStream).EnsureCompleted(); + + public Task BuildAsync( + ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, string contentType, long contentLength, + CancellationToken cancellationToken) where TResponse : TransportResponse, new() => + SetBodyCoreAsync(true, apiCallDetails, requestData, responseStream, cancellationToken).AsTask(); + + private static async ValueTask SetBodyCoreAsync(bool isAsync, + ApiCallDetails details, RequestData requestData, Stream responseStream, + CancellationToken cancellationToken = default) + where TResponse : TransportResponse, new() { - if (response is ElasticsearchResponse elasticResponse) + TResponse response = null; + + if (details.HttpStatusCode.HasValue && + requestData.SkipDeserializationForStatusCodes.Contains(details.HttpStatusCode.Value)) + { + return response; + } + + try + { + if (details.HttpStatusCode > 399) + { + var ownsStream = false; + + if (!responseStream.CanSeek) + { + var inMemoryStream = requestData.MemoryStreamFactory.Create(); + await responseStream.CopyToAsync(inMemoryStream, BufferedResponseHelpers.BufferSize, cancellationToken).ConfigureAwait(false); + details.ResponseBodyInBytes = BufferedResponseHelpers.SwapStreams(ref responseStream, ref inMemoryStream); + ownsStream = true; + } + + if (TryGetError(requestData, responseStream, out var error) && error.HasError()) + { + response = new TResponse(); + + if (response is ElasticsearchResponse elasticResponse) + elasticResponse.ElasticsearchServerError = error; + + if (ownsStream) + responseStream.Dispose(); + + return response; + } + + responseStream.Position = 0; + } + + var beforeTicks = Stopwatch.GetTimestamp(); + + if (isAsync) + response = await requestData.ConnectionSettings.RequestResponseSerializer.DeserializeAsync(responseStream, cancellationToken).ConfigureAwait(false); + else + response = requestData.ConnectionSettings.RequestResponseSerializer.Deserialize(responseStream); + + var deserializeResponseMs = (Stopwatch.GetTimestamp() - beforeTicks) / (Stopwatch.Frequency / 1000); + + if (deserializeResponseMs > OpenTelemetry.MinimumMillisecondsToEmitTimingSpanAttribute && OpenTelemetry.CurrentSpanIsElasticTransportOwnedHasListenersAndAllDataRequested) + Activity.Current?.SetTag(OpenTelemetryAttributes.ElasticTransportDeserializeResponseMs, deserializeResponseMs); + + return response; + } + catch (JsonException ex) when (ex.Message.Contains("The input does not contain any JSON tokens")) { - elasticResponse.ElasticsearchServerError = error; + return response; } } - protected override bool TryGetError(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, out ElasticsearchServerError error) + private static bool TryGetError(RequestData requestData, Stream responseStream, out ElasticsearchServerError error) { - error = null; - Debug.Assert(responseStream.CanSeek); - var serializer = requestData.ConnectionSettings.RequestResponseSerializer; + error = null; try { - error = serializer.Deserialize(responseStream); + error = requestData.ConnectionSettings.RequestResponseSerializer.Deserialize(responseStream); return error is not null; } catch (JsonException) { // Empty catch as we'll try the original response type if the error serialization fails } - finally - { - responseStream.Position = 0; - } return false; } - - protected sealed override bool RequiresErrorDeserialization(ApiCallDetails apiCallDetails, RequestData requestData) => apiCallDetails.HttpStatusCode > 399; } diff --git a/src/Elastic.Transport/Products/ProductRegistration.cs b/src/Elastic.Transport/Products/ProductRegistration.cs index d2eed60..2aeb4ef 100644 --- a/src/Elastic.Transport/Products/ProductRegistration.cs +++ b/src/Elastic.Transport/Products/ProductRegistration.cs @@ -26,7 +26,7 @@ public abstract class ProductRegistration /// /// The default MIME type used for Accept and Content-Type headers for requests. /// - public abstract string DefaultMimeType { get; } + public abstract string DefaultContentType { get; } /// /// The name of the current product utilizing @@ -100,7 +100,7 @@ public abstract class ProductRegistration public abstract bool NodePredicate(Node node); /// - /// Used by to determine if it needs to return true or false for + /// Used by the to determine if it needs to return true or false for /// /// public abstract bool HttpStatusCodeClassifier(HttpMethod method, int statusCode); @@ -137,8 +137,8 @@ public abstract class ProductRegistration /// public abstract Dictionary? ParseOpenTelemetryAttributesFromApiCallDetails(ApiCallDetails callDetails); - /// - /// Allows product implementations to take full control of building transport responses if needed. + /// + /// /// - public virtual ResponseBuilder ResponseBuilder => ResponseBuilder.Default; + public virtual IReadOnlyCollection ResponseBuilders { get; } = [new DefaultResponseBuilder()]; } diff --git a/src/Elastic.Transport/Responses/BufferedResponseHelpers.cs b/src/Elastic.Transport/Responses/BufferedResponseHelpers.cs new file mode 100644 index 0000000..6ebf8dc --- /dev/null +++ b/src/Elastic.Transport/Responses/BufferedResponseHelpers.cs @@ -0,0 +1,24 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO; + +namespace Elastic.Transport; + +internal class BufferedResponseHelpers +{ + public const int BufferSize = 81920; + + public static byte[] SwapStreams(ref Stream responseStream, ref MemoryStream ms, bool disposeOriginal = false) + { + var bytes = ms.ToArray(); + + if (disposeOriginal) + responseStream.Dispose(); + + responseStream = ms; + responseStream.Position = 0; + return bytes; + } +} diff --git a/src/Elastic.Transport/Responses/CustomResponseBuilder.cs b/src/Elastic.Transport/Responses/CustomResponseBuilder.cs deleted file mode 100644 index 26512f6..0000000 --- a/src/Elastic.Transport/Responses/CustomResponseBuilder.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -namespace Elastic.Transport; - -/// -/// Allows callers to override completely how `TResponse` should be deserialized to a `TResponse` that implements instance. -/// Expert setting only -/// -public abstract class CustomResponseBuilder -{ - /// Custom routine that deserializes from to an instance of . - public abstract object DeserializeResponse(Serializer serializer, ApiCallDetails response, Stream stream); - - /// - public abstract Task DeserializeResponseAsync(Serializer serializer, ApiCallDetails response, Stream stream, CancellationToken ctx = default); -} diff --git a/src/Elastic.Transport/Responses/DefaultResponseFactory.cs b/src/Elastic.Transport/Responses/DefaultResponseFactory.cs new file mode 100644 index 0000000..38874c2 --- /dev/null +++ b/src/Elastic.Transport/Responses/DefaultResponseFactory.cs @@ -0,0 +1,152 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Net.NetworkInformation; +using System.Threading; +using System.Threading.Tasks; +using Elastic.Transport.Diagnostics; +using Elastic.Transport.Extensions; + +namespace Elastic.Transport; + +/// +/// A which resolves an for each response being created. +/// +/// +/// Create an instance of the factory using the provided configuration. +/// +internal sealed class DefaultResponseFactory(ITransportConfiguration transportConfiguration) : ResponseFactory +{ + private readonly ConcurrentDictionary _resolvedBuilders = new() + { + [typeof(BytesResponse)] = new BytesResponseBuilder(), + [typeof(StreamResponse)] = new StreamResponseBuilder(), + [typeof(StringResponse)] = new StringResponseBuilder(), + [typeof(DynamicResponse)] = new DynamicResponseBuilder(), + [typeof(VoidResponse)] = new VoidResponseBuilder() + }; + + private readonly ITransportConfiguration? _transportConfiguration = transportConfiguration; + + /// + public override TResponse Create( + Endpoint endpoint, + RequestData requestData, + PostData? postData, + Exception? ex, + int? statusCode, + Dictionary>? headers, + Stream responseStream, + string? contentType, + long contentLength, + IReadOnlyDictionary? threadPoolStats, + IReadOnlyDictionary? tcpStats) => + CreateCoreAsync(false, endpoint, requestData, postData, ex, statusCode, headers, responseStream, + contentType, contentLength, threadPoolStats, tcpStats).EnsureCompleted(); + + /// + public override Task CreateAsync( + Endpoint endpoint, + RequestData requestData, + PostData? postData, + Exception? ex, + int? statusCode, + Dictionary>? headers, + Stream responseStream, + string? contentType, + long contentLength, + IReadOnlyDictionary? threadPoolStats, + IReadOnlyDictionary? tcpStats, + CancellationToken cancellationToken = default) => + CreateCoreAsync(true, endpoint, requestData, postData, ex, statusCode, headers, responseStream, + contentType, contentLength, threadPoolStats, tcpStats, cancellationToken).AsTask(); + + private async ValueTask CreateCoreAsync( + bool isAsync, + Endpoint endpoint, + RequestData requestData, + PostData? postData, + Exception? ex, + int? statusCode, + Dictionary>? headers, + Stream responseStream, + string? contentType, + long contentLength, + IReadOnlyDictionary? threadPoolStats, + IReadOnlyDictionary? tcpStats, + CancellationToken cancellationToken = default) where TResponse : TransportResponse, new() + { + responseStream.ThrowIfNull(nameof(responseStream)); + + var details = InitializeApiCallDetails(endpoint, requestData, postData, ex, statusCode, headers, contentType, threadPoolStats, tcpStats, contentLength); + + TResponse? response = null; + + if (MayHaveBody(statusCode, endpoint.Method, contentLength) && TryResolveBuilder(out var builder)) + { + var ownsStream = false; + + // We always pre-buffer when there may be a body, even if the content type does not match. + // That way, we ensure the caller can access the bytes themselves for "invalid" responses. + if (requestData.DisableDirectStreaming) + { + var inMemoryStream = requestData.MemoryStreamFactory.Create(); + + if (isAsync) + await responseStream.CopyToAsync(inMemoryStream, BufferedResponseHelpers.BufferSize, cancellationToken).ConfigureAwait(false); + else + responseStream.CopyTo(inMemoryStream, BufferedResponseHelpers.BufferSize); + + details.ResponseBodyInBytes = BufferedResponseHelpers.SwapStreams(ref responseStream, ref inMemoryStream); + ownsStream = true; + } + + // We only attempt to build a response when the Content-Type matches the accepted type. + if (ValidateResponseContentType(requestData.Accept, contentType)) + { + if (isAsync) + response = await builder.BuildAsync(details, requestData, responseStream, contentType, contentLength, cancellationToken).ConfigureAwait(false); + else + response = builder.Build(details, requestData, responseStream, contentType, contentLength); + } + + if (ownsStream && (response is null || !response.LeaveOpen)) + responseStream.Dispose(); + } + + response ??= new TResponse(); + response.ApiCallDetails = details; + return response; + } + + private bool TryResolveBuilder(out IResponseBuilder builder) where TResponse : TransportResponse, new() + { + if (_resolvedBuilders.TryGetValue(typeof(TResponse), out builder)) + return true; + + if (TryFindResponseBuilder(_transportConfiguration.ResponseBuilders, _resolvedBuilders, ref builder)) + return true; + + return TryFindResponseBuilder(_transportConfiguration.ProductRegistration.ResponseBuilders, _resolvedBuilders, ref builder); + + static bool TryFindResponseBuilder(IEnumerable responseBuilders, ConcurrentDictionary resolvedBuilders, ref IResponseBuilder builder) + { + foreach (var potentialBuilder in responseBuilders) + { + if (potentialBuilder.CanBuild()) + { + resolvedBuilders.TryAdd(typeof(TResponse), potentialBuilder); + builder = potentialBuilder; + return true; + } + } + + return false; + } + } +} diff --git a/src/Elastic.Transport/Responses/Dynamic/DynamicResponseBuilder.cs b/src/Elastic.Transport/Responses/Dynamic/DynamicResponseBuilder.cs new file mode 100644 index 0000000..7d3282b --- /dev/null +++ b/src/Elastic.Transport/Responses/Dynamic/DynamicResponseBuilder.cs @@ -0,0 +1,96 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +#if NET8_0_OR_GREATER +using System; +using System.Buffers; +#endif + +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Elastic.Transport; + +internal class DynamicResponseBuilder : TypedResponseBuilder +{ + protected override DynamicResponse Build(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, string contentType, long contentLength) => + BuildCoreAsync(false, apiCallDetails, requestData, responseStream, contentType, contentLength).EnsureCompleted(); + + protected override Task BuildAsync(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, string contentType, long contentLength, CancellationToken cancellationToken = default) => + BuildCoreAsync(true, apiCallDetails, requestData, responseStream, contentType, contentLength, cancellationToken).AsTask(); + + private static async ValueTask BuildCoreAsync(bool isAsync, ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, + string contentType, long contentLength, CancellationToken cancellationToken = default) + { + DynamicResponse response; + + //if not json store the result under "body" + if (contentType == null || !contentType.StartsWith(RequestData.DefaultContentType)) + { + DynamicDictionary dictionary; + string stringValue; + + if (apiCallDetails.ResponseBodyInBytes is not null) + { + stringValue = Encoding.UTF8.GetString(apiCallDetails.ResponseBodyInBytes); + + dictionary = new DynamicDictionary + { + ["body"] = new DynamicValue(stringValue) + }; + + return new DynamicResponse(dictionary); + } + +#if NET8_0_OR_GREATER + if (contentLength > -1 && contentLength <= 1_048_576) + { + var buffer = ArrayPool.Shared.Rent((int)contentLength); + responseStream.ReadExactly(buffer, 0, (int)contentLength); + stringValue = Encoding.UTF8.GetString(buffer.AsSpan(0, (int)contentLength)); + ArrayPool.Shared.Return(buffer); + + dictionary = new DynamicDictionary + { + ["body"] = new DynamicValue(stringValue) + }; + + return new DynamicResponse(dictionary); + } +#endif + + var sr = new StreamReader(responseStream); + + if (isAsync) + { + stringValue = await sr.ReadToEndAsync + ( +#if NET8_0_OR_GREATER + cancellationToken +#endif + ).ConfigureAwait(false); + } + else + { + stringValue = sr.ReadToEnd(); + } + + dictionary = new DynamicDictionary + { + ["body"] = new DynamicValue(stringValue) + }; + + response = new DynamicResponse(dictionary); + } + else + { + var body = LowLevelRequestResponseSerializer.Instance.Deserialize(responseStream); + response = new DynamicResponse(body); + } + + return response; + } +} diff --git a/src/Elastic.Transport/Responses/EmptyError.cs b/src/Elastic.Transport/Responses/EmptyError.cs deleted file mode 100644 index 5fad61b..0000000 --- a/src/Elastic.Transport/Responses/EmptyError.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -namespace Elastic.Transport; - -/// -/// -/// -internal sealed class EmptyError : ErrorResponse -{ - public static readonly EmptyError Instance = new(); - - /// - public override bool HasError() => false; -} diff --git a/src/Elastic.Transport/Responses/HttpDetails/ApiCallDetails.cs b/src/Elastic.Transport/Responses/HttpDetails/ApiCallDetails.cs index 08bc240..142f241 100644 --- a/src/Elastic.Transport/Responses/HttpDetails/ApiCallDetails.cs +++ b/src/Elastic.Transport/Responses/HttpDetails/ApiCallDetails.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Net.NetworkInformation; using System.Text; using Elastic.Transport.Diagnostics; @@ -22,22 +23,22 @@ public sealed class ApiCallDetails internal ApiCallDetails() { } /// - /// + /// Access to the collection of events that occurred during the request. /// > public IEnumerable AuditTrail { get; internal set; } /// - /// + /// Statistics about the worker and I/O completion port threads at the time of the request. /// internal IReadOnlyDictionary ThreadPoolStats { get; set; } /// - /// + /// Statistics about the number of ports in various TCP states at the time of the request. /// internal IReadOnlyDictionary TcpStats { get; set; } /// - /// + /// Information used to debug the request. /// public string DebugInformation { @@ -55,50 +56,49 @@ public string DebugInformation } /// - /// + /// The used in the request. /// public HttpMethod HttpMethod { get; internal set; } /// - /// + /// The of the response. /// public int? HttpStatusCode { get; internal set; } /// - /// + /// The that occurred during the request, othwerwise null. /// public Exception? OriginalException { get; internal set; } /// - /// + /// The buffered request bytes when using + /// otherwise, null. /// - public byte[] RequestBodyInBytes { get; internal set; } + public byte[]? RequestBodyInBytes { get; internal set; } /// - /// + /// The buffered response bytes when using + /// otherwise, null. /// - public byte[] ResponseBodyInBytes { get; internal set; } + public byte[]? ResponseBodyInBytes { get; internal set; } /// - /// + /// The value of the Content-Type header in the response. /// - public string ResponseMimeType { get; set; } + public string ResponseContentType { get; set; } /// - /// + /// Indicates whether the response has a status code that is considered successful. /// public bool HasSuccessfulStatusCode { get; internal set; } /// - /// + /// Indicates whether the response has a Content-Type header that is expected. /// public bool HasExpectedContentType { get; internal set; } internal bool HasSuccessfulStatusCodeAndExpectedContentType => HasSuccessfulStatusCode && HasExpectedContentType; - /// - /// - /// internal bool SuccessOrKnownError => HasSuccessfulStatusCodeAndExpectedContentType || HttpStatusCode >= 400 @@ -109,36 +109,25 @@ public string DebugInformation && HasExpectedContentType; /// - /// + /// The of the request. /// public Uri? Uri { get; internal set; } - /// - /// - /// internal ITransportConfiguration TransportConfiguration { get; set; } - /// - /// - /// internal IReadOnlyDictionary> ParsedHeaders { get; set; } = EmptyReadOnly>.Dictionary; /// - /// + /// Tries to get the value of a header if present in the parsed headers. /// - /// - /// - /// - // TODO: Nullable annotations - public bool TryGetHeader(string key, out IEnumerable headerValues) => + /// The name of the header to locate. + /// When this method returns, the value associated with the specified key, if the + /// key is found; otherwise, the default value for the type of the value parameter. This parameter is passed uninitialized. + /// A indiciating whether the header was located. + public bool TryGetHeader(string key, [NotNullWhen(true)] out IEnumerable? headerValues) => ParsedHeaders.TryGetValue(key, out headerValues); - /// - /// The error response if the server returned JSON describing a server error. - /// - internal ErrorResponse ErrorResponse { get; set; } = EmptyError.Instance; - /// /// A string summarising the API call. /// diff --git a/src/Elastic.Transport/Responses/IResponseBuilder.cs b/src/Elastic.Transport/Responses/IResponseBuilder.cs new file mode 100644 index 0000000..1743d0d --- /dev/null +++ b/src/Elastic.Transport/Responses/IResponseBuilder.cs @@ -0,0 +1,49 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Elastic.Transport; + +/// +/// A builder that handles one or more response types derived from . +/// +public interface IResponseBuilder +{ + /// + /// Determines whether the builder can build a . + /// + /// The response type to be built. + /// A bool which indicates whether the builder can build the . + bool CanBuild() where TResponse : TransportResponse, new(); + + /// + /// Build a from the supplied . + /// + /// The specific type of the to be built. + /// The initialized for the response. + /// The for the HTTP request. + /// The readable containing the response body. + /// The value of the Content-Type header for the response. + /// The length of the content, if available in the response headers. + /// A potentiall null response of type . + TResponse? Build(ApiCallDetails apiCallDetails, RequestData requestData, + Stream responseStream, string contentType, long contentLength) where TResponse : TransportResponse, new(); + + /// + /// Build a from the supplied . + /// + /// The specific type of the to be built. + /// The initialized for the response. + /// The for the HTTP request. + /// The readable containing the response body. + /// The value of the Content-Type header for the response. + /// The length of the content, if available in the response headers. + /// An optional that can trigger cancellation. + /// A potentiall null response of type . + Task BuildAsync(ApiCallDetails apiCallDetails, RequestData requestData, + Stream responseStream, string contentType, long contentLength, CancellationToken cancellationToken = default) where TResponse : TransportResponse, new(); +} diff --git a/src/Elastic.Transport/Components/Pipeline/ResponseBuilder.cs b/src/Elastic.Transport/Responses/ResponseFactory.cs similarity index 78% rename from src/Elastic.Transport/Components/Pipeline/ResponseBuilder.cs rename to src/Elastic.Transport/Responses/ResponseFactory.cs index 643089b..30f86de 100644 --- a/src/Elastic.Transport/Components/Pipeline/ResponseBuilder.cs +++ b/src/Elastic.Transport/Responses/ResponseFactory.cs @@ -17,15 +17,12 @@ namespace Elastic.Transport; /// /// Builds a from the provided response data. /// -public abstract class ResponseBuilder +public abstract class ResponseFactory { - /// Exposes a default response builder to implementers without sharing more internal types to handle empty errors - public static ResponseBuilder Default { get; } = new DefaultResponseBuilder(); - /// /// Create an instance of from /// - public abstract TResponse ToResponse( + public abstract TResponse Create( Endpoint endpoint, RequestData requestData, PostData? postData, @@ -33,17 +30,16 @@ public abstract TResponse ToResponse( int? statusCode, Dictionary>? headers, Stream responseStream, - string? mimeType, + string? contentType, long contentLength, IReadOnlyDictionary? threadPoolStats, IReadOnlyDictionary? tcpStats - ) where TResponse : TransportResponse, new(); /// /// Create an instance of from /// - public abstract Task ToResponseAsync( + public abstract Task CreateAsync( Endpoint endpoint, RequestData requestData, PostData? postData, @@ -51,24 +47,25 @@ public abstract Task ToResponseAsync( int? statusCode, Dictionary>? headers, Stream responseStream, - string? mimeType, + string? contentType, long contentLength, IReadOnlyDictionary? threadPoolStats, IReadOnlyDictionary? tcpStats, CancellationToken cancellationToken = default ) where TResponse : TransportResponse, new(); - internal static ApiCallDetails Initialize( + internal static ApiCallDetails InitializeApiCallDetails( Endpoint endpoint, RequestData requestData, PostData? postData, Exception exception, int? statusCode, - Dictionary> headers, string mimeType, + Dictionary> headers, + string contentType, IReadOnlyDictionary threadPoolStats, IReadOnlyDictionary tcpStats, - long contentLength - ) + ThreadPoolStatistics> threadPoolStats, + IReadOnlyDictionary tcpStats, + long contentLength) { var hasSuccessfulStatusCode = false; var allowedStatusCodes = requestData.AllowedStatusCodes; @@ -83,7 +80,7 @@ long contentLength // We don't validate the content-type (MIME type) for HEAD requests or responses that have no content (204 status code). // Elastic Cloud responses to HEAD requests strip the content-type header so we want to avoid validation in that case. - var hasExpectedContentType = !MayHaveBody(statusCode, endpoint.Method, contentLength) || ValidateResponseContentType(requestData.Accept, mimeType); + var hasExpectedContentType = !MayHaveBody(statusCode, endpoint.Method, contentLength) || ValidateResponseContentType(requestData.Accept, contentType); var details = new ApiCallDetails { @@ -96,7 +93,7 @@ long contentLength HttpMethod = endpoint.Method, TcpStats = tcpStats, ThreadPoolStats = threadPoolStats, - ResponseMimeType = mimeType, + ResponseContentType = contentType, TransportConfiguration = requestData.ConnectionSettings }; @@ -114,26 +111,25 @@ long contentLength protected static bool MayHaveBody(int? statusCode, HttpMethod httpMethod, long contentLength) => contentLength != 0 && (!statusCode.HasValue || statusCode.Value != 204 && httpMethod != HttpMethod.HEAD); - internal static bool ValidateResponseContentType(string accept, string responseMimeType) + internal static bool ValidateResponseContentType(string accept, string responseContentType) { - if (string.IsNullOrEmpty(responseMimeType)) return false; + if (string.IsNullOrEmpty(responseContentType)) return false; - if (accept == responseMimeType) + if (accept == responseContentType) return true; // TODO - Performance: Review options to avoid the replace here and compare more efficiently. var trimmedAccept = accept.Replace(" ", ""); - var trimmedResponseMimeType = responseMimeType.Replace(" ", ""); + var normalizedResponseContentType = responseContentType.Replace(" ", ""); - return trimmedResponseMimeType.Equals(trimmedAccept, StringComparison.OrdinalIgnoreCase) - || trimmedResponseMimeType.StartsWith(trimmedAccept, StringComparison.OrdinalIgnoreCase) + return normalizedResponseContentType.Equals(trimmedAccept, StringComparison.OrdinalIgnoreCase) + || normalizedResponseContentType.StartsWith(trimmedAccept, StringComparison.OrdinalIgnoreCase) // ES specific fallback required because: // - 404 responses from ES8 don't include the vendored header // - ES8 EQL responses don't include vendored type || trimmedAccept.Contains("application/vnd.elasticsearch+json") - && trimmedResponseMimeType.StartsWith(RequestData.DefaultMimeType, StringComparison.OrdinalIgnoreCase); + && normalizedResponseContentType.StartsWith(RequestData.DefaultContentType, StringComparison.OrdinalIgnoreCase); } - } diff --git a/src/Elastic.Transport/Responses/Special/BytesResponseBuilder.cs b/src/Elastic.Transport/Responses/Special/BytesResponseBuilder.cs new file mode 100644 index 0000000..d4df48b --- /dev/null +++ b/src/Elastic.Transport/Responses/Special/BytesResponseBuilder.cs @@ -0,0 +1,42 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Elastic.Transport; + +internal class BytesResponseBuilder : TypedResponseBuilder +{ + protected override BytesResponse Build(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, string contentType, long contentLength) => + BuildCoreAsync(false, apiCallDetails, requestData, responseStream).EnsureCompleted(); + + protected override Task BuildAsync(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, string contentType, long contentLength, CancellationToken cancellationToken = default) => + BuildCoreAsync(true, apiCallDetails, requestData, responseStream, cancellationToken).AsTask(); + + private static async ValueTask BuildCoreAsync(bool isAsync, ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, CancellationToken cancellationToken = default) + { + BytesResponse response; + + if (apiCallDetails.ResponseBodyInBytes is not null) + { + response = new BytesResponse(apiCallDetails.ResponseBodyInBytes); + return response; + } + + var tempStream = requestData.MemoryStreamFactory.Create(); + await responseStream.CopyToAsync(tempStream, BufferedResponseHelpers.BufferSize, cancellationToken).ConfigureAwait(false); + apiCallDetails.ResponseBodyInBytes = BufferedResponseHelpers.SwapStreams(ref responseStream, ref tempStream); + response = new BytesResponse(apiCallDetails.ResponseBodyInBytes); + +#if NET6_0_OR_GREATER + await responseStream.DisposeAsync().ConfigureAwait(false); +#else + responseStream.Dispose(); +#endif + + return response; + } +} diff --git a/src/Elastic.Transport/Responses/Special/StreamResponse.cs b/src/Elastic.Transport/Responses/Special/StreamResponse.cs index 08b2de6..d097ac3 100644 --- a/src/Elastic.Transport/Responses/Special/StreamResponse.cs +++ b/src/Elastic.Transport/Responses/Special/StreamResponse.cs @@ -20,20 +20,20 @@ public class StreamResponse : TransportResponse, IDisposable /// /// The MIME type of the response, if present. /// - public string MimeType { get; } + public string ContentType { get; } /// public StreamResponse() { Body = Stream.Null; - MimeType = string.Empty; + ContentType = string.Empty; } /// - public StreamResponse(Stream body, string? mimeType) + public StreamResponse(Stream body, string? contentType) { Body = body; - MimeType = mimeType ?? string.Empty; + ContentType = contentType ?? string.Empty; } internal override bool LeaveOpen => true; diff --git a/src/Elastic.Transport/Responses/Special/StreamResponseBuilder.cs b/src/Elastic.Transport/Responses/Special/StreamResponseBuilder.cs new file mode 100644 index 0000000..1e0cb3a --- /dev/null +++ b/src/Elastic.Transport/Responses/Special/StreamResponseBuilder.cs @@ -0,0 +1,19 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Elastic.Transport; + +internal class StreamResponseBuilder : TypedResponseBuilder +{ + protected override StreamResponse Build(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, string contentType, long contentLength) => + new(responseStream, contentType); + + protected override Task BuildAsync(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, string contentType, + long contentLength, CancellationToken cancellationToken = default) => + Task.FromResult(new StreamResponse(responseStream, contentType)); +} diff --git a/src/Elastic.Transport/Responses/Special/StringResponseBuilder.cs b/src/Elastic.Transport/Responses/Special/StringResponseBuilder.cs new file mode 100644 index 0000000..313ffb5 --- /dev/null +++ b/src/Elastic.Transport/Responses/Special/StringResponseBuilder.cs @@ -0,0 +1,75 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +#if NET8_0_OR_GREATER +using System; +using System.Buffers; +#endif + +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Elastic.Transport; + +internal class StringResponseBuilder : TypedResponseBuilder +{ + protected override StringResponse Build(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, string contentType, long contentLength) + { + string responseString; + + if (apiCallDetails.ResponseBodyInBytes is not null) + { + responseString = Encoding.UTF8.GetString(apiCallDetails.ResponseBodyInBytes); + return new StringResponse(responseString); + } + +#if NET8_0_OR_GREATER + if (contentLength > -1 && contentLength <= 1_048_576) + { + var buffer = ArrayPool.Shared.Rent((int)contentLength); + responseStream.ReadExactly(buffer, 0, (int)contentLength); + responseString = Encoding.UTF8.GetString(buffer.AsSpan(0, (int)contentLength)); + ArrayPool.Shared.Return(buffer); + return new StringResponse(responseString); + } +#endif + + var sr = new StreamReader(responseStream); + responseString = sr.ReadToEnd(); + return new StringResponse(responseString); + } + + protected override async Task BuildAsync(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, string contentType, long contentLength, + CancellationToken cancellationToken = default) + { + string responseString; + + if (apiCallDetails.ResponseBodyInBytes is not null) + { + responseString = Encoding.UTF8.GetString(apiCallDetails.ResponseBodyInBytes); + return new StringResponse(responseString); + } + +#if NET8_0_OR_GREATER + if (contentLength > -1 && contentLength < 1_048_576) + { + var buffer = ArrayPool.Shared.Rent((int)contentLength); + await responseStream.ReadExactlyAsync(buffer, 0, (int)contentLength, cancellationToken).ConfigureAwait(false); + responseString = Encoding.UTF8.GetString(buffer.AsSpan(0, (int)contentLength)); + ArrayPool.Shared.Return(buffer); + return new StringResponse(responseString); + } +#endif + + var sr = new StreamReader(responseStream); +#if NET8_0_OR_GREATER + responseString = await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false); +#else + responseString = await sr.ReadToEndAsync().ConfigureAwait(false); +#endif + return new StringResponse(responseString); + } +} diff --git a/src/Elastic.Transport/Responses/Special/VoidResponseBuilder.cs b/src/Elastic.Transport/Responses/Special/VoidResponseBuilder.cs new file mode 100644 index 0000000..476c049 --- /dev/null +++ b/src/Elastic.Transport/Responses/Special/VoidResponseBuilder.cs @@ -0,0 +1,19 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Elastic.Transport; + +internal class VoidResponseBuilder : TypedResponseBuilder +{ + protected override VoidResponse Build(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, string contentType, long contentLength) => + VoidResponse.Default; + + protected override Task BuildAsync(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, string contentType, long contentLength, + CancellationToken cancellationToken = default) => + Task.FromResult(VoidResponse.Default); +} diff --git a/src/Elastic.Transport/Responses/TypedResponseBuilder.cs b/src/Elastic.Transport/Responses/TypedResponseBuilder.cs new file mode 100644 index 0000000..ef2a6bc --- /dev/null +++ b/src/Elastic.Transport/Responses/TypedResponseBuilder.cs @@ -0,0 +1,32 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Elastic.Transport; + +/// +/// A builder for a specific type. +/// +/// +public abstract class TypedResponseBuilder : IResponseBuilder +{ + bool IResponseBuilder.CanBuild() => typeof(TResponse) == typeof(T); + + /// + protected abstract TResponse? Build(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, string contentType, long contentLength); + + T IResponseBuilder.Build(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, string contentType, long contentLength) => + Build(apiCallDetails, requestData, responseStream, contentType, contentLength) as T; + + /// + protected abstract Task BuildAsync(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, + string contentType, long contentLength, CancellationToken cancellationToken = default); + + Task IResponseBuilder.BuildAsync(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, string contentType, + long contentLength, CancellationToken cancellationToken) => + BuildAsync(apiCallDetails, requestData, responseStream, contentType, contentLength, cancellationToken) as Task; +} diff --git a/tests/Elastic.Transport.IntegrationTests/Elastic.Transport.IntegrationTests.csproj b/tests/Elastic.Transport.IntegrationTests/Elastic.Transport.IntegrationTests.csproj index d3da112..1778621 100644 --- a/tests/Elastic.Transport.IntegrationTests/Elastic.Transport.IntegrationTests.csproj +++ b/tests/Elastic.Transport.IntegrationTests/Elastic.Transport.IntegrationTests.csproj @@ -29,6 +29,7 @@ + diff --git a/tests/Elastic.Transport.IntegrationTests/Http/StreamResponseTests.cs b/tests/Elastic.Transport.IntegrationTests/Http/StreamResponseTests.cs deleted file mode 100644 index 1e8d2a0..0000000 --- a/tests/Elastic.Transport.IntegrationTests/Http/StreamResponseTests.cs +++ /dev/null @@ -1,208 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Elastic.Transport.IntegrationTests.Plumbing; -using Elastic.Transport.Products.Elasticsearch; -using FluentAssertions; -using Microsoft.AspNetCore.Mvc; -using Xunit; - -namespace Elastic.Transport.IntegrationTests.Http; - -public class StreamResponseTests(TransportTestServer instance) : AssemblyServerTestsBase(instance) -{ - private const string Path = "/streamresponse"; - - [Fact] - public async Task StreamResponse_ShouldNotBeDisposed() - { - var nodePool = new SingleNodePool(Server.Uri); - var config = new TransportConfiguration(nodePool, productRegistration: new ElasticsearchProductRegistration(typeof(Clients.Elasticsearch.ElasticsearchClient))); - var transport = new DistributedTransport(config); - - var response = await transport.PostAsync(Path, PostData.String("{}")); - - // Ensure the stream is readable - using var sr = new StreamReader(response.Body); - _ = sr.ReadToEndAsync(); - } - - [Fact] - public async Task StreamResponse_MemoryStreamShouldNotBeDisposed() - { - var nodePool = new SingleNodePool(Server.Uri); - var memoryStreamFactory = new TrackMemoryStreamFactory(); - var config = new TransportConfiguration(nodePool, productRegistration: new ElasticsearchProductRegistration(typeof(Clients.Elasticsearch.ElasticsearchClient))) - { - MemoryStreamFactory = memoryStreamFactory, - DisableDirectStreaming = true - }; - - var transport = new DistributedTransport(config); - - _ = await transport.PostAsync(Path, PostData.String("{}")); - - // When disable direct streaming, we have 1 for the original content, 1 for the buffered request bytes and the last for the buffered response - memoryStreamFactory.Created.Count.Should().Be(3); - memoryStreamFactory.Created.Last().IsDisposed.Should().BeFalse(); - } - - [Fact] - public async Task StringResponse_MemoryStreamShouldBeDisposed() - { - var nodePool = new SingleNodePool(Server.Uri); - var memoryStreamFactory = new TrackMemoryStreamFactory(); - var config = new TransportConfiguration(nodePool, productRegistration: new ElasticsearchProductRegistration(typeof(Clients.Elasticsearch.ElasticsearchClient))) - { - MemoryStreamFactory = memoryStreamFactory - }; - - var transport = new DistributedTransport(config); - - _ = await transport.PostAsync(Path, PostData.String("{}")); - - memoryStreamFactory.Created.Count.Should().Be(2); - foreach (var memoryStream in memoryStreamFactory.Created) - memoryStream.IsDisposed.Should().BeTrue(); - } - - [Fact] - public async Task WhenInvalidJson_MemoryStreamShouldBeDisposed() - { - var nodePool = new SingleNodePool(Server.Uri); - var memoryStreamFactory = new TrackMemoryStreamFactory(); - var config = new TransportConfiguration(nodePool, productRegistration: new ElasticsearchProductRegistration(typeof(Clients.Elasticsearch.ElasticsearchClient))) - { - MemoryStreamFactory = memoryStreamFactory, - DisableDirectStreaming = true - }; - - var transport = new DistributedTransport(config); - - var payload = new Payload { ResponseJsonString = " " }; - _ = await transport.PostAsync(Path, PostData.Serializable(payload)); - - memoryStreamFactory.Created.Count.Should().Be(3); - foreach (var memoryStream in memoryStreamFactory.Created) - memoryStream.IsDisposed.Should().BeTrue(); - } - - [Fact] - public async Task WhenNoContent_MemoryStreamShouldBeDisposed() - { - var nodePool = new SingleNodePool(Server.Uri); - var memoryStreamFactory = new TrackMemoryStreamFactory(); - var config = new TransportConfiguration(nodePool, productRegistration: new ElasticsearchProductRegistration(typeof(Clients.Elasticsearch.ElasticsearchClient))) - { - MemoryStreamFactory = memoryStreamFactory, - }; - - var transport = new DistributedTransport(config); - - var payload = new Payload { ResponseJsonString = "", StatusCode = 204 }; - _ = await transport.PostAsync(Path, PostData.Serializable(payload)); - - // We expect one for sending the request payload, but as the response is 204, we shouldn't - // see other memory streams being created for the response. - memoryStreamFactory.Created.Count.Should().Be(2); - foreach (var memoryStream in memoryStreamFactory.Created) - memoryStream.IsDisposed.Should().BeTrue(); - } - - [Fact] - public async Task PlainText_MemoryStreamShouldBeDisposed() - { - var nodePool = new SingleNodePool(Server.Uri); - var memoryStreamFactory = new TrackMemoryStreamFactory(); - var config = new TransportConfiguration(nodePool, productRegistration: new ElasticsearchProductRegistration(typeof(Clients.Elasticsearch.ElasticsearchClient))) - { - MemoryStreamFactory = memoryStreamFactory, - DisableDirectStreaming = true - }; - - var transport = new DistributedTransport(config); - - var payload = new Payload { ResponseJsonString = "text", ContentType = "text/plain" }; - _ = await transport.PostAsync(Path, PostData.Serializable(payload)); - - memoryStreamFactory.Created.Count.Should().Be(3); - foreach (var memoryStream in memoryStreamFactory.Created) - memoryStream.IsDisposed.Should().BeTrue(); - } - - private class TestResponse : TransportResponse; - - private class TrackDisposeStream : MemoryStream - { - public TrackDisposeStream() { } - - public TrackDisposeStream(byte[] bytes) : base(bytes) { } - - public TrackDisposeStream(byte[] bytes, int index, int count) : base(bytes, index, count) { } - - public bool IsDisposed { get; private set; } - - protected override void Dispose(bool disposing) - { - IsDisposed = true; - base.Dispose(disposing); - } - } - - private class TrackMemoryStreamFactory : MemoryStreamFactory - { - public IList Created { get; } = []; - - public override MemoryStream Create() - { - var stream = new TrackDisposeStream(); - Created.Add(stream); - return stream; - } - - public override MemoryStream Create(byte[] bytes) - { - var stream = new TrackDisposeStream(bytes); - Created.Add(stream); - return stream; - } - - public override MemoryStream Create(byte[] bytes, int index, int count) - { - var stream = new TrackDisposeStream(bytes, index, count); - Created.Add(stream); - return stream; - } - } -} - -public class Payload -{ - public string ResponseJsonString { get; set; } = "{}"; - public string ContentType { get; set; } = "application/json"; - public int StatusCode { get; set; } = 200; -} - -[ApiController, Route("[controller]")] -public class StreamResponseController : ControllerBase -{ - [HttpPost] - public async Task Post([FromBody] Payload payload) - { - Response.ContentType = payload.ContentType; - - if (payload.StatusCode != 204) - { - await Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(payload.ResponseJsonString)); - await Response.BodyWriter.CompleteAsync(); - } - - return StatusCode(payload.StatusCode); - } -} diff --git a/tests/Elastic.Transport.IntegrationTests/Http/TransferEncodingChunckedTests.cs b/tests/Elastic.Transport.IntegrationTests/Http/TransferEncodingChunckedTests.cs index 7891ba0..b3491c3 100644 --- a/tests/Elastic.Transport.IntegrationTests/Http/TransferEncodingChunckedTests.cs +++ b/tests/Elastic.Transport.IntegrationTests/Http/TransferEncodingChunckedTests.cs @@ -21,11 +21,8 @@ public class ChunkedController : ControllerBase public Task Post([FromBody]JsonElement body) => Task.FromResult(body); } - public class TransferEncodingChunkedTests : AssemblyServerTestsBase + public class TransferEncodingChunkedTests(TransportTestServer instance) : AssemblyServerTestsBase(instance) { - public TransferEncodingChunkedTests(TransportTestServer instance) : base(instance) { } - - private const string BodyString = "{\"query\":{\"match_all\":{}}}"; private static readonly PostData Body = PostData.String(BodyString); private const string Path = "/chunked"; @@ -44,6 +41,7 @@ private ITransport Setup( TransferEncodingChunked = transferEncodingChunked, EnableHttpCompression = httpCompression }; + config = disableAutomaticProxyDetection.HasValue ? config with { DisableAutomaticProxyDetection = disableAutomaticProxyDetection.Value } //make sure we the requests in debugging proxy diff --git a/tests/Elastic.Transport.IntegrationTests/Plumbing/Stubs/TrackingRequestInvoker.cs b/tests/Elastic.Transport.IntegrationTests/Plumbing/Stubs/TrackingRequestInvoker.cs index 7a05aea..0409fbf 100644 --- a/tests/Elastic.Transport.IntegrationTests/Plumbing/Stubs/TrackingRequestInvoker.cs +++ b/tests/Elastic.Transport.IntegrationTests/Plumbing/Stubs/TrackingRequestInvoker.cs @@ -15,6 +15,9 @@ public class TrackingRequestInvoker : IRequestInvoker private TestableClientHandler _handler; public int CallCount { get; private set; } public HttpClientHandler LastHttpClientHandler => (HttpClientHandler)_handler.InnerHandler; + + public ResponseFactory ResponseFactory => _requestInvoker.ResponseFactory; + private readonly HttpRequestInvoker _requestInvoker; public TrackingRequestInvoker(Action response) : this() => _response = response; diff --git a/tests/Elastic.Transport.IntegrationTests/Plumbing/TransportTestServer.cs b/tests/Elastic.Transport.IntegrationTests/Plumbing/TransportTestServer.cs index 96a26f2..fd89584 100644 --- a/tests/Elastic.Transport.IntegrationTests/Plumbing/TransportTestServer.cs +++ b/tests/Elastic.Transport.IntegrationTests/Plumbing/TransportTestServer.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Xunit; [assembly: TestFramework("Xunit.Extensions.Ordering.TestFramework", "Xunit.Extensions.Ordering")] @@ -35,7 +36,6 @@ public static TransportConfiguration RerouteToProxyIfNeeded(TransportConfigurati return config with { ProxyAddress = "http://127.0.0.1:8080" }; } - } public class TransportTestServer : HttpTransportTestServer, IDisposable, IAsyncDisposable, IAsyncLifetime diff --git a/tests/Elastic.Transport.IntegrationTests/Responses/SpecialisedResponseTests.cs b/tests/Elastic.Transport.IntegrationTests/Responses/SpecialisedResponseTests.cs new file mode 100644 index 0000000..ecd3830 --- /dev/null +++ b/tests/Elastic.Transport.IntegrationTests/Responses/SpecialisedResponseTests.cs @@ -0,0 +1,907 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Elastic.Transport.IntegrationTests.Plumbing; +using Elastic.Transport.Tests.Shared; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Xunit; + +namespace Elastic.Transport.IntegrationTests.Responses; + +public class SpecialisedResponseTests(TransportTestServer instance) : AssemblyServerTestsBase(instance) +{ + private const string Path = "/specialresponse"; + private const string EmptyJson = "{}"; + private static readonly byte[] EmptyJsonBytes = [(byte)'{', (byte)'}']; + private const string LargeJson = "[{\"_id\":\"672b13c7666cae7721b7f5c8\",\"index\":0,\"guid\":\"f8a9356c-660b-4f4f-a1c2-84048e0599b9\",\"isActive\":false,\"balance\":\"$3,856.23\"," + + "\"picture\":\"http://placehold.it/32x32\",\"age\":29,\"eyeColor\":\"green\",\"name\":\"Noemi Reed\",\"gender\":\"female\",\"company\":\"LUNCHPOD\",\"email\":" + + "\"noemireed@lunchpod.com\",\"phone\":\"+1 (961) 417-3668\",\"address\":\"954 Cameron Court, Onton, South Dakota, 1148\",\"about\":\"Qui ad id veniam aute amet " + + "commodo officia est cillum. Elit nostrud Lorem tempor duis. Commodo velit nulla nisi velit laborum qui minim nostrud aute dolor tempor officia. Commodo proident " + + "nulla eu adipisicing incididunt eu. Quis nostrud Lorem amet deserunt pariatur ea elit adipisicing qui. Voluptate exercitation id esse tempor occaecat.\\r\\n\"," + + "\"registered\":\"2017-02-28T04:33:12 -00:00\",\"latitude\":30.32678,\"longitude\":-156.977981,\"tags\":[\"sit\",\"culpa\",\"cillum\",\"labore\",\"in\",\"labore\"," + + "\"quis\"],\"friends\":[{\"id\":0,\"name\":\"Good Lyons\"},{\"id\":1,\"name\":\"Mccarthy Delaney\"},{\"id\":2,\"name\":\"Winters Combs\"}],\"greeting\":\"Hello, " + + "Noemi Reed! You have 8 unread messages.\",\"favoriteFruit\":\"strawberry\"},{\"_id\":\"672b13c741693abd9d0173a9\",\"index\":1,\"guid\":" + + "\"fa3d27ec-213c-4365-92e9-39774eec9d01\",\"isActive\":false,\"balance\":\"$2,275.63\",\"picture\":\"http://placehold.it/32x32\",\"age\":23,\"eyeColor\":\"brown\"," + + "\"name\":\"Cooley Williams\",\"gender\":\"male\",\"company\":\"GALLAXIA\",\"email\":\"cooleywilliams@gallaxia.com\",\"phone\":\"+1 (961) 439-2700\",\"address\":" + + "\"791 Montgomery Place, Garfield, Guam, 9900\",\"about\":\"Officia consectetur do quis id cillum quis esse. Aliqua deserunt eiusmod laboris cupidatat enim commodo " + + "est Lorem id nisi mollit non. Eiusmod adipisicing pariatur culpa nostrud incididunt dolor commodo fugiat amet ex dolor ex. Nostrud incididunt consequat ullamco " + + "pariatur cupidatat nulla eu voluptate cupidatat nulla. Mollit est id adipisicing ad mollit exercitation. Ullamco non ad aliquip ea sit culpa pariatur commodo " + + "veniam. In occaecat et tempor ea Lorem eu incididunt sit commodo officia.\\r\\n\",\"registered\":\"2019-05-25T11:41:44 -01:00\",\"latitude\":-85.996713,\"longitude\"" + + ":-140.910029,\"tags\":[\"esse\",\"qui\",\"magna\",\"et\",\"irure\",\"est\",\"in\"],\"friends\":[{\"id\":0,\"name\":\"Pamela Castillo\"},{\"id\":1,\"name\"" + + ":\"Suzanne Herman\"},{\"id\":2,\"name\":\"Gonzales Bush\"}],\"greeting\":\"Hello, Cooley Williams! You have 8 unread messages.\",\"favoriteFruit\":\"apple\"}]"; + + [Fact] + public async Task VoidResponse_ShouldReturnExpectedResponse() + { + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory + }; + var transport = new DistributedTransport(config); + + var payload = new Payload { ResponseString = EmptyJson, StatusCode = 200 }; + + var response = await transport.PostAsync(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + memoryStreamFactory.Reset(); + response = transport.Post(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + static void Validate(TrackingMemoryStreamFactory memoryStreamFactory, VoidResponse response) + { + response.Body.Should().BeSameAs(VoidResponse.Default.Body); + response.ApiCallDetails.ResponseBodyInBytes.Should().BeNull(); + memoryStreamFactory.Created.Count.Should().Be(1); // One required for setting request content + } + } + + [Fact] + public async Task DynamicResponse_WhenContentIsJson_AndNotDisableDirectStreaming_ShouldReturnExpectedResponse() + { + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory + }; + var transport = new DistributedTransport(config); + + var json = "{\"propertyOne\":\"value1\",\"propertyTwo\":100}"; + var payload = new Payload { ResponseString = json, StatusCode = 200 }; + + var response = await transport.PostAsync(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + memoryStreamFactory.Reset(); + response = transport.Post(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + static void Validate(TrackingMemoryStreamFactory memoryStreamFactory, DynamicResponse response) + { + response.Body.Should().BeOfType(); + response.Body.Values.Count.Should().Be(2); + response.Body.Get("propertyOne").Should().Be("value1"); + response.Body.Get("propertyTwo").Should().Be(100); + + response.ApiCallDetails.ResponseBodyInBytes.Should().BeNull(); + memoryStreamFactory.Created.Count.Should().Be(1); // One required for setting request content + } + } + + [Fact] + public async Task DynamicResponse_WhenContentIsNotJson_AndContentIsChunked_AndNotDisableDirectStreaming_ShouldReturnExpectedResponse() + { + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory + }; + var transport = new DistributedTransport(config); + + var stringValue = "this is a string"; + var payload = new Payload { ResponseString = stringValue, StatusCode = 200, ContentType = "text/plain", IsChunked = true }; + + var requestConfig = new RequestConfiguration { Accept = "text/plain" }; + var response = await transport.RequestAsync(new EndpointPath(HttpMethod.POST, Path), PostData.Serializable(payload), default, requestConfig); + + Validate(memoryStreamFactory, response, stringValue); + + memoryStreamFactory.Reset(); + response = transport.Request(new EndpointPath(HttpMethod.POST, Path), PostData.Serializable(payload), default, requestConfig); + + Validate(memoryStreamFactory, response, stringValue); + + static void Validate(TrackingMemoryStreamFactory memoryStreamFactory, DynamicResponse response, string expected) + { + response.Body.Should().BeOfType(); + response.Body.Values.Count.Should().Be(1); + response.Body.Get("body").Should().Be(expected); + + response.ApiCallDetails.ResponseBodyInBytes.Should().BeNull(); + memoryStreamFactory.Created.Count.Should().Be(1); // One required for setting request content + } + } + + [Fact] + public async Task DynamicResponse_WhenContentIsJson_AndDisableDirectStreaming_ShouldReturnExpectedResponse() + { + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + DisableDirectStreaming = true, + MemoryStreamFactory = memoryStreamFactory + }; + var transport = new DistributedTransport(config); + + var json = "{\"propertyOne\":\"value1\",\"propertyTwo\":100}"; + var payload = new Payload { ResponseString = json, StatusCode = 200 }; + + var response = await transport.PostAsync(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + memoryStreamFactory.Reset(); + response = transport.Post(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + static void Validate(TrackingMemoryStreamFactory memoryStreamFactory, DynamicResponse response) + { + response.Body.Should().BeOfType(); + response.Body.Values.Count.Should().Be(2); + response.Body.Get("propertyOne").Should().Be("value1"); + response.Body.Get("propertyTwo").Should().Be(100); + + response.ApiCallDetails.ResponseBodyInBytes.Should().NotBeNull(); + memoryStreamFactory.Created.Count.Should().Be(3); + } + } + + [Fact] + public async Task DynamicResponse_WhenContentIsNotJson_AndNotDisableDirectStreaming_ShouldReturnExpectedResponse() + { + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory + }; + var transport = new DistributedTransport(config); + + var stringValue = "this is a string"; + var payload = new Payload { ResponseString = stringValue, StatusCode = 200, ContentType = "text/plain" }; + + var requestConfig = new RequestConfiguration { Accept = "text/plain" }; + var response = await transport.RequestAsync(new EndpointPath(HttpMethod.POST, Path), PostData.Serializable(payload), default, requestConfig); + + Validate(memoryStreamFactory, response, stringValue); + + memoryStreamFactory.Reset(); + response = transport.Request(new EndpointPath(HttpMethod.POST, Path), PostData.Serializable(payload), default, requestConfig); + + Validate(memoryStreamFactory, response, stringValue); + + static void Validate(TrackingMemoryStreamFactory memoryStreamFactory, DynamicResponse response, string expected) + { + response.Body.Should().BeOfType(); + response.Body.Values.Count.Should().Be(1); + response.Body.Get("body").Should().Be(expected); + + response.ApiCallDetails.ResponseBodyInBytes.Should().BeNull(); + memoryStreamFactory.Created.Count.Should().Be(1); // One required for setting request content + } + } + + [Fact] + public async Task DynamicResponse_WhenContentIsNotJson_AndDisableDirectStreaming_ShouldReturnExpectedResponse() + { + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + DisableDirectStreaming = true, + MemoryStreamFactory = memoryStreamFactory + }; + var transport = new DistributedTransport(config); + + var stringValue = "this is a string"; + var payload = new Payload { ResponseString = stringValue, StatusCode = 200, ContentType = "text/plain" }; + + var requestConfig = new RequestConfiguration { Accept = "text/plain" }; + var response = await transport.RequestAsync(new EndpointPath(HttpMethod.POST, Path), PostData.Serializable(payload), default, requestConfig); + + Validate(memoryStreamFactory, response); + + memoryStreamFactory.Reset(); + response = transport.Request(new EndpointPath(HttpMethod.POST, Path), PostData.Serializable(payload), default, requestConfig); + + Validate(memoryStreamFactory, response); + + void Validate(TrackingMemoryStreamFactory memoryStreamFactory, DynamicResponse response) + { + response.Body.Should().BeOfType(); + response.Body.Values.Count.Should().Be(1); + response.Body.Get("body").Should().Be(stringValue); + + response.ApiCallDetails.ResponseBodyInBytes.Should().NotBeNull(); + memoryStreamFactory.Created.Count.Should().Be(3); + } + } + + [Fact] + public async Task BytesResponse_WithoutDisableDirectStreaming_ShouldReturnExpectedResponse() + { + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory + }; + var transport = new DistributedTransport(config); + + var payload = new Payload { ResponseString = EmptyJson, StatusCode = 200 }; + + var response = await transport.PostAsync(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + memoryStreamFactory.Reset(); + response = transport.Post(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + static void Validate(TrackingMemoryStreamFactory memoryStreamFactory, BytesResponse response) + { + response.Body.AsSpan().SequenceEqual(EmptyJsonBytes); + // Even when not using DisableDirectStreaming, we have a byte[] so the builder sets ResponseBodyInBytes too + response.ApiCallDetails.ResponseBodyInBytes.Should().NotBeNull(); + memoryStreamFactory.Created.Count.Should().Be(2); // One required for setting request content and one to buffer the stream + foreach (var memoryStream in memoryStreamFactory.Created) + memoryStream.IsDisposed.Should().BeTrue(); + } + } + + [Fact] + public async Task BytesResponse_WithDisableDirectStreaming_ShouldReturnExpectedResponse() + { + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + DisableDirectStreaming = true, + MemoryStreamFactory = memoryStreamFactory + }; + var transport = new DistributedTransport(config); + + var payload = new Payload { ResponseString = EmptyJson, StatusCode = 200 }; + + var response = await transport.PostAsync(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + memoryStreamFactory.Reset(); + response = transport.Post(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + static void Validate(TrackingMemoryStreamFactory memoryStreamFactory, BytesResponse response) + { + response.Body.AsSpan().SequenceEqual(EmptyJsonBytes); + response.ApiCallDetails.ResponseBodyInBytes.Should().NotBeNull(); + memoryStreamFactory.Created.Count.Should().Be(3); + foreach (var memoryStream in memoryStreamFactory.Created) + memoryStream.IsDisposed.Should().BeTrue(); + } + } + + [Fact] + public async Task StreamResponse_WithoutDisableDirectStreaming_BodyShouldBeSet_NotDisposed_AndReadable() + { + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory + }; + var transport = new DistributedTransport(config); + + var payload = new Payload { ResponseString = EmptyJson, StatusCode = 200 }; + + var response = await transport.PostAsync(Path, PostData.Serializable(payload)); + + await ValidateAsync(memoryStreamFactory, response); + + memoryStreamFactory.Reset(); + response = transport.Post(Path, PostData.Serializable(payload)); + + await ValidateAsync(memoryStreamFactory, response); + + static async Task ValidateAsync(TrackingMemoryStreamFactory memoryStreamFactory, StreamResponse response) + { + response.Body.Should().NotBeSameAs(Stream.Null); + response.ApiCallDetails.ResponseBodyInBytes.Should().BeNull(); + + memoryStreamFactory.Created.Count.Should().Be(1); + var sr = new StreamReader(response.Body); + var result = await sr.ReadToEndAsync(); + result.Should().Be(EmptyJson); + } + } + + [Fact] + public async Task StreamResponse_WithDisableDirectStreaming_BodyShouldBeSet_NotDisposed_AndReadable() + { + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory, + DisableDirectStreaming = true + }; + var transport = new DistributedTransport(config); + + var payload = new Payload { ResponseString = EmptyJson, StatusCode = 200 }; + var response = await transport.PostAsync(Path, PostData.Serializable(payload)); + + await ValidateAsync(memoryStreamFactory, response); + + memoryStreamFactory.Reset(); + response = transport.Post(Path, PostData.Serializable(payload)); + + await ValidateAsync(memoryStreamFactory, response); + + static async Task ValidateAsync(TrackingMemoryStreamFactory memoryStreamFactory, StreamResponse response) + { + response.Should().BeOfType(); + response.Body.Should().NotBeSameAs(Stream.Null); + response.ApiCallDetails.ResponseBodyInBytes.Should().NotBeNull(); + + // When disable direct streaming, we have 1 for the original content, 1 for the buffered request bytes and the last for the buffered response + memoryStreamFactory.Created.Count.Should().Be(3); + memoryStreamFactory.Created[0].IsDisposed.Should().BeTrue(); + memoryStreamFactory.Created[1].IsDisposed.Should().BeTrue(); + memoryStreamFactory.Created[2].IsDisposed.Should().BeFalse(); + + var sr = new StreamReader(response.Body); + var result = await sr.ReadToEndAsync(); + result.Should().Be(EmptyJson); + } + } + + [Fact] + public async Task StringResponse_WithoutDisableDirectStreaming_MemoryStreamShouldBeDisposed() + { + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory + }; + + var transport = new DistributedTransport(config); + + var payload = new Payload { ResponseString = EmptyJson, StatusCode = 200 }; + var response = await transport.PostAsync(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + memoryStreamFactory.Reset(); + response = transport.Post(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + static void Validate(TrackingMemoryStreamFactory memoryStreamFactory, StringResponse response) + { + response.Should().BeOfType(); + // All scenarios in the implementation buffer the bytes in some form and therefore expose those + response.ApiCallDetails.ResponseBodyInBytes.Should().BeNull(); + + // We expect one for the initial request stream + memoryStreamFactory.Created.Count.Should().Be(1); + foreach (var memoryStream in memoryStreamFactory.Created) + memoryStream.IsDisposed.Should().BeTrue(); + + response.Body.Should().Be(EmptyJson); + } + } + + [Fact] + public async Task StringResponse_WithContentLongerThan1024_WithoutDisableDirectStreaming_BuildsExpectedResponse_AndMemoryStreamIsDisposed() + { + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory + }; + + var transport = new DistributedTransport(config); + + var payload = new Payload { ResponseString = LargeJson, StatusCode = 200 }; + var response = await transport.PostAsync(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + memoryStreamFactory.Reset(); + response = transport.Post(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + static void Validate(TrackingMemoryStreamFactory memoryStreamFactory, StringResponse response) + { + response.Should().BeOfType(); + // All scenarios in the implementation buffer the bytes in some form and therefore expose those + response.ApiCallDetails.ResponseBodyInBytes.Should().BeNull(); + + // We expect one for the initial request stream + memoryStreamFactory.Created.Count.Should().Be(1); + foreach (var memoryStream in memoryStreamFactory.Created) + memoryStream.IsDisposed.Should().BeTrue(); + + response.Body.Should().Be(LargeJson); + } + } + + [Fact] + public async Task WhenInvalidJson_WithDisableDirectStreaming_MemoryStreamShouldBeDisposed() + { + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory, + DisableDirectStreaming = true + }; + + var transport = new DistributedTransport(config); + + var payload = new Payload { ResponseString = " " }; + var response = await transport.PostAsync(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + memoryStreamFactory.Reset(); + + response = transport.Post(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + static void Validate(TrackingMemoryStreamFactory memoryStreamFactory, TestResponse response) + { + response.Should().BeOfType(); + response.ApiCallDetails.ResponseBodyInBytes.Should().NotBeNull(); + + memoryStreamFactory.Created.Count.Should().Be(3); + foreach (var memoryStream in memoryStreamFactory.Created) + memoryStream.IsDisposed.Should().BeTrue(); + + response.Value.Should().Be(string.Empty); + } + } + + [Fact] + public async Task WhenInvalidJson_WithoutDisableDirectStreaming_MemoryStreamShouldBeDisposed() + { + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory + }; + + var transport = new DistributedTransport(config); + + var payload = new Payload { ResponseString = " " }; + var response = await transport.PostAsync(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + memoryStreamFactory.Reset(); + + response = transport.Post(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + static void Validate(TrackingMemoryStreamFactory memoryStreamFactory, TestResponse response) + { + response.Should().BeOfType(); + response.ApiCallDetails.ResponseBodyInBytes.Should().BeNull(); + + memoryStreamFactory.Created.Count.Should().Be(1); + foreach (var memoryStream in memoryStreamFactory.Created) + memoryStream.IsDisposed.Should().BeTrue(); + + response.Value.Should().Be(string.Empty); + } + } + + [Fact] + public async Task WhenNoContent_MemoryStreamShouldBeDisposed() + { + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory, + }; + + var transport = new DistributedTransport(config); + + var payload = new Payload { ResponseString = "", StatusCode = 204 }; + var response = await transport.PostAsync(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + memoryStreamFactory.Reset(); + + response = transport.Post(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + static void Validate(TrackingMemoryStreamFactory memoryStreamFactory, TestResponse response) + { + response.Should().BeOfType(); + response.ApiCallDetails.ResponseBodyInBytes.Should().BeNull(); + + memoryStreamFactory.Created.Count.Should().Be(1); + foreach (var memoryStream in memoryStreamFactory.Created) + memoryStream.IsDisposed.Should().BeTrue(); + + response.Value.Should().Be(string.Empty); + } + } + + [Fact] + public async Task WhenNoContent_WithDisableDirectStreaming_MemoryStreamShouldBeDisposed() + { + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory, + DisableDirectStreaming = true + }; + + var transport = new DistributedTransport(config); + + var payload = new Payload { ResponseString = "", StatusCode = 204 }; + var response = await transport.PostAsync(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + memoryStreamFactory.Reset(); + + response = transport.Post(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + static void Validate(TrackingMemoryStreamFactory memoryStreamFactory, TestResponse response) + { + response.Should().BeOfType(); + response.ApiCallDetails.ResponseBodyInBytes.Should().BeNull(); + + // We expect one for sending the request payload, but as the response is 204, we shouldn't + // see other memory streams being created for the response. + memoryStreamFactory.Created.Count.Should().Be(2); + foreach (var memoryStream in memoryStreamFactory.Created) + memoryStream.IsDisposed.Should().BeTrue(); + + response.Value.Should().Be(string.Empty); + } + } + + [Fact] + public async Task PlainText_WithoutCustomResponseBuilder_WithDisableDirectStreaming_MemoryStreamShouldBeDisposed() + { + const string expectedString = "test-value"; + + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory, + DisableDirectStreaming = true, + ContentType = "application/json" + }; + + var transport = new DistributedTransport(config); + + var payload = new Payload { ResponseString = expectedString, ContentType = "text/plain" }; + var response = await transport.PostAsync(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + memoryStreamFactory.Reset(); + + response = transport.Post(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + static void Validate(TrackingMemoryStreamFactory memoryStreamFactory, TestResponse response) + { + memoryStreamFactory.Created.Count.Should().Be(3); + foreach (var memoryStream in memoryStreamFactory.Created) + memoryStream.IsDisposed.Should().BeTrue(); + + response.ApiCallDetails.ResponseBodyInBytes.Should().NotBeNull(); + response.Value.Should().Be(string.Empty); // default value as no custom builder + + var value = Encoding.UTF8.GetString(response.ApiCallDetails.ResponseBodyInBytes); + value.Should().Be(expectedString); // The buffered bytes should include the response string + } + } + + [Fact] + public async Task PlainText_WithoutCustomResponseBuilder_WithoutDisableDirectStreaming_MemoryStreamShouldBeDisposed() + { + const string expectedString = "test-value"; + + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory, + ContentType = "application/json" + }; + + var transport = new DistributedTransport(config); + + var payload = new Payload { ResponseString = expectedString, ContentType = "text/plain" }; + var response = await transport.PostAsync(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + memoryStreamFactory.Reset(); + + response = transport.Post(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + static void Validate(TrackingMemoryStreamFactory memoryStreamFactory, TestResponse response) + { + memoryStreamFactory.Created.Count.Should().Be(1); + foreach (var memoryStream in memoryStreamFactory.Created) + memoryStream.IsDisposed.Should().BeTrue(); + + response.ApiCallDetails.ResponseBodyInBytes.Should().BeNull(); + response.Value.Should().Be(string.Empty); // default value as no custom builder + } + } + + [Fact] + public async Task PlainText_WithoutCustomResponseBuilder_WithoutDisableDirectStreaming__AcceptingPlainText_MemoryStreamShouldBeDisposed() + { + const string expectedString = "test-value"; + + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory, + ContentType = "application/json" + }; + + var transport = new DistributedTransport(config); + + var requestConfig = new RequestConfiguration { Accept = "text/plain" }; + var payload = new Payload { ResponseString = expectedString, ContentType = "text/plain" }; + + await transport.Invoking(async t => await t.RequestAsync(new EndpointPath(HttpMethod.POST, Path), PostData.Serializable(payload), default, requestConfig)) + .Should() + .ThrowAsync("when there is no custom builder, it falls through to the default builder using STJ.") + .WithInnerException(); + + transport.Invoking(t => t.Request(new EndpointPath(HttpMethod.POST, Path), PostData.Serializable(payload), default, requestConfig)) + .Should() + .Throw("when there is no custom builder, it falls through to the default builder using STJ.") + .WithInnerException(); + } + + [Fact] + public async Task PlainText_WithoutCustomResponseBuilder_WithoutDisableDirectStreaming_WhenChunkedResponse_MemoryStreamShouldBeDisposed() + { + const string expectedString = "test-value"; + + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory, + ContentType = "application/json" + }; + + var transport = new DistributedTransport(config); + + var payload = new Payload { ResponseString = expectedString, ContentType = "text/plain", IsChunked = true }; + var response = await transport.PostAsync(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + memoryStreamFactory.Reset(); + + response = transport.Post(Path, PostData.Serializable(payload)); + + Validate(memoryStreamFactory, response); + + static void Validate(TrackingMemoryStreamFactory memoryStreamFactory, TestResponse response) + { + memoryStreamFactory.Created.Count.Should().Be(1); + foreach (var memoryStream in memoryStreamFactory.Created) + memoryStream.IsDisposed.Should().BeTrue(); + + response.ApiCallDetails.ResponseBodyInBytes.Should().BeNull(); + response.Value.Should().Be(string.Empty); // default value as no custom builder + } + } + + [Fact] + public async Task PlainText_WithCustomResponseBuilder_WithDisableDirectStreaming_MemoryStreamShouldBeDisposed() + { + const string expectedString = "test-value"; + + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory, + DisableDirectStreaming = true, + ContentType = "application/json", + ResponseBuilders = [new TestResponseBuilder()] + }; + + var transport = new DistributedTransport(config); + + var requestConfig = new RequestConfiguration { Accept = "text/plain" }; + var payload = new Payload { ResponseString = expectedString, ContentType = "text/plain" }; + var response = await transport.RequestAsync(new EndpointPath(HttpMethod.POST, Path), PostData.Serializable(payload), default, requestConfig); + + memoryStreamFactory.Created.Count.Should().Be(3); + foreach (var memoryStream in memoryStreamFactory.Created) + memoryStream.IsDisposed.Should().BeTrue(); + + response.ApiCallDetails.ResponseBodyInBytes.Should().NotBeNull(); + response.Value.Should().Be(expectedString); + + var value = Encoding.UTF8.GetString(response.ApiCallDetails.ResponseBodyInBytes); + value.Should().Be(expectedString); + + memoryStreamFactory.Reset(); + + response = transport.Request(new EndpointPath(HttpMethod.POST, Path), PostData.Serializable(payload), default, requestConfig); + + memoryStreamFactory.Created.Count.Should().Be(3); + foreach (var memoryStream in memoryStreamFactory.Created) + memoryStream.IsDisposed.Should().BeTrue(); + + response.ApiCallDetails.ResponseBodyInBytes.Should().NotBeNull(); + response.Value.Should().Be(expectedString); + } + + [Fact] + public async Task PlainText_WithCustomResponseBuilder_WithoutDisableDirectStreaming() + { + const string expectedString = "test-value"; + + var nodePool = new SingleNodePool(Server.Uri); + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration(nodePool) + { + EnableHttpCompression = false, + MemoryStreamFactory = memoryStreamFactory, + DisableDirectStreaming = false, + ContentType = "application/json", + ResponseBuilders = [new TestResponseBuilder()] + }; + + var transport = new DistributedTransport(config); + + var requestConfig = new RequestConfiguration { Accept = "text/plain" }; + var payload = new Payload { ResponseString = expectedString, ContentType = "text/plain" }; + var response = await transport.RequestAsync(new EndpointPath(HttpMethod.POST, Path), PostData.Serializable(payload), default, requestConfig); + + memoryStreamFactory.Created.Count.Should().Be(1); + response.ApiCallDetails.ResponseBodyInBytes.Should().BeNull(); + response.Value.Should().Be(expectedString); + + memoryStreamFactory.Reset(); + + response = transport.Request(new EndpointPath(HttpMethod.POST, Path), PostData.Serializable(payload), default, requestConfig); + + memoryStreamFactory.Created.Count.Should().Be(1); + response.ApiCallDetails.ResponseBodyInBytes.Should().BeNull(); + response.Value.Should().Be(expectedString); + } + + private class TestResponse : TransportResponse + { + public string Value { get; internal set; } = string.Empty; + }; + + private class TestResponseBuilder : TypedResponseBuilder + { + protected override TestResponse Build(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, string contentType, long contentLength) + { + var sr = new StreamReader(responseStream); + var value = sr.ReadToEnd(); + return new TestResponse { Value = value }; + } + + protected override async Task BuildAsync(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, + string contentType, long contentLength, CancellationToken cancellationToken = default) + { + var sr = new StreamReader(responseStream); + var value = await sr.ReadToEndAsync(cancellationToken); + return new TestResponse { Value = value }; + } + } +} + +public class Payload +{ + public string ResponseString { get; set; } = "{}"; + public string ContentType { get; set; } = "application/json"; + public int StatusCode { get; set; } = 200; + public bool IsChunked { get; set; } = false; +} + +[ApiController, Route("[controller]")] +public class SpecialResponseController : ControllerBase +{ + [HttpPost] + public async Task Post([FromBody] Payload payload) + { + var bytes = Encoding.UTF8.GetBytes(payload.ResponseString); + + Response.ContentType = payload.ContentType; + Response.StatusCode = payload.StatusCode; + + if (!payload.IsChunked) + { + Response.ContentLength = bytes.Length; + } + + if (payload.StatusCode != 204) + { + await Response.BodyWriter.WriteAsync(bytes); + await Response.BodyWriter.CompleteAsync(); + } + } +} diff --git a/tests/Elastic.Transport.Tests.Shared/Elastic.Transport.Tests.Shared.csproj b/tests/Elastic.Transport.Tests.Shared/Elastic.Transport.Tests.Shared.csproj new file mode 100644 index 0000000..8123f2e --- /dev/null +++ b/tests/Elastic.Transport.Tests.Shared/Elastic.Transport.Tests.Shared.csproj @@ -0,0 +1,15 @@ + + + + net8.0;net481 + false + CS8002 + enable + enable + + + + + + + diff --git a/tests/Elastic.Transport.Tests.Shared/TrackDisposeStream.cs b/tests/Elastic.Transport.Tests.Shared/TrackDisposeStream.cs new file mode 100644 index 0000000..ddc5c8c --- /dev/null +++ b/tests/Elastic.Transport.Tests.Shared/TrackDisposeStream.cs @@ -0,0 +1,26 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Transport.Tests.Shared; + +public class TrackDisposeStream : MemoryStream +{ + private readonly bool _canSeek; + + public TrackDisposeStream(bool canSeek = true) : base() => _canSeek = canSeek; + + public TrackDisposeStream(byte[] bytes, bool canSeek = true) : base(bytes) => _canSeek = canSeek; + + public TrackDisposeStream(byte[] bytes, int index, int count, bool canSeek = true) : base(bytes, index, count) => _canSeek = canSeek; + + public override bool CanSeek => _canSeek; + + public bool IsDisposed { get; private set; } + + protected override void Dispose(bool disposing) + { + IsDisposed = true; + base.Dispose(disposing); + } +} diff --git a/tests/Elastic.Transport.Tests.Shared/TrackingMemoryStreamFactory.cs b/tests/Elastic.Transport.Tests.Shared/TrackingMemoryStreamFactory.cs new file mode 100644 index 0000000..dd78f7a --- /dev/null +++ b/tests/Elastic.Transport.Tests.Shared/TrackingMemoryStreamFactory.cs @@ -0,0 +1,33 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Transport.Tests.Shared; + +public class TrackingMemoryStreamFactory() : MemoryStreamFactory +{ + public IList Created { get; private set; } = []; + + public override MemoryStream Create() + { + var stream = new TrackDisposeStream(); + Created.Add(stream); + return stream; + } + + public override MemoryStream Create(byte[] bytes) + { + var stream = new TrackDisposeStream(bytes); + Created.Add(stream); + return stream; + } + + public override MemoryStream Create(byte[] bytes, int index, int count) + { + var stream = new TrackDisposeStream(bytes, index, count); + Created.Add(stream); + return stream; + } + + public void Reset() => Created = []; +} diff --git a/tests/Elastic.Transport.Tests/CodeStandards/NamingConventions.doc.cs b/tests/Elastic.Transport.Tests/CodeStandards/NamingConventions.doc.cs index 505c952..4c43cdd 100644 --- a/tests/Elastic.Transport.Tests/CodeStandards/NamingConventions.doc.cs +++ b/tests/Elastic.Transport.Tests/CodeStandards/NamingConventions.doc.cs @@ -22,7 +22,7 @@ public class NamingConventions */ [Fact] public void ClassNameContainsBaseShouldBeAbstract() { - var exceptions = new Type[] { }; + var exceptions = Array.Empty(); var baseClassesNotAbstract = typeof(ITransport<>).Assembly.GetTypes() .Where(t => t.IsClass && !exceptions.Contains(t)) @@ -34,7 +34,7 @@ [Fact] public void ClassNameContainsBaseShouldBeAbstract() baseClassesNotAbstract.Should().BeEmpty(); } - private List Scan() + private static List Scan() { var assembly = typeof(ITransport<>).Assembly; @@ -48,12 +48,13 @@ private List Scan() }; var types = assembly.GetTypes(); - var typesNotInNestNamespace = types + var typesNotInTransportNamespace = types .Where(t => t != null) .Where(t => t.Namespace != null) .Where(t => !exceptions.Contains(t)) .ToList(); - return typesNotInNestNamespace; + + return typesNotInTransportNamespace; } [Fact] public void AllTransportTypesAreInTheRoot() diff --git a/tests/Elastic.Transport.Tests/Elastic.Transport.Tests.csproj b/tests/Elastic.Transport.Tests/Elastic.Transport.Tests.csproj index 688d7d8..2c439d6 100644 --- a/tests/Elastic.Transport.Tests/Elastic.Transport.Tests.csproj +++ b/tests/Elastic.Transport.Tests/Elastic.Transport.Tests.csproj @@ -21,11 +21,12 @@ - + + diff --git a/tests/Elastic.Transport.Tests/OpenTelemetryTests.cs b/tests/Elastic.Transport.Tests/OpenTelemetryTests.cs index fd9eb5d..637e468 100644 --- a/tests/Elastic.Transport.Tests/OpenTelemetryTests.cs +++ b/tests/Elastic.Transport.Tests/OpenTelemetryTests.cs @@ -149,7 +149,7 @@ private async Task TestCoreAsync(Action assertions, OpenTelemetryData transport ??= new DistributedTransport(InMemoryConnectionFactory.Create()); - _ = await transport.RequestAsync(new EndpointPath(HttpMethod.GET, "/"), null, openTelemetryData, null, null, default); + _ = await transport.RequestAsync(new EndpointPath(HttpMethod.GET, "/"), null, openTelemetryData, null, default); mre.WaitOne(TimeSpan.FromSeconds(1)).Should().BeTrue(); } diff --git a/tests/Elastic.Transport.Tests/Plumbing/InMemoryConnectionFactory.cs b/tests/Elastic.Transport.Tests/Plumbing/InMemoryConnectionFactory.cs index f4eb21d..6f1222b 100644 --- a/tests/Elastic.Transport.Tests/Plumbing/InMemoryConnectionFactory.cs +++ b/tests/Elastic.Transport.Tests/Plumbing/InMemoryConnectionFactory.cs @@ -11,7 +11,7 @@ public static class InMemoryConnectionFactory { public static TransportConfiguration Create(ProductRegistration productRegistration = null) { - var invoker = new InMemoryRequestInvoker(); + var invoker = new InMemoryRequestInvoker(productRegistration); var pool = new SingleNodePool(new Uri("http://localhost:9200")); var settings = new TransportConfiguration(pool, invoker, productRegistration: productRegistration); return settings; diff --git a/tests/Elastic.Transport.Tests/ResponseBuilderDisposeTests.cs b/tests/Elastic.Transport.Tests/ResponseBuilderDisposeTests.cs deleted file mode 100644 index 79562d1..0000000 --- a/tests/Elastic.Transport.Tests/ResponseBuilderDisposeTests.cs +++ /dev/null @@ -1,228 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -using System; -using System.Collections.Generic; -using System.IO; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Elastic.Transport.Products; -using Elastic.Transport.Tests.Plumbing; -using FluentAssertions; -using Xunit; - -namespace Elastic.Transport.Tests; - -public class ResponseBuilderDisposeTests -{ - private readonly ITransportConfiguration _settings = InMemoryConnectionFactory.Create() with { DisableDirectStreaming = false }; - private readonly ITransportConfiguration _settingsDisableDirectStream = InMemoryConnectionFactory.Create() with { DisableDirectStreaming = true }; - - [Fact] - public async Task StreamResponseWithPotentialBody_StreamIsNotDisposed() => - await AssertResponse(false, expectedDisposed: false); - - [Fact] - public async Task StreamResponseWithPotentialBodyAndDisableDirectStreaming_MemoryStreamIsNotDisposed() => - await AssertResponse(true, expectedDisposed: false); - - [Fact] - public async Task ResponseWithPotentialBodyButInvalidMimeType_MemoryStreamIsDisposed() => - await AssertResponse(true, mimeType: "application/not-valid", expectedDisposed: true); - - [Fact] - public async Task ResponseWithPotentialBodyButSkippedStatusCode_MemoryStreamIsDisposed() => - await AssertResponse(true, skipStatusCode: 200, expectedDisposed: true); - - [Fact] - public async Task ResponseWithPotentialBodyButEmptyJson_MemoryStreamIsDisposed() => - await AssertResponse(true, responseJson: " ", expectedDisposed: true); - - [Fact] - // NOTE: The empty string here hits a fast path in STJ which returns default if the stream length is zero. - public async Task ResponseWithPotentialBodyButNullResponseDuringDeserialization_MemoryStreamIsDisposed() => - await AssertResponse(true, responseJson: "", expectedDisposed: true); - - [Fact] - public async Task ResponseWithPotentialBodyAndCustomResponseBuilder_MemoryStreamIsDisposed() => - await AssertResponse(true, customResponseBuilder: new TestCustomResponseBuilder(), expectedDisposed: true); - - [Fact] - // NOTE: We expect one memory stream factory creation when handling error responses - public async Task ResponseWithPotentialBodyAndErrorResponse_StreamIsDisposed() => - await AssertResponse(true, productRegistration: new TestProductRegistration(), expectedDisposed: true); - - [Fact] - public async Task StringResponseWithPotentialBodyAndDisableDirectStreaming_MemoryStreamIsDisposed() => - await AssertResponse(false, expectedDisposed: true, memoryStreamCreateExpected: 1); - - private async Task AssertResponse(bool disableDirectStreaming, int statusCode = 200, HttpMethod httpMethod = HttpMethod.GET, int contentLength = 10, - bool expectedDisposed = true, string mimeType = "application/json", string responseJson = "{}", int skipStatusCode = -1, - CustomResponseBuilder customResponseBuilder = null, ProductRegistration productRegistration = null, int memoryStreamCreateExpected = -1) - where T : TransportResponse, new() - { - ITransportConfiguration config; - - var memoryStreamFactory = new TrackMemoryStreamFactory(); - - if (skipStatusCode > -1) - config = InMemoryConnectionFactory.Create(productRegistration) with - { - DisableDirectStreaming = disableDirectStreaming, SkipDeserializationForStatusCodes = [skipStatusCode] - }; - - else if (productRegistration is not null) - config = InMemoryConnectionFactory.Create(productRegistration) with { DisableDirectStreaming = disableDirectStreaming, }; - else - config = disableDirectStreaming ? _settingsDisableDirectStream : _settings; - - config = new TransportConfiguration(config) { MemoryStreamFactory = memoryStreamFactory }; - - var endpoint = new Endpoint(new EndpointPath(httpMethod, "/"), new Node(new Uri("http://localhost:9200"))); - var requestData = new RequestData(config, null, customResponseBuilder); - - var stream = new TrackDisposeStream(); - - if (!string.IsNullOrEmpty(responseJson)) - { - stream.Write(Encoding.UTF8.GetBytes(responseJson), 0, responseJson.Length); - stream.Position = 0; - } - - var response = config.ProductRegistration.ResponseBuilder.ToResponse(endpoint, requestData, null, null, statusCode, null, stream, mimeType, contentLength, null, null); - - response.Should().NotBeNull(); - - memoryStreamFactory.Created.Count.Should().Be(memoryStreamCreateExpected > -1 ? memoryStreamCreateExpected : disableDirectStreaming ? 1 : 0); - if (disableDirectStreaming) - { - var memoryStream = memoryStreamFactory.Created[0]; - memoryStream.IsDisposed.Should().Be(expectedDisposed); - } - - // The latest implementation should never dispose the incoming stream and assumes the caller will handler disposal - stream.IsDisposed.Should().Be(false); - - stream = new TrackDisposeStream(); - var ct = new CancellationToken(); - - response = await config.ProductRegistration.ResponseBuilder.ToResponseAsync(endpoint, requestData, null, null, statusCode, null, stream, null, contentLength, null, null, - cancellationToken: ct); - - response.Should().NotBeNull(); - - memoryStreamFactory.Created.Count.Should().Be(memoryStreamCreateExpected > -1 ? memoryStreamCreateExpected + 1 : disableDirectStreaming ? 2 : 0); - if (disableDirectStreaming) - { - var memoryStream = memoryStreamFactory.Created[0]; - memoryStream.IsDisposed.Should().Be(expectedDisposed); - } - - // The latest implementation should never dispose the incoming stream and assumes the caller will handler disposal - stream.IsDisposed.Should().Be(false); - } - - private class TestProductRegistration : ProductRegistration - { - public override string DefaultMimeType => "application/json"; - public override string Name => "name"; - public override string ServiceIdentifier => "id"; - public override bool SupportsPing => false; - public override bool SupportsSniff => false; - public override HeadersList ResponseHeadersToParse => []; - public override MetaHeaderProvider MetaHeaderProvider => null; - public override string ProductAssemblyVersion => "0.0.0"; - public override IReadOnlyDictionary DefaultOpenTelemetryAttributes => new Dictionary(); - public override IReadOnlyCollection DefaultHeadersToParse() => []; - public override bool HttpStatusCodeClassifier(HttpMethod method, int statusCode) => true; - public override ResponseBuilder ResponseBuilder => new TestErrorResponseBuilder(); - - public override Endpoint CreatePingEndpoint(Node node, IRequestConfiguration requestConfiguration) => throw new NotImplementedException(); - public override Task PingAsync(IRequestInvoker requestInvoker, Endpoint endpoint, RequestData pingData, CancellationToken cancellationToken) => throw new NotImplementedException(); - public override TransportResponse Ping(IRequestInvoker requestInvoker, Endpoint endpoint, RequestData pingData) => throw new NotImplementedException(); - public override Endpoint CreateSniffEndpoint(Node node, IRequestConfiguration requestConfiguration, ITransportConfiguration settings) => throw new NotImplementedException(); - public override Task>> SniffAsync(IRequestInvoker requestInvoker, bool forceSsl, Endpoint endpoint, RequestData requestData, CancellationToken cancellationToken) => throw new NotImplementedException(); - public override Tuple> Sniff(IRequestInvoker requestInvoker, bool forceSsl, Endpoint endpoint, RequestData requestData) => throw new NotImplementedException(); - public override bool NodePredicate(Node node) => throw new NotImplementedException(); - public override Dictionary ParseOpenTelemetryAttributesFromApiCallDetails(ApiCallDetails callDetails) => throw new NotImplementedException(); - public override int SniffOrder(Node node) => throw new NotImplementedException(); - public override bool TryGetServerErrorReason(TResponse response, out string reason) => throw new NotImplementedException(); - } - - private class TestError : ErrorResponse - { - public string MyError { get; set; } - - public override bool HasError() => true; - } - - private class TestErrorResponseBuilder : DefaultResponseBuilder - { - protected override void SetErrorOnResponse(TResponse response, TestError error) - { - // nothing to do in this scenario - } - - protected override bool TryGetError(ApiCallDetails apiCallDetails, RequestData requestData, Stream responseStream, out TestError error) - { - error = new TestError(); - return true; - } - - protected override bool RequiresErrorDeserialization(ApiCallDetails details, RequestData requestData) => true; - } - - private class TestCustomResponseBuilder : CustomResponseBuilder - { - public override object DeserializeResponse(Serializer serializer, ApiCallDetails response, Stream stream) => - new TestResponse { ApiCallDetails = response }; - - public override Task DeserializeResponseAsync(Serializer serializer, ApiCallDetails response, Stream stream, CancellationToken ctx = default) => - Task.FromResult(new TestResponse { ApiCallDetails = response }); - } - - private class TrackDisposeStream : MemoryStream - { - public TrackDisposeStream() { } - - public TrackDisposeStream(byte[] bytes) : base(bytes) { } - - public TrackDisposeStream(byte[] bytes, int index, int count) : base(bytes, index, count) { } - - public bool IsDisposed { get; private set; } - - protected override void Dispose(bool disposing) - { - IsDisposed = true; - base.Dispose(disposing); - } - } - - private class TrackMemoryStreamFactory : MemoryStreamFactory - { - public IList Created { get; } = []; - - public override MemoryStream Create() - { - var stream = new TrackDisposeStream(); - Created.Add(stream); - return stream; - } - - public override MemoryStream Create(byte[] bytes) - { - var stream = new TrackDisposeStream(bytes); - Created.Add(stream); - return stream; - } - - public override MemoryStream Create(byte[] bytes, int index, int count) - { - var stream = new TrackDisposeStream(bytes, index, count); - Created.Add(stream); - return stream; - } - } -} diff --git a/tests/Elastic.Transport.Tests/ResponseFactoryDisposeTests.cs b/tests/Elastic.Transport.Tests/ResponseFactoryDisposeTests.cs new file mode 100644 index 0000000..8667a18 --- /dev/null +++ b/tests/Elastic.Transport.Tests/ResponseFactoryDisposeTests.cs @@ -0,0 +1,150 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; +using System.Text; +using System.Threading.Tasks; +using Elastic.Transport.Products; +using Elastic.Transport.Products.Elasticsearch; +using Elastic.Transport.Tests.Plumbing; +using Elastic.Transport.Tests.Shared; +using FluentAssertions; +using Xunit; + +namespace Elastic.Transport.Tests; + +public class ResponseFactoryDisposeTests +{ + [Fact] + public async Task StreamResponse_WithPotentialBody_StreamIsNotDisposed() => + // We expect no streams to be created as the original response stream should be directly returned and not disposed + await AssertResponse(disableDirectStreaming: false, expectMemoryStreamDisposal: false); + + [Fact] + public async Task StreamResponse_WithPotentialBody_AndDisableDirectStreaming_MemoryStreamIsNotDisposed() => + await AssertResponse(disableDirectStreaming: true, expectMemoryStreamDisposal: false, memoryStreamCreateExpected: 1); + + [Fact] + public async Task Response_WithPotentialBody_AndDisableDirectStreaming_ButInvalidContentType_MemoryStreamIsDisposed() => + await AssertResponse(disableDirectStreaming: true, contentType: "application/not-valid", expectMemoryStreamDisposal: true, + memoryStreamCreateExpected: 1); + + [Fact] + public async Task Response_WithPotentialBody_AndDisableDirectStreaming_ButSkippedStatusCode_MemoryStreamIsDisposed() => + await AssertResponse(disableDirectStreaming: true, skipStatusCode: 200, expectMemoryStreamDisposal: true, memoryStreamCreateExpected: 1); + + [Fact] + public async Task Response_WithPotentialBody_AndDisableDirectStreaming_ButEmptyJson_MemoryStreamIsDisposed() => + await AssertResponse(disableDirectStreaming: true, responseJson: " ", expectMemoryStreamDisposal: true, memoryStreamCreateExpected: 1); + + [Fact] + public async Task Response_WithPotentialBody_AndNotDisableDirectStreaming_ButEmptyJson_MemoryStreamIsDisposed() => + await AssertResponse(disableDirectStreaming: false, responseJson: " ", expectMemoryStreamDisposal: true, memoryStreamCreateExpected: 0); + + [Fact] + // NOTE: The empty string here hits a fast path in STJ which returns default if the stream length is zero. + public async Task Response_WithPotentialBody_AndDisableDirectStreaming_ButNullResponseDuringDeserialization_MemoryStreamIsDisposed() => + await AssertResponse(disableDirectStreaming: true, responseJson: "", expectMemoryStreamDisposal: true, memoryStreamCreateExpected: 1); + + [Fact] + // NOTE: The empty string here hits a fast path in STJ which returns default if the stream length is zero. + public async Task Response_WithPotentialBody_AndNotDisableDirectStreaming_ButNullResponseDuringDeserialization_MemoryStreamIsDisposed() => + await AssertResponse(disableDirectStreaming: false, responseJson: "", expectMemoryStreamDisposal: true, memoryStreamCreateExpected: 0); + + [Fact] + // NOTE: We expect one memory stream factory creation when handling error responses even when not using DisableDirectStreaming + public async Task Response_WithPotentialBody_AndNotDisableDirectStreaming_AndErrorResponse_StreamIsDisposed() => + await AssertResponse(disableDirectStreaming: false, productRegistration: new ElasticsearchProductRegistration(), statusCode: 400, + expectMemoryStreamDisposal: true, memoryStreamCreateExpected: 1); + + [Fact] + public async Task Response_WithPotentialBody_AndDisableDirectStreaming_AndErrorResponse_StreamIsDisposed() => + await AssertResponse(disableDirectStreaming: true, productRegistration: new ElasticsearchProductRegistration(), statusCode: 400, + expectMemoryStreamDisposal: true, memoryStreamCreateExpected: 1); + + [Fact] + public async Task StringResponse_WithPotentialBody_AndNotDisableDirectStreaming_AndNotChunkedReponse_NoMemoryStreamIsCreated() => + await AssertResponse(disableDirectStreaming: false, expectMemoryStreamDisposal: true, memoryStreamCreateExpected: 0); + + [Fact] + public async Task StringResponse_WithPotentialBody_AndNotDisableDirectStreaming_AndChunkedReponse_NoMemoryStreamIsCreated() => + await AssertResponse(disableDirectStreaming: false, expectMemoryStreamDisposal: true, memoryStreamCreateExpected: 0, isChunked: true); + + [Fact] + public async Task StringResponse_WithPotentialBody_AndDisableDirectStreaming_AndChunkedReponse_MemoryStreamIsDisposed() => + await AssertResponse(disableDirectStreaming: true, expectMemoryStreamDisposal: true, memoryStreamCreateExpected: 1, isChunked: true); + + [Fact] + public async Task StringResponse_WithPotentialBody_AndDisableDirectStreaming_AndNotChunkedReponse_MemoryStreamIsDisposed() => + await AssertResponse(disableDirectStreaming: true, expectMemoryStreamDisposal: true, memoryStreamCreateExpected: 1); + + private async Task AssertResponse(bool disableDirectStreaming, int statusCode = 200, HttpMethod httpMethod = HttpMethod.GET, bool isChunked = true, + bool expectMemoryStreamDisposal = true, string contentType = "application/json", string responseJson = "{}", int skipStatusCode = -1, + ProductRegistration productRegistration = null, int memoryStreamCreateExpected = 0, bool responseStreamCanSeek = false) + where T : TransportResponse, new() + { + ITransportConfiguration config; + + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + + if (skipStatusCode > -1) + { + config = InMemoryConnectionFactory.Create(productRegistration) with + { + DisableDirectStreaming = disableDirectStreaming, + SkipDeserializationForStatusCodes = [skipStatusCode], + MemoryStreamFactory = memoryStreamFactory + }; + } + else + { + config = InMemoryConnectionFactory.Create(productRegistration) with + { + DisableDirectStreaming = disableDirectStreaming, + MemoryStreamFactory = memoryStreamFactory + }; + } + + var endpoint = new Endpoint(new EndpointPath(httpMethod, "/"), new Node(new Uri("http://localhost:9200"))); + + var requestData = new RequestData(config, null); + + var stream = new TrackDisposeStream(responseStreamCanSeek); + + if (!string.IsNullOrEmpty(responseJson)) + { + stream.Write(Encoding.UTF8.GetBytes(responseJson), 0, responseJson.Length); + stream.Position = 0; + } + + var response = config.RequestInvoker.ResponseFactory.Create(endpoint, requestData, null, null, statusCode, null, stream, contentType, isChunked ? -1 : responseJson.Length, null, null); + + Validate(disableDirectStreaming, expectMemoryStreamDisposal, memoryStreamCreateExpected, memoryStreamFactory, stream, response); + + memoryStreamFactory.Reset(); + stream = new TrackDisposeStream(responseStreamCanSeek); + if (!string.IsNullOrEmpty(responseJson)) + { + stream.Write(Encoding.UTF8.GetBytes(responseJson), 0, responseJson.Length); + stream.Position = 0; + } + + response = await config.RequestInvoker.ResponseFactory.CreateAsync(endpoint, requestData, null, null, statusCode, null, stream, contentType, isChunked ? -1 : responseJson.Length, null, null); + + Validate(disableDirectStreaming, expectMemoryStreamDisposal, memoryStreamCreateExpected, memoryStreamFactory, stream, response); + + static void Validate(bool disableDirectStreaming, bool expectedDisposed, int memoryStreamCreateExpected, TrackingMemoryStreamFactory memoryStreamFactory, TrackDisposeStream stream, T response) + { + response.Should().NotBeNull(); + + // The latest implementation should never dispose the incoming stream and assumes the caller will handler disposal + stream.IsDisposed.Should().Be(false); + + memoryStreamFactory.Created.Count.Should().Be(memoryStreamCreateExpected); + + if (disableDirectStreaming) + memoryStreamFactory.Created[0].IsDisposed.Should().Be(expectedDisposed); + } + } +} diff --git a/tests/Elastic.Transport.Tests/Responses/Dynamic/DynamicResponseBuilderTests.cs b/tests/Elastic.Transport.Tests/Responses/Dynamic/DynamicResponseBuilderTests.cs new file mode 100644 index 0000000..a48b3c9 --- /dev/null +++ b/tests/Elastic.Transport.Tests/Responses/Dynamic/DynamicResponseBuilderTests.cs @@ -0,0 +1,56 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; + +namespace Elastic.Transport.Tests.Responses.Dynamic; + +public class DynamicResponseBuilderTests +{ + [Fact] + public async Task ReturnsExpectedResponse_ForJsonData() + { + IResponseBuilder sut = new DynamicResponseBuilder(); + + var config = new TransportConfiguration(); + var apiCallDetails = new ApiCallDetails(); + var requestData = new RequestData(config); + + var data = Encoding.UTF8.GetBytes("{\"_index\":\"my-index\",\"_id\":\"pZqC6JIB9RdSpcF8-3lq\",\"_version\":1,\"result\":\"created\",\"_shards\":{\"total\":1,\"successful\":1,\"failed\":0},\"_seq_no\":2,\"_primary_term\":1}"); + var stream = new MemoryStream(data); + + var result = await sut.BuildAsync(apiCallDetails, requestData, stream, RequestData.DefaultContentType, data.Length); + result.Body.Get("_index").Should().Be("my-index"); + + stream.Position = 0; + + result = sut.Build(apiCallDetails, requestData, stream, RequestData.DefaultContentType, data.Length); + result.Body.Get("_index").Should().Be("my-index"); + } + + [Fact] + public async Task ReturnsExpectedResponse_ForNonJsonData() + { + IResponseBuilder sut = new DynamicResponseBuilder(); + + var config = new TransportConfiguration(); + var apiCallDetails = new ApiCallDetails(); + var requestData = new RequestData(config); + + var data = Encoding.UTF8.GetBytes("This is not JSON"); + var stream = new MemoryStream(data); + + var result = await sut.BuildAsync(apiCallDetails, requestData, stream, "text/plain", data.Length); + result.Body.Get("body").Should().Be("This is not JSON"); + + stream.Position = 0; + + result = sut.Build(apiCallDetails, requestData, stream, "text/plain", data.Length); + result.Body.Get("body").Should().Be("This is not JSON"); + } +} diff --git a/tests/Elastic.Transport.Tests/Responses/Special/BytesResponseBuilderTests.cs b/tests/Elastic.Transport.Tests/Responses/Special/BytesResponseBuilderTests.cs new file mode 100644 index 0000000..cacc830 --- /dev/null +++ b/tests/Elastic.Transport.Tests/Responses/Special/BytesResponseBuilderTests.cs @@ -0,0 +1,80 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Elastic.Transport.Tests.Shared; +using FluentAssertions; +using Xunit; + +namespace Elastic.Transport.Tests.Responses.Special; + +public class BytesResponseBuilderTests +{ + private static readonly byte[] Data = Encoding.UTF8.GetBytes("{\"_index\":\"my-index\",\"_id\":\"pZqC6JIB9RdSpcF8-3lq\",\"_version\":1,\"result\"" + + ":\"created\",\"_shards\":{\"total\":1,\"successful\":1,\"failed\":0},\"_seq_no\":2,\"_primary_term\":1}"); + + [Fact] + public async Task ReturnsExpectedResponse() + { + IResponseBuilder sut = new BytesResponseBuilder(); + + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration() { MemoryStreamFactory = memoryStreamFactory }; + var apiCallDetails = new ApiCallDetails(); + var requestData = new RequestData(config); + var stream = new MemoryStream(Data); + + var result = await sut.BuildAsync(apiCallDetails, requestData, stream, RequestData.DefaultContentType, Data.Length); + + Validate(memoryStreamFactory, result); + + result = sut.Build(apiCallDetails, requestData, stream, RequestData.DefaultContentType, Data.Length); + + Validate(memoryStreamFactory, result); + + static void Validate(TrackingMemoryStreamFactory memoryStreamFactory, BytesResponse result) + { + result.Body.AsSpan().SequenceEqual(Data).Should().BeTrue(); + + // As the incoming stream is seekable, no need to create a copy + memoryStreamFactory.Created.Count.Should().Be(1); + } + } + + [Fact] + public async Task ReturnsExpectedResponse_WhenDisableDirectStreaming() + { + IResponseBuilder sut = new BytesResponseBuilder(); + + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration() { DisableDirectStreaming = true, MemoryStreamFactory = memoryStreamFactory }; + var apiCallDetails = new ApiCallDetails() { ResponseBodyInBytes = Data }; + var requestData = new RequestData(config); + var stream = new MemoryStream(Data); + + var result = await sut.BuildAsync(apiCallDetails, requestData, stream, RequestData.DefaultContentType, Data.Length); + + Validate(memoryStreamFactory, result); + + memoryStreamFactory.Reset(); + stream.Position = 0; + + result = sut.Build(apiCallDetails, requestData, stream, RequestData.DefaultContentType, Data.Length); + + Validate(memoryStreamFactory, result); + + static void Validate(TrackingMemoryStreamFactory memoryStreamFactory, BytesResponse result) + { + result.Body.AsSpan().SequenceEqual(Data).Should().BeTrue(); + + memoryStreamFactory.Created.Count.Should().Be(0); + foreach (var memoryStream in memoryStreamFactory.Created) + memoryStream.IsDisposed.Should().BeTrue(); + } + } +} diff --git a/tests/Elastic.Transport.Tests/Responses/Special/StreamResponseBuilderTests.cs b/tests/Elastic.Transport.Tests/Responses/Special/StreamResponseBuilderTests.cs new file mode 100644 index 0000000..236c47e --- /dev/null +++ b/tests/Elastic.Transport.Tests/Responses/Special/StreamResponseBuilderTests.cs @@ -0,0 +1,77 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; +using System.Buffers; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Elastic.Transport.Tests.Shared; +using FluentAssertions; +using Xunit; + +namespace Elastic.Transport.Tests.Responses.Special; + +public class StreamResponseBuilderTests +{ + private static readonly byte[] Data = Encoding.UTF8.GetBytes("{\"_index\":\"my-index\",\"_id\":\"pZqC6JIB9RdSpcF8-3lq\",\"_version\":1,\"result\"" + + ":\"created\",\"_shards\":{\"total\":1,\"successful\":1,\"failed\":0},\"_seq_no\":2,\"_primary_term\":1}"); + + [Fact] + public async Task ReturnsExpectedResponse() + { + IResponseBuilder sut = new StreamResponseBuilder(); + + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration() { MemoryStreamFactory = memoryStreamFactory }; + var apiCallDetails = new ApiCallDetails(); + var requestData = new RequestData(config); + var stream = new MemoryStream(Data); + + var result = await sut.BuildAsync(apiCallDetails, requestData, stream, RequestData.DefaultContentType, Data.Length); + await ValidateAsync(memoryStreamFactory, result); + + memoryStreamFactory.Reset(); + stream.Position = 0; + + result = sut.Build(apiCallDetails, requestData, stream, RequestData.DefaultContentType, Data.Length); + await ValidateAsync(memoryStreamFactory, result); + } + + [Fact] + public async Task ReturnsExpectedResponse_WhenDisableDirectStreaming() + { + IResponseBuilder sut = new StreamResponseBuilder(); + + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration() { MemoryStreamFactory = memoryStreamFactory, DisableDirectStreaming = true }; + var apiCallDetails = new ApiCallDetails() { ResponseBodyInBytes = Data }; + var requestData = new RequestData(config); + var stream = new MemoryStream(Data); + + var result = await sut.BuildAsync(apiCallDetails, requestData, stream, RequestData.DefaultContentType, Data.Length); + await ValidateAsync(memoryStreamFactory, result); + + memoryStreamFactory.Reset(); + stream.Position = 0; + + result = sut.Build(apiCallDetails, requestData, stream, RequestData.DefaultContentType, Data.Length); + await ValidateAsync(memoryStreamFactory, result); + } + + private static async Task ValidateAsync(TrackingMemoryStreamFactory memoryStreamFactory, StreamResponse result) + { + var buffer = ArrayPool.Shared.Rent(Data.Length); + +#pragma warning disable CA1835 // Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync' + var read = await result.Body.ReadAsync(buffer, 0, Data.Length); +#pragma warning restore CA1835 // Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync' + +#pragma warning disable IDE0057 // Use range operator + buffer.AsSpan().Slice(0, read).SequenceEqual(Data).Should().BeTrue(); +#pragma warning restore IDE0057 // Use range operator + + memoryStreamFactory.Created.Count.Should().Be(0); + } +} diff --git a/tests/Elastic.Transport.Tests/Responses/Special/StringResponseBuilderTests.cs b/tests/Elastic.Transport.Tests/Responses/Special/StringResponseBuilderTests.cs new file mode 100644 index 0000000..d6089b6 --- /dev/null +++ b/tests/Elastic.Transport.Tests/Responses/Special/StringResponseBuilderTests.cs @@ -0,0 +1,158 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Elastic.Transport.Tests.Shared; +using FluentAssertions; +using Xunit; + +namespace Elastic.Transport.Tests.Responses.Special; + +public class StringResponseBuilderTests +{ + private const string Json = "{\"_index\":\"my-index\",\"_id\":\"pZqC6JIB9RdSpcF8-3lq\",\"_version\":1,\"result\"" + + ":\"created\",\"_shards\":{\"total\":1,\"successful\":1,\"failed\":0},\"_seq_no\":2,\"_primary_term\":1}"; + + private static readonly byte[] Data = Encoding.UTF8.GetBytes(Json); + + [Fact] + public async Task ReturnsExpectedResponse() + { + IResponseBuilder sut = new StringResponseBuilder(); + + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration() { MemoryStreamFactory = memoryStreamFactory }; + var apiCallDetails = new ApiCallDetails(); + var requestData = new RequestData(config); + var stream = new MemoryStream(Data); + + var result = await sut.BuildAsync(apiCallDetails, requestData, stream, RequestData.DefaultContentType, Data.Length); + result.Body.Should().Be(Json); + + memoryStreamFactory.Created.Count.Should().Be(0); + + stream.Position = 0; + memoryStreamFactory.Reset(); + + result = sut.Build(apiCallDetails, requestData, stream, RequestData.DefaultContentType, Data.Length); + result.Body.Should().Be(Json); + + memoryStreamFactory.Created.Count.Should().Be(0); + } + + [Fact] + public async Task ReturnsExpectedResponse_WhenLargeNonChunkedResponse() + { + const string largeJson = "[{\"_id\":\"672b13c7666cae7721b7f5c8\",\"index\":0,\"guid\":\"f8a9356c-660b-4f4f-a1c2-84048e0599b9\",\"isActive\":false,\"balance\":\"$3,856.23\"," + + "\"picture\":\"http://placehold.it/32x32\",\"age\":29,\"eyeColor\":\"green\",\"name\":\"Noemi Reed\",\"gender\":\"female\",\"company\":\"LUNCHPOD\",\"email\":" + + "\"noemireed@lunchpod.com\",\"phone\":\"+1 (961) 417-3668\",\"address\":\"954 Cameron Court, Onton, South Dakota, 1148\",\"about\":\"Qui ad id veniam aute amet " + + "commodo officia est cillum. Elit nostrud Lorem tempor duis. Commodo velit nulla nisi velit laborum qui minim nostrud aute dolor tempor officia. Commodo proident " + + "nulla eu adipisicing incididunt eu. Quis nostrud Lorem amet deserunt pariatur ea elit adipisicing qui. Voluptate exercitation id esse tempor occaecat.\\r\\n\"," + + "\"registered\":\"2017-02-28T04:33:12 -00:00\",\"latitude\":30.32678,\"longitude\":-156.977981,\"tags\":[\"sit\",\"culpa\",\"cillum\",\"labore\",\"in\",\"labore\"," + + "\"quis\"],\"friends\":[{\"id\":0,\"name\":\"Good Lyons\"},{\"id\":1,\"name\":\"Mccarthy Delaney\"},{\"id\":2,\"name\":\"Winters Combs\"}],\"greeting\":\"Hello, " + + "Noemi Reed! You have 8 unread messages.\",\"favoriteFruit\":\"strawberry\"},{\"_id\":\"672b13c741693abd9d0173a9\",\"index\":1,\"guid\":" + + "\"fa3d27ec-213c-4365-92e9-39774eec9d01\",\"isActive\":false,\"balance\":\"$2,275.63\",\"picture\":\"http://placehold.it/32x32\",\"age\":23,\"eyeColor\":\"brown\"," + + "\"name\":\"Cooley Williams\",\"gender\":\"male\",\"company\":\"GALLAXIA\",\"email\":\"cooleywilliams@gallaxia.com\",\"phone\":\"+1 (961) 439-2700\",\"address\":" + + "\"791 Montgomery Place, Garfield, Guam, 9900\",\"about\":\"Officia consectetur do quis id cillum quis esse. Aliqua deserunt eiusmod laboris cupidatat enim commodo " + + "est Lorem id nisi mollit non. Eiusmod adipisicing pariatur culpa nostrud incididunt dolor commodo fugiat amet ex dolor ex. Nostrud incididunt consequat ullamco " + + "pariatur cupidatat nulla eu voluptate cupidatat nulla. Mollit est id adipisicing ad mollit exercitation. Ullamco non ad aliquip ea sit culpa pariatur commodo " + + "veniam. In occaecat et tempor ea Lorem eu incididunt sit commodo officia.\\r\\n\",\"registered\":\"2019-05-25T11:41:44 -01:00\",\"latitude\":-85.996713,\"longitude\"" + + ":-140.910029,\"tags\":[\"esse\",\"qui\",\"magna\",\"et\",\"irure\",\"est\",\"in\"],\"friends\":[{\"id\":0,\"name\":\"Pamela Castillo\"},{\"id\":1,\"name\"" + + ":\"Suzanne Herman\"},{\"id\":2,\"name\":\"Gonzales Bush\"}],\"greeting\":\"Hello, Cooley Williams! You have 8 unread messages.\",\"favoriteFruit\":\"apple\"}]"; + + var data = Encoding.UTF8.GetBytes(largeJson); + + IResponseBuilder sut = new StringResponseBuilder(); + + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration() { MemoryStreamFactory = memoryStreamFactory }; + var apiCallDetails = new ApiCallDetails(); + var requestData = new RequestData(config); + var stream = new MemoryStream(data); + + var result = await sut.BuildAsync(apiCallDetails, requestData, stream, RequestData.DefaultContentType, data.Length); + result.Body.Should().Be(largeJson); + + memoryStreamFactory.Created.Count.Should().Be(0); + + stream.Position = 0; + memoryStreamFactory.Reset(); + + result = sut.Build(apiCallDetails, requestData, stream, RequestData.DefaultContentType, data.Length); + result.Body.Should().Be(largeJson); + + memoryStreamFactory.Created.Count.Should().Be(0); + } + + [Fact] + public async Task ReturnsExpectedResponse_WhenLargeChunkedResponse() + { + const string largeJson = "[{\"_id\":\"672b13c7666cae7721b7f5c8\",\"index\":0,\"guid\":\"f8a9356c-660b-4f4f-a1c2-84048e0599b9\",\"isActive\":false,\"balance\":\"$3,856.23\"," + + "\"picture\":\"http://placehold.it/32x32\",\"age\":29,\"eyeColor\":\"green\",\"name\":\"Noemi Reed\",\"gender\":\"female\",\"company\":\"LUNCHPOD\",\"email\":" + + "\"noemireed@lunchpod.com\",\"phone\":\"+1 (961) 417-3668\",\"address\":\"954 Cameron Court, Onton, South Dakota, 1148\",\"about\":\"Qui ad id veniam aute amet " + + "commodo officia est cillum. Elit nostrud Lorem tempor duis. Commodo velit nulla nisi velit laborum qui minim nostrud aute dolor tempor officia. Commodo proident " + + "nulla eu adipisicing incididunt eu. Quis nostrud Lorem amet deserunt pariatur ea elit adipisicing qui. Voluptate exercitation id esse tempor occaecat.\\r\\n\"," + + "\"registered\":\"2017-02-28T04:33:12 -00:00\",\"latitude\":30.32678,\"longitude\":-156.977981,\"tags\":[\"sit\",\"culpa\",\"cillum\",\"labore\",\"in\",\"labore\"," + + "\"quis\"],\"friends\":[{\"id\":0,\"name\":\"Good Lyons\"},{\"id\":1,\"name\":\"Mccarthy Delaney\"},{\"id\":2,\"name\":\"Winters Combs\"}],\"greeting\":\"Hello, " + + "Noemi Reed! You have 8 unread messages.\",\"favoriteFruit\":\"strawberry\"},{\"_id\":\"672b13c741693abd9d0173a9\",\"index\":1,\"guid\":" + + "\"fa3d27ec-213c-4365-92e9-39774eec9d01\",\"isActive\":false,\"balance\":\"$2,275.63\",\"picture\":\"http://placehold.it/32x32\",\"age\":23,\"eyeColor\":\"brown\"," + + "\"name\":\"Cooley Williams\",\"gender\":\"male\",\"company\":\"GALLAXIA\",\"email\":\"cooleywilliams@gallaxia.com\",\"phone\":\"+1 (961) 439-2700\",\"address\":" + + "\"791 Montgomery Place, Garfield, Guam, 9900\",\"about\":\"Officia consectetur do quis id cillum quis esse. Aliqua deserunt eiusmod laboris cupidatat enim commodo " + + "est Lorem id nisi mollit non. Eiusmod adipisicing pariatur culpa nostrud incididunt dolor commodo fugiat amet ex dolor ex. Nostrud incididunt consequat ullamco " + + "pariatur cupidatat nulla eu voluptate cupidatat nulla. Mollit est id adipisicing ad mollit exercitation. Ullamco non ad aliquip ea sit culpa pariatur commodo " + + "veniam. In occaecat et tempor ea Lorem eu incididunt sit commodo officia.\\r\\n\",\"registered\":\"2019-05-25T11:41:44 -01:00\",\"latitude\":-85.996713,\"longitude\"" + + ":-140.910029,\"tags\":[\"esse\",\"qui\",\"magna\",\"et\",\"irure\",\"est\",\"in\"],\"friends\":[{\"id\":0,\"name\":\"Pamela Castillo\"},{\"id\":1,\"name\"" + + ":\"Suzanne Herman\"},{\"id\":2,\"name\":\"Gonzales Bush\"}],\"greeting\":\"Hello, Cooley Williams! You have 8 unread messages.\",\"favoriteFruit\":\"apple\"}]"; + + var data = Encoding.UTF8.GetBytes(largeJson); + + IResponseBuilder sut = new StringResponseBuilder(); + + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration() { MemoryStreamFactory = memoryStreamFactory }; + var apiCallDetails = new ApiCallDetails(); + var requestData = new RequestData(config); + var stream = new MemoryStream(data); + + var result = await sut.BuildAsync(apiCallDetails, requestData, stream, RequestData.DefaultContentType, -1); + result.Body.Should().Be(largeJson); + + memoryStreamFactory.Created.Count.Should().Be(0); + + stream.Position = 0; + memoryStreamFactory.Reset(); + + result = sut.Build(apiCallDetails, requestData, stream, RequestData.DefaultContentType, -1); + result.Body.Should().Be(largeJson); + + memoryStreamFactory.Created.Count.Should().Be(0); + } + + [Fact] + public async Task ReturnsExpectedResponse_WhenDisableDirectStreaming() + { + IResponseBuilder sut = new StringResponseBuilder(); + + var memoryStreamFactory = new TrackingMemoryStreamFactory(); + var config = new TransportConfiguration() { MemoryStreamFactory = memoryStreamFactory, DisableDirectStreaming = true }; + var apiCallDetails = new ApiCallDetails() { ResponseBodyInBytes = Data }; + var requestData = new RequestData(config); + var stream = new MemoryStream(Data); + + var result = await sut.BuildAsync(apiCallDetails, requestData, stream, RequestData.DefaultContentType, -1); + result.Body.Should().Be(Json); + + memoryStreamFactory.Created.Count.Should().Be(0); + + stream.Position = 0; + memoryStreamFactory.Reset(); + + result = sut.Build(apiCallDetails, requestData, stream, RequestData.DefaultContentType, -1); + result.Body.Should().Be(Json); + + memoryStreamFactory.Created.Count.Should().Be(0); + } +} diff --git a/tests/Elastic.Transport.Tests/Responses/Special/VoidResponseBuilderTests.cs b/tests/Elastic.Transport.Tests/Responses/Special/VoidResponseBuilderTests.cs new file mode 100644 index 0000000..cd8fe46 --- /dev/null +++ b/tests/Elastic.Transport.Tests/Responses/Special/VoidResponseBuilderTests.cs @@ -0,0 +1,52 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using static Elastic.Transport.VoidResponse; + +namespace Elastic.Transport.Tests.Responses.Special; + +public class VoidResponseBuilderTests +{ + private static readonly byte[] Data = Encoding.UTF8.GetBytes("{\"_index\":\"my-index\",\"_id\":\"pZqC6JIB9RdSpcF8-3lq\",\"_version\":1,\"result\"" + + ":\"created\",\"_shards\":{\"total\":1,\"successful\":1,\"failed\":0},\"_seq_no\":2,\"_primary_term\":1}"); + + [Fact] + public async Task ReturnsExpectedResponse() + { + IResponseBuilder sut = new VoidResponseBuilder(); + + var config = new TransportConfiguration(); + var apiCallDetails = new ApiCallDetails(); + var requestData = new RequestData(config); + var stream = new MemoryStream(Data); + + var result = await sut.BuildAsync(apiCallDetails, requestData, stream, RequestData.DefaultContentType, Data.Length); + result.Body.Should().BeOfType(typeof(VoidBody)); + + result = sut.Build(apiCallDetails, requestData, stream, RequestData.DefaultContentType, Data.Length); + result.Body.Should().BeOfType(typeof(VoidBody)); + } + + [Fact] + public async Task ReturnsExpectedResponse_WhenDisableDirectStreaming() + { + IResponseBuilder sut = new VoidResponseBuilder(); + + var config = new TransportConfiguration() { DisableDirectStreaming = true }; + var apiCallDetails = new ApiCallDetails() { ResponseBodyInBytes = Data }; + var requestData = new RequestData(config); + var stream = new MemoryStream(Data); + + var result = await sut.BuildAsync(apiCallDetails, requestData, stream, RequestData.DefaultContentType, Data.Length); + result.Body.Should().BeOfType(typeof(VoidBody)); + + result = sut.Build(apiCallDetails, requestData, stream, RequestData.DefaultContentType, Data.Length); + result.Body.Should().BeOfType(typeof(VoidBody)); + } +} diff --git a/tests/Elastic.Transport.Tests/UsageTests.cs b/tests/Elastic.Transport.Tests/UsageTests.cs index fab3719..b8e76c4 100644 --- a/tests/Elastic.Transport.Tests/UsageTests.cs +++ b/tests/Elastic.Transport.Tests/UsageTests.cs @@ -15,7 +15,7 @@ public class UsageTests { public void Usage() { - var pool = new StaticNodePool(new[] {new Node(new Uri("http://localhost:9200"))}); + var pool = new StaticNodePool([new Node(new Uri("http://localhost:9200"))]); var requestInvoker = new HttpRequestInvoker(); var serializer = LowLevelRequestResponseSerializer.Instance; var product = ElasticsearchProductRegistration.Default; From 5e8cfe91fdee6e2f5b1c436316feb44c6e5693c3 Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Thu, 7 Nov 2024 09:40:15 +0000 Subject: [PATCH 2/3] Add obsolete `ResponseMimeType` alias for `ResponseContentType` A new property `ResponseContentType` has been added to the `ApiCallDetails` class. This reintroduces the original `ResponseMimeType` property marked as obsolete and serves as a temporary alias for the existing `ResponseContentType` property. --- .../Responses/HttpDetails/ApiCallDetails.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Elastic.Transport/Responses/HttpDetails/ApiCallDetails.cs b/src/Elastic.Transport/Responses/HttpDetails/ApiCallDetails.cs index 142f241..679463d 100644 --- a/src/Elastic.Transport/Responses/HttpDetails/ApiCallDetails.cs +++ b/src/Elastic.Transport/Responses/HttpDetails/ApiCallDetails.cs @@ -82,6 +82,17 @@ public string DebugInformation /// public byte[]? ResponseBodyInBytes { get; internal set; } + /// + /// The value of the Content-Type header in the response. + /// + [Obsolete("This property has been retired and replaced by ResponseContentType. " + + "Prefer using the updated property as this will be removed in a future release.")] + public string ResponseMimeType + { + get => ResponseContentType; + set => ResponseContentType = value; + } + /// /// The value of the Content-Type header in the response. /// From 0c68b548813ee80b7a648318c7336ca6b3579cbc Mon Sep 17 00:00:00 2001 From: Steve Gordon Date: Thu, 7 Nov 2024 10:41:27 +0000 Subject: [PATCH 3/3] PR feedback - JSON highlighting --- .../Responses/SpecialisedResponseTests.cs | 112 +++++++++++++++--- 1 file changed, 96 insertions(+), 16 deletions(-) diff --git a/tests/Elastic.Transport.IntegrationTests/Responses/SpecialisedResponseTests.cs b/tests/Elastic.Transport.IntegrationTests/Responses/SpecialisedResponseTests.cs index ecd3830..bec1ce3 100644 --- a/tests/Elastic.Transport.IntegrationTests/Responses/SpecialisedResponseTests.cs +++ b/tests/Elastic.Transport.IntegrationTests/Responses/SpecialisedResponseTests.cs @@ -21,22 +21,102 @@ public class SpecialisedResponseTests(TransportTestServer instance) : AssemblySe private const string Path = "/specialresponse"; private const string EmptyJson = "{}"; private static readonly byte[] EmptyJsonBytes = [(byte)'{', (byte)'}']; - private const string LargeJson = "[{\"_id\":\"672b13c7666cae7721b7f5c8\",\"index\":0,\"guid\":\"f8a9356c-660b-4f4f-a1c2-84048e0599b9\",\"isActive\":false,\"balance\":\"$3,856.23\"," + - "\"picture\":\"http://placehold.it/32x32\",\"age\":29,\"eyeColor\":\"green\",\"name\":\"Noemi Reed\",\"gender\":\"female\",\"company\":\"LUNCHPOD\",\"email\":" + - "\"noemireed@lunchpod.com\",\"phone\":\"+1 (961) 417-3668\",\"address\":\"954 Cameron Court, Onton, South Dakota, 1148\",\"about\":\"Qui ad id veniam aute amet " + - "commodo officia est cillum. Elit nostrud Lorem tempor duis. Commodo velit nulla nisi velit laborum qui minim nostrud aute dolor tempor officia. Commodo proident " + - "nulla eu adipisicing incididunt eu. Quis nostrud Lorem amet deserunt pariatur ea elit adipisicing qui. Voluptate exercitation id esse tempor occaecat.\\r\\n\"," + - "\"registered\":\"2017-02-28T04:33:12 -00:00\",\"latitude\":30.32678,\"longitude\":-156.977981,\"tags\":[\"sit\",\"culpa\",\"cillum\",\"labore\",\"in\",\"labore\"," + - "\"quis\"],\"friends\":[{\"id\":0,\"name\":\"Good Lyons\"},{\"id\":1,\"name\":\"Mccarthy Delaney\"},{\"id\":2,\"name\":\"Winters Combs\"}],\"greeting\":\"Hello, " + - "Noemi Reed! You have 8 unread messages.\",\"favoriteFruit\":\"strawberry\"},{\"_id\":\"672b13c741693abd9d0173a9\",\"index\":1,\"guid\":" + - "\"fa3d27ec-213c-4365-92e9-39774eec9d01\",\"isActive\":false,\"balance\":\"$2,275.63\",\"picture\":\"http://placehold.it/32x32\",\"age\":23,\"eyeColor\":\"brown\"," + - "\"name\":\"Cooley Williams\",\"gender\":\"male\",\"company\":\"GALLAXIA\",\"email\":\"cooleywilliams@gallaxia.com\",\"phone\":\"+1 (961) 439-2700\",\"address\":" + - "\"791 Montgomery Place, Garfield, Guam, 9900\",\"about\":\"Officia consectetur do quis id cillum quis esse. Aliqua deserunt eiusmod laboris cupidatat enim commodo " + - "est Lorem id nisi mollit non. Eiusmod adipisicing pariatur culpa nostrud incididunt dolor commodo fugiat amet ex dolor ex. Nostrud incididunt consequat ullamco " + - "pariatur cupidatat nulla eu voluptate cupidatat nulla. Mollit est id adipisicing ad mollit exercitation. Ullamco non ad aliquip ea sit culpa pariatur commodo " + - "veniam. In occaecat et tempor ea Lorem eu incididunt sit commodo officia.\\r\\n\",\"registered\":\"2019-05-25T11:41:44 -01:00\",\"latitude\":-85.996713,\"longitude\"" + - ":-140.910029,\"tags\":[\"esse\",\"qui\",\"magna\",\"et\",\"irure\",\"est\",\"in\"],\"friends\":[{\"id\":0,\"name\":\"Pamela Castillo\"},{\"id\":1,\"name\"" + - ":\"Suzanne Herman\"},{\"id\":2,\"name\":\"Gonzales Bush\"}],\"greeting\":\"Hello, Cooley Williams! You have 8 unread messages.\",\"favoriteFruit\":\"apple\"}]"; + + // language=json + private const string LargeJson = """ + [ + { + "_id": "672b13c7666cae7721b7f5c8", + "index": 0, + "guid": "f8a9356c-660b-4f4f-a1c2-84048e0599b9", + "isActive": false, + "balance": "$3,856.23", + "picture": "http://placehold.it/32x32", + "age": 29, + "eyeColor": "green", + "name": "Noemi Reed", + "gender": "female", + "company": "LUNCHPOD", + "email": "noemireed@lunchpod.com", + "phone": "+1 (961) 417-3668", + "address": "954 Cameron Court, Onton, South Dakota, 1148", + "about": "Qui ad id veniam aute amet commodo officia est cillum. Elit nostrud Lorem tempor duis. Commodo velit nulla nisi velit laborum qui minim nostrud aute dolor tempor officia. Commodo proident nulla eu adipisicing incididunt eu. Quis nostrud Lorem amet deserunt pariatur ea elit adipisicing qui. Voluptate exercitation id esse tempor occaecat.\r\n", + "registered": "2017-02-28T04:33:12 -00:00", + "latitude": 30.32678, + "longitude": -156.977981, + "tags": [ + "sit", + "culpa", + "cillum", + "labore", + "in", + "labore", + "quis" + ], + "friends": [ + { + "id": 0, + "name": "Good Lyons" + }, + { + "id": 1, + "name": "Mccarthy Delaney" + }, + { + "id": 2, + "name": "Winters Combs" + } + ], + "greeting": "Hello, Noemi Reed! You have 8 unread messages.", + "favoriteFruit": "strawberry" + }, + { + "_id": "672b13c741693abd9d0173a9", + "index": 1, + "guid": "fa3d27ec-213c-4365-92e9-39774eec9d01", + "isActive": false, + "balance": "$2,275.63", + "picture": "http://placehold.it/32x32", + "age": 23, + "eyeColor": "brown", + "name": "Cooley Williams", + "gender": "male", + "company": "GALLAXIA", + "email": "cooleywilliams@gallaxia.com", + "phone": "+1 (961) 439-2700", + "address": "791 Montgomery Place, Garfield, Guam, 9900", + "about": "Officia consectetur do quis id cillum quis esse. Aliqua deserunt eiusmod laboris cupidatat enim commodo est Lorem id nisi mollit non. Eiusmod adipisicing pariatur culpa nostrud incididunt dolor commodo fugiat amet ex dolor ex. Nostrud incididunt consequat ullamco pariatur cupidatat nulla eu voluptate cupidatat nulla. Mollit est id adipisicing ad mollit exercitation. Ullamco non ad aliquip ea sit culpa pariatur commodo veniam. In occaecat et tempor ea Lorem eu incididunt sit commodo officia.\r\n", + "registered": "2019-05-25T11:41:44 -01:00", + "latitude": -85.996713, + "longitude": -140.910029, + "tags": [ + "esse", + "qui", + "magna", + "et", + "irure", + "est", + "in" + ], + "friends": [ + { + "id": 0, + "name": "Pamela Castillo" + }, + { + "id": 1, + "name": "Suzanne Herman" + }, + { + "id": 2, + "name": "Gonzales Bush" + } + ], + "greeting": "Hello, Cooley Williams! You have 8 unread messages.", + "favoriteFruit": "apple" + } + ] + """; [Fact] public async Task VoidResponse_ShouldReturnExpectedResponse()