Skip to content

Commit ac1439b

Browse files
authored
feat: add the request/response pipeline and core policies (#6)
PR: #6
1 parent 3302c6f commit ac1439b

31 files changed

Lines changed: 4736 additions & 2 deletions

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<ItemGroup>
66
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
77
<PackageVersion Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="3.3.4" />
8+
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.5" />
89
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
910
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
1011
<PackageVersion Include="xunit" Version="2.9.2" />

src/Dexpace.Sdk.Core/Dexpace.Sdk.Core.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
</PropertyGroup>
1515

1616
<ItemGroup>
17+
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
1718
<PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="all" />
1819
</ItemGroup>
1920

src/Dexpace.Sdk.Core/Http/Response/Response.cs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2026 dexpace and Omar Aljarrah.
22
// Licensed under the MIT License. See LICENSE in the repository root for details.
33

4+
using Dexpace.Sdk.Core.Errors;
45
using Dexpace.Sdk.Core.Http.Common;
56

67
namespace Dexpace.Sdk.Core.Http.Response;
@@ -48,6 +49,71 @@ public Response(
4849
/// <summary>Shorthand for <c>Status.IsSuccess</c>.</summary>
4950
public bool IsSuccess => Status.IsSuccess;
5051

52+
/// <summary>
53+
/// Throws <see cref="HttpResponseException"/> if the status is not in the 2xx success range.
54+
/// </summary>
55+
/// <remarks>
56+
/// When the status is an error, the response body is drained up to
57+
/// <see cref="MaxBufferedErrorBytes"/> into an in-memory buffer and attached to the
58+
/// thrown exception so that <see cref="HttpResponseException.GetErrorAsync{T}"/> can read
59+
/// it. The cap guards against oversized error pages consuming unbounded memory.
60+
/// </remarks>
61+
/// <param name="cancellationToken">A token that can cancel the body-drain operation.</param>
62+
/// <returns>A <see cref="ValueTask"/> that completes when the check has been performed.</returns>
63+
/// <exception cref="HttpResponseException">
64+
/// The response status is not in the 2xx range. The exception carries a buffered copy of
65+
/// the error body (up to <see cref="MaxBufferedErrorBytes"/> bytes).
66+
/// </exception>
67+
public async ValueTask EnsureSuccessAsync(CancellationToken cancellationToken = default)
68+
{
69+
if (IsSuccess)
70+
{
71+
return;
72+
}
73+
74+
// Drain and cap the body so the caller can read it from the exception.
75+
var rawBytes = await DrainCappedAsync(Body, MaxBufferedErrorBytes, cancellationToken)
76+
.ConfigureAwait(false);
77+
78+
var bufferedBody = ResponseBody.FromBytes(rawBytes, Body.ContentType);
79+
var bufferedResponse = new Response(Status, Headers, bufferedBody, Protocol);
80+
throw new HttpResponseException(bufferedResponse);
81+
}
82+
83+
/// <summary>
84+
/// The maximum number of bytes buffered from an error response body by
85+
/// <see cref="EnsureSuccessAsync"/>. Larger bodies are silently truncated to this limit.
86+
/// </summary>
87+
public const int MaxBufferedErrorBytes = 1024 * 1024; // 1 MiB
88+
89+
private static async Task<byte[]> DrainCappedAsync(
90+
ResponseBody body,
91+
int maxBytes,
92+
CancellationToken cancellationToken)
93+
{
94+
await using var stream = await body.OpenReadAsync(cancellationToken).ConfigureAwait(false);
95+
using var buffer = new MemoryStream();
96+
97+
var remaining = maxBytes;
98+
var chunk = new byte[Math.Min(81920, maxBytes)];
99+
100+
while (remaining > 0)
101+
{
102+
var toRead = Math.Min(chunk.Length, remaining);
103+
var read = await stream.ReadAsync(chunk.AsMemory(0, toRead), cancellationToken)
104+
.ConfigureAwait(false);
105+
if (read == 0)
106+
{
107+
break;
108+
}
109+
110+
buffer.Write(chunk, 0, read);
111+
remaining -= read;
112+
}
113+
114+
return buffer.ToArray();
115+
}
116+
51117
/// <inheritdoc/>
52118
public void Dispose()
53119
{
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) 2026 dexpace and Omar Aljarrah.
2+
// Licensed under the MIT License. See LICENSE in the repository root for details.
3+
4+
using Dexpace.Sdk.Core.Client;
5+
using Dexpace.Sdk.Core.Pipeline.Policies;
6+
using Microsoft.Extensions.Logging;
7+
8+
namespace Dexpace.Sdk.Core.Pipeline;
9+
10+
/// <summary>
11+
/// Factory for the default Dexpace SDK HTTP pipeline.
12+
/// </summary>
13+
/// <remarks>
14+
/// <see cref="CreateDefault"/> assembles the standard policy set in the correct stage order:
15+
/// <see cref="OperationPolicy"/> → <see cref="RedirectPolicy"/> → <see cref="IdempotencyPolicy"/>
16+
/// → <see cref="ClientIdentityPolicy"/> → <see cref="RetryPolicy"/> → <see cref="SetDatePolicy"/>
17+
/// → optional auth policy → <see cref="InstrumentationPolicy"/> → transport.
18+
/// Because <see cref="PipelineBuilder"/> sorts by <see cref="HttpPipelinePolicy.Stage"/> at build
19+
/// time, the insertion order of the <c>Add</c> calls here does not affect the final ordering.
20+
/// </remarks>
21+
public static class DexpacePipeline
22+
{
23+
/// <summary>
24+
/// Builds the default HTTP pipeline with all standard policies wired in the correct stage order.
25+
/// </summary>
26+
/// <param name="transport">
27+
/// The transport that executes HTTP requests. Called after all policies have run.
28+
/// </param>
29+
/// <param name="authPolicy">
30+
/// An optional authentication policy inserted at <see cref="PipelineStage.Auth"/>. When
31+
/// <see langword="null"/> no auth policy is added.
32+
/// </param>
33+
/// <param name="logger">
34+
/// An optional logger forwarded to <see cref="InstrumentationPolicy"/>. When
35+
/// <see langword="null"/> the policy falls back to <c>NullLogger.Instance</c>.
36+
/// </param>
37+
/// <param name="timeProvider">
38+
/// An optional <see cref="TimeProvider"/> forwarded to <see cref="RetryPolicy"/> and
39+
/// <see cref="SetDatePolicy"/>. Defaults to <see cref="TimeProvider.System"/> when
40+
/// <see langword="null"/>.
41+
/// </param>
42+
/// <returns>A fully assembled <see cref="HttpPipeline"/> ready for use.</returns>
43+
public static HttpPipeline CreateDefault(
44+
IAsyncHttpClient transport,
45+
HttpPipelinePolicy? authPolicy = null,
46+
ILogger? logger = null,
47+
TimeProvider? timeProvider = null)
48+
{
49+
ArgumentNullException.ThrowIfNull(transport);
50+
51+
var builder = new PipelineBuilder()
52+
.Add(new OperationPolicy())
53+
.Add(new RedirectPolicy())
54+
.Add(new IdempotencyPolicy())
55+
.Add(new ClientIdentityPolicy())
56+
.Add(new RetryPolicy(timeProvider))
57+
.Add(new SetDatePolicy(timeProvider))
58+
.Add(new InstrumentationPolicy(logger));
59+
60+
if (authPolicy is not null)
61+
{
62+
builder.Add(authPolicy);
63+
}
64+
65+
return builder.Build(transport);
66+
}
67+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2026 dexpace and Omar Aljarrah.
2+
// Licensed under the MIT License. See LICENSE in the repository root for details.
3+
4+
using Dexpace.Sdk.Core.Client;
5+
using Dexpace.Sdk.Core.Configuration;
6+
using Dexpace.Sdk.Core.Errors;
7+
using Dexpace.Sdk.Core.Http.Request;
8+
using Dexpace.Sdk.Core.Http.Response;
9+
10+
namespace Dexpace.Sdk.Core.Pipeline;
11+
12+
/// <summary>
13+
/// The entry point for sending an HTTP request through the configured policy chain.
14+
/// </summary>
15+
/// <remarks>
16+
/// <para>
17+
/// Instances are created exclusively by <see cref="PipelineBuilder.Build"/>. The pipeline is
18+
/// immutable after construction: the sorted policy array and transport are captured at build time.
19+
/// </para>
20+
/// <para>
21+
/// <b>Sync bridge.</b> <see cref="Send"/> blocks the calling thread by driving the async chain
22+
/// synchronously via <c>GetAwaiter().GetResult()</c>. Callers on a thread pool should prefer
23+
/// <see cref="SendAsync"/> to avoid thread starvation.
24+
/// </para>
25+
/// </remarks>
26+
public sealed class HttpPipeline
27+
{
28+
private readonly HttpPipelinePolicy[] _policies;
29+
private readonly IAsyncHttpClient _transport;
30+
31+
internal HttpPipeline(HttpPipelinePolicy[] policies, IAsyncHttpClient transport)
32+
{
33+
_policies = policies;
34+
_transport = transport;
35+
}
36+
37+
/// <summary>
38+
/// Asynchronously sends <paramref name="request"/> through the pipeline and returns the
39+
/// response produced by the terminal transport.
40+
/// </summary>
41+
/// <param name="request">The request to send.</param>
42+
/// <param name="options">Client options that apply to this call.</param>
43+
/// <param name="cancellationToken">An optional token to cancel the call.</param>
44+
/// <returns>
45+
/// A <see cref="ValueTask{TResult}"/> that completes with the <see cref="Response"/> once
46+
/// the pipeline chain has finished.
47+
/// </returns>
48+
/// <exception cref="PipelineAbortedException">
49+
/// No policy or the transport produced a <see cref="Response"/> by the time the chain
50+
/// completed (i.e. the pipeline was short-circuited without setting a response).
51+
/// </exception>
52+
public async ValueTask<Response> SendAsync(
53+
Request request,
54+
DexpaceClientOptions options,
55+
CancellationToken cancellationToken = default)
56+
{
57+
ArgumentNullException.ThrowIfNull(request);
58+
ArgumentNullException.ThrowIfNull(options);
59+
60+
var context = new PipelineContext(request, options, cancellationToken);
61+
await new PipelineRunner(_policies, 0, _transport).RunAsync(context).ConfigureAwait(false);
62+
63+
return context.Response
64+
?? throw new PipelineAbortedException(
65+
"The pipeline completed without producing a response.");
66+
}
67+
68+
/// <summary>
69+
/// Synchronously sends <paramref name="request"/> through the pipeline and returns the
70+
/// response. Blocks the calling thread until the async chain completes.
71+
/// </summary>
72+
/// <param name="request">The request to send.</param>
73+
/// <param name="options">Client options that apply to this call.</param>
74+
/// <param name="cancellationToken">An optional token to cancel the call.</param>
75+
/// <returns>The <see cref="Response"/> produced by the pipeline.</returns>
76+
/// <exception cref="PipelineAbortedException">
77+
/// The pipeline completed without producing a response.
78+
/// </exception>
79+
public Response Send(
80+
Request request,
81+
DexpaceClientOptions options,
82+
CancellationToken cancellationToken = default) =>
83+
SendAsync(request, options, cancellationToken).AsTask().GetAwaiter().GetResult();
84+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright (c) 2026 dexpace and Omar Aljarrah.
2+
// Licensed under the MIT License. See LICENSE in the repository root for details.
3+
4+
namespace Dexpace.Sdk.Core.Pipeline;
5+
6+
/// <summary>
7+
/// Base class for every policy in the HTTP pipeline.
8+
/// </summary>
9+
/// <remarks>
10+
/// <para>
11+
/// A policy participates in the request/response lifecycle by implementing
12+
/// <see cref="ProcessAsync"/>. Before calling <c>next.RunAsync</c>, a policy may mutate
13+
/// <see cref="PipelineContext.Request"/>; after the call returns, it may inspect or replace
14+
/// <see cref="PipelineContext.Response"/>.
15+
/// </para>
16+
/// <para>
17+
/// <b>Re-entrancy.</b> <see cref="PipelineRunner"/> is a value type, so a policy may call
18+
/// <c>next.RunAsync</c> more than once — this is how redirect and retry policies work.
19+
/// </para>
20+
/// <para>
21+
/// <b>Async-only in v1.</b> There is no synchronous <c>Process</c> override on this base class.
22+
/// The sync entry point on the pipeline drives the async chain via a blocking
23+
/// bridge; concrete policy subclasses are only required to implement the async path.
24+
/// </para>
25+
/// </remarks>
26+
public abstract class HttpPipelinePolicy
27+
{
28+
/// <summary>
29+
/// The stage at which this policy is inserted in the pipeline.
30+
/// </summary>
31+
public abstract PipelineStage Stage { get; }
32+
33+
/// <summary>
34+
/// Asynchronously participates in processing the request/response.
35+
/// </summary>
36+
/// <param name="context">
37+
/// The mutable context carrying the current <see cref="PipelineContext.Request"/>,
38+
/// <see cref="PipelineContext.Response"/>, and ancillary state for this call.
39+
/// </param>
40+
/// <param name="continuation">
41+
/// The continuation that runs the remaining policies and eventually invokes the transport.
42+
/// Call this to forward the request; omit the call to short-circuit the chain.
43+
/// </param>
44+
/// <returns>A <see cref="ValueTask"/> that completes when the policy has finished.</returns>
45+
public abstract ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation);
46+
}

0 commit comments

Comments
 (0)