From 071e59903203113d6c8766b0e61d626ab78f08d7 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 15 Jun 2026 22:42:55 +0300 Subject: [PATCH 1/4] feat: add pipeline stage, policy base, and runner Introduces PipelineStage (sparse enum with pillar/non-pillar semantics), HttpPipelinePolicy (abstract base; async-only in v1), and PipelineRunner (immutable readonly struct that advances the policy chain and terminates at the transport). Tests verify stage-order execution and re-entrancy. Co-Authored-By: Claude Opus 4.8 --- .../Pipeline/HttpPipelinePolicy.cs | 46 ++++++++ .../Pipeline/PipelineRunner.cs | 63 +++++++++++ .../Pipeline/PipelineStage.cs | 70 ++++++++++++ .../Pipeline/PipelineRunnerTests.cs | 106 ++++++++++++++++++ 4 files changed, 285 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Pipeline/HttpPipelinePolicy.cs create mode 100644 src/Dexpace.Sdk.Core/Pipeline/PipelineRunner.cs create mode 100644 src/Dexpace.Sdk.Core/Pipeline/PipelineStage.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineRunnerTests.cs diff --git a/src/Dexpace.Sdk.Core/Pipeline/HttpPipelinePolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/HttpPipelinePolicy.cs new file mode 100644 index 0000000..695c184 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/HttpPipelinePolicy.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Pipeline; + +/// +/// Base class for every policy in the HTTP pipeline. +/// +/// +/// +/// A policy participates in the request/response lifecycle by implementing +/// . Before calling next.RunAsync, a policy may mutate +/// ; after the call returns, it may inspect or replace +/// . +/// +/// +/// Re-entrancy. is a value type, so a policy may call +/// next.RunAsync more than once — this is how redirect and retry policies work. +/// +/// +/// Async-only in v1. There is no synchronous Process override on this base class. +/// The sync entry point on the pipeline drives the async chain via a blocking +/// bridge; concrete policy subclasses are only required to implement the async path. +/// +/// +public abstract class HttpPipelinePolicy +{ + /// + /// The stage at which this policy is inserted in the pipeline. + /// + public abstract PipelineStage Stage { get; } + + /// + /// Asynchronously participates in processing the request/response. + /// + /// + /// The mutable context carrying the current , + /// , and ancillary state for this call. + /// + /// + /// The continuation that runs the remaining policies and eventually invokes the transport. + /// Call this to forward the request; omit the call to short-circuit the chain. + /// + /// A that completes when the policy has finished. + public abstract ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation); +} diff --git a/src/Dexpace.Sdk.Core/Pipeline/PipelineRunner.cs b/src/Dexpace.Sdk.Core/Pipeline/PipelineRunner.cs new file mode 100644 index 0000000..72d61d3 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/PipelineRunner.cs @@ -0,0 +1,63 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; + +namespace Dexpace.Sdk.Core.Pipeline; + +/// +/// The "next" continuation passed to each call. +/// Advances the policy index and ultimately invokes the transport. +/// +/// +/// +/// is a readonly struct so it carries zero allocation per +/// policy hop. A policy may call more than once (e.g. retry, redirect) +/// because the runner is immutable — each call re-advances from the same index with its own +/// in-flight state. +/// +/// +/// Callers must not retain or share a beyond the duration of +/// . +/// +/// +public readonly struct PipelineRunner +{ + private readonly HttpPipelinePolicy[] _policies; + private readonly int _index; + private readonly IAsyncHttpClient _transport; + + /// + /// Initializes a runner. Called by the pipeline entry point and recursively by + /// . + /// + /// The ordered (sorted-by-stage) policy array. + /// The index of the next policy to invoke. + /// The terminal transport invoked when all policies have run. + internal PipelineRunner(HttpPipelinePolicy[] policies, int index, IAsyncHttpClient transport) + { + _policies = policies; + _index = index; + _transport = transport; + } + + /// + /// Runs the remainder of the pipeline starting at the current index, then invokes the + /// transport if no earlier policy short-circuited. + /// + /// The mutable context for the current call. + /// A that completes when the pipeline tail has run. + public async ValueTask RunAsync(PipelineContext context) + { + if (_index >= _policies.Length) + { + context.Response = await _transport + .ExecuteAsync(context.Request, context.CancellationToken) + .ConfigureAwait(false); + return; + } + + var next = new PipelineRunner(_policies, _index + 1, _transport); + await _policies[_index].ProcessAsync(context, next).ConfigureAwait(false); + } +} diff --git a/src/Dexpace.Sdk.Core/Pipeline/PipelineStage.cs b/src/Dexpace.Sdk.Core/Pipeline/PipelineStage.cs new file mode 100644 index 0000000..8f0ccef --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/PipelineStage.cs @@ -0,0 +1,70 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Pipeline; + +/// +/// Identifies where in the pipeline chain a policy is inserted. +/// Policies execute in ascending numeric order (outermost first on the way in; +/// innermost first on the way out). +/// +/// +/// +/// Numbers are sparse to leave room for future stages without breaking existing values. +/// +/// +/// Pillar stages, , , +/// , and — admit exactly one policy each. +/// Adding a second policy to a pillar stage is a configuration error detected at +/// pipeline build time. +/// +/// +/// Non-pillar stages and — may hold +/// multiple policies, which execute in the order they were registered. +/// +/// +public enum PipelineStage +{ + /// + /// Outermost stage. Runs once per logical operation — opens the operation span and applies + /// the overall deadline. Pillar: at most one policy. + /// + Operation = 100, + + /// + /// Redirect-following stage. Runs outside the retry loop so each hop triggers a full retry + /// sequence. Pillar: at most one policy. + /// + Redirect = 200, + + /// + /// Per-call stage (non-pillar). Policies here run once per logical call, above the retry + /// boundary — suitable for stable cross-attempt concerns such as idempotency keys and + /// client identity headers. + /// + PerCall = 250, + + /// + /// Retry stage. Wraps everything below it so that each retry attempt re-executes all + /// inner stages. Pillar: at most one policy. + /// + Retry = 300, + + /// + /// Per-attempt stage (non-pillar). Policies here run on every attempt inside the retry + /// loop — suitable for per-attempt concerns such as a fresh Date header. + /// + PerAttempt = 400, + + /// + /// Auth stage. Placed inside the retry loop so a token refresh applies to the next + /// retry attempt. Pillar: at most one policy. + /// + Auth = 500, + + /// + /// Diagnostics stage. Closest to the transport wire; wraps the per-attempt span, + /// metrics, and structured log events. Pillar: at most one policy. + /// + Diagnostics = 600, +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineRunnerTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineRunnerTests.cs new file mode 100644 index 0000000..59e50ca --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineRunnerTests.cs @@ -0,0 +1,106 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline; + +public class PipelineRunnerTests +{ + private static Request MakeRequest() => + Request.Get("https://api.example.com/v1/resource"); + + private static PipelineContext MakeContext() => + new(MakeRequest(), new DexpaceClientOptions()); + + // --------------------------------------------------------------------------- + // Test fakes + // --------------------------------------------------------------------------- + + private sealed class RecordingPolicy(string name, PipelineStage stage, List log) + : HttpPipelinePolicy + { + public override PipelineStage Stage => stage; + + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + log.Add($"{name}:in"); + await continuation.RunAsync(context).ConfigureAwait(false); + log.Add($"{name}:out"); + } + } + + private sealed class FakeTransport : IAsyncHttpClient + { + private readonly Response _canned; + + public FakeTransport(Response? canned = null) => + _canned = canned ?? new Response(Status.Ok); + + public int InvocationCount { get; private set; } + + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) + { + InvocationCount++; + return Task.FromResult(_canned); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + [Fact] + public async Task ExecutionOrder_PoliciesRunInStageOrderInAndReversedOut_TransportInvokedOnce() + { + var log = new List(); + var transport = new FakeTransport(); + + // a = Operation (100), b = PerAttempt (400) — stage ordering: a before b + var policies = new HttpPipelinePolicy[] + { + new RecordingPolicy("a", PipelineStage.Operation, log), + new RecordingPolicy("b", PipelineStage.PerAttempt, log), + }; + + var runner = new PipelineRunner(policies, 0, transport); + var context = MakeContext(); + await runner.RunAsync(context); + + Assert.Equal(["a:in", "b:in", "b:out", "a:out"], log); + Assert.Equal(1, transport.InvocationCount); + } + + [Fact] + public async Task Reentrancy_PolicyCallingNextTwice_TransportInvokedTwice() + { + var transport = new FakeTransport(); + var doubleCallPolicy = new DoubleDipPolicy(); + var policies = new HttpPipelinePolicy[] { doubleCallPolicy }; + + var runner = new PipelineRunner(policies, 0, transport); + var context = MakeContext(); + await runner.RunAsync(context); + + Assert.Equal(2, transport.InvocationCount); + } + + private sealed class DoubleDipPolicy : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.PerAttempt; + + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + await continuation.RunAsync(context).ConfigureAwait(false); + await continuation.RunAsync(context).ConfigureAwait(false); + } + } +} From 8a96e78084da42bc3471e7467b92179396661d70 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 15 Jun 2026 22:45:51 +0300 Subject: [PATCH 2/4] feat: add PipelineBuilder Introduces PipelineBuilder with fluent Add/InsertBefore/InsertAfter/ Replace/Remove operations. Build stable-sorts by stage, validates pillar cardinality (throws InvalidOperationException naming the stage when violated), and produces an HttpPipeline. Also adds PipelineStageHelper (internal IsPillar/PillarStages) and HttpPipeline (entry point with async SendAsync and blocking Send bridge). Co-Authored-By: Claude Opus 4.8 --- src/Dexpace.Sdk.Core/Pipeline/HttpPipeline.cs | 84 +++++++ .../Pipeline/PipelineBuilder.cs | 161 +++++++++++++ .../Pipeline/PipelineStageHelper.cs | 37 +++ .../Pipeline/PipelineBuilderTests.cs | 212 ++++++++++++++++++ 4 files changed, 494 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Pipeline/HttpPipeline.cs create mode 100644 src/Dexpace.Sdk.Core/Pipeline/PipelineBuilder.cs create mode 100644 src/Dexpace.Sdk.Core/Pipeline/PipelineStageHelper.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineBuilderTests.cs diff --git a/src/Dexpace.Sdk.Core/Pipeline/HttpPipeline.cs b/src/Dexpace.Sdk.Core/Pipeline/HttpPipeline.cs new file mode 100644 index 0000000..9f4fe56 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/HttpPipeline.cs @@ -0,0 +1,84 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; + +namespace Dexpace.Sdk.Core.Pipeline; + +/// +/// The entry point for sending an HTTP request through the configured policy chain. +/// +/// +/// +/// Instances are created exclusively by . The pipeline is +/// immutable after construction: the sorted policy array and transport are captured at build time. +/// +/// +/// Sync bridge. blocks the calling thread by driving the async chain +/// synchronously via GetAwaiter().GetResult(). Callers on a thread pool should prefer +/// to avoid thread starvation. +/// +/// +public sealed class HttpPipeline +{ + private readonly HttpPipelinePolicy[] _policies; + private readonly IAsyncHttpClient _transport; + + internal HttpPipeline(HttpPipelinePolicy[] policies, IAsyncHttpClient transport) + { + _policies = policies; + _transport = transport; + } + + /// + /// Asynchronously sends through the pipeline and returns the + /// response produced by the terminal transport. + /// + /// The request to send. + /// Client options that apply to this call. + /// An optional token to cancel the call. + /// + /// A that completes with the once + /// the pipeline chain has finished. + /// + /// + /// No policy or the transport produced a by the time the chain + /// completed (i.e. the pipeline was short-circuited without setting a response). + /// + public async ValueTask SendAsync( + Request request, + DexpaceClientOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(options); + + var context = new PipelineContext(request, options, cancellationToken); + await new PipelineRunner(_policies, 0, _transport).RunAsync(context).ConfigureAwait(false); + + return context.Response + ?? throw new PipelineAbortedException( + "The pipeline completed without producing a response."); + } + + /// + /// Synchronously sends through the pipeline and returns the + /// response. Blocks the calling thread until the async chain completes. + /// + /// The request to send. + /// Client options that apply to this call. + /// An optional token to cancel the call. + /// The produced by the pipeline. + /// + /// The pipeline completed without producing a response. + /// + public Response Send( + Request request, + DexpaceClientOptions options, + CancellationToken cancellationToken = default) => + SendAsync(request, options, cancellationToken).AsTask().GetAwaiter().GetResult(); +} diff --git a/src/Dexpace.Sdk.Core/Pipeline/PipelineBuilder.cs b/src/Dexpace.Sdk.Core/Pipeline/PipelineBuilder.cs new file mode 100644 index 0000000..5df44ab --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/PipelineBuilder.cs @@ -0,0 +1,161 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; + +namespace Dexpace.Sdk.Core.Pipeline; + +/// +/// Builds an from an ordered set of +/// instances and a terminal transport. +/// +/// +/// +/// Policies are kept in an internal list. appends to that list; +/// InsertBefore<T>, InsertAfter<T>, Replace<T>, and +/// Remove<T> operate relative to the first policy of runtime type T. +/// +/// +/// performs a stable sort by +/// (preserving list order within a stage) and then validates pillar-stage cardinality: +/// stages marked as pillar admit exactly one policy. A violation throws +/// with an actionable message naming the offending stage. +/// +/// +public sealed class PipelineBuilder +{ + private readonly List _list = []; + + /// + /// Appends to the internal list. The stage-based sort happens at + /// time, not here. + /// + /// The policy to add. + /// This builder (fluent interface). + public PipelineBuilder Add(HttpPipelinePolicy policy) + { + ArgumentNullException.ThrowIfNull(policy); + _list.Add(policy); + return this; + } + + /// + /// Inserts immediately before the first policy of runtime type + /// in the current list. + /// + /// The type to search for. + /// The policy to insert. + /// This builder (fluent interface). + /// + /// No policy of type is present in the list. + /// + public PipelineBuilder InsertBefore(HttpPipelinePolicy policy) + where T : HttpPipelinePolicy + { + ArgumentNullException.ThrowIfNull(policy); + var index = FindFirst(); + _list.Insert(index, policy); + return this; + } + + /// + /// Inserts immediately after the first policy of runtime type + /// in the current list. + /// + /// The type to search for. + /// The policy to insert. + /// This builder (fluent interface). + /// + /// No policy of type is present in the list. + /// + public PipelineBuilder InsertAfter(HttpPipelinePolicy policy) + where T : HttpPipelinePolicy + { + ArgumentNullException.ThrowIfNull(policy); + var index = FindFirst(); + _list.Insert(index + 1, policy); + return this; + } + + /// + /// Replaces the first policy of runtime type with + /// . + /// + /// The type to replace. + /// The replacement policy. + /// This builder (fluent interface). + /// + /// No policy of type is present in the list. + /// + public PipelineBuilder Replace(HttpPipelinePolicy policy) + where T : HttpPipelinePolicy + { + ArgumentNullException.ThrowIfNull(policy); + var index = FindFirst(); + _list[index] = policy; + return this; + } + + /// + /// Removes every policy of runtime type from the list. + /// If none are present, this is a no-op. + /// + /// The type to remove. + /// This builder (fluent interface). + public PipelineBuilder Remove() + where T : HttpPipelinePolicy + { + _list.RemoveAll(p => p is T); + return this; + } + + /// + /// Stable-sorts the registered policies by , validates + /// pillar-stage cardinality, and constructs the with the given + /// as the terminal. + /// + /// + /// The terminal transport; invoked after all policies have run. + /// + /// A fully configured . + /// + /// A pillar stage contains more than one policy. + /// + public HttpPipeline Build(IAsyncHttpClient transport) + { + ArgumentNullException.ThrowIfNull(transport); + + // Stable sort by Stage value + HttpPipelinePolicy[] sorted = [.. _list.OrderBy(p => (int)p.Stage)]; + + // Validate pillar cardinality + foreach (var stage in PipelineStageHelper.PillarStages) + { + var count = sorted.Count(p => p.Stage == stage); + if (count > 1) + { + throw new InvalidOperationException( + $"Pipeline stage '{stage}' is a pillar stage and may contain at most one policy, " + + $"but {count} policies were registered for it. " + + $"Remove the duplicate or use a non-pillar stage."); + } + } + + return new HttpPipeline(sorted, transport); + } + + // Returns the index of the first policy of type T, or throws. + private int FindFirst() where T : HttpPipelinePolicy + { + for (var i = 0; i < _list.Count; i++) + { + if (_list[i] is T) + { + return i; + } + } + + throw new InvalidOperationException( + $"No policy of type '{typeof(T).Name}' is registered in this builder."); + } +} diff --git a/src/Dexpace.Sdk.Core/Pipeline/PipelineStageHelper.cs b/src/Dexpace.Sdk.Core/Pipeline/PipelineStageHelper.cs new file mode 100644 index 0000000..78d3f4d --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/PipelineStageHelper.cs @@ -0,0 +1,37 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Pipeline; + +/// +/// Internal helpers for pillar classification. +/// +internal static class PipelineStageHelper +{ + /// + /// Returns when is a pillar stage that + /// admits at most one policy. + /// + internal static bool IsPillar(PipelineStage stage) => stage switch + { + PipelineStage.Operation => true, + PipelineStage.Redirect => true, + PipelineStage.Retry => true, + PipelineStage.Auth => true, + PipelineStage.Diagnostics => true, + _ => false, + }; + + /// + /// The set of all pillar stages, used for cardinality validation during + /// . + /// + internal static readonly PipelineStage[] PillarStages = + [ + PipelineStage.Operation, + PipelineStage.Redirect, + PipelineStage.Retry, + PipelineStage.Auth, + PipelineStage.Diagnostics, + ]; +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineBuilderTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineBuilderTests.cs new file mode 100644 index 0000000..7184624 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineBuilderTests.cs @@ -0,0 +1,212 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Pipeline; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline; + +public class PipelineBuilderTests +{ + // --------------------------------------------------------------------------- + // Concrete test policy stubs + // --------------------------------------------------------------------------- + + private abstract class StubPolicy(PipelineStage stage) : HttpPipelinePolicy + { + public override PipelineStage Stage => stage; + public override ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) => + continuation.RunAsync(context); + } + + private sealed class OperationStub() : StubPolicy(PipelineStage.Operation); + private sealed class RetryStub() : StubPolicy(PipelineStage.Retry); + private sealed class AuthStub() : StubPolicy(PipelineStage.Auth); + private sealed class DiagnosticsStub() : StubPolicy(PipelineStage.Diagnostics); + private sealed class PerCallStubA() : StubPolicy(PipelineStage.PerCall); + private sealed class PerCallStubB() : StubPolicy(PipelineStage.PerCall); + private sealed class PerAttemptStub() : StubPolicy(PipelineStage.PerAttempt); + + private sealed class FakeTransport : IAsyncHttpClient + { + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) => + Task.FromResult(new Response(Dexpace.Sdk.Core.Http.Response.Status.Ok)); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private static FakeTransport MakeTransport() => new FakeTransport(); + + // --------------------------------------------------------------------------- + // Helper: build and extract the sorted policies from the pipeline via + // a probe policy that records the chain at call time. + // --------------------------------------------------------------------------- + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + [Fact] + public void Add_PoliciesAreSortedByStageAfterBuild() + { + // Add in "wrong" order; Build must sort by Stage + var retry = new RetryStub(); + var operation = new OperationStub(); + var perAttempt = new PerAttemptStub(); + + var pipeline = new PipelineBuilder() + .Add(retry) + .Add(operation) + .Add(perAttempt) + .Build(MakeTransport()); + + Assert.NotNull(pipeline); + + // Indirect verification: the pipeline must execute without throwing. + // We can't easily inspect internals without reflection; correctness of + // ordering is primarily proven by the PipelineRunnerTests + HttpPipelineTests. + } + + [Fact] + public void Add_MultiplePoliciesInSameNonPillarStage_DoesNotThrow() + { + var a = new PerCallStubA(); + var b = new PerCallStubB(); + + // Should succeed — PerCall is non-pillar + var pipeline = new PipelineBuilder() + .Add(a) + .Add(b) + .Build(MakeTransport()); + + Assert.NotNull(pipeline); + } + + [Fact] + public void Build_TwoPoliciesInPillarStage_Throws() + { + var retry1 = new RetryStub(); + var retry2 = new RetryStub(); + + var ex = Assert.Throws(() => + new PipelineBuilder() + .Add(retry1) + .Add(retry2) + .Build(MakeTransport())); + + Assert.Contains("Retry", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void InsertBefore_InsertsBeforeFirstMatchingType() + { + var retry = new RetryStub(); + var auth = new AuthStub(); + var diag = new DiagnosticsStub(); + + // After build, sorted order without InsertBefore would be: retry, auth, diag. + // InsertBefore for a PerCall policy should still resolve by type, + // not by stage order (InsertBefore is position-relative, not stage-relative). + // We test that the call does not throw and produces a valid pipeline. + var perCall = new PerCallStubA(); + + var pipeline = new PipelineBuilder() + .Add(retry) + .Add(auth) + .Add(diag) + .InsertBefore(perCall) + .Build(MakeTransport()); + + Assert.NotNull(pipeline); + } + + [Fact] + public void InsertBefore_TypeNotPresent_Throws() + { + var ex = Assert.Throws(() => + new PipelineBuilder() + .InsertBefore(new PerCallStubA())); + + Assert.Contains("RetryStub", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void InsertAfter_InsertsAfterFirstMatchingType() + { + var retry = new RetryStub(); + var auth = new AuthStub(); + var perCall = new PerCallStubA(); + + var pipeline = new PipelineBuilder() + .Add(retry) + .Add(auth) + .InsertAfter(perCall) + .Build(MakeTransport()); + + Assert.NotNull(pipeline); + } + + [Fact] + public void InsertAfter_TypeNotPresent_Throws() + { + var ex = Assert.Throws(() => + new PipelineBuilder() + .InsertAfter(new PerCallStubA())); + + Assert.Contains("RetryStub", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Replace_SwapsFirstMatchingType() + { + var retry1 = new RetryStub(); + var retry2 = new RetryStub(); + + // Replace the first RetryStub with retry2 — pillar only allows one, so after + // replace there should be exactly one RetryStub and Build must not throw. + var pipeline = new PipelineBuilder() + .Add(retry1) + .Replace(retry2) + .Build(MakeTransport()); + + Assert.NotNull(pipeline); + } + + [Fact] + public void Replace_TypeNotPresent_Throws() + { + var ex = Assert.Throws(() => + new PipelineBuilder() + .Replace(new RetryStub())); + + Assert.Contains("RetryStub", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Remove_RemovesAllMatchingTypes() + { + var a = new PerCallStubA(); + var b = new PerCallStubA(); + var retry = new RetryStub(); + + // After Remove, only the retry remains. + var pipeline = new PipelineBuilder() + .Add(a) + .Add(b) + .Add(retry) + .Remove() + .Build(MakeTransport()); + + Assert.NotNull(pipeline); + } + + [Fact] + public void Build_EmptyPipeline_ProducesValidPipeline() + { + var pipeline = new PipelineBuilder().Build(MakeTransport()); + Assert.NotNull(pipeline); + } +} From 8807e536c8cdabc5c11f7eed06b4be7e1b2de1ff Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 15 Jun 2026 22:46:25 +0300 Subject: [PATCH 3/4] feat: add HttpPipeline entry point with sync bridge Adds HttpPipelineTests covering SendAsync and the blocking Send bridge. The implementation was already landed with PipelineBuilder in the prior commit; this commit captures the Group 3 test coverage. Co-Authored-By: Claude Opus 4.8 --- .../Pipeline/HttpPipelineTests.cs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/HttpPipelineTests.cs diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/HttpPipelineTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/HttpPipelineTests.cs new file mode 100644 index 0000000..ce4da1f --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/HttpPipelineTests.cs @@ -0,0 +1,79 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline; + +public class HttpPipelineTests +{ + private static Request MakeRequest() => + Request.Get("https://api.example.com/v1/resource"); + + private static DexpaceClientOptions MakeOptions() => new(); + + private sealed class CannedTransport(Response canned) : IAsyncHttpClient + { + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) => + Task.FromResult(canned); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + [Fact] + public async Task SendAsync_ReturnsTransportResponse() + { + var expected = new Response(Status.Ok); + var pipeline = new PipelineBuilder().Build(new CannedTransport(expected)); + + var actual = await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + Assert.Same(expected, actual); + } + + [Fact] + public void Send_ReturnsTransportResponse() + { + var expected = new Response(Status.Ok); + var pipeline = new PipelineBuilder().Build(new CannedTransport(expected)); + + var actual = pipeline.Send(MakeRequest(), MakeOptions()); + + Assert.Same(expected, actual); + } + + [Fact] + public async Task SendAsync_WithPolicies_PoliciesInvokedAndResponseReturned() + { + var log = new List(); + var expected = new Response(Status.Ok); + + var pipeline = new PipelineBuilder() + .Add(new LoggingPolicy("a", PipelineStage.Operation, log)) + .Add(new LoggingPolicy("b", PipelineStage.PerAttempt, log)) + .Build(new CannedTransport(expected)); + + var actual = await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + Assert.Same(expected, actual); + Assert.Equal(["a:in", "b:in", "b:out", "a:out"], log); + } + + private sealed class LoggingPolicy(string name, PipelineStage stage, List log) + : HttpPipelinePolicy + { + public override PipelineStage Stage => stage; + + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + log.Add($"{name}:in"); + await continuation.RunAsync(context); + log.Add($"{name}:out"); + } + } +} From 8cff50d9ad054ebcdaa375a6178fd3de8450d470 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Mon, 15 Jun 2026 22:55:29 +0300 Subject: [PATCH 4/4] test: verify PipelineBuilder ordering and edits; fix import ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace tautological NotNull-only assertions in PipelineBuilderTests with execution-log tests that exercise the actual pipeline chain. New tests confirm: stage-sort order (Operation → Redirect → Diagnostics) via recording stubs, InsertAfter/InsertBefore within the same stage, Replace swapping one policy for another, and Remove dropping the target while leaving others intact. Also fixes the import ordering that caused dotnet format --verify-no-changes to fail (Http.Response was listed before Http.Request; corrected to alphabetical order and added the missing Configuration using directive required by the new tests). Co-Authored-By: Claude Sonnet 4.6 --- .../Pipeline/PipelineBuilderTests.cs | 261 ++++++++++++------ 1 file changed, 182 insertions(+), 79 deletions(-) diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineBuilderTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineBuilderTests.cs index 7184624..a20c88c 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineBuilderTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineBuilderTests.cs @@ -2,8 +2,9 @@ // Licensed under the MIT License. See LICENSE in the repository root for details. using Dexpace.Sdk.Core.Client; -using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Configuration; using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; using Dexpace.Sdk.Core.Pipeline; using Xunit; @@ -15,166 +16,254 @@ public class PipelineBuilderTests // Concrete test policy stubs // --------------------------------------------------------------------------- + /// + /// Pass-through stub for cardinality / type-not-found tests. + /// private abstract class StubPolicy(PipelineStage stage) : HttpPipelinePolicy { public override PipelineStage Stage => stage; + public override ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) => continuation.RunAsync(context); } private sealed class OperationStub() : StubPolicy(PipelineStage.Operation); + private sealed class RedirectStub() : StubPolicy(PipelineStage.Redirect); private sealed class RetryStub() : StubPolicy(PipelineStage.Retry); private sealed class AuthStub() : StubPolicy(PipelineStage.Auth); private sealed class DiagnosticsStub() : StubPolicy(PipelineStage.Diagnostics); private sealed class PerCallStubA() : StubPolicy(PipelineStage.PerCall); - private sealed class PerCallStubB() : StubPolicy(PipelineStage.PerCall); private sealed class PerAttemptStub() : StubPolicy(PipelineStage.PerAttempt); + /// + /// Recording stub: appends "name:in" before and "name:out" after the continuation. + /// Used by execution-log tests to verify actual invocation order. + /// + private sealed class RecordingPolicy(string name, PipelineStage stage, List log) + : HttpPipelinePolicy + { + public override PipelineStage Stage => stage; + + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + log.Add($"{name}:in"); + await continuation.RunAsync(context).ConfigureAwait(false); + log.Add($"{name}:out"); + } + } + + /// Two distinct PerCall recording types for InsertAfter/InsertBefore/Replace/Remove tests. + private sealed class RecordingPerCallA(List log) : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.PerCall; + + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + log.Add("A:in"); + await continuation.RunAsync(context).ConfigureAwait(false); + log.Add("A:out"); + } + } + + private sealed class RecordingPerCallA2(List log) : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.PerCall; + + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + log.Add("A2:in"); + await continuation.RunAsync(context).ConfigureAwait(false); + log.Add("A2:out"); + } + } + + private sealed class RecordingPerCallB(List log) : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.PerCall; + + public override async ValueTask ProcessAsync(PipelineContext context, PipelineRunner continuation) + { + log.Add("B:in"); + await continuation.RunAsync(context).ConfigureAwait(false); + log.Add("B:out"); + } + } + + // --------------------------------------------------------------------------- + // Fakes / helpers + // --------------------------------------------------------------------------- + private sealed class FakeTransport : IAsyncHttpClient { public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) => - Task.FromResult(new Response(Dexpace.Sdk.Core.Http.Response.Status.Ok)); + Task.FromResult(new Response(Status.Ok)); public ValueTask DisposeAsync() => ValueTask.CompletedTask; } - private static FakeTransport MakeTransport() => new FakeTransport(); + private static FakeTransport MakeTransport() => new(); - // --------------------------------------------------------------------------- - // Helper: build and extract the sorted policies from the pipeline via - // a probe policy that records the chain at call time. - // --------------------------------------------------------------------------- + private static DexpaceClientOptions MakeOptions() => new(); + + private static Request MakeRequest() => Request.Get("https://api.example.com/v1/resource"); // --------------------------------------------------------------------------- - // Tests + // Tests: Stage sort // --------------------------------------------------------------------------- + /// + /// Policies added in reverse stage order must execute in ascending stage order + /// (Operation=100, Redirect=200, Diagnostics=600). + /// [Fact] - public void Add_PoliciesAreSortedByStageAfterBuild() + public async Task Add_StageSortedExecution_PoliciesRunInStageOrder() { - // Add in "wrong" order; Build must sort by Stage - var retry = new RetryStub(); - var operation = new OperationStub(); - var perAttempt = new PerAttemptStub(); + var log = new List(); + // Added in reverse stage order: Diagnostics, Redirect, Operation var pipeline = new PipelineBuilder() - .Add(retry) - .Add(operation) - .Add(perAttempt) + .Add(new RecordingPolicy("diag", PipelineStage.Diagnostics, log)) + .Add(new RecordingPolicy("redirect", PipelineStage.Redirect, log)) + .Add(new RecordingPolicy("op", PipelineStage.Operation, log)) .Build(MakeTransport()); - Assert.NotNull(pipeline); + await pipeline.SendAsync(MakeRequest(), MakeOptions()); - // Indirect verification: the pipeline must execute without throwing. - // We can't easily inspect internals without reflection; correctness of - // ordering is primarily proven by the PipelineRunnerTests + HttpPipelineTests. + // Build stable-sorts by stage (ascending), so execution order is: + // op (100) → redirect (200) → diag (600), then unwind. + Assert.Equal( + ["op:in", "redirect:in", "diag:in", "diag:out", "redirect:out", "op:out"], + log); } + /// + /// Multiple policies in the same non-pillar stage must not throw at Build time. + /// [Fact] public void Add_MultiplePoliciesInSameNonPillarStage_DoesNotThrow() { - var a = new PerCallStubA(); - var b = new PerCallStubB(); - - // Should succeed — PerCall is non-pillar var pipeline = new PipelineBuilder() - .Add(a) - .Add(b) + .Add(new PerCallStubA()) + .Add(new PerCallStubA()) .Build(MakeTransport()); Assert.NotNull(pipeline); } + /// + /// Two policies in a pillar stage must cause Build to throw with the stage name in the message. + /// [Fact] public void Build_TwoPoliciesInPillarStage_Throws() { - var retry1 = new RetryStub(); - var retry2 = new RetryStub(); - var ex = Assert.Throws(() => new PipelineBuilder() - .Add(retry1) - .Add(retry2) + .Add(new RetryStub()) + .Add(new RetryStub()) .Build(MakeTransport())); Assert.Contains("Retry", ex.Message, StringComparison.OrdinalIgnoreCase); } + // --------------------------------------------------------------------------- + // Tests: InsertAfter within a stage + // --------------------------------------------------------------------------- + + /// + /// InsertAfter<A>(B) where A and B share the same stage (PerCall) must produce + /// execution order [A, B] — Build preserves within-stage list order after the sort. + /// [Fact] - public void InsertBefore_InsertsBeforeFirstMatchingType() + public async Task InsertAfter_SameStage_InsertsAfterAnchor() { - var retry = new RetryStub(); - var auth = new AuthStub(); - var diag = new DiagnosticsStub(); - - // After build, sorted order without InsertBefore would be: retry, auth, diag. - // InsertBefore for a PerCall policy should still resolve by type, - // not by stage order (InsertBefore is position-relative, not stage-relative). - // We test that the call does not throw and produces a valid pipeline. - var perCall = new PerCallStubA(); + var log = new List(); var pipeline = new PipelineBuilder() - .Add(retry) - .Add(auth) - .Add(diag) - .InsertBefore(perCall) + .Add(new RecordingPerCallA(log)) + .InsertAfter(new RecordingPerCallB(log)) // list: [A, B] .Build(MakeTransport()); - Assert.NotNull(pipeline); + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + Assert.Equal(["A:in", "B:in", "B:out", "A:out"], log); } + /// + /// InsertAfter when the anchor type is absent must throw with the type name in the message. + /// [Fact] - public void InsertBefore_TypeNotPresent_Throws() + public void InsertAfter_TypeNotPresent_Throws() { var ex = Assert.Throws(() => new PipelineBuilder() - .InsertBefore(new PerCallStubA())); + .InsertAfter(new PerCallStubA())); Assert.Contains("RetryStub", ex.Message, StringComparison.OrdinalIgnoreCase); } + // --------------------------------------------------------------------------- + // Tests: InsertBefore within a stage + // --------------------------------------------------------------------------- + + /// + /// InsertBefore<A>(B) where A and B share the same stage (PerCall) must produce + /// execution order [B, A] — Build preserves within-stage list order after the sort. + /// [Fact] - public void InsertAfter_InsertsAfterFirstMatchingType() + public async Task InsertBefore_SameStage_InsertsBeforeAnchor() { - var retry = new RetryStub(); - var auth = new AuthStub(); - var perCall = new PerCallStubA(); + var log = new List(); var pipeline = new PipelineBuilder() - .Add(retry) - .Add(auth) - .InsertAfter(perCall) + .Add(new RecordingPerCallA(log)) + .InsertBefore(new RecordingPerCallB(log)) // list: [B, A] .Build(MakeTransport()); - Assert.NotNull(pipeline); + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + Assert.Equal(["B:in", "A:in", "A:out", "B:out"], log); } + /// + /// InsertBefore when the anchor type is absent must throw with the type name in the message. + /// [Fact] - public void InsertAfter_TypeNotPresent_Throws() + public void InsertBefore_TypeNotPresent_Throws() { var ex = Assert.Throws(() => new PipelineBuilder() - .InsertAfter(new PerCallStubA())); + .InsertBefore(new PerCallStubA())); Assert.Contains("RetryStub", ex.Message, StringComparison.OrdinalIgnoreCase); } + // --------------------------------------------------------------------------- + // Tests: Replace + // --------------------------------------------------------------------------- + + /// + /// Replace<A>(A2) where A2 is a distinct PerCall type must put A2 in the execution + /// log and exclude A. + /// [Fact] - public void Replace_SwapsFirstMatchingType() + public async Task Replace_SubstitutesPolicy_LogContainsReplacementNotOriginal() { - var retry1 = new RetryStub(); - var retry2 = new RetryStub(); + var log = new List(); - // Replace the first RetryStub with retry2 — pillar only allows one, so after - // replace there should be exactly one RetryStub and Build must not throw. var pipeline = new PipelineBuilder() - .Add(retry1) - .Replace(retry2) + .Add(new RecordingPerCallA(log)) + .Replace(new RecordingPerCallA2(log)) // A swapped for A2 .Build(MakeTransport()); - Assert.NotNull(pipeline); + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + Assert.Contains("A2:in", log); + Assert.DoesNotContain("A:in", log); } + /// + /// Replace when the target type is absent must throw with the type name in the message. + /// [Fact] public void Replace_TypeNotPresent_Throws() { @@ -185,28 +274,42 @@ public void Replace_TypeNotPresent_Throws() Assert.Contains("RetryStub", ex.Message, StringComparison.OrdinalIgnoreCase); } + // --------------------------------------------------------------------------- + // Tests: Remove + // --------------------------------------------------------------------------- + + /// + /// Remove<A> with both A and B present must keep B in the execution log and drop A. + /// [Fact] - public void Remove_RemovesAllMatchingTypes() + public async Task Remove_RemovesMatchingType_OtherPolicyStillRuns() { - var a = new PerCallStubA(); - var b = new PerCallStubA(); - var retry = new RetryStub(); + var log = new List(); - // After Remove, only the retry remains. var pipeline = new PipelineBuilder() - .Add(a) - .Add(b) - .Add(retry) - .Remove() + .Add(new RecordingPerCallA(log)) + .Add(new RecordingPerCallB(log)) + .Remove() .Build(MakeTransport()); - Assert.NotNull(pipeline); + await pipeline.SendAsync(MakeRequest(), MakeOptions()); + + Assert.Contains("B:in", log); + Assert.DoesNotContain("A:in", log); } + // --------------------------------------------------------------------------- + // Tests: Edge cases + // --------------------------------------------------------------------------- + + /// + /// An empty builder must produce a valid pipeline that the transport can service. + /// [Fact] - public void Build_EmptyPipeline_ProducesValidPipeline() + public async Task Build_EmptyPipeline_TransportResponds() { var pipeline = new PipelineBuilder().Build(MakeTransport()); - Assert.NotNull(pipeline); + var response = await pipeline.SendAsync(MakeRequest(), MakeOptions()); + Assert.NotNull(response); } }