Files
Klaus Landsdorf a3166fa560 WebApiClient: report HTTP failures as StatusCodes and accept gzip responses (#4648)
# Description

`WebApiClient` threw the exceptions of `HttpClient` for every HTTP
failure except 429/503. It now throws `ServiceResultException` with a
matching StatusCode, as the transport channels already do. It can also
ask for and read gzip compressed JSON (OPC 10000-6 §7.4.5). Two commits:
the error mapping, then gzip.

## Changes

### HTTP failures (`src/Opc.Ua.Client/WebApi/WebApiClient.cs`, new
internal `WebApiHttpErrors`)

| HTTP / failure | StatusCode |
|---|---|
| 400 | `Bad_DecodingError` |
| 401 | `Bad_IdentityTokenInvalid` when the client sent no credentials
(bearer token or basic credentials), otherwise `Bad_UserAccessDenied` |
| 403 | `Bad_UserAccessDenied` |
| 404, 405, 501 | `Bad_ServiceUnsupported` |
| 408, 504 | `Bad_Timeout` |
| 413 | `Bad_RequestTooLarge` |
| 415 | `Bad_DataEncodingUnsupported` |
| 429, 503 | `Bad_ServerTooBusy` with the Retry-After hint (unchanged) |
| 500 | `Bad_InternalError` |
| any other status | `Bad_CommunicationError` |
| elapsed `RequestTimeout` / `HttpClient.Timeout` |
`Bad_RequestTimeout`, as in `HttpsTransportChannel` and
`WebApiTransportChannel` |
| failure below HTTP (connection, TLS handshake) | the code
`HttpsTransportChannel.MapRequestFailure` assigns, e.g.
`Bad_NotConnected` for a refused connection |

- The message names the HTTP status, the reason phrase and the route.
- For an HTTP status, the inner exception is an `HttpRequestException`
naming it (with `StatusCode` set on .NET 5 and later). For a failure
below HTTP, it is the original `HttpRequestException`; for a timeout,
the `OperationCanceledException`.
- A cancellation of the caller's token stays an
`OperationCanceledException`.
- `WebApiTransportChannel` sends through `WebApiClient`. For an HTTP
error status it now reports the mapped code instead of
`Bad_UnknownResponse`; its codes for timeouts and connection failures do
not change.
- `docs/WebApi.md`: new section "Client errors".

### gzip (`WebApiClientOptions.AcceptCompressedResponses`,
`src/Opc.Ua.Core/Stack/Https/HttpResponseBodyReader.cs`)

- **Requesting gzip:** `AcceptCompressedResponses` (default `false`)
sends `Accept-Encoding: gzip`; on a shared `HttpClient` it goes on each
request instead of the default headers. `WebApiTransportChannel` passes
the option through.
- **Reading gzip:** `HttpResponseBodyReader` inflates a body with
`Content-Encoding: gzip`.
- `MaxMessageSize` applies to the inflated size, so a small compressed
body cannot expand past it.
  - A corrupt gzip body is `Bad_DecodingError`.
- A content coding other than gzip or identity is rejected with
`Bad_DecodingError` instead of being decoded as if it were plain.
- **Stream ownership:** the reader releases only its inflating wrapper.
The response stream stays with the `HttpContent` the caller disposes.
- `Opc.Ua.Core` references `System.IO.Compression` on net48 explicitly
(it compiled through the expanded .NET Standard facades before, but
should not depend on them).
- `Opc.Ua.Client` grants `InternalsVisibleTo` to
`Opc.Ua.Bindings.Https.WebApi.Tests` for the internal
shared-`HttpClient` constructor.
- `docs/WebApi.md`: new section "Compressed responses".

## Tests

- **New:** `WebApiClientErrorAndCompressionTests` in
`Opc.Ua.Bindings.Https.WebApi.Tests` (stub `HttpMessageHandler`, no
server):
- error mapping: `HttpErrorStatusSurfacesAsServiceResultException` (14
cases, every row of the status table),
`ThrottledRequestSurfacesAsServerTooBusyWithRetryAfter` (429, 503),
`ConnectionFailureMapsLikeTheTransportChannel`,
`TlsHandshakeFailureMapsLikeTheTransportChannel`,
`ElapsedRequestTimeoutSurfacesAsBadRequestTimeout`,
`CallerCancellationStaysOperationCanceled`;
- gzip: `AcceptCompressedResponsesAdvertisesGzipAndDecompressesAsync`,
`SharedClientCarriesAcceptEncodingPerRequestAsync`,
`GzipResponseIsDecompressedEvenWhenNotAskedForAsync`,
`GzipBodyIsBoundedByMaxMessageSizeAfterInflating`,
`CorruptGzipIsADecodingError`, `UnsupportedContentEncodingIsRejected`,
`IdentityContentEncodingIsReadAsIsAsync`,
`ResponseStreamIsLeftToItsContentAsync` (plain and gzip).
- **Updated** (they expected the old exceptions):
-
`WebApiClientTests.InvokeAsyncThrowsServiceResultExceptionOnNon2xxStatus`
(was `InvokeAsyncThrowsHttpRequestExceptionOnNon2xxStatus`):
`Bad_InternalError` with the `HttpRequestException` as inner exception;
-
`WebApiResponseQuotaRegressionTests.RequestTimeoutRemainsActiveWhileReadingTheOpenApiBodyAsync`:
the elapsed timeout is `Bad_RequestTimeout` with the
`OperationCanceledException` as inner exception.
- **Fail without the fix:** with `WebApiClient.cs` and
`HttpResponseBodyReader.cs` from master, 24 of the 29 new test cases
fail, as do `InvokeAsyncThrowsServiceResultExceptionOnNon2xxStatus` and
three of the six
`RequestTimeoutRemainsActiveWhileReadingTheOpenApiBodyAsync` cases. The
five that pass there guard behaviour that must not change: throttling
(2), caller cancellation, identity coding, and a plain body's stream.
With a reader that disposes the response stream, both
`ResponseStreamIsLeftToItsContentAsync` cases fail.
- `Opc.Ua.Bindings.Https.WebApi.Tests` 436/436 and `Opc.Ua.Client.Tests`
2733/2733 on net10.0 and net8.0 (macOS).
- `Opc.Ua.Client` and `Opc.Ua.Core` build for net48, net8.0, net9.0 and
net10.0 with 0 warnings; `dotnet format` (whitespace, style, analyzers)
is clean on every changed file; changed lines covered 112/112 (100 %)
with the two suites above (`.azurepipelines/check-coverage.ps1` against
master).
- Fork CI (full matrix incl. macOS) and CodeQL: green — [CI run
37128530025](https://github.com/biancode/UA-.NETStandard/actions/runs/37128530025)
(154 jobs; one net48 job, `ReconnectAsyncSwapsFaultedLeaseEntryAsync` in
`Opc.Ua.Client.Tests`, timed out on the first attempt and passed on
rerun; this change does not touch reconnect), [CodeQL run
37128531971](https://github.com/biancode/UA-.NETStandard/actions/runs/37128531971).

## Notes for review

- `HttpResponseBodyReader` is shared with the binary
`HttpsTransportChannel`, so rejecting unknown content codings applies
there too. Before, such a body was decoded as plain bytes and failed
later with a less helpful error.
- The failure-below-HTTP and timeout codes reuse what the transport
channels report, so `WebApiTransportChannel`, which sends through
`WebApiClient`, keeps its codes for those cases. Its own
`HttpRequestException` path no longer sees exceptions from
`WebApiClient`; I left it in place.

## Related Issues

- Fixes #4647

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Affected
suites on net10.0 and net8.0 locally on macOS; .NET Framework is covered
by the fork CI's Windows net48 legs.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

---------

Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-05 06:26:01 +02:00

516 lines
22 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using Opc.Ua.Client.WebApi;
namespace Opc.Ua.Bindings.Https.WebApi.Tests
{
/// <summary>
/// How <see cref="WebApiClient"/> reports HTTP failures (as
/// <see cref="ServiceResultException"/>s) and handles gzip compressed
/// responses (OPC 10000-6 §7.4.5).
/// </summary>
[TestFixture]
[Category("WebApiClient")]
[SetCulture("en-us")]
[SetUICulture("en-us")]
[Parallelizable]
public class WebApiClientErrorAndCompressionTests
{
private static readonly Uri s_baseAddress = new("https://server.example/");
public static IEnumerable<TestCaseData> HttpStatusCases()
{
yield return new TestCaseData(HttpStatusCode.BadRequest, false, StatusCodes.BadDecodingError);
yield return new TestCaseData(HttpStatusCode.Unauthorized, false, StatusCodes.BadIdentityTokenInvalid);
yield return new TestCaseData(HttpStatusCode.Unauthorized, true, StatusCodes.BadUserAccessDenied);
yield return new TestCaseData(HttpStatusCode.Forbidden, true, StatusCodes.BadUserAccessDenied);
yield return new TestCaseData(HttpStatusCode.NotFound, false, StatusCodes.BadServiceUnsupported);
yield return new TestCaseData(HttpStatusCode.MethodNotAllowed, false, StatusCodes.BadServiceUnsupported);
yield return new TestCaseData(HttpStatusCode.NotImplemented, false, StatusCodes.BadServiceUnsupported);
yield return new TestCaseData(HttpStatusCode.RequestTimeout, false, StatusCodes.BadTimeout);
yield return new TestCaseData(HttpStatusCode.GatewayTimeout, false, StatusCodes.BadTimeout);
yield return new TestCaseData(HttpStatusCode.RequestEntityTooLarge, false, StatusCodes.BadRequestTooLarge);
yield return new TestCaseData(
HttpStatusCode.UnsupportedMediaType, false, StatusCodes.BadDataEncodingUnsupported);
yield return new TestCaseData(HttpStatusCode.InternalServerError, false, StatusCodes.BadInternalError);
yield return new TestCaseData(HttpStatusCode.BadGateway, false, StatusCodes.BadCommunicationError);
yield return new TestCaseData(HttpStatusCode.Conflict, false, StatusCodes.BadCommunicationError);
}
[TestCaseSource(nameof(HttpStatusCases))]
public void HttpErrorStatusSurfacesAsServiceResultException(
HttpStatusCode status,
bool sendCredentials,
StatusCode expected)
{
using var handler = new StubHandler(_ => new HttpResponseMessage(status) { ReasonPhrase = "Test reason" });
using WebApiClient client = CreateClient(handler, new WebApiClientOptions
{
BearerToken = sendCredentials ? "token" : null
});
ServiceResultException? error = Assert.ThrowsAsync<ServiceResultException>(
async () => await client.ReadAsync(new ReadRequest()).ConfigureAwait(false));
Assert.That(error!.StatusCode, Is.EqualTo(expected));
Assert.That(error.Message, Does.Contain(((int)status).ToString(CultureInfo.InvariantCulture)));
Assert.That(error.Message, Does.Contain("Test reason"));
Assert.That(error.Message, Does.Contain("/read"));
var inner = error.InnerException as HttpRequestException;
Assert.That(inner, Is.Not.Null);
Assert.That(inner!.StatusCode, Is.EqualTo(status));
}
/// <summary>
/// A throttled request (429, or 503 as a rate limiter answers by
/// default) stays BadServerTooBusy with the Retry-After hint, the
/// same answer the HTTPS binary channel gives.
/// </summary>
[TestCase(HttpStatusCode.TooManyRequests)]
[TestCase(HttpStatusCode.ServiceUnavailable)]
public void ThrottledRequestSurfacesAsServerTooBusyWithRetryAfter(HttpStatusCode status)
{
using var handler = new StubHandler(_ =>
{
var response = new HttpResponseMessage(status);
response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(2));
return response;
});
using WebApiClient client = CreateClient(handler, new WebApiClientOptions());
ServiceResultException? error = Assert.ThrowsAsync<ServiceResultException>(
async () => await client.ReadAsync(new ReadRequest()).ConfigureAwait(false));
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadServerTooBusy));
Assert.That(error.Result.AdditionalInfo, Is.EqualTo("RetryAfterMs=2000"));
}
[Test]
public void ConnectionFailureMapsLikeTheTransportChannel()
{
var failure = new HttpRequestException(
"Connection refused",
new SocketException((int)SocketError.ConnectionRefused));
using var handler = new StubHandler(_ => throw failure);
using WebApiClient client = CreateClient(handler, new WebApiClientOptions());
ServiceResultException? error = Assert.ThrowsAsync<ServiceResultException>(
async () => await client.ReadAsync(new ReadRequest()).ConfigureAwait(false));
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadNotConnected));
Assert.That(error.StatusCode, Is.EqualTo(HttpsTransportChannel.MapRequestFailure(failure)));
Assert.That(error.InnerException, Is.SameAs(failure));
Assert.That(error.Message, Does.Contain("/read"));
}
[Test]
public void TlsHandshakeFailureMapsLikeTheTransportChannel()
{
var failure = new HttpRequestException(
"The SSL connection could not be established.",
new System.Security.Authentication.AuthenticationException("The remote certificate was rejected."));
using var handler = new StubHandler(_ => throw failure);
using WebApiClient client = CreateClient(handler, new WebApiClientOptions());
ServiceResultException? error = Assert.ThrowsAsync<ServiceResultException>(
async () => await client.ReadAsync(new ReadRequest()).ConfigureAwait(false));
Assert.That(error!.StatusCode, Is.EqualTo(HttpsTransportChannel.MapRequestFailure(failure)));
Assert.That(error.InnerException, Is.SameAs(failure));
}
[Test]
public void ElapsedRequestTimeoutSurfacesAsBadRequestTimeout()
{
using var handler = new StubHandler(async (_, ct) =>
{
await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false);
return new HttpResponseMessage(HttpStatusCode.OK);
});
using WebApiClient client = CreateClient(handler, new WebApiClientOptions
{
RequestTimeout = TimeSpan.FromMilliseconds(100)
});
ServiceResultException? error = Assert.ThrowsAsync<ServiceResultException>(
async () => await client.ReadAsync(new ReadRequest()).ConfigureAwait(false));
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadRequestTimeout));
Assert.That(error.InnerException, Is.InstanceOf<OperationCanceledException>());
}
[Test]
public void CallerCancellationStaysOperationCanceled()
{
using var handler = new StubHandler(async (_, ct) =>
{
await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false);
return new HttpResponseMessage(HttpStatusCode.OK);
});
using WebApiClient client = CreateClient(handler, new WebApiClientOptions());
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
Assert.CatchAsync<OperationCanceledException>(
async () => await client.ReadAsync(new ReadRequest(), cts.Token).ConfigureAwait(false));
}
[Test]
public async Task AcceptCompressedResponsesAdvertisesGzipAndDecompressesAsync()
{
IServiceMessageContext context = CreateContext();
var expected = new ReadResponse { ResponseHeader = new ResponseHeader { RequestHandle = 42 } };
HttpRequestMessage? seen = null;
using var handler = new StubHandler(request =>
{
seen = request;
return GzipResponse(WebApiBodyCodec.EncodeBody(expected, context));
});
using WebApiClient client = CreateClient(handler, new WebApiClientOptions
{
MessageContext = context,
AcceptCompressedResponses = true
});
ReadResponse response = await client.ReadAsync(new ReadRequest()).ConfigureAwait(false);
Assert.That(response.ResponseHeader.RequestHandle, Is.EqualTo(42u));
Assert.That(seen!.Headers.AcceptEncoding.Select(e => e.Value), Does.Contain("gzip"));
}
[Test]
public async Task SharedClientCarriesAcceptEncodingPerRequestAsync()
{
IServiceMessageContext context = CreateContext();
HttpRequestMessage? seen = null;
using var handler = new StubHandler(request =>
{
seen = request;
return GzipResponse(WebApiBodyCodec.EncodeBody(new ReadResponse(), context));
});
using var http = new HttpClient(handler, disposeHandler: false);
using var client = new WebApiClient(http, s_baseAddress, new WebApiClientOptions
{
MessageContext = context,
AcceptCompressedResponses = true
});
await client.ReadAsync(new ReadRequest()).ConfigureAwait(false);
Assert.That(seen!.Headers.AcceptEncoding.Select(e => e.Value), Does.Contain("gzip"));
Assert.That(http.DefaultRequestHeaders.AcceptEncoding, Is.Empty, "a shared client is never mutated");
}
[Test]
public async Task GzipResponseIsDecompressedEvenWhenNotAskedForAsync()
{
IServiceMessageContext context = CreateContext();
HttpRequestMessage? seen = null;
using var handler = new StubHandler(request =>
{
seen = request;
return GzipResponse(WebApiBodyCodec.EncodeBody(new ReadResponse(), context));
});
using WebApiClient client = CreateClient(handler, new WebApiClientOptions { MessageContext = context });
ReadResponse response = await client.ReadAsync(new ReadRequest()).ConfigureAwait(false);
Assert.That(response, Is.Not.Null);
Assert.That(seen!.Headers.AcceptEncoding, Is.Empty);
}
[Test]
public void GzipBodyIsBoundedByMaxMessageSizeAfterInflating()
{
// 1 MiB of zeros compresses to about 1 KiB; the budget applies
// to the inflated body.
ServiceMessageContext context = CreateContext();
context.MaxMessageSize = 64 * 1024;
using var handler = new StubHandler(_ => GzipResponse(new byte[1024 * 1024]));
using WebApiClient client = CreateClient(handler, new WebApiClientOptions { MessageContext = context });
ServiceResultException? error = Assert.ThrowsAsync<ServiceResultException>(
async () => await client.ReadAsync(new ReadRequest()).ConfigureAwait(false));
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadResponseTooLarge));
}
/// <summary>
/// The declared Content-Length of a gzip body is its compressed size,
/// which the gzip header and trailer can make larger than the inflated
/// body; only the inflated size counts against MaxMessageSize.
/// </summary>
[Test]
public async Task GzipBodyLargerThanTheLimitButInflatingWithinItIsReadAsync()
{
byte[] body = Encoding.UTF8.GetBytes("{}");
byte[] compressed = Gzip(body);
const int maxMessageSize = 16;
Assert.That(compressed, Has.Length.GreaterThan(maxMessageSize));
using var content = new ByteArrayContent(compressed);
content.Headers.ContentEncoding.Add("gzip");
content.Headers.ContentLength = compressed.Length;
byte[] read = await HttpResponseBodyReader.ReadAsync(content, maxMessageSize, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(read, Is.EqualTo(body));
}
[Test]
public void PlainBodyDeclaringMoreThanTheLimitIsRejectedUpFront()
{
byte[] body = new byte[32];
using var content = new ByteArrayContent(body);
content.Headers.ContentLength = body.Length;
ServiceResultException? error = Assert.ThrowsAsync<ServiceResultException>(
async () => await HttpResponseBodyReader.ReadAsync(content, 16, CancellationToken.None)
.ConfigureAwait(false));
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadResponseTooLarge));
}
[Test]
public void CorruptGzipIsADecodingError()
{
using var handler = new StubHandler(_ =>
{
// A complete gzip header followed by a deflate block of the
// reserved block type 3, which no inflater accepts.
var content = new ByteArrayContent(
[0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x07, 0x00]);
content.Headers.ContentEncoding.Add("gzip");
return new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
});
using WebApiClient client = CreateClient(handler, new WebApiClientOptions { MessageContext = CreateContext() });
ServiceResultException? error = Assert.ThrowsAsync<ServiceResultException>(
async () => await client.ReadAsync(new ReadRequest()).ConfigureAwait(false));
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadDecodingError));
Assert.That(error.InnerException, Is.InstanceOf<InvalidDataException>());
}
[Test]
public void UnsupportedContentEncodingIsRejected()
{
using var handler = new StubHandler(_ =>
{
var content = new ByteArrayContent([1, 2, 3]);
content.Headers.ContentEncoding.Add("br");
return new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
});
using WebApiClient client = CreateClient(handler, new WebApiClientOptions { MessageContext = CreateContext() });
ServiceResultException? error = Assert.ThrowsAsync<ServiceResultException>(
async () => await client.ReadAsync(new ReadRequest()).ConfigureAwait(false));
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadDecodingError));
Assert.That(error.Message, Does.Contain("br"));
}
[Test]
public async Task IdentityContentEncodingIsReadAsIsAsync()
{
IServiceMessageContext context = CreateContext();
using var handler = new StubHandler(_ =>
{
var content = new ByteArrayContent(WebApiBodyCodec.EncodeBody(new ReadResponse(), context));
content.Headers.ContentEncoding.Add("identity");
return new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
});
using WebApiClient client = CreateClient(handler, new WebApiClientOptions { MessageContext = context });
ReadResponse response = await client.ReadAsync(new ReadRequest()).ConfigureAwait(false);
Assert.That(response, Is.Not.Null);
}
/// <summary>
/// The response stream belongs to the <see cref="HttpContent"/>, which
/// the caller disposes with the response; the reader must not dispose
/// it, with or without gzip.
/// </summary>
[TestCase(false)]
[TestCase(true)]
public async Task ResponseStreamIsLeftToItsContentAsync(bool gzip)
{
byte[] body = Encoding.UTF8.GetBytes("{\"Value\":1}");
using var stream = new TrackingStream(gzip ? Gzip(body) : body);
using var content = new StreamOwningContent(stream);
if (gzip)
{
content.Headers.ContentEncoding.Add("gzip");
}
byte[] read = await HttpResponseBodyReader.ReadAsync(content, 1024, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(read, Is.EqualTo(body));
Assert.That(stream.IsDisposed, Is.False, "The reader disposed the stream of the content.");
content.Dispose();
Assert.That(stream.IsDisposed, Is.True);
}
private static HttpResponseMessage GzipResponse(byte[] body)
{
var content = new ByteArrayContent(Gzip(body));
content.Headers.ContentType = new MediaTypeHeaderValue(WebApiMediaType.ContentType);
content.Headers.ContentEncoding.Add("gzip");
return new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
}
private static byte[] Gzip(byte[] body)
{
using var buffer = new MemoryStream();
using (var gzip = new GZipStream(buffer, CompressionLevel.Fastest, leaveOpen: true))
{
gzip.Write(body, 0, body.Length);
}
return buffer.ToArray();
}
private static ServiceMessageContext CreateContext()
{
return ServiceMessageContext.CreateEmpty(new TestTelemetryContext());
}
private static WebApiClient CreateClient(HttpMessageHandler handler, WebApiClientOptions options)
{
options.HttpMessageHandler = handler;
options.MessageContext ??= CreateContext();
return WebApiClient.Create(s_baseAddress, options);
}
private sealed class StubHandler : HttpMessageHandler
{
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
{
m_respond = (request, _) => Task.FromResult(respond(request));
}
public StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> respond)
{
m_respond = respond;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return m_respond(request, cancellationToken);
}
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> m_respond;
}
/// <summary>
/// Content that hands out the stream it owns, as the content of an
/// HTTP response does, and disposes it with itself.
/// </summary>
private sealed class StreamOwningContent : HttpContent
{
public StreamOwningContent(Stream stream)
{
m_stream = stream;
}
protected override Task<Stream> CreateContentReadStreamAsync()
{
return Task.FromResult(m_stream);
}
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context)
{
return m_stream.CopyToAsync(stream);
}
protected override bool TryComputeLength(out long length)
{
length = 0;
return false;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
m_stream.Dispose();
}
base.Dispose(disposing);
}
private readonly Stream m_stream;
}
/// <summary>
/// A memory stream that records whether it was disposed.
/// </summary>
private sealed class TrackingStream : MemoryStream
{
public TrackingStream(byte[] buffer)
: base(buffer, writable: false)
{
}
public bool IsDisposed { get; private set; }
protected override void Dispose(bool disposing)
{
IsDisposed = true;
base.Dispose(disposing);
}
}
private sealed class TestTelemetryContext : TelemetryContextBase
{
public TestTelemetryContext()
: base(NullLoggerFactory.Instance)
{
}
}
}
}