From ec01eac32728cf904d26efe94ac399f7ff8fa31c Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Tue, 13 Oct 2026 17:38:17 +0300 Subject: [PATCH] feat: phase 9 host integration (CFG-1..-38 subset, SEAM-5, SEAM-6, OBS-35) Adds Dexpace.Sdk.Extensions.DependencyInjection: AddDexpaceClient and the sealed DexpaceClientBuilder register a client as keyed singletons over a named IHttpClientFactory client with a redirect-off primary handler and a container-owned lease that releases it. Settings are read by a hand-written configuration reader (the binder cannot set init accessors and is not AOT-safe) and validated on start without quoting configured values. Also: SystemNetHttpClient.CreatePrimaryHandler, the ConfigurationBinder ban, a per-library policy record in the dependency audit, a third conformance driver over the DI-built transport, NamedClientRedirectWireTests (Security), AOT smoke coverage, the checklist, the user page and dated design corrections. Closes #96 --- .github/workflows/ci.yml | 7 +- BannedSymbols.txt | 5 + CHANGELOG.md | 16 + CLAUDE.md | 18 +- Dexpace.Sdk.sln | 30 + Directory.Packages.props | 10 + README.md | 9 +- docs/README.md | 2 +- docs/first-release.md | 13 +- docs/knowledge/notes/configuration.md | 24 + docs/knowledge/notes/transport-adapter.md | 7 + .../02-project-and-solution-layout.md | 5 + .../03-seam-by-seam-idiomatic-mapping.md | 9 + .../06-retry-redirect-and-authentication.md | 4 + .../08-instrumentation-and-configuration.md | 6 + .../09-toolchain-and-quality-gates.md | 7 + ...-deviations-from-the-reference-contract.md | 8 + ...guities-and-how-this-port-resolves-them.md | 13 + .../12-appendix-requirement-coverage-index.md | 6 +- docs/sdk-documentation/conformance.md | 7 + .../sdk-documentation/dependency-injection.md | 175 ++++++ docs/sdk-documentation/retry.md | 5 + docs/sdk-documentation/transport.md | 8 + ...2026-09-27-dotnet-sdk-v1-roadmap-design.md | 24 +- ...6-09-28-phase1-security-fixes-checklist.md | 5 + .../2026-10-02-phase2b-seams-checklist.md | 5 + .../2026-10-07-phase5b-logging-checklist.md | 5 + ...10-09-phase8a-conformance-kit-checklist.md | 5 + ...9-phase8b-reference-transport-checklist.md | 5 + ...10-12-phase9-host-integration-checklist.md | 102 ++++ ...26-10-12-phase9-host-integration-design.md | 11 + scripts/ci/dependency-audit.cs | 61 +- .../ClientConfiguration.cs | 25 + .../ConfigSection.cs | 149 +++++ .../ConfigurationFailure.cs | 17 + .../ConfigurationReader.Auth.cs | 125 ++++ .../ConfigurationReader.Client.cs | 38 ++ .../ConfigurationReader.Logging.cs | 23 + .../ConfigurationReader.Proxy.cs | 94 +++ .../ConfigurationReader.Redirect.cs | 21 + .../ConfigurationReader.Retry.cs | 24 + .../ConfigurationReader.Transport.cs | 45 ++ .../ConfigurationReader.cs | 99 ++++ ....Sdk.Extensions.DependencyInjection.csproj | 31 + .../DexpaceClientBuilder.cs | 235 ++++++++ .../DexpaceClientRegistration.cs | 64 ++ .../DexpaceClientRegistrations.cs | 30 + .../DexpaceClientState.cs | 36 ++ .../DexpaceClientStateSetup.cs | 71 +++ .../DexpaceClientValidator.cs | 81 +++ .../DexpaceConfigurationKeys.cs | 30 + .../DexpaceServiceCollectionExtensions.cs | 77 +++ .../DurationGrammar.cs | 208 +++++++ .../GlobalUsings.cs | 10 + .../ImplementationNames.cs | 43 ++ .../KeyTable.cs | 117 ++++ .../NamedClient.cs | 110 ++++ .../PrimaryHandlerLease.cs | 74 +++ .../PublicAPI.Shipped.txt | 0 .../PublicAPI.Unshipped.txt | 27 + .../README.md | 50 ++ .../RecordBinder.cs | 94 +++ .../ScalarGrammar.cs | 236 ++++++++ .../ServiceSlot.cs | 49 ++ .../packages.lock.json | 160 +++++ .../PublicAPI.Unshipped.txt | 1 + src/Dexpace.Sdk.Http.SystemNet/README.md | 4 + .../SystemNetHttpClient.cs | 37 +- .../Dexpace.Sdk.AotSmoke.csproj | 4 +- .../SmokeChecks.DependencyInjection.cs | 91 +++ tests/Dexpace.Sdk.AotSmoke/SmokeChecks.cs | 1 + .../AddDexpaceClientTests.cs | 87 +++ .../Architecture/IlCallScanner.cs | 170 ++++++ ...mplicitEnvironmentReadArchitectureTests.cs | 111 ++++ .../AsyncOnlyTransport.cs | 23 + .../AuthKeyTests.cs | 73 +++ .../AuthRegistrationTests.cs | 124 ++++ .../BooleanGrammarTests.cs | 50 ++ .../ChainSpy.cs | 51 ++ .../Config.cs | 39 ++ .../ConfigurationPrecedenceTests.cs | 84 +++ .../ConfigurationSeamTests.cs | 52 ++ .../DependencyInjectionConformanceTests.cs | 100 ++++ .../Conformance/DependencyInjectionSubject.cs | 108 ++++ .../CredentialRedactionTests.cs | 82 +++ ...xtensions.DependencyInjection.Tests.csproj | 39 ++ .../DurationGrammarTests.cs | 156 +++++ .../EmptyValueTests.cs | 53 ++ .../EnvironmentNormalizationTests.cs | 110 ++++ .../GlobalUsings.cs | 13 + .../IntegerGrammarTests.cs | 62 ++ .../KeyConstantsTests.cs | 89 +++ .../KeyTableTests.cs | 133 +++++ .../KeyedSingletonTests.cs | 74 +++ .../LogLevelBindingTests.cs | 45 ++ .../NamedClientTests.cs | 129 ++++ .../NamedPipelineTests.cs | 129 ++++ .../NoRetryingHandlerTests.cs | 42 ++ .../NullGuardTests.cs | 100 ++++ .../PrimaryHandlerLeaseTests.cs | 153 +++++ .../ProcessEnvironmentDefinition.cs | 10 + .../ProviderValidationTests.cs | 81 +++ .../ProxyKeyTests.cs | 91 +++ .../ProxyRegistrationTests.cs | 111 ++++ .../RecordBindingTests.cs | 188 ++++++ .../RecordingLoggerProvider.cs | 45 ++ .../ResilienceCompositionTests.cs | 113 ++++ .../ScalarGrammarTests.cs | 150 +++++ .../Security/NamedClientRedirectWireTests.cs | 110 ++++ .../SerdeRegistrationTests.cs | 53 ++ .../SingleRegistrationTests.cs | 152 +++++ .../StateHost.cs | 48 ++ .../StubSerde.cs | 14 + .../TestCategoryTests.cs | 16 + .../TestClients.cs | 26 + .../TransportBindingTests.cs | 56 ++ .../TypedLookupTests.cs | 45 ++ .../ValidationTests.cs | 197 +++++++ .../packages.lock.json | 550 ++++++++++++++++++ .../Handlers/CancelOnDemandHandler.cs | 42 ++ .../Conformance/Handlers/ExplodingContent.cs | 25 + .../Handlers/FaultingAdaptationHandler.cs | 31 + .../Conformance/Handlers/ResendingHandler.cs | 25 + .../Conformance/SystemNetSubject.cs | 66 --- .../PrimaryHandlerTests.cs | 103 ++++ .../SystemNetHttpClientSurfaceTests.cs | 10 + 126 files changed, 7976 insertions(+), 111 deletions(-) create mode 100644 docs/knowledge/notes/configuration.md create mode 100644 docs/sdk-documentation/dependency-injection.md create mode 100644 docs/work/mvp/phase9/2026-10-12-phase9-host-integration-checklist.md create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/ClientConfiguration.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigSection.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationFailure.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Auth.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Client.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Logging.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Proxy.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Redirect.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Retry.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Transport.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/Dexpace.Sdk.Extensions.DependencyInjection.csproj create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientBuilder.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientRegistration.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientRegistrations.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientState.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientStateSetup.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientValidator.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceConfigurationKeys.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceServiceCollectionExtensions.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/DurationGrammar.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/GlobalUsings.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/ImplementationNames.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/KeyTable.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/NamedClient.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/PrimaryHandlerLease.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/PublicAPI.Shipped.txt create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/PublicAPI.Unshipped.txt create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/README.md create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/RecordBinder.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/ScalarGrammar.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/ServiceSlot.cs create mode 100644 src/Dexpace.Sdk.Extensions.DependencyInjection/packages.lock.json create mode 100644 tests/Dexpace.Sdk.AotSmoke/SmokeChecks.DependencyInjection.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/AddDexpaceClientTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Architecture/IlCallScanner.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Architecture/NoImplicitEnvironmentReadArchitectureTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/AsyncOnlyTransport.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/AuthKeyTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/AuthRegistrationTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/BooleanGrammarTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ChainSpy.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Config.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ConfigurationPrecedenceTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ConfigurationSeamTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/DependencyInjectionConformanceTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/DependencyInjectionSubject.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/CredentialRedactionTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests.csproj create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/DurationGrammarTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/EmptyValueTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/EnvironmentNormalizationTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/GlobalUsings.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/IntegerGrammarTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/KeyConstantsTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/KeyTableTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/KeyedSingletonTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/LogLevelBindingTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/NamedClientTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/NamedPipelineTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/NoRetryingHandlerTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/NullGuardTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/PrimaryHandlerLeaseTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ProcessEnvironmentDefinition.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ProviderValidationTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ProxyKeyTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ProxyRegistrationTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/RecordBindingTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/RecordingLoggerProvider.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ResilienceCompositionTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ScalarGrammarTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Security/NamedClientRedirectWireTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/SerdeRegistrationTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/SingleRegistrationTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/StateHost.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/StubSerde.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/TestCategoryTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/TestClients.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/TransportBindingTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/TypedLookupTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ValidationTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/packages.lock.json create mode 100644 tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/Handlers/CancelOnDemandHandler.cs create mode 100644 tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/Handlers/ExplodingContent.cs create mode 100644 tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/Handlers/FaultingAdaptationHandler.cs create mode 100644 tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/Handlers/ResendingHandler.cs create mode 100644 tests/Dexpace.Sdk.Http.SystemNet.Tests/PrimaryHandlerTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a75e29d..049511a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,11 @@ name: CI # Transport conformance kit (Dexpace.Sdk.Conformance, phase 8a): on. Two drivers run in the default test step, the reference # transport (tests/Dexpace.Sdk.Http.SystemNet.Tests/SystemNetConformanceTests.cs, whose Owner = "8b" waivers phase 8b deletes # as it closes their rows) and a test-only raw-socket client reached through DelegateHttpClient -# (tests/Dexpace.Sdk.Conformance.Tests/Drivers/); both carry [Trait("Category", "Conformance")] and run on every OS row. The kit -# is a measured library of the coverage gate, and a slice of it runs in the NativeAOT smoke consumer. +# (tests/Dexpace.Sdk.Conformance.Tests/Drivers/); both carry [Trait("Category", "Conformance")] and run on every OS row. Phase 9 adds a +# third, over the transport AddDexpaceClient builds from IHttpClientFactory +# (tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/), which needs no CI step of its own: it runs in the solution's +# test step and its coverage report is picked up by name. The kit is a measured library of the coverage gate, and a slice of it runs +# in the NativeAOT smoke consumer, which also exercises the DI package (phase 9). # # Gates wired but inert until their subject exists, and the phase that turns each on: # - SSE-37 architecture test (serde independence of SSE and paging): wired in diff --git a/BannedSymbols.txt b/BannedSymbols.txt index 56ccd7f..db5f85d 100644 --- a/BannedSymbols.txt +++ b/BannedSymbols.txt @@ -119,3 +119,8 @@ P:System.Diagnostics.Activity.TraceIdGenerator;A library never sets the process- # spec's bypass dialect (no NO_PROXY=* bypass, port defaulting, userinfo in the proxy URI, regex bypass entries). The transport # installs a ProxyOptionsWebProxy from an explicit ProxyOptions, or goes direct (CFG-23, CFG-25, CFG-27, CFG-28, design §8.2, P8b-4). P:System.Net.Http.HttpClient.DefaultProxy;The runtime's process-wide proxy reads HTTP(S)_PROXY and NO_PROXY implicitly and diverges from the spec's bypass dialect; install a ProxyOptionsWebProxy from an explicit ProxyOptions (CFG-28, P8b-4) + +# The configuration binder (CFG-5, CFG-6, CFG-7, OBS-35; phase 9 P9-4, V1). Its source generator binds no init accessor and says +# nothing, and its reflection mode is not AOT-safe and mis-converts durations, integers and enums, so values reach the records +# through the DI package's own reader. The type arrives transitively with Microsoft.Extensions.Http: banned, not removed. +T:Microsoft.Extensions.Configuration.ConfigurationBinder;Bind through the DI package's reader; the binder's generator binds no init accessor and its reflection mode is not AOT-safe (CFG-5..CFG-7, P9-4) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eab217..1257812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -406,8 +406,24 @@ Roadmap phase 7b, issue #10 (a later addition, not one of S1–S9), pinned by a (`Content-Length: 0`, byte for byte), so `SocketsHttpHandler`'s own connection-failure retry cannot fire and the request reaches the server once. A body-less `GET`, `HEAD`, `OPTIONS`, `DELETE` and `TRACE` is still re-sent by the native client (up to three more times), which is recorded as design §10 entry 33 and waived permanently for `transport-2.bodyless-not-resent`. +- **Guidance (phase 9, `XCUT-10`; documentation only):** composing `AddStandardResilienceHandler` under a named client is `o.Retry.ShouldHandle = _ => ValueTask.FromResult(false)`. The older guidance to set the SDK's own + `MaxRetryAttempts = 0` is withdrawn: the handler would still re-send a `POST` body behind the SDK's re-send gate, which measurably multiplies the attempts, and the handler-side `o.Retry.MaxRetryAttempts = 0` is refused by + the handler's own options validation. See `docs/sdk-documentation/dependency-injection.md`. + ### Added +- **Phase 9 host integration (`CFG-1`, `-2`, `-3`, `-4`, `-5`, `-6`, `-7`, `-10`, `-11`, `-14`, `-37`, `-38`, `SEAM-5`, `SEAM-6`, `OBS-35`; design and plan under `docs/work/mvp/phase9/`):** the new package + `Dexpace.Sdk.Extensions.DependencyInjection`. `AddDexpaceClient(name[, IConfigurationSection])` returns a sealed `DexpaceClientBuilder` (`Configure`, `ConfigureTransport`, `ConfigureCredentials`, `UseTransport`, `UseSerde`, + `UseAuth`, `UseAuthPolicy`, `AddPolicy`, `UseProxyFromEnvironment` with and without a lookup, and `HttpClient`, the `IHttpClientBuilder` of the named client) and registers the client as keyed singletons (`HttpPipeline`, + `IAsyncHttpClient`, `ISerde`, `DexpaceClientOptions`). Settings are read by the package's own reader, not by the configuration binder (neither mode can serve: the generator binds no `init` accessor, the reflection mode is + not AOT-safe): base-10 integers, strict booleans, `CFG-7` durations (ISO-8601 day-time, shorthand, bare milliseconds, `none`), enums by name, lists that replace the defaults; an empty value is absent; every bad key, accessor + rejection and unknown key fails `ValidateOnStart` at once as `": "`, never quoting a configured value, and a host-less provider fails at first resolution. The named client is built over `IHttpClientFactory` with a + redirect-off primary, no factory loggers and an infinite handler lifetime with a pooled-connection lifetime, and a container-owned lease releases the primary. `DexpaceConfigurationKeys` publishes the conventional keys + (never read). One transport and one codec per client, explicit installs replace the defaults, a different second install throws at the call and keyed duplicates fail at start. `SystemNetHttpClient.CreatePrimaryHandler(options)` + is the one place the owned-handler invariants are built. `BannedSymbols.txt` bans `ConfigurationBinder` in `src/`; `scripts/ci/dependency-audit.cs` holds each library to a policy record (one third-party root, plus the + first-party projects it names). A third conformance driver runs the kit over the DI-built transport on both faces with the two permanent waivers; `NamedClientRedirectWireTests` is phase 1 S3's row 9; the AOT smoke covers + the package; `docs/sdk-documentation/dependency-injection.md`. + - `docs/sdk-documentation/tracing-and-metrics.md`; the AOT smoke covers the operation span, its attempt child and the two metrics (`OBS-31`, `OBS-32`). - `docs/sdk-documentation/pipelines.md`; the AOT smoke covers the synchronous pipeline, `ErrorMappingPolicy` and diff --git a/CLAUDE.md b/CLAUDE.md index b9d2d0e..a982183 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ dotnet format Dexpace.Sdk.sln --verify-no-changes # formatting gate dotnet test --solution Dexpace.Sdk.sln --configuration Release dotnet test --project tests/Dexpace.Sdk.Core.Tests --configuration Release --filter-trait "Category=Security" dotnet test --project tests/Dexpace.Sdk.Http.SystemNet.Tests --configuration Release --filter-trait "Category=Conformance" # the kit against the reference transport +dotnet test --project tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests --configuration Release --filter-trait "Category=Conformance" # the kit against the DI-built named-client transport dotnet pack Dexpace.Sdk.sln --configuration Release --output artifacts/packages ``` @@ -77,7 +78,8 @@ tests and tools — targets **`net10.0` only**, set once in `Directory.Build.pro `Microsoft.Extensions.Logging.Abstractions` (band-matched to the target, 10.0.x), and build-only packages marked `PrivateAssets="all"`. The logging facade is a recorded deviation (`logging-abstractions-dependency`, design §10); nobody re-litigates it, and nothing else is added. Adapters are held to the same rule plus - NFR-2's "at most one third-party library". `scripts/ci/dependency-audit.cs` enforces it on every pack. + NFR-2's "at most one third-party library", where a root package whose closure is one publisher's lock-stepped family counts as one (the DI package's is `Microsoft.Extensions.Http`, design §10 entry 2). + `scripts/ci/dependency-audit.cs` enforces it on every pack, from one policy record per library (its one third-party root and the first-party projects it may reference). - **Narrow, fully-documented public API.** `GenerateDocumentationFile` is on, so every public member needs a `///` XML doc comment (missing docs are CS1591 → build error). Implementation helpers are `internal`, with `InternalsVisibleTo` granted to the library's own test project only. @@ -143,16 +145,18 @@ dotnet-sdk/ │ │ └── Errors/ # SdkException hierarchy, IRetryableError, OperationTimeoutException, RedirectException (+ downgrade / not-replayable leaves), SerdeException, ExceptionFacts, ExceptionTrail │ ├── Dexpace.Sdk.Http.SystemNet/ # reference transport over System.Net.Http.HttpClient; SystemNetHttpClientOptions, HeaderDropLogging │ ├── Dexpace.Sdk.Serialization.SystemTextJson/ # ISerde over source-generated System.Text.Json +│ ├── Dexpace.Sdk.Extensions.DependencyInjection/ # host integration: AddDexpaceClient, DexpaceClientBuilder, the configuration reader and its grammars, validation, the named client over IHttpClientFactory │ └── Dexpace.Sdk.Conformance/ # the transport conformance kit (core only): TransportSuite, TransportSubject, the report and waivers, Wire/ (the loopback fixture) ├── tests/ │ ├── Dexpace.Sdk.Core.Tests/ # core + fakes only; Architecture/ holds SEAM-1, SEAM-2, SEAM-22, SSE-37 and SSE-38 │ ├── Dexpace.Sdk.Http.SystemNet.Tests/ # the transport, incl. wire tests over the kit's Wire/ server and SystemNetConformanceTests (the kit's driver over both constructions, with its two permanent waivers) │ ├── Dexpace.Sdk.Conformance.Tests/ # the kit's own tests, a negative control per assertion (Controls/), the raw-socket client (RawSocket/) and its driver (Drivers/) │ ├── Dexpace.Sdk.Serialization.SystemTextJson.Tests/ +│ ├── Dexpace.Sdk.Extensions.DependencyInjection.Tests/ # the reader, registration, the named client, the third conformance driver (Conformance/) and NamedClientRedirectWireTests (Security/) │ ├── Dexpace.Sdk.TestSupport/ # fake transports, time, diagnostics listeners (not packed) │ └── Dexpace.Sdk.AotSmoke/ # NativeAOT smoke consumer, published and run in CI ├── scripts/ -│ ├── ci/ # coverage-gate{.cs,-selftest.sh}, dependency-audit.cs, reproducible-pack.sh +│ ├── ci/ # coverage-gate{.cs,-selftest.sh}, dependency-audit.cs (one policy record per library), reproducible-pack.sh │ └── knowledge # the knowledge-lookup CLI ├── tools/Dexpace.Tools.sln # tools/Knowledge{,.Tests} + .claude/skills/{housekeeping,knowledge-harvest}/{src,tests} ├── .github/ # ci.yml, labels.yml, dependabot.yml, CODEOWNERS, issue/PR templates @@ -224,6 +228,11 @@ Layered, bottom-up: `tests/Dexpace.Sdk.Conformance.Tests/Controls/` that it must fail (`AssertionCatalogueTests` fails an assertion with none); a leaked-socket control must root what it leaks, because a finalizer closes it otherwise. A `ConformanceWaiver` whose assertion now passes fails the run, so fixing a requirement means deleting its waiver in the same change. The kit never calls `Task.Delay`, and a release check needs a keep-alive reply, never a `Connection: close` one. +- **Host integration is configured by a reader, not the binder, and fails at start naming the key.** `Dexpace.Sdk.Extensions.DependencyInjection` reads the `IConfigurationSection` the caller hands `AddDexpaceClient` with its own grammars + (`ConfigurationBinder` is banned in `src/`: the generator binds no `init` accessor, the reflection mode is not AOT-safe); every bad value, rejected setting and unknown key fails `ValidateOnStart` at once as `": "`, never quoting a configured + value (a numeric accessor message may echo the number), and with no host `IStartupValidator.Validate()` or the first resolution fails the same way. An empty value is absent (it silently shadows a lower provider); there is no reload; a codec is required + (`UseSerde`); dispose the provider with `DisposeAsync` (a synchronous dispose throws for an async-only transport); no configuration value turns the environment proxy read on. Compose `AddStandardResilienceHandler` with + `o.Retry.ShouldHandle = _ => ValueTask.FromResult(false)`, never with the SDK's `MaxRetryAttempts = 0` (`docs/sdk-documentation/dependency-injection.md`). - **Central Package Management is on.** Add new dependency versions to `Directory.Packages.props`, and reference them without a `Version` attribute. @@ -302,5 +311,6 @@ form — see `docs/sdk-documentation/pagination.md`; and phase 8a's transport co the six result statuses and the by-ID waivers that must stay needed, the loopback `Wire` fixture promoted into it, the two drivers (the reference transport and a test-only raw-socket client) and the requirement catalogue generated from appendix C — see `docs/sdk-documentation/conformance.md`; and phase 8b's hardening of the reference transport — the options record and four constructors, the handler check, the installed proxy, the per-call deadline with its one failure classification, the real synchronous send, the header partition and drop policy, the bounded re-send capture and the `ObjectDisposedException` latch, with the kit green against both constructions and two permanent assertion-scoped -waivers (`TRANSPORT-14`'s malformed-name clause and `TRANSPORT-2`'s native retry for a body-less `GET`/`HEAD`/`OPTIONS`/`DELETE`/`TRACE`, design §10 entries 32 and 33) — see `docs/sdk-documentation/transport.md`): the DI package -`Dexpace.Sdk.Extensions.DependencyInjection` (9), and the release path (12). +waivers (`TRANSPORT-14`'s malformed-name clause and `TRANSPORT-2`'s native retry for a body-less `GET`/`HEAD`/`OPTIONS`/`DELETE`/`TRACE`, design §10 entries 32 and 33) — see `docs/sdk-documentation/transport.md`; and phase 9's host integration — the DI package `Dexpace.Sdk.Extensions.DependencyInjection`: `AddDexpaceClient` and the builder, the hand-written configuration reader and its grammars, the validator with `ValidateOnStart`, the named client over +`IHttpClientFactory` with its redirect-off primary and container-owned lease, the single-registration check, `DexpaceConfigurationKeys`, the audit's policy record, the third conformance driver — see `docs/sdk-documentation/dependency-injection.md`): only the cross-cutting invariant and +conformance suites (10), the deviation audit (11) and the release path (12). diff --git a/Dexpace.Sdk.sln b/Dexpace.Sdk.sln index 6fa04c5..de2c8ca 100644 --- a/Dexpace.Sdk.sln +++ b/Dexpace.Sdk.sln @@ -27,6 +27,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dexpace.Sdk.Conformance", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dexpace.Sdk.Conformance.Tests", "tests\Dexpace.Sdk.Conformance.Tests\Dexpace.Sdk.Conformance.Tests.csproj", "{6BEC45D3-771D-4381-8FA8-B6A84A90EF33}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dexpace.Sdk.Extensions.DependencyInjection", "src\Dexpace.Sdk.Extensions.DependencyInjection\Dexpace.Sdk.Extensions.DependencyInjection.csproj", "{A7B97526-D016-42EE-9854-C9E8B979835A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dexpace.Sdk.Extensions.DependencyInjection.Tests", "tests\Dexpace.Sdk.Extensions.DependencyInjection.Tests\Dexpace.Sdk.Extensions.DependencyInjection.Tests.csproj", "{E75C4EEC-F6D7-42BF-9C80-1243EB327285}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -157,6 +161,30 @@ Global {6BEC45D3-771D-4381-8FA8-B6A84A90EF33}.Release|x64.Build.0 = Release|Any CPU {6BEC45D3-771D-4381-8FA8-B6A84A90EF33}.Release|x86.ActiveCfg = Release|Any CPU {6BEC45D3-771D-4381-8FA8-B6A84A90EF33}.Release|x86.Build.0 = Release|Any CPU + {A7B97526-D016-42EE-9854-C9E8B979835A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7B97526-D016-42EE-9854-C9E8B979835A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7B97526-D016-42EE-9854-C9E8B979835A}.Debug|x64.ActiveCfg = Debug|Any CPU + {A7B97526-D016-42EE-9854-C9E8B979835A}.Debug|x64.Build.0 = Debug|Any CPU + {A7B97526-D016-42EE-9854-C9E8B979835A}.Debug|x86.ActiveCfg = Debug|Any CPU + {A7B97526-D016-42EE-9854-C9E8B979835A}.Debug|x86.Build.0 = Debug|Any CPU + {A7B97526-D016-42EE-9854-C9E8B979835A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7B97526-D016-42EE-9854-C9E8B979835A}.Release|Any CPU.Build.0 = Release|Any CPU + {A7B97526-D016-42EE-9854-C9E8B979835A}.Release|x64.ActiveCfg = Release|Any CPU + {A7B97526-D016-42EE-9854-C9E8B979835A}.Release|x64.Build.0 = Release|Any CPU + {A7B97526-D016-42EE-9854-C9E8B979835A}.Release|x86.ActiveCfg = Release|Any CPU + {A7B97526-D016-42EE-9854-C9E8B979835A}.Release|x86.Build.0 = Release|Any CPU + {E75C4EEC-F6D7-42BF-9C80-1243EB327285}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E75C4EEC-F6D7-42BF-9C80-1243EB327285}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E75C4EEC-F6D7-42BF-9C80-1243EB327285}.Debug|x64.ActiveCfg = Debug|Any CPU + {E75C4EEC-F6D7-42BF-9C80-1243EB327285}.Debug|x64.Build.0 = Debug|Any CPU + {E75C4EEC-F6D7-42BF-9C80-1243EB327285}.Debug|x86.ActiveCfg = Debug|Any CPU + {E75C4EEC-F6D7-42BF-9C80-1243EB327285}.Debug|x86.Build.0 = Debug|Any CPU + {E75C4EEC-F6D7-42BF-9C80-1243EB327285}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E75C4EEC-F6D7-42BF-9C80-1243EB327285}.Release|Any CPU.Build.0 = Release|Any CPU + {E75C4EEC-F6D7-42BF-9C80-1243EB327285}.Release|x64.ActiveCfg = Release|Any CPU + {E75C4EEC-F6D7-42BF-9C80-1243EB327285}.Release|x64.Build.0 = Release|Any CPU + {E75C4EEC-F6D7-42BF-9C80-1243EB327285}.Release|x86.ActiveCfg = Release|Any CPU + {E75C4EEC-F6D7-42BF-9C80-1243EB327285}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -172,5 +200,7 @@ Global {73508377-AAE0-4F22-BBCD-BFAEA76BD08E} = {892AF1FA-5CA2-4CB1-8286-F7BC8E8B6D93} {A993A744-89E7-45DB-A33D-3A87620FA062} = {1345587B-8949-4F11-B5B7-5F5508DA63FF} {6BEC45D3-771D-4381-8FA8-B6A84A90EF33} = {892AF1FA-5CA2-4CB1-8286-F7BC8E8B6D93} + {A7B97526-D016-42EE-9854-C9E8B979835A} = {1345587B-8949-4F11-B5B7-5F5508DA63FF} + {E75C4EEC-F6D7-42BF-9C80-1243EB327285} = {892AF1FA-5CA2-4CB1-8286-F7BC8E8B6D93} EndGlobalSection EndGlobal diff --git a/Directory.Packages.props b/Directory.Packages.props index 518bf05..c2bcd97 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,6 +18,10 @@ net10.0 target: its 10.0.x net10.0 group resolves System.Diagnostics.DiagnosticSource to the in-box assembly instead of a package copy. scripts/ci/dependency-audit.cs enforces the band. --> + + @@ -41,6 +45,12 @@ + + + + diff --git a/README.md b/README.md index aaf4410..d586f22 100644 --- a/README.md +++ b/README.md @@ -13,18 +13,17 @@ Java and Python ports. ## Status -Pre-release: nothing is published yet, and every package is at `0.0.1-alpha.1`. Four NuGet packages are built: +Pre-release: nothing is published yet, and every package is at `0.0.1-alpha.1`. Five NuGet packages are built: | Package | What it is | |---|---| | [`Dexpace.Sdk.Core`](src/Dexpace.Sdk.Core/README.md) | The toolkit: HTTP models, bodies, the transport SPI, errors, the staged pipeline and its policies (operation timeout, redirect, retry, idempotency, `Date`, client identity, instrumentation), auth (credentials, token cache, auth policies), pagination, options, diagnostics and the `ISerde` seam | | [`Dexpace.Sdk.Http.SystemNet`](src/Dexpace.Sdk.Http.SystemNet/README.md) | The reference transport over `System.Net.Http.HttpClient` | | [`Dexpace.Sdk.Serialization.SystemTextJson`](src/Dexpace.Sdk.Serialization.SystemTextJson/README.md) | The `ISerde` codec over source-generated `System.Text.Json` | +| [`Dexpace.Sdk.Extensions.DependencyInjection`](src/Dexpace.Sdk.Extensions.DependencyInjection/README.md) | Host integration: named clients as keyed services over `Microsoft.Extensions.DependencyInjection`, configured from an `IConfigurationSection` with strict grammars and fail-at-start validation ([guide](docs/sdk-documentation/dependency-injection.md)) | | [`Dexpace.Sdk.Conformance`](src/Dexpace.Sdk.Conformance/README.md) | The transport conformance kit: framework-free assertions for the `TRANSPORT` and `ASYNC` requirements, a loopback HTTP/1.1 wire fixture, and per-requirement reporting with waivers; for adapter authors' test projects ([guide](docs/sdk-documentation/conformance.md)) | -What is not built yet — the domain-model rework (query parameters, request options and conditions), file, -form and multipart bodies, the execution-context and recovery chains, layered configuration and body logging, -RFC 7235 challenges and Digest auth, tri-state PATCH, SSE, the reference transport's hardening and the DI package — is +What is not built yet — the cross-cutting invariant and conformance suites (roadmap phase 10), the deviation audit (phase 11) and the release path (phase 12) — is scheduled by the [v1 roadmap](docs/work/mvp/2026-09-27-dotnet-sdk-v1-roadmap-design.md), and what the first release needs is in [docs/first-release.md](docs/first-release.md). The normative specification is [docs/product-spec/](docs/product-spec.md) and the .NET design is [docs/sdk-design-dotnet/](docs/sdk-design-dotnet.md); @@ -56,12 +55,14 @@ dotnet-sdk/ │ │ └── Errors/ # SdkException hierarchy │ ├── Dexpace.Sdk.Http.SystemNet/ # reference transport over System.Net.Http.HttpClient │ ├── Dexpace.Sdk.Serialization.SystemTextJson/ # ISerde over source-generated System.Text.Json +│ ├── Dexpace.Sdk.Extensions.DependencyInjection/ # named clients as keyed services, configuration reader, validation │ └── Dexpace.Sdk.Conformance/ # transport conformance kit + Wire/ loopback fixture ├── tests/ │ ├── Dexpace.Sdk.Core.Tests/ # core against in-memory fakes only │ ├── Dexpace.Sdk.Http.SystemNet.Tests/ # the transport, incl. loopback wire tests and its conformance driver │ ├── Dexpace.Sdk.Conformance.Tests/ # the kit's own tests, the negative controls and the raw-socket driver │ ├── Dexpace.Sdk.Serialization.SystemTextJson.Tests/ +│ ├── Dexpace.Sdk.Extensions.DependencyInjection.Tests/ │ ├── Dexpace.Sdk.TestSupport/ # shared fakes (not packed) │ └── Dexpace.Sdk.AotSmoke/ # NativeAOT smoke consumer ├── scripts/ci/ # coverage gate, dependency audit, reproducible pack diff --git a/docs/README.md b/docs/README.md index 8eba1e4..8ca8304 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,7 +17,7 @@ only describes what already exists is a table that gets edited after the fact. | [`superpowers/`](./superpowers/README.md) | Nothing, for long. The **inbox** the Superpowers skills write into | `brainstorming`, `writing-plans` | Yes — it drains it | | [`first-release.md`](./first-release.md) | Release-readiness register: package versions, blockers before first publish, the release path, what v1 ships without, post-release triggers | A human, updated as blockers close | No | | `deviations.md` **(planned)** | As-built audit of `sdk-design-dotnet/10`'s deviation ledger, plus deviations found outside a phase. Created by roadmap phase 11 | A human, following a phase or review | No — judgment, not a mechanical append | -| [`sdk-documentation/`](./sdk-documentation/http.md) | **As-built** user documentation — how the packages compose, worked examples — one page per phase that ships a layer, mirroring `ruby-sdk/docs/sdk-documentation/`. Today: [`http.md`](./sdk-documentation/http.md) (phase 2a), [`seams.md`](./sdk-documentation/seams.md) (phase 2b), [`io.md`](./sdk-documentation/io.md) (phase 3a), [`bodies.md`](./sdk-documentation/bodies.md) (phase 3b), [`execution-context.md`](./sdk-documentation/execution-context.md) (phase 4a), [`recovery.md`](./sdk-documentation/recovery.md) (phase 4b), [`pipelines.md`](./sdk-documentation/pipelines.md) (phase 4c), [`configuration.md`](./sdk-documentation/configuration.md) (phase 5a), [`logging-and-redaction.md`](./sdk-documentation/logging-and-redaction.md) (phase 5b), [`tracing-and-metrics.md`](./sdk-documentation/tracing-and-metrics.md) (phase 5c), [`retry.md`](./sdk-documentation/retry.md) (phase 6a), [`redirect.md`](./sdk-documentation/redirect.md) (phase 6b), [`auth.md`](./sdk-documentation/auth.md) (phase 6c), [`serde.md`](./sdk-documentation/serde.md) (phase 7a), [`sse.md`](./sdk-documentation/sse.md) (phase 7b), [`pagination.md`](./sdk-documentation/pagination.md) (phase 7c), [`conformance.md`](./sdk-documentation/conformance.md) (phase 8a), [`transport.md`](./sdk-documentation/transport.md) (phase 8b) | A human, or a skill on request | Yes | +| [`sdk-documentation/`](./sdk-documentation/http.md) | **As-built** user documentation — how the packages compose, worked examples — one page per phase that ships a layer, mirroring `ruby-sdk/docs/sdk-documentation/`. Today: [`http.md`](./sdk-documentation/http.md) (phase 2a), [`seams.md`](./sdk-documentation/seams.md) (phase 2b), [`io.md`](./sdk-documentation/io.md) (phase 3a), [`bodies.md`](./sdk-documentation/bodies.md) (phase 3b), [`execution-context.md`](./sdk-documentation/execution-context.md) (phase 4a), [`recovery.md`](./sdk-documentation/recovery.md) (phase 4b), [`pipelines.md`](./sdk-documentation/pipelines.md) (phase 4c), [`configuration.md`](./sdk-documentation/configuration.md) (phase 5a), [`logging-and-redaction.md`](./sdk-documentation/logging-and-redaction.md) (phase 5b), [`tracing-and-metrics.md`](./sdk-documentation/tracing-and-metrics.md) (phase 5c), [`retry.md`](./sdk-documentation/retry.md) (phase 6a), [`redirect.md`](./sdk-documentation/redirect.md) (phase 6b), [`auth.md`](./sdk-documentation/auth.md) (phase 6c), [`serde.md`](./sdk-documentation/serde.md) (phase 7a), [`sse.md`](./sdk-documentation/sse.md) (phase 7b), [`pagination.md`](./sdk-documentation/pagination.md) (phase 7c), [`conformance.md`](./sdk-documentation/conformance.md) (phase 8a), [`transport.md`](./sdk-documentation/transport.md) (phase 8b), [`dependency-injection.md`](./sdk-documentation/dependency-injection.md) (phase 9) | A human, or a skill on request | Yes | | [`architecture.md`](./architecture.md) | The original foundation-slice architecture note, marked superseded by `sdk-design-dotnet/01-overview.md`; kept until roadmap phase 12 writes `sdk-documentation/architecture.md` | A human | Yes | | [`README.md`](./README.md) | This index | A human | Yes | diff --git a/docs/first-release.md b/docs/first-release.md index 5c6b175..1b6ebaa 100644 --- a/docs/first-release.md +++ b/docs/first-release.md @@ -27,7 +27,7 @@ release. | `Dexpace.Sdk.Http.SystemNet` | yes | `net8.0` | no — `0.0.1-alpha.1` | phases 1, 8b | | `Dexpace.Sdk.Serialization.SystemTextJson` | yes | `net8.0;net10.0` | no — `0.0.1-alpha.1` | phase 7a | | `Dexpace.Sdk.Conformance` | yes (phase 8a) | `net10.0` | no — `0.0.1-alpha.1`; the first published version is the first release's lockstep version, `0.1.0` proposed (a release that adds an assertion is at least a minor version) | phase 10 completes it (the invariant, packaging and codec suites); phase 12 publishes it and decides the number | -| `Dexpace.Sdk.Extensions.DependencyInjection` | no | — | no | phase 9 | +| `Dexpace.Sdk.Extensions.DependencyInjection` | yes (phase 9) | `net10.0` | no — `0.0.1-alpha.1`; it references `Dexpace.Sdk.Core`, `Dexpace.Sdk.Http.SystemNet` and `Microsoft.Extensions.Http` (the hosting family's one root), at the lockstep version | phase 12 (the release path) | The target frameworks changed in phase 0. The lead approved the roadmap's decision D1 on 2026-09-28: every package targets `net10.0` only, set once in `Directory.Build.props` (PR #21), because .NET 8 leaves support on 2026-11-10. @@ -158,6 +158,13 @@ Each phase adds an entry for each requirement it declines, with the requirement on a call that sets no `RequestOptions.Timeout`, where before only that client's `Timeout` bounded it. The shorter of the two wins; `Timeout = null` in the options leaves the client's own `Timeout` the only bound. Recorded by the phase 8b review (2026-10-11), as a breaking change, not an asymmetry the design chose to hide; see [`transport.md`](./sdk-documentation/transport.md). +- **`di-no-reload` (phase 9, P9-11).** A named client's settings are read once, at first resolution; the package registers no change-token source, so a reloaded `appsettings.json` does not reach a built client. A built client is immutable + (`CFG-8`), and rebuilding a pipeline mid-flight would orphan the token cache and the transport's per-instance state. Recorded by phase 9 (2026-10-13); see [`dependency-injection.md`](./sdk-documentation/dependency-injection.md). +- **`di-handler-lifetime` (phase 9, P9-13, P9-14).** The DI package holds one handler chain per named client for the process, with an infinite `IHttpClientFactory` handler lifetime and `Transport:PooledConnectionLifetime` (default 2 minutes) doing + the rotation's job inside the one handler; a container-owned lease releases the primary the package built. `builder.HttpClient.ConfigureHttpClient(...)` has no effect (the package uses the handler, never `CreateClient`), and the factory's own logging + handlers are removed so the SDK's redacted events are the one HTTP log stream. Recorded by phase 9 (2026-10-13). +- **`di-empty-shadows` (phase 9, P9-7; design §10 entry 25).** An empty or whitespace-only configuration value is absent, so the record's default applies, and `IConfiguration` merges its providers before the package reads a key: an empty environment value + over a value in `appsettings.json` therefore resolves the record default, silently. The alternative (an empty value fails start) is the lead's to rule on. Recorded by phase 9 (2026-10-13). - **`public-query-splice` (post-1.0 candidate, P7c-9).** The query splice behind `Cursor` and `PageNumber` is internal in v1, so a custom strategy builds its own query with `Query` and `Request.WithUrl`. Making the splice public is a candidate for a later release, if custom strategies turn out to need it. Recorded by phase 7c (2026-10-09). @@ -206,3 +213,7 @@ Each entry names an event that no v1 phase can produce, and the one job to do wh - **The first external adapter author.** The job is to confirm that `Dexpace.Sdk.Conformance` runs under a test framework other than xUnit, which is the claim design §9.3 makes and nothing in this repository exercises. +- **The first consumer that needs a call's context below the transport.** The `CallKey` carrier on `RequestOptions`, the stamp from `SystemNetHttpClient` and a reader (a DI-registered `DelegatingHandler`, say) are not built: phase 9 registered no handler + that reads a call's context, so the stamp would have been surface without a consumer, which `NFR-4` locks at the first release (P9-20; design §11 item 30). The job is to build the three together. +- **The first generated SDK that asks for a typed-client overload.** `AddDexpaceClient` is not built (P9-5): it needs `ActivatorUtilities` under trimming annotations for a convenience a generated SDK's own `AddXClient` already provides. The job is to add it + additively if one asks. diff --git a/docs/knowledge/notes/configuration.md b/docs/knowledge/notes/configuration.md new file mode 100644 index 0000000..78f4ff6 --- /dev/null +++ b/docs/knowledge/notes/configuration.md @@ -0,0 +1,24 @@ +# configuration — notes + +Hand-written. `../harvested/configuration.md` is what the documents say; this is what phase 9's implementation found, and it wins. Facts measured on .NET SDK 10.0.401 / runtime 10.0.12, Linux, over the repository's own option records and +`Microsoft.Extensions.*` 10.0.12 (the phase 9 design's V1 and V2). + +## Superseded +- **An empty environment value is absent, not a `ValidateOnStart` failure.** Supersedes `configuration/bfbcbf8c` ("an environment variable set to the empty string is a present, empty value to `IConfiguration`, and binding it into a typed option fails `ValidateOnStart`"): the binder maps an empty value to `null` for a nullable target (V1), and the DI package's reader treats an empty or whitespace-only value as absent, so the record default applies and a lower provider's value that the empty one shadows is not consulted (`CFG-2`, design §10 entry 25, P9-7). The shadowing is silent: it is a recorded asymmetry (`di-empty-shadows`, `docs/first-release.md`) and an open ruling for the lead. + review · `docs/work/mvp/phase9/2026-10-12-phase9-host-integration-checklist.md` · high · sha:manual-p9-empty-is-absent +- **`OBS-35`'s layered level lookup is satisfied by the DI package's reader, not by `IConfiguration` binding.** Supersedes `observability/4997350a`: both binder modes admit a numeric level (`"2"` becomes `Body`) and an undefined one (`"7"` is accepted silently), so binding alone cannot meet `OBS-35`'s recognised-names rule; the reader parses `Logging:Level` by name only and fails a number or an unknown name naming the key and listing the names (design §10 entry 26 now touches `OBS-35`). + review · `docs/work/mvp/phase9/2026-10-12-phase9-host-integration-checklist.md` · high · sha:manual-p9-obs35-reader + +## Conflicts +- **The configuration binder is not the reader; the DI package reads `IConfiguration` itself.** Departs from the styleguide companion's `csharp-aspnetcore/01-host-and-configuration.md` 1.3 ("the Options pattern is the only sanctioned reader") and from design §8.2's binder-plus-registered-converters text (P9-4, open for the lead): neither binder mode can serve (V1), so an internal reader walks the section by known keys with the package's own grammars and builds the records with `with`. No consumer code indexes `IConfiguration`, which is 1.3's intent; the reader is the binding layer itself, and the options pattern still carries the result (`ValidateOnStart`, `IStartupValidator`). `ConfigurationBinder` is banned in `src/`. + review · `docs/styleguide/csharp-aspnetcore/01-host-and-configuration.md` · high · sha:manual-p9-binder-departure +- **`AddDexpaceClient` returns a `DexpaceClientBuilder`, not the service collection.** Departs from `csharp-aspnetcore/02-dependency-injection.md` 2.7 ("a feature extension returns the collection so calls chain"): the `IHttpClientBuilder` precedent; `builder.Services` chains back. + review · `docs/styleguide/csharp-aspnetcore/02-dependency-injection.md` · high · sha:manual-p9-builder-return + +## Conclusions +- **The configuration binder's source generator binds no `init` accessor and says nothing, and its reflection mode is not AOT-safe and mis-binds.** Over the repository's option records (V1): the generator leaves every value of every record at its default (`MaxRetryAttempts = "5"` stays 2, `BaseDelay = "PT5S"` stays 200 ms, `Level = "Body"` stays `None`), fails to compile over `ProxyOptions` (`CS9035` for the `required` members) and reports `SYSLIB1100`/`SYSLIB1101` for `IReadOnlySet` and `ICredentials`; the reflection mode raises 32 `IL2026` and 32 `IL3050` warnings, sets `init` accessors (surfacing an accessor's exception as `TargetInvocationException`), reads a `TimeSpan` `"1000"` as 1000 days and rejects `"PT5S"` and `"500ms"`, accepts an `int` `"0x10"` as 16, accepts a `bool` `" TRUE "`, maps an enum `"7"` to the undefined value 7, silently does not bind `IReadOnlySet` or `IReadOnlySet`, and **appends** to an `IReadOnlyCollection` default instead of replacing it. + review · `docs/work/mvp/phase9/2026-10-12-phase9-host-integration-design.md` · high · sha:manual-p9-v1-binder +- **`XmlConvert.ToTimeSpan` is not `CFG-7`'s duration grammar.** It reads `P5M` as 150 days and `P1Y` as 365, `-PT5S` as minus five seconds, rejects lower case (`pt5s`) and truncates `PT0.00000001S` to zero (V2); `int.TryParse` with `AllowLeadingSign` accepts `010`, `-5`, `+3` and rejects `" 5 "`, `0x10`, `1_000`, `1e3`, `1.5`, non-ASCII digits and overflow. The DI package's reader has its own duration grammar (the ISO day-time subset with a seven-digit fraction, ``, bare milliseconds) over an ASCII trim. + review · `docs/work/mvp/phase9/2026-10-12-phase9-host-integration-design.md` · high · sha:manual-p9-v2-grammars +- **`ValidateOnBuild` does not run options validation, and `ProxyOptions.FromEnvironment` reads `NO_PROXY` only once it found a proxy.** A provider built with `ValidateScopes` and `ValidateOnBuild` does not run `IValidateOptions`; `ValidateOnStart()` registers `IStartupValidator`, whose `Validate()` runs the named validators with no host, and a keyed factory that reads `IOptionsMonitor.Get(name)` fails lazily at first resolution (V4). Two keyed registrations under one key resolve silently to the last. + review · `docs/work/mvp/phase9/2026-10-12-phase9-host-integration-checklist.md` · high · sha:manual-p9-v4-validation diff --git a/docs/knowledge/notes/transport-adapter.md b/docs/knowledge/notes/transport-adapter.md index 69055a8..e02424b 100644 --- a/docs/knowledge/notes/transport-adapter.md +++ b/docs/knowledge/notes/transport-adapter.md @@ -35,3 +35,10 @@ loopback fixture, so a later reader does not have to re-measure them. review · `docs/work/mvp/phase8/phase8b/2026-10-09-phase8b-reference-transport-checklist.md` · high · sha:manual-p8b-pool-starving-barrier - **A per-call default deadline armed by the transport is a bound on top of a borrowed `HttpClient`'s own `Timeout`, not a replacement for it, so "the 100 s default replaces `HttpClient.Timeout`" is true only of a client the transport builds.** `CallDeadline.Effective(options, _settings.Timeout)` runs for every construction, and a borrowed client's `Timeout` is untouched (the shared client is never mutated), so the shorter of the two cuts a call: a borrowed client with an infinite or five-minute `Timeout` and a server that takes 120 s to send headers succeeded on `main` and fails after 8b with a retryable `ServiceRequestTimeoutException`, which a pipeline re-sends. A design that says the default leaves "no behaviour changed" has to say it for the owned construction only, and give the borrowed one a migration (`SystemNetHttpClientOptions { Timeout = null }` or a longer `RequestOptions.Timeout`); a fake clock pins it without waiting (`DeadlineTests`). review · `docs/work/mvp/phase8/phase8b/2026-10-09-phase8b-reference-transport-checklist.md` · high · sha:manual-p8b-borrowed-default-deadline +- **`IHttpClientFactory`'s default chain follows redirects, composes with the 8b handler constructor once the primary has redirects off, and never releases a handler whose lifetime is infinite.** The default chain is `LifetimeTrackingHttpMessageHandler -> LoggingScopeHttpMessageHandler -> LoggingHttpMessageHandler -> SocketsHttpHandler` with `AllowAutoRedirect = true`, so `new SystemNetHttpClient(factory.CreateHandler(name))` throws naming `AllowAutoRedirect`; with `ConfigurePrimaryHttpMessageHandler` building `SystemNetHttpClient.CreatePrimaryHandler`, `RemoveAllLoggers()` and a caller's `AddHttpMessageHandler`, the chain is `LifetimeTracking -> -> SocketsHttpHandler` and the check walks it to the primary (phase 9 V3). A transport that holds one chain for the process needs `SetHandlerLifetime(Timeout.InfiniteTimeSpan)`, and then the factory never releases the handler: the kit's `transport-15.owned-released` failed over the DI-built transport until a container-owned lease disposed the primary the package created (V8). + review · `docs/work/mvp/phase9/2026-10-12-phase9-host-integration-checklist.md` · high · sha:manual-p9-v3-v8-factory-chain +- **The factory's default logging handlers write the request URI with its path verbatim at `Information`.** Per request: `Start processing HTTP request GET http://127.0.0.1:.../secret-path/token123?*` and `Sending HTTP request ...` (the query reduced to `*`, userinfo dropped, the path verbatim), plus header-name dumps at `Trace`: a second, differently-redacting HTTP log stream beside the SDK's own, which the named client removes with `RemoveAllLoggers()` (V9). + review · `docs/work/mvp/phase9/2026-10-12-phase9-host-integration-checklist.md` · high · sha:manual-p9-v9-factory-logging +- **`Microsoft.Extensions.Http.Resilience`'s standard handler refuses `Retry.MaxRetryAttempts = 0` and, under its defaults, re-sends a `POST` body.** `AddStandardResilienceHandler(o => o.Retry.MaxRetryAttempts = 0)` fails its own options validation at first handler creation ("must be between 1 and 2147483647"); over a loopback server answering `503`, a `POST` with a body reached the server four times under the defaults and once with `o.Retry.ShouldHandle = _ => ValueTask.FromResult(false)`, which keeps the circuit breaker, rate limiter and timeouts (V6). + review · `docs/work/mvp/phase9/2026-10-12-phase9-host-integration-checklist.md` · high · sha:manual-p9-v6-resilience + diff --git a/docs/sdk-design-dotnet/02-project-and-solution-layout.md b/docs/sdk-design-dotnet/02-project-and-solution-layout.md index 02b8c95..22ae037 100644 --- a/docs/sdk-design-dotnet/02-project-and-solution-layout.md +++ b/docs/sdk-design-dotnet/02-project-and-solution-layout.md @@ -56,6 +56,11 @@ property the seam exists for; a second adapter over the same property is a later the first release's lockstep version, `0.1.0` proposed, phase 12's decision) and carries the `Dexpace.Sdk.Conformance.Wire` loopback fixture promoted from the SystemNet test project. Its tests are `tests/Dexpace.Sdk.Conformance.Tests/`; `Dexpace.Sdk.Http.SystemNet.Tests` drives it against the reference transport. See [`docs/sdk-documentation/conformance.md`](../sdk-documentation/conformance.md). +**Correction (2026-10-13, phase 9): `Dexpace.Sdk.Extensions.DependencyInjection` is built.** `src/Dexpace.Sdk.Extensions.DependencyInjection/` references `Dexpace.Sdk.Core`, `Dexpace.Sdk.Http.SystemNet` (the named client's primary handler is +`SystemNetHttpClient.CreatePrimaryHandler`) and one third-party root, `Microsoft.Extensions.Http` 10.0.12, whose closure is the hosting family of 13 packages, all band-matched (phase 9 V5); it does not reference a codec (P9-2, P9-17). +The dependency audit holds it to a policy record, `("Microsoft.Extensions.Http", ["Dexpace.Sdk.Http.SystemNet"])`. Packable at `0.0.1-alpha.1`, unpublished. See [`docs/sdk-documentation/dependency-injection.md`](../sdk-documentation/dependency-injection.md). +**As built (phase 9):** the package exists; the SystemNet reference departs from this section's "core only" cell and is recorded against `NFR-2` in §10 entry 2. + ### 2.2 Later packages Deliberately deferred, in rough priority order: `Dexpace.Sdk.Reactive` (`System.Reactive`) bridging pagination and diff --git a/docs/sdk-design-dotnet/03-seam-by-seam-idiomatic-mapping.md b/docs/sdk-design-dotnet/03-seam-by-seam-idiomatic-mapping.md index 75d2910..8d8968e 100644 --- a/docs/sdk-design-dotnet/03-seam-by-seam-idiomatic-mapping.md +++ b/docs/sdk-design-dotnet/03-seam-by-seam-idiomatic-mapping.md @@ -470,6 +470,10 @@ not reproduced as "not reproduced here" any more: the kit measures it (`HttpConn extension method is sent with a zero-length content, which is byte for byte what the native client writes for those methods, and cannot be re-sent; a body-less `GET`, `HEAD`, `OPTIONS`, `DELETE` and `TRACE` still is (**TRANSPORT-2**, §10 entry 33; P8b-T2). *Dated correction*; the paragraphs above stand as written. +**Correction (2026-10-13, phase 9, `TRANSPORT-1`): the named client's primary handler.** The DI package builds each named client's primary through `SystemNetHttpClient.CreatePrimaryHandler(options)` (`AllowAutoRedirect = false`, direct unless a proxy was asked +for, server credentials unset), composes it with `IHttpClientFactory`'s chain (`RemoveAllLoggers()`, an infinite handler lifetime and a `PooledConnectionLifetime`, a container-owned lease that releases the primary) and builds the transport with the 8b handler constructor, whose +redirect check walks the chain to the primary: a caller who swaps in a redirect-following primary fails at first resolution (phase 9 V3, V8, P9-12, P9-13). `NamedClientRedirectWireTests` (`Security`) is phase 1 S3's row 9. + ### 3.3 The asynchronous transport seam and the canonical pivot **What the reference requires and why.** **SEAM-16** requires the async transport's future to "complete either with a @@ -856,6 +860,11 @@ why neither is ever activated by presence. **As built (d45e64b):** built — diverges: core has no registry and takes the transport explicitly, as designed; the DI package and its single-registration validation are not built. +**As built (2026-10-13, phase 9): `SEAM-5`, `SEAM-6` and `SEAM-7` under DI.** `AddDexpaceClient` registers `HttpPipeline`, `IAsyncHttpClient`, `ISerde` and `DexpaceClientOptions` as keyed singletons per client. `UseTransport` and `UseSerde` replace the package's default +descriptor; `SEAM-6`'s "different provider" failure is thrown **at the `Use*` call**, naming the client and both implementations, earlier than this section's validator timing (P9-16); the validator scans the captured `IServiceCollection` for keyed `IAsyncHttpClient` and `ISerde` +descriptors under the client's key (more than one fails listing each, none fails with the install hint), because `GetRequiredKeyedService` silently resolves the last (§11 item 8, measured, V4). `SEAM-7` is the keyed singleton, created once under 64 concurrent first resolutions (`KeyedSingletonTests`); +`SEAM-7` stays N/A. `SEAM-8` to `SEAM-10` are unchanged. + ### 3.7 Lifecycle and ownership Six requirements — **SEAM-14**, **SEAM-25**, **HTTP-43**, **ASYNC-15**–**ASYNC-17**, **XCUT-13** and **XCUT-22** — diff --git a/docs/sdk-design-dotnet/06-retry-redirect-and-authentication.md b/docs/sdk-design-dotnet/06-retry-redirect-and-authentication.md index b453251..451ba9d 100644 --- a/docs/sdk-design-dotnet/06-retry-redirect-and-authentication.md +++ b/docs/sdk-design-dotnet/06-retry-redirect-and-authentication.md @@ -212,6 +212,10 @@ above stands as written, with six *dated corrections*: set is classified (P6a-6). - **The random source is injectable through internal constructors only (P6a-27)**, not a public parameter. +**Correction (2026-10-13, phase 9, `XCUT-10`, P9-19, V6): the guidance for a resilience handler.** Measured on `Microsoft.Extensions.Http.Resilience` 10.10.0: `AddStandardResilienceHandler(o => o.Retry.MaxRetryAttempts = 0)` fails the handler's own options validation, and under its defaults the handler +re-sent a `POST` body (the server was reached four times for one SDK attempt). The "set the SDK's own `MaxRetryAttempts` to 0" option is therefore **withdrawn** (it leaves a handler retry behind the SDK's re-send gate, which is this section's hazard); the "handler without its retry" option stands and its spelling is +`o.Retry.ShouldHandle = _ => ValueTask.FromResult(false)`, which keeps the circuit breaker, rate limiter and timeouts. This replaces "cited from its documentation, not verified here". `ResilienceCompositionTests` pins it. + ### 6.2 Redirect **P13 — the transport follows redirects by default, and does it wrong.** `HttpClientHandler.AllowAutoRedirect` and diff --git a/docs/sdk-design-dotnet/08-instrumentation-and-configuration.md b/docs/sdk-design-dotnet/08-instrumentation-and-configuration.md index 8ee6838..6ac6dc0 100644 --- a/docs/sdk-design-dotnet/08-instrumentation-and-configuration.md +++ b/docs/sdk-design-dotnet/08-instrumentation-and-configuration.md @@ -353,6 +353,12 @@ contains a space; the version fallback is `unknown`, and the default `User-Agent P5a-12)*: `HttpDate` is the shared RFC 1123 formatter and parser. *Equality (P5a-20)*: `DeepValue` is internal and recursion-bounded (128 levels) because a self-referential array would end the process. +**As built (2026-10-13, phase 9) and corrections to this section.** (1) **Binding is the DI package's reader, not the configuration binder** (P9-4): the source generator binds no `init` accessor and says nothing, the reflection mode is 64 trim and AOT warnings and mis-binds `TimeSpan`, `int`, +enums, two sets and a list default (V1), so an internal reader walks the section by known keys with the package's own grammars and builds each record with `with`; `ConfigurationBinder` is banned in `src/`. The text above that expects the binder plus two registered converters is superseded. +(2) **The duration grammar is the reader's own** (V2: `XmlConvert` reads `P5M` as 150 days and `-PT5S` as minus five seconds, rejects lower case and truncates sub-tick fractions): ISO-8601 day-time, ``, bare milliseconds, `none` (P9-8, P9-29). (3) **`CFG-2`: an empty value is absent** and the record default applies; the +shadowed lower provider is not consulted (P9-7, §10 entry 25). (4) **Tier 1 is a `with` transform**: the records are immutable (`CFG-8`), so `Configure(Func)` replaces `Action`, applied after the configuration values in call order (P9-6). +(5) **No reload**: the snapshot is taken at first resolution. (6) **The proxy has two sources**, the explicit `Transport:Proxy:*` keys or code-only `UseProxyFromEnvironment`, never a configuration switch (P9-15). (7) **`OBS-35`'s key is the caller's section**: the package bakes in none; `DexpaceConfigurationKeys` publishes the names (P9-23). + ### 8.3 The clock, the wait, and the prohibition **CFG-15** requires the time seam to expose a wall clock, a monotonic elapsed counter and a blocking interruptible diff --git a/docs/sdk-design-dotnet/09-toolchain-and-quality-gates.md b/docs/sdk-design-dotnet/09-toolchain-and-quality-gates.md index 8c5acbd..567ce7c 100644 --- a/docs/sdk-design-dotnet/09-toolchain-and-quality-gates.md +++ b/docs/sdk-design-dotnet/09-toolchain-and-quality-gates.md @@ -91,6 +91,8 @@ names `BoundedMap`'s slot. **Dated correction, 2026-10-11 (phase 8b, P8b-4).** The banned list gains `HttpClient.DefaultProxy` (get and set): the runtime's process-wide proxy reads `HTTP(S)_PROXY` and `NO_PROXY` implicitly and diverges from the specification's bypass dialect (§8.2), so no `src/` code reads or sets it. The transport installs a `ProxyOptions`-backed `IWebProxy` or goes direct. +**Correction (2026-10-13, phase 9): the banned list gains `ConfigurationBinder`** (`T:Microsoft.Extensions.Configuration.ConfigurationBinder`): bind through the DI package's reader (P9-4, V1). The type arrives transitively with `Microsoft.Extensions.Http` and is banned, not removed. + ### 9.2 The zero-dependency gate, and NFR-8 applying literally **NFR-8** exempts ecosystems without whole-program dead-code elimination; .NET has two — ILLink trimming and @@ -201,6 +203,9 @@ security measure on .NET strong-named consumers cannot reference an unsigned assembly, with the key committed as Microsoft's library guidance recommends. +**Correction (2026-10-13, phase 9): the audit's policy record and the AOT smoke.** `scripts/ci/dependency-audit.cs` holds each library to `Policy(ThirdParty, Projects)`: the allowed packages are the closure of the facade and of the one third-party root, the allowed projects the closure of core and of the +first-party projects the policy names, and every listed Dexpace project must carry the library's lockstep version (P9-3). "At most one third-party library" counts a root package whose closure is one publisher's lock-stepped family as one library (§10 entry 2). The AOT smoke gains `CheckPhase9Async` over the DI package (P9-21, V7). + ### 9.3 Tests, and why a local server rather than only a stubbing library **xUnit is the framework, and the conformance kit is framework-free.** The house guide wants xUnit v3 on @@ -289,6 +294,8 @@ vacuous-by-antecedent (§11 item 21). **B.9** is this chapter's table, with **NF applicable and exercised. A failing item the port has decided not to satisfy is reported as a failure by the kit and suppressed in this repository's build by a named waiver listing the requirement ID, so the gap stays visible. +**Correction (2026-10-13, phase 9): a third conformance driver.** `tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/` runs the kit over the DI-built transport on both faces, with the two permanent assertion-scoped waivers of the SystemNet drivers (§10 entries 32 and 33) (P9-22). + ### 9.4 The vendored styleguide as quality gates The styleguide under `docs/styleguide/` is binding for every `.cs` file here, and it is only as real as its diff --git a/docs/sdk-design-dotnet/10-deliberate-deviations-from-the-reference-contract.md b/docs/sdk-design-dotnet/10-deliberate-deviations-from-the-reference-contract.md index 6c21112..d654442 100644 --- a/docs/sdk-design-dotnet/10-deliberate-deviations-from-the-reference-contract.md +++ b/docs/sdk-design-dotnet/10-deliberate-deviations-from-the-reference-contract.md @@ -37,6 +37,9 @@ line and in the roadmap. `Options.ConfigurationExtensions` — one publisher's lock-stepped hosting family, which is what "integrate with the host" means on .NET. Splitting it would hand the consumer four packages to wire one feature. The transport and codec adapters stay within the one-library budget. §2.1. + *Dated correction, 2026-10-13, phase 9 (P9-2, P9-3):* measured: `Microsoft.Extensions.Http` is the one root, and its closure is the 13 `Microsoft.Extensions.*` packages (`Http`, `Configuration.Abstractions`, `Configuration`, `Configuration.Binder`, + `DependencyInjection.Abstractions`, `DependencyInjection`, `Diagnostics.Abstractions`, `Diagnostics`, `Logging.Abstractions`, `Logging`, `Options`, `Options.ConfigurationExtensions`, `Primitives`), all band-matched; the package also references the reference transport + (`Dexpace.Sdk.Http.SystemNet`), a first-party project the audit's policy names. The audit counts the root as one library. 3. **The byte-stream provider seam is retired; its behavioural contract is not.** *Touches* **SEAM-3**–**SEAM-10** as applied to this seam, **IO-30**–**IO-36**, **IO-39**, **XCUT-23** as applied to this seam. *Judged* (P2). `Stream`, `Memory`, `IBufferWriter`, `MemoryStream`, `ArrayPool` and `Encoding` are in the @@ -111,6 +114,7 @@ line and in the roadmap. and lists every registered type on ambiguity; `TryAdd` gives **SEAM-6**'s idempotence and singleton lifetime **SEAM-7**'s caching. **SEAM-8** has no auto-resolved provider to replace, and **SEAM-10** is vacuous in one default load context. §3.6. + *Dated correction, 2026-10-13, phase 9 (P9-16): as built:* `SEAM-6`'s different-provider failure is thrown at the `UseTransport`/`UseSerde` call; the validator scans the keyed descriptors for `SEAM-5`; `SEAM-7` is the keyed singleton. 10. **No builder objects beyond the multimap builders, and no generic `IBuilder`.** *Touches* **SEAM-29** (its generic builder contract), **HTTP-2** (its builder clause), **HTTP-3** (`newBuilder()` for the non-multimap models), **HTTP-4** (its message form). *Judged* (P11, P14). Object initializers with `required` members, @@ -270,6 +274,8 @@ line and in the roadmap. system-property layer — the same substitution applied twice. §8.2. *Dated correction, 2026-10-07, phase 5a (P5a-15):* `FromEnvironment` reads the upper- and lower-case names, skips upper-case `HTTP_PROXY` when `GATEWAY_INTERFACE` is present, serves one proxy for every target, and never falls through on a malformed value. + *Dated correction, 2026-10-13, phase 9 (P9-7; open for the lead):* an empty or whitespace-only value is **absent**: the record default applies, and a lower provider's value that the empty one shadows is not consulted (`IConfiguration` merges providers before the reader sees a key). + This replaces "an empty environment value is *present* ... and then fails binding at startup": the deviation is now a silent shadow rather than a loud failure, recorded as `di-empty-shadows` in `docs/first-release.md`. The bind is the DI package's reader, not the binder (§8.2). 26. **Unparseable configuration fails at startup instead of falling back silently.** *Touches* **CFG-5**, **CFG-6**, **CFG-7** (their never-throw letters; the **CFG-6** and **CFG-7** grammars are kept). *Judged, against the specification's letter and with the house guide* (`docs/styleguide/csharp-aspnetcore/01-host-and-configuration.md` @@ -278,6 +284,8 @@ line and in the roadmap. fails instead. The grammars are kept exactly: a custom `TimeSpan` converter implements **CFG-7** (the default one reads `"1000"` as 1000 days, verified), and a base-10 integer converter replaces `Int32Converter`, which accepts `"0x10"` (verified). §8.2. + *Dated correction, 2026-10-13, phase 9 (P9-8, P9-9):* touches **OBS-35** as well (an unrecognised level fails the boot naming the key and listing the names, where the specification falls back). The grammars are the DI package's reader's, not converters registered with the binder: both binder modes + were measured (V1, V2) and neither can serve; every failure is reported at once as `": "` and never quotes a configured string, URL or credential (an accessor's numeric range message may echo the number, P9-28). ### Departures from the house styleguide diff --git a/docs/sdk-design-dotnet/11-appendix-reference-spec-ambiguities-and-how-this-port-resolves-them.md b/docs/sdk-design-dotnet/11-appendix-reference-spec-ambiguities-and-how-this-port-resolves-them.md index e223df9..fcdd74a 100644 --- a/docs/sdk-design-dotnet/11-appendix-reference-spec-ambiguities-and-how-this-port-resolves-them.md +++ b/docs/sdk-design-dotnet/11-appendix-reference-spec-ambiguities-and-how-this-port-resolves-them.md @@ -54,6 +54,8 @@ the pull request (#3–#9) that built it. No decision recorded here changed. loudly": `Microsoft.Extensions.DependencyInjection` resolves a single service to its *last* registration and silently ignores earlier ones (documented behaviour). The DI package must detect multiple registrations of the SDK's seams itself (§3.6, §8.2). +**Correction 2026-10-13 (phase 9, V4).** Item 8 measured: two keyed `IAsyncHttpClient` registrations under one key resolve silently to the **last**; `ValidateScopes` and `ValidateOnBuild` do not run options validation, which runs at `IStartupValidator.Validate()` (what the hosting startup calls) or at first resolution. A validator that scans the captured `IServiceCollection` lists every candidate. + 9. **Appendix B covers only nine of nineteen prefixes.** *Carries over.* Appendix B (PAGE, SSE, SERDE, OBS, CFG, TRANSPORT, ASYNC, XCUT, NFR) reads like a conformance definition but is not one. *Resolved* (§9.3): Appendix B conformance is stated as a strictly weaker claim than full conformance; the conformance kit reports per @@ -138,6 +140,9 @@ the pull request (#3–#9) that built it. No decision recorded here changed. underneath, and two engines multiply sends and re-send bodies the SDK's safety gate never saw (entry 18's verification). *Resolved*: one retry layer per call path, the SDK's; nothing the SDK creates adds a retrying handler, and composing a resilience handler without its retry strategy is documented (§6.1). +**Correction 2026-10-13 (phase 9, P9-19, V6).** Item 24 as built: the composition is `AddStandardResilienceHandler(o => o.Retry.ShouldHandle = _ => ValueTask.FromResult(false))`. The SDK-side `MaxRetryAttempts = 0` option is withdrawn (the handler's default retry re-sent a `POST` body behind the SDK's gate, four server arrivals for one SDK attempt), and the handler-side +`MaxRetryAttempts = 0` is refused by the handler's own options validation. + 25. **The HTTPS guard has no loopback exemption**. **XCUT-16** and **AUTH-28** are silent on `http://localhost`, and a reader could assume one. *Resolved*: no exemption, matching Azure.Core's bearer policy; local development uses HTTPS via `dotnet dev-certs` (§6.3). @@ -163,6 +168,8 @@ the pull request (#3–#9) that built it. No decision recorded here changed. up, so a port cannot tell substance from scaffolding (P11). *Resolved*: kept, with a .NET reader — a `DelegatingHandler` below the transport resolves the live call from a `CallKey` stamped into `HttpRequestMessage.Options` (§5.4). *Correction 2026-10-07 (4a, P4a-13):* the route is open; 4c carries the key on `RequestOptions` and 8b stamps it. +**Correction 2026-10-13 (phase 9, P9-20).** Item 30's "kept, with a .NET reader" has no production reader as built: the store is read only through `DexpaceCallContexts.TryGet` in policies, and phase 9 registers no handler below the transport that needs a call's context, so the `CallKey` stamp (P8b-18) is not built. It is a post-release trigger in `docs/first-release.md`. + 31. **REDIR-11's marker has a trap the requirement itself flags.** "Only the auth step strips the marker, so a pipeline with no auth step … forwards the internal marker to the transport; a robust port should strip the signal independently." *Resolved* by removing the marker: redirect and auth both compare against the call's @@ -348,3 +355,9 @@ carries no `[Experimental]` diagnostic on SDK 10.0.401) with `http.request.metho `url.scheme` and, on completion, `http.response.status_code` and `network.protocol.version`, or `error.type` for an exception or a status of 400 or more; `http.client.active_requests` (up-down counter, `{request}`) with the four start attributes, the same list for `+1` and `-1`. There is no count instrument and no operation-level instrument. + +67. **`CFG-7` says "ISO-8601 durations" and "a bare number"; `CFG-5` to `CFG-7` do not say what surrounds a value.** *Added by dated correction, 2026-10-13 (phase 9, P9-29; open for the lead).* The DI package's reader reads "ISO-8601" as the **day-time subset** + `P[nD][T[nH][nM][n[.f]S]]` (any case; a fraction of at most seven digits, the tick; Java's `Duration.parse`, the reference, accepts the same, with a nine-digit fraction), rejecting years, months, weeks and a sign; reads "a bare number" as **digits only**, in milliseconds (both siblings also accept a + bare fraction, `1.5`); and trims ASCII whitespace from every scalar first, **booleans included** (the Ruby port does not trim a boolean). `CFG-6` therefore accepts `" true "`. `1_000` is rejected where the Ruby port tolerates it. §8.2. +68. **`CFG-2` says an empty value "falls through to the system-property layer" and "rather than resolving to the empty string"; `IConfiguration` merges providers before the reader sees a key.** *Added by dated correction, 2026-10-13 (phase 9, P9-7; open for the lead).* The "rather than resolving to the empty string" half is + met (an empty or whitespace-only value is absent and the record default applies); the fall-through half cannot be, so an empty environment value silently shadows a lower provider's value (§10 entry 25). The alternative, an empty value failing start, turns a blank templated variable into a boot failure and drops the met half. §8.2. diff --git a/docs/sdk-design-dotnet/12-appendix-requirement-coverage-index.md b/docs/sdk-design-dotnet/12-appendix-requirement-coverage-index.md index d22f55f..dbf6719 100644 --- a/docs/sdk-design-dotnet/12-appendix-requirement-coverage-index.md +++ b/docs/sdk-design-dotnet/12-appendix-requirement-coverage-index.md @@ -53,7 +53,7 @@ itself a conformance claim. | Prefix | Count | Cited | Addressed in | Not cited by ID / notes | |---|---|---|---|---| -| SEAM | 30 | 30 | §1, §3.1–§3.8, §9, §9.2 (+6 single-ID sections) | *Notes:* the byte-stream provider and its apparatus (**SEAM-3**–**SEAM-10** as applied to it) retire (§10 entry 3); discovery retires for the surviving seams too (§10 entry 9); **SEAM-15** (MAY) taken — a send after dispose throws (§3.7); **SEAM-17**'s pivot is `Task` and its adapter apparatus collapses (§3.3) | +| SEAM | 30 | 30 | §1, §3.1–§3.8, §9, §9.2 (+6 single-ID sections) | *Notes:* the byte-stream provider and its apparatus (**SEAM-3**–**SEAM-10** as applied to it) retire (§10 entry 3); discovery retires for the surviving seams too (§10 entry 9); **SEAM-15** (MAY) taken — a send after dispose throws (§3.7); **SEAM-17**'s pivot is `Task` and its adapter apparatus collapses (§3.3) *Phase 9 (2026-10-13):* **SEAM-5** and **SEAM-6** are ✅ for their DI half (§3.6, §10 entry 9); **SEAM-7** stays N/A with `KeyedSingletonTests` cited. | | HTTP | 53 | 52 | §1, §3.1, §3.2, §3.5, §3.7, §4, §4.1–§4.4, §7.3 (+3) | **HTTP-40** (MUST, file-body fail-fast validation) — substance in §3.1 under **BODY-11**. *Deferred:* **HTTP-22** (MAY). *Notes:* **HTTP-9**'s set is an internal constant with no public accessor (§4.3, §11 item 22); **HTTP-2**/**HTTP-3**/**HTTP-4** builder clauses (§10 entries 10, 11) | | IO | 42 | 41 | §3.1 | **IO-7** (MUST, the `Buffer` as a FIFO source-and-sink) — no `Buffer` type ships (§3.1, §10 entry 3), and the retirement is not argued by ID *(closed by dated correction, 2026-10-03, phase 3a: argued in the phase 3a design, position C, with **IO-10** in §11 item 44)*. *Notes:* **IO-30**–**IO-36**, **IO-39** retire with the provider (§10 entry 3); the −1 sentinel family is §10 entry 4 | | BODY | 37 | 35 | §1, §3.1, §3.7, §5.1 (+5) | **BODY-1** (MUST, the replayability property) — substance in §3.1 (`IsReplayable`); **BODY-33** (SHOULD, non-consuming error preview) — unaddressed *(closed by dated correction, 2026-10-03, phase 3b: met by construction, because the exception's error body is the replayable bytes body of phase 1's S8, and pinned by `ErrorBodyPreviewTests`)*. *Deferred:* **BODY-36** (MAY). *Notes:* **BODY-8** is §10 entry 5, **BODY-14** §10 entry 6 | @@ -66,8 +66,8 @@ itself a conformance claim. | PAGE | 36 | 35 | §1, §3.7, §7.1 (+1) | **PAGE-7** (MUST, forward-only, on-demand fetching; idempotent end probes) — substance in §7.1 (`null` as the single end signal), not argued by ID. *Notes:* §10 entries 17, 18, 19. *Correction 2026-10-09 (phase 7c):* 33 are built and evidenced in the 7c checklist (**PAGE-1**, **PAGE-2**, **PAGE-4**–**PAGE-28**, **PAGE-31**–**PAGE-36**), 3 are 🚫 (**PAGE-3** by entry 17, **PAGE-29** and **PAGE-30** by entry 19); the count is unchanged, and **PAGE-19**'s cross-origin reading is §11 item 66 | | SSE | 41 | 40 | §1, §7.1, §7.2 (+5) | *Not applicable:* **SSE-41** (MAY, reactive-adapter error latitude) — no reactive adapter ships, so the clause is adapter-scoped and vacuous (§11 item 21), reported as that and never as satisfied or deferred (*dated correction, 2026-10-09, phase 7b*). *Notes:* no strict-WHATWG mode (§11 item 17); **SSE-19**'s cap is sanctioned (§10 entry 20) | | SERDE | 30 | 30 | §1, §3.4, §7.3 (+3) | *Notes:* **SERDE-4**'s fixed-buffer profile is met by a core derivation (§3.4, §7.3); **SERDE-14**'s covariance SHOULD is §10 entry 21. *Correction 2026-10-09 (phase 7a):* all 30 rows are built and cite the [7a checklist](../work/mvp/phase7/phase7a/2026-10-09-phase7a-serde-checklist.md) (`HTTP-44` and `HTTP-45` are carried there from 3b); **SERDE-13** is read as the non-null overloads (P7a-9, open) | -| OBS | 40 | 40 | §8.1, §9.3 | *Notes:* §10 entries 22, 23, 24; **OBS-32**'s naming conflict is §11 item 38. *Correction 2026-10-07 (phase 5b):* the roadmap's 5b owns 28 rows and 5c 12; the HTTP events are `ILogger.Log` records for the header-bearing pair and `Define` for the fixed-key ones (§8.1, P5b-3); **OBS-19** is ⏳ 8b and **OBS-35** ⏳ 9. *Correction 2026-10-07 (phase 5c):* `OBS-21`-`OBS-23` and `OBS-25`-`OBS-33` are built (§8.1, §10 entries 23 and 24, §11 item 38); the operation span opens at call entry (P5c-2), success leaves its status `Unset` (P5c-5), `OBS-27`'s zero-draw clause is admitted (P5c-14), `OBS-28`'s byte-count milestones are not emitted | -| CFG | 38 | 38 | §6.1, §8.2, §8.3 (+3) | *Deferred:* **CFG-13** (declined), **CFG-20** (retired). *Notes:* §10 entries 25, 26; **CFG-34**'s floating-point clause is live on .NET (§11 item 15). *Phase 5a (2026-10-07):* **CFG-12** is N/A (no configuration builder, §11 item 51); **CFG-21** is built as the internal `LateResult` | +| OBS | 40 | 40 | §8.1, §9.3 | *Notes:* §10 entries 22, 23, 24; **OBS-32**'s naming conflict is §11 item 38. *Correction 2026-10-07 (phase 5b):* the roadmap's 5b owns 28 rows and 5c 12; the HTTP events are `ILogger.Log` records for the header-bearing pair and `Define` for the fixed-key ones (§8.1, P5b-3); **OBS-19** is ⏳ 8b and **OBS-35** ⏳ 9. *Correction 2026-10-07 (phase 5c):* `OBS-21`-`OBS-23` and `OBS-25`-`OBS-33` are built (§8.1, §10 entries 23 and 24, §11 item 38); the operation span opens at call entry (P5c-2), success leaves its status `Unset` (P5c-5), `OBS-27`'s zero-draw clause is admitted (P5c-14), `OBS-28`'s byte-count milestones are not emitted *Phase 9 (2026-10-13):* **OBS-35** is ✅ (split on §10 entry 26, which now touches it): the level is read by name from the caller's section by the DI package's reader (V1 answered 5b's V4). | +| CFG | 38 | 38 | §6.1, §8.2, §8.3 (+3) | *Deferred:* **CFG-13** (declined), **CFG-20** (retired). *Notes:* §10 entries 25, 26; **CFG-34**'s floating-point clause is live on .NET (§11 item 15). *Phase 5a (2026-10-07):* **CFG-12** is N/A (no configuration builder, §11 item 51); **CFG-21** is built as the internal `LateResult` *Phase 9 (2026-10-13):* the 12 `CFG` rows it owns are built by the DI package: **CFG-1**, **-2** (split: empty is absent; the fall-through clause is §10 entry 25), **-3**, **-5**, **-6**, **-7** (split on §10 entry 26), **-11**, **-14**, **-37**, **-38** are ✅ and **CFG-4**, **CFG-10** are N/A (vacuous); binding is the package's reader, not the binder (§8.2, V1); the grammar readings are §11 items 67 and 68. | | TRANSPORT | 30 | 30 | §3.2, §3.3, §3.7, §9.3 (+5) | *Notes:* **TRANSPORT-3**'s interrupt shape is §10 entry 8 and **TRANSPORT-20**'s I/O family §10 entry 7; **TRANSPORT-18** binds the SystemNet transport (§11 item 18) | | ASYNC | 22 | 22 | §1, §3.3, §3.7, §8.1, §8.3, §9.3 (+3) | *Vacuous:* **ASYNC-4**, **ASYNC-21**. *Notes:* **ASYNC-3** is met cooperatively (§10 entry 8) | | XCUT | 24 | 24 | §1, §3.2, §3.3, §3.7, §3.8, §4, §5.2, §6.1, §6.3, §8.1 (+11) | *Notes:* **XCUT-4**'s I/O family (§10 entry 7, §11 item 23); **XCUT-20** versus **OBS-30** (§11 item 37) | diff --git a/docs/sdk-documentation/conformance.md b/docs/sdk-documentation/conformance.md index a89e5a1..4a66f00 100644 --- a/docs/sdk-documentation/conformance.md +++ b/docs/sdk-documentation/conformance.md @@ -283,3 +283,10 @@ version: `0.1.0` is proposed, and `docs/first-release.md` makes the number phase banned, §10 entry 8). - **Codec and pagination vectors.** The `SERDE` lift of phase 7a and the pagination vectors of phase 7c are phase 10's invariant and codec suites, in this same package. + +## The third driver (phase 9, P9-22) + +*Added 2026-10-13.* `tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/` runs the kit over the transport `AddDexpaceClient` builds from `IHttpClientFactory`: a fresh container per transport, the kit's logger forwarded through a +single-logger factory, the hooks added as handlers of the named client (the faulting, re-sending and cancel-on-demand handlers are the SystemNet suite's, shared by linked source) and the proxy hook through the `Transport:Proxy:*` keys. It is green on both +faces with the two permanent assertion-scoped waivers of the SystemNet drivers (design §10 entries 32 and 33); `transport-15.borrowed-survives` is `NotExercised` because the package never borrows a client. `transport-1.redirect-not-followed` passing on both +faces is the proof the 8a hand-off named, and `transport-15.owned-released` is what the container-owned primary-handler lease earns: it failed on the first build of the driver, because the factory never releases a handler whose lifetime is infinite. diff --git a/docs/sdk-documentation/dependency-injection.md b/docs/sdk-documentation/dependency-injection.md new file mode 100644 index 0000000..c650bf6 --- /dev/null +++ b/docs/sdk-documentation/dependency-injection.md @@ -0,0 +1,175 @@ +# Host integration: `Dexpace.Sdk.Extensions.DependencyInjection` + +**As built by phase 9, written against source on 2026-10-13** (branch `96-phase-9-host-integration`). This page describes how a host registers a +named SDK client with `Microsoft.Extensions.DependencyInjection`, how the package reads the client's settings from an `IConfigurationSection`, when and how a +bad setting fails, how the named client's transport is built over `IHttpClientFactory`, and how to compose a resilience handler without a second retry +engine. The requirement IDs it cites are the normative text (appendix C of the [product spec](../product-spec.md) indexes them: `CFG-1` to `CFG-38`, `SEAM-5` to `SEAM-7`, `OBS-35`, `TRANSPORT-1`, `XCUT-10`); the decisions behind each behaviour are in the [phase 9 design](../work/mvp/phase9/2026-10-12-phase9-host-integration-design.md) (rulings `P9-1` to +`P9-29`, facts `V1` to `V9`) and in [design §3.6 and §8.2](../sdk-design-dotnet/03-seam-by-seam-idiomatic-mapping.md). Neither is restated here. + +## Registration (`CFG-37`, `SEAM-6`; P9-5) + +```csharp +services.AddDexpaceClient("github", builder.Configuration.GetSection("Dexpace:GitHub")) + .UseSerde(_ => new SystemTextJsonSerde(SystemTextJsonSerde.CreateDefaultOptions(GitHubJsonContext.Default))) + .UseAuth(GitHubAuth.Descriptor) + .Configure(o => o with { Redirect = o.Redirect with { Predicate = GitHubRedirects.Allow } }); + +public sealed class GitHubClient( + [FromKeyedServices("github")] HttpPipeline pipeline, + [FromKeyedServices("github")] ISerde serde, + [FromKeyedServices("github")] DexpaceClientOptions options) { /* ... */ } +``` + +- **A client is four keyed singletons**, under the client's name: `HttpPipeline`, `IAsyncHttpClient` (the transport), `ISerde` and `DexpaceClientOptions` (the snapshot a + generated client needs for `OperationDescriptor.BuildRequest`). A generated SDK ships its own `AddGitHubClient(this IServiceCollection, IConfiguration)` that calls + `AddDexpaceClient` and registers its client against the keyed services. There is **no generic typed-client overload**: it would need `ActivatorUtilities` with + trimming annotations for a convenience that is additive later, and the first release locks every name (`NFR-4`). +- **The builder is a sealed class**, not an interface, so an adapter extends it with extension methods. `AddDexpaceClient` returns it instead of the collection (the + `IHttpClientBuilder` precedent; `builder.Services` chains back). +- **One name registers once.** A second `AddDexpaceClient` with the same section instance (or both with none) returns the first builder; a different section throws + `InvalidOperationException` naming the client. Names are case-sensitive: `GitHub` and `github` are two clients. +- **The section parameter is the client's own section, never the configuration root.** A root holds every process variable (`PATH`, `HOME`, `AllowedHosts`), which the + unknown-key rule would reject, so the call throws `ArgumentException` with a pointed message. The package never reads a key the caller did not hand it: `SectionName` + (`"Dexpace"`) is published in `DexpaceConfigurationKeys` and never read (`CFG-14`, `OBS-35`). +- **Dispose the provider with `DisposeAsync`.** `IAsyncHttpClient` is `IAsyncDisposable` and not necessarily `IDisposable`, so a synchronous `ServiceProvider.Dispose()` throws for + an async-only transport. A host disposes asynchronously. +- Factories (`UseSerde(sp => ...)`, `UseTransport(sp => ...)`, `AddPolicy(sp => ...)`) run on the **root** provider: a scoped dependency in one fails under `ValidateScopes`. + +## Configuration (`CFG-1` to `CFG-7`, `CFG-38`; P9-4, P9-6 to P9-9) + +Tiers, lowest first: the record's initializer, the caller's `IConfiguration` providers (environment variables over `appsettings.{Environment}.json` over `appsettings.json` in +a default host), then the `Configure` / `ConfigureTransport` / `ConfigureCredentials` transforms in call order (`CFG-1`). The option records are immutable, so a transform +returns a modified copy (`o => o with { ... }`); one that returns `null` or throws fails validation naming the client. + +**The package reads the section itself, not through the configuration binder.** The binder's source generator binds no `init` accessor and says nothing, and its reflection +mode is not AOT-safe and mis-converts durations, integers and enums (design V1). `ConfigurationBinder` is banned in `src/`. + +Keys are relative to the section, case-insensitive and `:`-separated (`Dexpace__Retry__MaxRetryAttempts` as an environment variable, `CFG-3`): + +| Key | Target | Grammar | +|---|---|---| +| `BaseAddress` | `DexpaceClientOptions.BaseAddress` | absolute `http`/`https` URI, no fragment | +| `UserAgent` | `.UserAgent` | printable ASCII header value | +| `OverallTimeout`, `AttemptTimeout` | `.OverallTimeout`, `.AttemptTimeout` | duration or `none` | +| `Retry:MaxRetryAttempts` | `RetryOptions.MaxRetryAttempts` | integer | +| `Retry:BaseDelay`, `Retry:MaxDelay` | `.BaseDelay`, `.MaxDelay` | duration | +| `Retry:FixedDelay` | `.FixedDelay` | duration or `none` | +| `Retry:Multiplier`, `Retry:Jitter` | `.Multiplier`, `.Jitter` | decimal (invariant, finite) | +| `Retry:HonorRetryAfter` | `.HonorRetryAfter` | boolean | +| `Retry:RetryableStatusCodes` | `.RetryableStatusCodes` | list of integers, or `none` | +| `Retry:AttemptHeaderName` | `.AttemptHeaderName` | header name | +| `Redirect:MaxRedirects` | `RedirectOptions.MaxRedirects` | integer | +| `Redirect:AllowHttpsToHttpDowngrade`, `Redirect:FollowSeeOther` | the two flags | boolean | +| `Redirect:AllowedMethods` | `.AllowedMethods` | list of method tokens, or `none` | +| `Logging:Level` | `HttpLoggingOptions.Level` | `None`, `Headers` or `Body`, by name | +| `Logging:BodyPreviewSize` | `.BodyPreviewSize` | integer | +| `Logging:AllowedHeaderNames`, `Logging:AllowedQueryParameters`, `Logging:UrlValuedHeaderNames` | the three lists | list of strings, or `none` | +| `Logging:OmitDisallowedHeaders` | `.OmitDisallowedHeaders` | boolean | +| `Transport:Timeout` | `SystemNetHttpClientOptions.Timeout` | duration or `none` | +| `Transport:HeaderDropLogging` | `.HeaderDropLogging` | `FirstPerName`, `Every` or `VerboseOnly`, by name | +| `Transport:ResendBufferLimit` | `.ResendBufferLimit` | integer (64-bit) | +| `Transport:PooledConnectionLifetime` | the primary handler | duration or `none`; default 2 minutes | +| `Transport:Proxy:Type`, `Host`, `Port`, `NonProxyHosts`, `UserName`, `Password`, `BypassAll` | `ProxyOptions` | name; string; integer; list; string; string; boolean | +| `Auth:ApiKey:Key`, `HeaderName`, `Scheme` | `ApiKeyCredential` | strings | +| `Auth:Basic:Username`, `Password`; `Auth:Digest:Username`, `Password` | `BasicCredential`, `DigestCredential` | strings | +| `Auth:TokenRefreshMargin` | `AuthCredentials.TokenRefreshMargin` | duration | + +Code-only, never read from configuration: `RedirectOptions.Predicate`, `SystemNetHttpClientOptions.Logger` and `TimeProvider` (the container supplies them), +`ProxyOptions.ChallengeCredentials` and `AuthCredentials.Token` (use `ConfigureCredentials`). A drift test fails the build when a record gains a member that is neither keyed nor code-only. + +**Grammars** (`CFG-5` to `CFG-7`; P9-8, P9-27, P9-29). Every scalar is trimmed of ASCII whitespace first, and an empty value is then absent. + +- *Integer*: `[+-]?[0-9]+` in ASCII, base 10 (`010` is 10). Hex, separators (`1_000`), exponents, fractions, non-ASCII digits and overflow fail. A negative is parsed and handed to the record, whose accessor decides. +- *Boolean*: `true` or `false`, in any case. `1`, `yes`, `on` and the rest fail. +- *Duration*: ISO-8601 day-time `P[nD][T[nH][nM][n[.f]S]]` (any case; `f` has at most seven digits, the tick), a number with a unit (`250ms`, `3s`, `2m`, `1h`, `1d`, a single space allowed), or digits alone as + milliseconds. A sign, years, months, weeks, a bare fraction and anything above `TimeSpan.MaxValue` fail. This narrows `CFG-7`'s "ISO-8601" to the day-time subset (`XmlConvert` is not used: it reads + `P5M` as 150 days, `-PT5S` as minus five seconds and truncates `PT0.00000001S` to zero). +- *Enum*: a name, in any case. A number or an undefined value fails and the failure lists the names (`OBS-35`). +- *List*: child keys (`Key:0`, `Key:1`, in the configuration's order) or one comma-separated value, never both; `none` is the empty list. A list **replaces** the record's default. + +**An empty value is absent, and it silently shadows** (`CFG-2`, design §10 entry 25, P9-7). `IConfiguration` merges its providers before the package sees a key, so an empty environment value +over a value in `appsettings.json` resolves the **record default**, never `""` and never the JSON value. A blank templated variable is therefore invisible: say so in your deployment notes. + +**No failure quotes a configured string, URL or credential.** A failure reads `": "` (`Dexpace:GitHub:Retry:MaxRetryAttempts: not a base-10 integer ...`). An accessor's numeric range message may echo the +rejected number (`value ('-7') must be a non-negative value`); no numeric option carries a secret (P9-28). + +**Unknown keys fail** the boot, naming the key in the configuration's own spelling. A misspelt key is a misconfiguration nobody otherwise sees; a generated SDK's own settings live outside the client's section. A value set on a group key (`Transport:Proxy`, `Auth`, `Retry`) fails too, as "a group of keys, not a value", without quoting it. + +## Validation and timing (P9-9, P9-11; V4) + +One validator per client reports every failure at once as an `OptionsValidationException`: the reader's, the records' accessor rejections, the unknown keys and the wiring checks (below). It runs at host start through +`ValidateOnStart`; with no host, `IStartupValidator.Validate()` runs it, and the first resolution of every keyed service the package builds (the transport included) fails the same way; an instance you install with `UseSerde` or `UseTransport` is returned as given and never consults the validated state. `ValidateOnBuild` does **not** run +options validation. A client with no codec fails both. + +**There is no reload.** The snapshot is taken once, at first resolution; the package registers no change-token source, so a reloaded `appsettings.json` does not reach a built client. A built client is immutable (`CFG-8`), +and a pipeline rebuilt mid-flight would orphan the token cache and the transport's per-instance state. + +**Combinations the records accept do not fail** (P9-10): `BaseDelay` above `MaxDelay` waits `MaxDelay`; an `AttemptTimeout` above `OverallTimeout` never fires; a `Transport:Timeout` below `AttemptTimeout` bounds the +attempt at the transport. Failing them only under DI would give one record two validities. + +## The named client and its transport (`TRANSPORT-1`, `-15`; P9-12 to P9-14; V3, V8, V9) + +Per client the package calls `AddHttpClient("Dexpace.")` and builds the transport from `IHttpMessageHandlerFactory.CreateHandler`, with the handler constructor +`new SystemNetHttpClient(chain, options)`: + +- the **primary** is `SystemNetHttpClient.CreatePrimaryHandler(options)`: `AllowAutoRedirect = false`, direct unless a proxy was asked for, server credentials unset. The handler constructor walks the chain to the + primary and refuses one that follows redirects, so a caller who substitutes a default `SocketsHttpHandler` through `builder.HttpClient.ConfigurePrimaryHttpMessageHandler` fails at first resolution naming `AllowAutoRedirect`; +- **`RemoveAllLoggers()`**: the factory's logging handlers write the request URI with its path verbatim at `Information`, beside the SDK's own redacted `http.request`/`http.response` events (`OBS-11` to `OBS-18`). The SDK's events + are the one HTTP log stream; add the factory's back on `builder.HttpClient` if you want them; +- **an infinite handler lifetime** with `Transport:PooledConnectionLifetime` (default 2 minutes): the transport is a singleton holding one chain for the process, so the factory's rotation could never reach it, and the pooled-connection + lifetime does rotation's job (DNS) inside the one handler; +- **a container-owned lease** releases the primary the package created when the container is disposed: the factory never releases a handler whose lifetime is infinite, so without the lease the pooled connections outlive the container + (`transport-15.owned-released` failed until it existed). The caller's `DelegatingHandler`s are the factory's and are never touched (`XCUT-22`); +- `builder.HttpClient.ConfigureHttpClient(...)` has no effect: the package uses the handler and never `CreateClient`. `BaseAddress` is `DexpaceClientOptions.BaseAddress`. + +`ILoggerFactory` supplies the categories `Dexpace.Sdk` (pipeline) and `Dexpace.Sdk.Http.SystemNet` (transport); `TimeProvider` is `GetService() ?? TimeProvider.System`. + +## Proxy (`CFG-28`, `CFG-11`; P9-15) + +At most one source per client. Either the **explicit keys** `Transport:Proxy:*` (`Host` and `Port` are required when any is present), or **code**: + +```csharp +builder.UseProxyFromEnvironment(); // HTTPS_PROXY, HTTP_PROXY, NO_PROXY from the process environment +builder.UseProxyFromEnvironment(key => configuration[key]); // the same names, served from any configuration provider +``` + +Both are resolved when the transport is first resolved, never at registration. **No configuration value switches the environment read on**: a value that makes the package read the environment is the implicit read `CFG-28` forbids by another +route, and an IL scan test guards it. Two sources (including `ConfigureTransport` setting `Proxy`) fail validation. + +## Authentication and the codec (P9-17, P9-18) + +- **`UseSerde` is required.** The package has no default codec and does not reference one. With the System.Text.Json adapter: + `UseSerde(_ => new SystemTextJsonSerde(SystemTextJsonSerde.CreateDefaultOptions(MyContext.Default)))` (the composition the 7a hand-off documents; a test pins it). +- `Auth:*` binds the redacting credentials. `UseAuth(descriptor)` installs a `MultiSchemeAuthPolicy` over the descriptor a generated SDK owns and those credentials; `UseAuthPolicy(sp => policy)` installs any other + (a `ChallengeAuthPolicy`, your own); the two cannot be combined. `ConfigureCredentials((sp, credentials) => credentials with { Token = ... })` adds what configuration cannot hold. Credentials with neither + fail validation: credentials that are silently not sent are the misconfiguration nobody sees. No failure message, `ToString` or log line contains a configured secret. + +## One retry layer (`XCUT-10`, design §6.1, §11 item 24; P9-19; V6) + +The package registers no handler besides the primary. To add the standard resilience handler without a second retry engine: + +```csharp +services.AddDexpaceClient("github", section) + .HttpClient.AddStandardResilienceHandler(o => o.Retry.ShouldHandle = _ => ValueTask.FromResult(false)); +``` + +This keeps the rate limiter, the total and attempt timeouts and the circuit breaker and removes the handler's retry; the SDK's retry is the only one, so a request reaches the server `MaxRetryAttempts + 1` times at most +for a replayable body and once for a single-use one. Two spellings that look right are not: + +- the **handler-side** `o.Retry.MaxRetryAttempts = 0` is refused by the handler's own options validation ("must be between 1 and 2147483647"); +- the **SDK-side** `Retry:MaxRetryAttempts = 0` (the older guidance) leaves the handler's default retry in place, and that retry re-sends a `POST` body behind the SDK's re-send gate: measured, a `POST` answered `503` reached the + server four times under the handler's defaults. That option is withdrawn. + +`ResilienceCompositionTests` pins all three. + +## Single registration (`SEAM-5`, `SEAM-6`, `SEAM-7`; P9-16; V4) + +`UseTransport` and `UseSerde` take an instance (the container never disposes an instance it did not create, `XCUT-22`) or a factory (the container disposes what it returns), and replace the package's default. The same instance or +delegate again is a no-op; a different one **throws at the call**, naming the client and both implementations. At start the validator scans the collection for keyed `IAsyncHttpClient` and `ISerde` descriptors under the client's key, +because `GetRequiredKeyedService` silently resolves the last of several: more than one fails listing each implementation, and none fails with the install hint +`client 'github' has no ISerde: call UseSerde(...) on AddDexpaceClient("github")`. Every keyed registration is a singleton created once, whatever the concurrency (`SEAM-7`). + +## What ships and what does not (P9-20) + +The `CallKey` stamp (a carrier on `RequestOptions`, a stamp from the transport and a reader below it) is **not built**: phase 9 registers no `DelegatingHandler` that reads a call's context, so it has no reader. It is a post-release trigger in +[`first-release.md`](../first-release.md). The package carries one ordinary overhead the page does not hide: the handler construction is always re-send-capable, so a single-use body is teed into the bounded capture only when it is actually re-sent. diff --git a/docs/sdk-documentation/retry.md b/docs/sdk-documentation/retry.md index 5e71847..0e541d8 100644 --- a/docs/sdk-documentation/retry.md +++ b/docs/sdk-documentation/retry.md @@ -40,6 +40,11 @@ There is **one retry layer per call path** (`TRANSPORT-2`, design §11 item 24): itself (for example `AddStandardResilienceHandler` on an `HttpClient`), turn its retries off or set `MaxRetryAttempts = 0` here. The DI package's guidance (phase 9) says the same. +*Dated correction, 2026-10-13 (phase 9, `XCUT-10`, P9-19):* the "or set `MaxRetryAttempts = 0` here" half is withdrawn. A handler that retries by itself keeps retrying behind the SDK's re-send gate, and measured against a +server answering `503`, a `POST` with a body reached it four times under the standard resilience handler's defaults. The composition is to turn the handler's retry off, which for +`AddStandardResilienceHandler` is `o.Retry.ShouldHandle = _ => ValueTask.FromResult(false)` (the handler-side `o.Retry.MaxRetryAttempts = 0` is refused by the handler's own options validation). See +[`dependency-injection.md`](./dependency-injection.md). + The reference transport's own `SocketsHttpHandler` is the one native retry the SDK cannot turn off yet: it exposes no switch, and it re-sends a request whose connection ends before any response byte (the server closed it; a reset is not re-sent) up to three more times, so a request that carries no content (a `GET`, a `DELETE`, a body-less `POST`) can reach the server more than once for a single SDK attempt. A request with a single-use body is never diff --git a/docs/sdk-documentation/transport.md b/docs/sdk-documentation/transport.md index 21027eb..d15e366 100644 --- a/docs/sdk-documentation/transport.md +++ b/docs/sdk-documentation/transport.md @@ -250,3 +250,11 @@ content there would be wire-identical; it is left out deliberately (`TRANSPORT-2 A body-less `POST`, `PUT`, `PATCH` or extension-method request now carries a zero-length content (the same bytes on the wire) and is no longer re-sent by the native client; that is a fix of `TRANSPORT-2`, not a break. + +## Factory-built chains (`TRANSPORT-1`, `-15`, `-30`; phase 9, P9-12) + +*Added 2026-10-13 (phase 9).* `SystemNetHttpClient.CreatePrimaryHandler(SystemNetHttpClientOptions)` returns the `SocketsHttpHandler` the owned construction builds: `AllowAutoRedirect = false`, `UseProxy` only when `Proxy` is set (through the +internal adapter over `ProxyOptions`), server `Credentials` unset, and the `TRANSPORT-30` warnings through `options.Logger`. The owned construction is built on it, so the invariants have one source. The caller owns the returned handler: pass it as the +primary of a chain given to `new SystemNetHttpClient(chain, options)` with `options.Proxy` left `null` (that constructor refuses a proxy: a handler you own keeps its own). The DI package builds each named client's primary this way and +composes it with `IHttpClientFactory`'s chain; see [`dependency-injection.md`](./dependency-injection.md). The handler constructor's redirect check walks the chain to the primary, so a factory chain whose primary was left at its default +(`AllowAutoRedirect = true`) is refused. diff --git a/docs/work/mvp/2026-09-27-dotnet-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-27-dotnet-sdk-v1-roadmap-design.md index eb37647..2a287ca 100644 --- a/docs/work/mvp/2026-09-27-dotnet-sdk-v1-roadmap-design.md +++ b/docs/work/mvp/2026-09-27-dotnet-sdk-v1-roadmap-design.md @@ -270,7 +270,7 @@ ID in the clause is stated in that chapter. | 6 | Retry, Redirect and Authentication Completion (6a retry · 6b redirect · 6c auth) | `Dexpace.Sdk.Core` | ch.09 — `RETRY-1`–`RETRY-45` (45); ch.10 — `REDIR-1`–`REDIR-28` (28); ch.11 — `AUTH-1`–`AUTH-38` (38); plus the work for the recovery-stack IDs `RECOV-17`–`RECOV-30` and `RECOV-34`, whose rows stay in phase 4 as ⏳ | §6.1–§6.3, §8.3; [6a design](phase6/phase6a/2026-10-08-phase6a-retry-design.md), [6a plan](phase6/phase6a/2026-10-08-phase6a-retry.md), [6b design](phase6/phase6b/2026-10-08-phase6b-redirect-design.md), [6b plan](phase6/phase6b/2026-10-08-phase6b-redirect.md), [6c design](phase6/phase6c/2026-10-08-phase6c-auth-design.md), [6c plan](phase6/phase6c/2026-10-08-phase6c-auth.md), [6a checklist](phase6/phase6a/2026-10-08-phase6a-retry-checklist.md), [6b checklist](phase6/phase6b/2026-10-08-phase6b-redirect-checklist.md), [6c checklist](phase6/phase6c/2026-10-08-phase6c-auth-checklist.md) | | 7 | Serde, SSE and Pagination (7a serde · 7b SSE · 7c pagination) | `Dexpace.Sdk.Core`, `Dexpace.Sdk.Serialization.SystemTextJson` | ch.14 — `SERDE-1`–`SERDE-30` (30); ch.13 — `SSE-1`–`SSE-41` (41); ch.12 — `PAGE-1`–`PAGE-36` (36) | §3.4, §7.1–§7.3; [7a design](phase7/phase7a/2026-10-09-phase7a-serde-design.md), [7a plan](phase7/phase7a/2026-10-09-phase7a-serde.md), [7b design](phase7/phase7b/2026-10-09-phase7b-sse-design.md), [7b plan](phase7/phase7b/2026-10-09-phase7b-sse.md), [7c design](phase7/phase7c/2026-10-09-phase7c-pagination-design.md), [7c plan](phase7/phase7c/2026-10-09-phase7c-pagination.md), [7c checklist](phase7/phase7c/2026-10-09-phase7c-pagination-checklist.md) | | 8 | Transports and the Conformance Kit (8a kit · 8b reference-transport hardening) | the new `Dexpace.Sdk.Conformance` package (phase 8 owns its csproj, version and first release), `Dexpace.Sdk.Http.SystemNet`, `tests/Dexpace.Sdk.Http.SystemNet.Tests` | ch.17 — `TRANSPORT-1`–`TRANSPORT-30` (30); ch.18 — `ASYNC-1`–`ASYNC-22` (22) | §2.1, §2.2, §3.2, §3.3, §3.7, §9.3; [8a design](phase8/phase8a/2026-10-09-phase8a-conformance-kit-design.md), [8a plan](phase8/phase8a/2026-10-09-phase8a-conformance-kit.md), [8b design](phase8/phase8b/2026-10-09-phase8b-reference-transport-design.md), [8b plan](phase8/phase8b/2026-10-09-phase8b-reference-transport.md), [8a checklist](phase8/phase8a/2026-10-09-phase8a-conformance-kit-checklist.md), [8b checklist](phase8/phase8b/2026-10-09-phase8b-reference-transport-checklist.md) | -| 9 | Host Integration | the new `Dexpace.Sdk.Extensions.DependencyInjection` package, plus its test project | ch.16 — `CFG-1`–`CFG-7`, `CFG-10`, `CFG-11`, `CFG-14`, `CFG-37`, `CFG-38`; `SEAM-5` and `SEAM-6`'s single-registration check (rows carried from 2b); `TRANSPORT-1` for named clients; `XCUT-10`'s one-retry-layer rule | §2.1, §3.6, §8.2; [design](phase9/2026-10-12-phase9-host-integration-design.md), [plan](phase9/2026-10-12-phase9-host-integration.md) | +| 9 | Host Integration | the new `Dexpace.Sdk.Extensions.DependencyInjection` package, plus its test project | ch.16 — `CFG-1`–`CFG-7`, `CFG-10`, `CFG-11`, `CFG-14`, `CFG-37`, `CFG-38`; `SEAM-5` and `SEAM-6`'s single-registration check (rows carried from 2b); `TRANSPORT-1` for named clients; `XCUT-10`'s one-retry-layer rule | §2.1, §3.6, §8.2; [design](phase9/2026-10-12-phase9-host-integration-design.md), [plan](phase9/2026-10-12-phase9-host-integration.md), [checklist](phase9/2026-10-12-phase9-host-integration-checklist.md) | | 10 | Cross-Cutting Invariants and Conformance | every package, audited; `Dexpace.Sdk.Conformance` gains the invariant, packaging and codec suites, but phase 10 does not own that package's csproj or its release | ch.19 — `XCUT-1`–`XCUT-24` (24); ch.20 — `NFR-1`–`NFR-17` (17); appendix B (61 items) | §9, §9.2, §9.3, §11 | | 11 | Deviation Reconciliation | every package; the audit leads, and the phase ships code wherever the audit finds a defect | appendix C — every ID named by a design §10 entry; the §11 resolutions; the §12 coverage index | §10, §11, §12 | | 12 | First Release | every package; `.github/workflows/release.yml`; `docs/sdk-documentation/` | `NFR-4`'s baseline, and the release halves of `NFR-12` and `NFR-16` | §2.3, §9.1, §9.2 | @@ -977,6 +977,11 @@ The package's reliance on the `Microsoft.Extensions` family is recorded under th `ruby-sdk/gems/dexpace-core/test/dexpace/{configuration,config_test.rb}` and `nodejs-sdk/packages/core/src/config/`. Their precedence assertions are re-expressed against `IConfiguration` providers. +- *Status, 2026-10-13: phase 9 is built (issue #96, branch `96-phase-9-host-integration`); see the Phase Status Notes.* +- *Dated correction, 2026-10-13 (P9-23): the scope's "`IConfiguration` binding under section `Dexpace`" is built as a section the caller passes to `AddDexpaceClient`; `DexpaceConfigurationKeys.SectionName` (`"Dexpace"`) is published and never read, because `OBS-35` forbids a baked key.* +- *Dated correction, 2026-10-13 (P9-4, phase 9 V1): the scope's "the TimeSpan converter (ISO-8601, ``, bare ms) and the base-10 integer converter" is a hand-written reader over `IConfiguration`, not converters registered with the binder: the binder's generator binds no `init` accessor and its reflection mode is not AOT-safe; `ConfigurationBinder` is banned in `src/`.* +- *Dated correction, 2026-10-13 (P9-19, V6): the "without its retry" guidance stands. The 6a status note's and issue #94's shorthand "`MaxRetryAttempts = 0`" is clarified: the SDK-side option is withdrawn (the handler would re-send a `POST` body behind the SDK's gate) and the handler-side option is refused by the handler's own validation; the composition is `o.Retry.ShouldHandle = _ => ValueTask.FromResult(false)`.* + ### Phase 10 — Cross-Cutting Invariants and Conformance **Scope.** @@ -1240,7 +1245,7 @@ weakening them (constraint 5): | 6b | S3: the redirect rewrite (the `REDIR-15` error, `REDIR-16`, `REDIR-17`, removing `StripSensitiveHeadersOnCrossOrigin`) | `RedirectCredentialHygieneTests`, `RedirectWireTests` | | 6c | S3/S4: stamping against the seed origin, `AUTH-29`; S5's `XCUT-19`(d) | `AuthHttpsGuardTests`, `RedirectWireTests`, `UrlRedactionDefaultDenyTests` | | 8b | S1, S2, S9: header mapping (`TRANSPORT-10`, `TRANSPORT-13`'s once-per-name latch, `TRANSPORT-14`); the handler-taking constructor that refuses a redirect-following handler; inbound adaptation on the real synchronous path | `HeaderInjectionWireTests`, `FramingHeaderDropWireTests`, `RedirectWireTests`, `MalformedContentTypeWireTests` | -| 9 | S3: the DI package configures each named client's primary handler with `AllowAutoRedirect = false` | `RedirectWireTests` | +| 9 | S3: the DI package configures each named client's primary handler with `AllowAutoRedirect = false` | `RedirectWireTests` (row 9 closed 2026-10-13: `NamedClientRedirectWireTests`, phase 9) | Nothing goes to phase 11's inbound list or to `docs/first-release.md`. @@ -1795,3 +1800,18 @@ waivers (`transport-14.name-malformed-dropped`, design §10 entry 32; `transport - **Dated corrections** filed: design §3.2, §3.3, §3.7, §8.2, §9.1, §10 (entries 32 and 33), §11 item 18, §12; `docs/first-release.md` (*Unsatisfied MUSTs*, *SHOULD declined*, *Behavioural asymmetries*); the phase 1, 2b, 4c, 5a and 5b checklists; ten user pages; `CLAUDE.md`; this roadmap's Phase 8 card and Phase List row. - **Hand-offs.** 9: the handler constructor is the named clients' primary-handler seam, `SystemNetHttpClientOptions` is the binding target (`Proxy` through the one explicit `FromEnvironment` call), the third conformance driver runs over it, and the `CallKey` stamp lands with its first reader. 10: audit `XCUT-2`, `XCUT-18`, `XCUT-22` against the checklist. 11: entries 32 and 33 take the count of MUST clauses unmet on a stated domain (§10 entries 16, 18, 24, 26, 31, 32 and 33) to **seven against the parity target of at most five**; the premise of the P8b-14 and P8b-T2 rulings ("one of the five") was false, and the over-budget of two is an open item for the lead and for the audit. 12: release notes carry breaking changes 1 (no implicit proxy), 2 (genuine sync), 3 for a borrowed client (the 100 s default deadline) and 4 (internal cancel is terminal) prominently. The lead: the `TRACE` question above, the parity over-budget (seven against five), and the Core flake. + +**2026-10-13 — Phase 9 exit (host integration).** Phase 9 (no sub-phases, P9-1) closes on branch `96-phase-9-host-integration` (issue #96): the **15 rows it owns** (13 MUST, 2 SHOULD), **13 ✅ (5 split: `CFG-2`, `CFG-5`, `CFG-6`, `CFG-7`, `OBS-35`) / 2 N/A (`CFG-4`, `CFG-10`) / 0 🚫 / 0 ⏳**, with `SEAM-5` and `SEAM-6` ✅ for their DI half. The new package +`Dexpace.Sdk.Extensions.DependencyInjection` and one addition to the reference transport, `SystemNetHttpClient.CreatePrimaryHandler`. Evidence added to `SEAM-7` (N/A stays, `KeyedSingletonTests`), `TRANSPORT-1` (the named-client clause) and `XCUT-10` (the DI clause; the row stays ⏳ 10). Checklist: +[`phase9/…-checklist.md`](phase9/2026-10-12-phase9-host-integration-checklist.md); user page: `docs/sdk-documentation/dependency-injection.md`. + +- **Built.** `AddDexpaceClient(name[, IConfigurationSection])` and the sealed `DexpaceClientBuilder`; four keyed singletons per client; the hand-written configuration reader (grammars for `CFG-5` to `CFG-7`, enums by name, replacing lists, unknown keys, every failure at once and never quoting a value); one validator with `ValidateOnStart` + (and `IStartupValidator` with no host); the named client over `IHttpClientFactory` (redirect-off primary, `RemoveAllLoggers`, infinite handler lifetime with a pooled-connection lifetime, a container-owned primary-handler lease); explicit proxy sources; `Auth:*`; the single-registration check; `DexpaceConfigurationKeys`; the audit's policy record; the `ConfigurationBinder` ban; the third + conformance driver; `NamedClientRedirectWireTests`; `CheckPhase9Async` in the AOT smoke. +- **The reader replaces the binder.** V1 measured both modes on the installed packages: the generator binds no `init` accessor (silently), the reflection mode is 64 trim/AOT warnings and mis-binds `TimeSpan`, `int`, enums, two sets and a list default. V2 measured `XmlConvert`'s four divergences from `CFG-7`. +- **Rulings open for the lead, taken at their recommended defaults:** P9-1, P9-2, P9-4, P9-5, **P9-7** (an empty value is absent and silently shadows a lower provider), P9-9 (unknown keys fail), P9-10, P9-12, P9-15, P9-17, P9-19, P9-20, **P9-28** (a numeric accessor message may echo the number) and **P9-29** (the grammar readings). +- **Findings.** (1) The standard resilience handler's own validation refuses `Retry.MaxRetryAttempts = 0`, and under its defaults re-sends a `POST` body behind the SDK's gate (the guidance is `Retry.ShouldHandle` returning `false`). (2) The factory never releases a handler whose lifetime is infinite, so the kit's `transport-15.owned-released` failed until the lease existed. (3) `ProxyOptions.FromEnvironment` reads + `NO_PROXY` only once a proxy was found. (4) The `ValidateOnBuild` provider option does not run options validation. +- **Dated corrections** filed: design §2.1, §3.2, §3.6, §6.1, §8.2, §9.1 to §9.3, §10 entries 2, 9, 25 and 26, §11 items 8, 24, 30 and two new items, §12; `docs/first-release.md`; the phase 1, 2b, 5b, 8a and 8b checklists; `retry.md`, `transport.md`, `conformance.md`; `CLAUDE.md`; this roadmap's Phase 9 card, Phase List row and the phase-1 hand-off table's row 9. +- **Hand-offs.** 10: `XCUT-10` cites `NoRetryingHandlerTests` and `ResilienceCompositionTests`; `XCUT-22` cites `PrimaryHandlerLeaseTests`; `XCUT-23` cites `SingleRegistrationTests`; `NFR-2` cites the audit's policy record and §10 entry 2's measurement; `NFR-8`/`NFR-9` keep `CheckPhase9Async`. 11: §10 entries 25 and 26 gain the as-built facts; the auditor decides whether `CFG-2`'s + shadowing clause belongs in the unmet-MUST count; §11 item 30's reader is unbuilt (P9-20). 12: the package joins the lockstep release; the release notes name the `ShouldHandle` guidance and the no-reload rule; the `CallKey` trigger stays in `docs/first-release.md`. diff --git a/docs/work/mvp/phase1/2026-09-28-phase1-security-fixes-checklist.md b/docs/work/mvp/phase1/2026-09-28-phase1-security-fixes-checklist.md index 4875f2c..b5d8508 100644 --- a/docs/work/mvp/phase1/2026-09-28-phase1-security-fixes-checklist.md +++ b/docs/work/mvp/phase1/2026-09-28-phase1-security-fixes-checklist.md @@ -96,3 +96,8 @@ The 8b clauses of S1, S2, S3 and S9 are closed by the [8b checklist](../phase8/p `HeaderDropLogging` (event 110 for a request header, 112 for a response header; the unsafe-header test still sees one `Warning` for the first drop of a name) and the `Content-Type` question is settled (the caller's header wins). S2 and `TRANSPORT-11`: the framing drops are event 111, still `Debug` and still never a value. S3 and `TRANSPORT-1`: the handler-taking constructor rejects a primary handler that follows redirects. S9 and `TRANSPORT-22`/`-27`: the dispose-on-throw runs through `NativeDisposal` on both faces. The rows above stand as written. + +## Dated correction, 2026-10-13 (phase 9) + +S3's row 9 hand-off is **closed**: the DI package configures each named client's primary handler with `AllowAutoRedirect = false`, and `NamedClientRedirectWireTests` (`Security`, `tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Security/`) proves it at the wire over two loopback origins: the named client returns the 3xx and never contacts the target, a cross-origin hop through +the pipeline carries no credential, and a substituted redirect-following primary fails the resolution before either origin is contacted. The phase-1 `Security` classes are unedited. diff --git a/docs/work/mvp/phase2/phase2b/2026-10-02-phase2b-seams-checklist.md b/docs/work/mvp/phase2/phase2b/2026-10-02-phase2b-seams-checklist.md index 9360a0a..a5b7c6d 100644 --- a/docs/work/mvp/phase2/phase2b/2026-10-02-phase2b-seams-checklist.md +++ b/docs/work/mvp/phase2/phase2b/2026-10-02-phase2b-seams-checklist.md @@ -171,3 +171,8 @@ builds the real synchronous send, the `ObjectDisposedException` latch and `Reque The ⏳ 8b clauses are closed by the [8b checklist](../../phase8/phase8b/2026-10-09-phase8b-reference-transport-checklist.md). `SEAM-11`: `SystemNetHttpClient.Execute` is genuinely synchronous (`HttpClient.Send`, the body written through `RequestBody.WriteTo`) and `RequestOptions.Timeout` is enforced (`SyncExecuteTests`, `SyncExecuteWireTests`, `DeadlineTests`; kit `transport-4`, `-5`, `-6`, `-17`, `-25`). `SEAM-15`: every construction throws `ObjectDisposedException` after dispose, a borrowed client included, the asynchronous face through a faulted task (`AfterDisposeTests`; kit `seam-15.after-dispose`, waiver deleted). Both rows are ✅. The rows above stand as written. + +## Dated correction, 2026-10-13 (phase 9) + +`SEAM-5` and `SEAM-6`: the DI half is **✅**, built by `Dexpace.Sdk.Extensions.DependencyInjection` (P9-16): an explicit `UseTransport`/`UseSerde` replaces the package's default and wins; a different second install throws `InvalidOperationException` at the call, naming the client and both implementations (earlier than design §3.6's validator timing); keyed +duplicates fail `ValidateOnStart` listing every implementation; no codec fails with the install hint (`SingleRegistrationTests`, `Unit`). The DI-less half stays N/A. `SEAM-7` stays **N/A**, citing the singleton lifetime: `KeyedSingletonTests` (`Unit`) proves one construction under 64 concurrent first resolutions. diff --git a/docs/work/mvp/phase5/phase5b/2026-10-07-phase5b-logging-checklist.md b/docs/work/mvp/phase5/phase5b/2026-10-07-phase5b-logging-checklist.md index c6d0388..6260318 100644 --- a/docs/work/mvp/phase5/phase5b/2026-10-07-phase5b-logging-checklist.md +++ b/docs/work/mvp/phase5/phase5b/2026-10-07-phase5b-logging-checklist.md @@ -183,3 +183,8 @@ The phase-start queries (`--origin note`, `--section conflicts`, `--prefix-info `OBS-19`'s ⏳ 8b is closed: the transport's drop-log policy is `SystemNetHttpClientOptions.HeaderDropLogging` (`FirstPerName`, `Every`, `VerboseOnly`; `HeaderDropLogTests`, kit `transport-13.drop-log-once-per-name`), and the reserved block 110-119 is filled to 113 with `http.transport.header_dropped` (110), `framing_header_dropped` (111), `inbound_header_dropped` (112) and `proxy_feature_unsupported` (113), constants in `DexpaceLogEvents` with their `PublicAPI.Unshipped.txt` lines (`LogVocabularyTests.Id_ranges_are_inside_the_reserved_blocks` now reserves 114-119). The row is ✅ and the totals above gain one. The row above stands as written. + +## Dated correction, 2026-10-13 (phase 9) + +`OBS-35` is **✅** (a split, §10 entry 26 now touching it): `Logging:Level` is read by name only, case-insensitive and trimmed, from the section the caller passes (no baked key); a number or an unknown name fails the boot naming the key and listing the names (`LogLevelBindingTests`, `Unit`). **V4 was run** (phase 9 V1): both binder modes admit a numeric level and an +undefined one, so the binder alone cannot satisfy the row; the DI package's hand-written reader does. diff --git a/docs/work/mvp/phase8/phase8a/2026-10-09-phase8a-conformance-kit-checklist.md b/docs/work/mvp/phase8/phase8a/2026-10-09-phase8a-conformance-kit-checklist.md index 64cd31c..0448419 100644 --- a/docs/work/mvp/phase8/phase8a/2026-10-09-phase8a-conformance-kit-checklist.md +++ b/docs/work/mvp/phase8/phase8a/2026-10-09-phase8a-conformance-kit-checklist.md @@ -205,3 +205,8 @@ hook (`transport-8`, `-15.borrowed`, `-18`, `-22`, `-30`) and two `Vacuous` (`se - **9:** a third driver over the transport `AddDexpaceClient` builds from `IHttpClientFactory`; `transport-1.redirect-not-followed` is the proof its named client has redirects off. - **10:** the invariant, packaging and codec suites in the same package (the 7a `SERDE` lift and 7c's `link-header.json` and splice properties go there); `APPENDIX_B.md` over all 61 items, extending the B.6/B.7 view; `RequirementCatalog` reused for the row-count gate. - **12:** publish the kit at the lockstep version; `conformance.md` joins the user documentation; the post-release trigger "first external adapter author" (a non-xUnit runner) stays open. + +## Dated correction, 2026-10-13 (phase 9) + +The third driver is built: `tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/` runs the kit over the transport `AddDexpaceClient` builds from `IHttpClientFactory`, green on both faces with the two permanent assertion-scoped waivers of the SystemNet drivers; `transport-1.redirect-not-followed` passes on both faces, and `transport-15.owned-released` is what the +container-owned primary-handler lease earns (it failed on the driver's first build). `transport-15.borrowed-survives` is `NotExercised` there: the package never borrows a client. diff --git a/docs/work/mvp/phase8/phase8b/2026-10-09-phase8b-reference-transport-checklist.md b/docs/work/mvp/phase8/phase8b/2026-10-09-phase8b-reference-transport-checklist.md index 9a5e1ea..5138b45 100644 --- a/docs/work/mvp/phase8/phase8b/2026-10-09-phase8b-reference-transport-checklist.md +++ b/docs/work/mvp/phase8/phase8b/2026-10-09-phase8b-reference-transport-checklist.md @@ -150,3 +150,8 @@ and the roadmap's Phase Status Notes and Phase List row. - **10:** audits `XCUT-2` (the classification table), `XCUT-18` (the wire re-check and drop logs) and `XCUT-22` against this evidence. - **11:** `TRANSPORT-14`'s name clause (entry 32) and `TRANSPORT-2`'s residual (entry 33) are the sixth and seventh clauses unmet on a stated domain, **seven against the parity target of at most five** (Open items, 1): the audit must retire clauses, re-rule the target or argue the excess. The lead's decision on `TRACE` (Open items, 2) can shrink entry 33 but not remove it. - **12:** release notes carry breaking changes 1, 2, 3 (for a borrowed client: the 100 s default deadline) and 4 prominently; `sync-borrowed-handlers`, `explicit-proxy` and the `TRANSPORT-28` declined SHOULD are in `docs/first-release.md`. + +## Dated correction, 2026-10-13 (phase 9) + +`TRANSPORT-1` stays **✅**, with the named-client clause added: the DI package's primary handler is `SystemNetHttpClient.CreatePrimaryHandler` (`AllowAutoRedirect = false`) and the handler constructor's check refuses a caller-substituted redirect-following primary at first resolution (`NamedClientTests`, `NamedClientRedirectWireTests` (`Security`), +`transport-1.redirect-not-followed` on the third driver). **P8b-18's disposition:** the `CallKey` stamp is **not built** in phase 9 either (P9-20): phase 9 registers no `DelegatingHandler` and reads no call context below the transport, so there is still no reader; it is a post-release trigger in `docs/first-release.md`. diff --git a/docs/work/mvp/phase9/2026-10-12-phase9-host-integration-checklist.md b/docs/work/mvp/phase9/2026-10-12-phase9-host-integration-checklist.md new file mode 100644 index 0000000..ae8c4e5 --- /dev/null +++ b/docs/work/mvp/phase9/2026-10-12-phase9-host-integration-checklist.md @@ -0,0 +1,102 @@ +# Phase 9 — Host Integration: Checklist + +The execution-time checklist for roadmap phase 9 ([phase card](../2026-09-27-dotnet-sdk-v1-roadmap-design.md)): one row per requirement ID, in the legend of the roadmap's cross-cutting constraint 3. Written from what was built on branch +`96-phase-9-host-integration` (issue #96, planning #94), following the [design](2026-10-12-phase9-host-integration-design.md) and the [plan](2026-10-12-phase9-host-integration.md). User page: +[`dependency-injection.md`](../../../sdk-documentation/dependency-injection.md). + +The scope is the **15 rows phase 9 owns** (`CFG-1`, `-2`, `-3`, `-4`, `-5`, `-6`, `-7`, `-10`, `-11`, `-14`, `-37`, `-38`, `SEAM-5`, `SEAM-6`, `OBS-35`; 13 MUST and 2 SHOULD by appendix C): **13 ✅ / 2 N/A / 0 🚫 / 0 ⏳**. Five of the 13 ✅ are split with a 🚫 clause on an existing §10 entry +(`CFG-2` on entry 25; `CFG-5`, `CFG-6`, `CFG-7` and `OBS-35` on entry 26); `SEAM-5` and `SEAM-6` are ✅ for their DI half, their DI-less half staying N/A (2b). Evidence is added to three rows other phases own (`SEAM-7`, `TRANSPORT-1`, `XCUT-10`) and the design's non-row hand-offs are +dispositioned in the [second table](#rows-owned-elsewhere-and-non-row-hand-offs). The plan's four stacked pull requests were built as one working tree on the one branch, in the plan's order (seams and gates; the package and the reader; registration; drivers and close-out), and left uncommitted for the lead. The kit is **green against the +DI-built transport on both faces, the two SystemNet constructions and the raw-socket driver**, with exactly the two permanent assertion-scoped waivers per SystemNet-family driver and no `Owner =` waiver anywhere. + +**Red evidence.** Taken where it was informative, and said as it was: most tests were written in the same step as the production code they pin, so the compile-red of a new type or member is the red for those rows (the DI test project was first built against types that did not exist for 2.1 to 2.9 and 3.1). What was measured: mutations +performed and reverted (never committed), each failing the pins it targets: `AllowAutoRedirect = true` in `CreatePrimaryHandler` (`NamedClientRedirectWireTests` and `NamedClientTests`: the factory chain refuses the primary at resolution, so the 3xx test fails at resolution rather than on the wire; the wire form is still +the structural proof that B is never contacted), and the `lease.Track(handler)` call removed (`PrimaryHandlerLeaseTests.Disposing_the_container_releases_the_pooled_connection`, which waits for the pooled connection and times out). The `ConfigurationBinder` ban: a scratch `GetValue` in the package gave `IL2026` +and no `RS0030` before the entry, and `RS0030` after it (the diagnostic diff, F6). The audit's controls: with the `Projects` entry emptied it names `references project Dexpace.Sdk.Http.SystemNet`; with the `ThirdParty` entry set to `null` it names the closure packages it no longer allows; with the real record it prints `5 libraries conform`; the +nuspec lockstep control was not separately run. `KeyTableTests.A_new_member_would_fail_the_guard`, `NoImplicitEnvironmentReadArchitectureTests.The_scan_would_catch_a_configuration_switch` and `The_scanner_finds_a_planted_ldstr` prove their guards able to fail with planted members. Not taken: the `ResilienceCompositionTests` negative +(its default-arrangement row is the regression it pins). + +| Mark | Meaning | +|---|---| +| ✅ | Implemented and tested. The row names the test, the `[Trait]` category and the file. | +| 🚫 | Not built: a permanent simplification or an unmet clause on a stated domain, with the reason and the design §10 entry. | +| ⏳ | Deferred or declined. The row names the plan task that will do it, or the owner. | +| N/A | Not applicable in this port. | + +## Requirement rows + +Tests are in `tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/` unless a row says otherwise. + +| ID | Level | Mark | Evidence | +|---|---|---|---| +| `CFG-1` | MUST | ✅ | Tier 4 is the record's initializer, tiers 2 and 3 the caller's `IConfiguration` providers, tier 1 the `Configure` / `ConfigureTransport` / `ConfigureCredentials` transforms applied last in call order (§10 entry 25, P9-6). `ConfigurationPrecedenceTests` (`Unit`, `ConfigurationPrecedenceTests.cs`): three in-memory providers, removing each from the top resolves the next; two transforms resolve the second; a transform overrides a configuration value; one returning `null` or throwing fails naming the client. | +| `CFG-2` | MUST | ✅ split | ✅ an empty or whitespace-only value is absent (the record default applies, never `""`); 🚫 the fall-through to a lower tier (§10 entry 25, P9-7: `IConfiguration` merges providers before the reader sees a key, so the shadowed lower value is not consulted). `EmptyValueTests` (`Unit`, `EmptyValueTests.cs`): an empty or blank value over a lower provider's value resolves the record default, for a string, an integer, a duration and a list key. Open for the lead (A: as built, a silent shadow; B: an empty value fails start). | +| `CFG-3` | MUST | ✅ | .NET's `__` to `:` rule, case-insensitive. `EnvironmentNormalizationTests` (`Integration`, `EnvironmentNormalizationTests.cs`): a process variable under a per-test GUID prefix through `AddEnvironmentVariables(prefix)` resolves `Retry:MaxRetryAttempts` through the reader; removed in `finally`. | +| `CFG-4` | MUST | N/A | Vacuous: nothing normalises casing away, so there is no raw accessor. `EnvironmentNormalizationTests.A_camel_case_and_an_upper_case_spelling_resolve_alike` pins that two spellings resolve alike. | +| `CFG-5` | MUST | ✅ split | ✅ base-10 `[+-]?[0-9]+` in ASCII, in the target's range, a negative parsed and handed to the record; 🚫 the never-throw letter (§10 entry 26): a bad integer fails the boot naming the key. `IntegerGrammarTests` (`Unit`, `IntegerGrammarTests.cs`; ports `configuration_test.rb`, `1_000` rejected where Ruby tolerates it, P9-26), `RecordBindingTests` (`Unit`). | +| `CFG-6` | MUST | ✅ split | ✅ only case-insensitive `true`/`false`, after an ASCII trim; 🚫 the fall-back to a default (§10 entry 26). `BooleanGrammarTests` (`Unit`, `BooleanGrammarTests.cs`): the eight tokens of the Ruby table fail; `" false "` reads as `false` (P9-27, §11 item 67). | +| `CFG-7` | MUST | ✅ split | ✅ the grammar, read as the day-time ISO subset with digits-only bare numbers (P9-29, §11 item 67); 🚫 return-the-default on rejection (§10 entry 26). `DurationGrammarTests` (`Unit`, `DurationGrammarTests.cs`): the sibling tables, the four `XmlConvert` divergences (case accepted; a sign, calendar units and sub-tick fractions rejected), the three-grammars-one-scale property over 0 to 100 000 s, no throw for 2 000 arbitrary strings, no input quoted. | +| `CFG-10` | MUST | N/A | Vacuous: a transform is code, and a record has no removal. `ConfigurationPrecedenceTests.Precedence_has_no_removal_operation` (`Unit`): the registration exposes no member named `Remove*` or `Clear*`. | +| `CFG-11` | MUST | ✅ | The seams are `IConfiguration` providers (in-memory in every test but `EnvironmentNormalizationTests`, which proves the production default) and `UseProxyFromEnvironment(Func)`. `ConfigurationSeamTests` (`Unit`, `ConfigurationSeamTests.cs`): a recording lookup observes exactly the proxy variables the resolver reads and no other; `EnvironmentNormalizationTests` (`Integration`). | +| `CFG-14` | SHOULD | ✅ | `DexpaceConfigurationKeys`: `SectionName`, `MaxRetryAttempts`, `LogLevel`, `HttpProxy`, `HttpsProxy`, `NoProxy`. `KeyConstantsTests` (`Unit`, `KeyConstantsTests.cs`): the values, the closed set of six, the proxy names equal what `ProxyOptions.FromEnvironment` reads, and an IL scan finds no `ldstr "Dexpace"` in the package (published, not read). | +| `CFG-37` | MUST | ✅ | `ArgumentNullException.ThrowIfNull` on every public parameter; `ArgumentException` for a blank name. `NullGuardTests` (`Unit`, `NullGuardTests.cs`): by reflection over the whole public surface of the extension class and the builder, every reference parameter of every method; it fails when a public parameter type has no example value. | +| `CFG-38` | MUST | ✅ | Every typed value reaches its parser through the one reader over the one `IConfiguration`. `TypedLookupTests` (`Unit`, `TypedLookupTests.cs`): an integer, a boolean and a duration supplied only by a lower provider resolve; the record default applies only when no provider has the key; `KeyTableTests` (`Unit`) is the drift guard over the seven records and the three credential classes. | +| `SEAM-5` | MUST | ✅ DI half | DI half: an explicit `UseTransport`/`UseSerde` wins and replaces the default; no codec fails with the install hint; two or more keyed descriptors fail listing every implementation. DI-less half N/A (2b). `SingleRegistrationTests` (`Unit`, `SingleRegistrationTests.cs`), `ProviderValidationTests` (`Unit`). | +| `SEAM-6` | MUST | ✅ DI half | DI half: the same instance or delegate twice is a no-op; a different one throws `InvalidOperationException` at the call naming the client and both implementations (P9-16: earlier than design §3.6's validator timing); one name registers once. `SingleRegistrationTests`, `AddDexpaceClientTests` (`Unit`), `AuthRegistrationTests` (the one auth authority). | +| `OBS-35` | SHOULD | ✅ split | ✅ layered, case-insensitive, trimmed level by name from the caller's section, no baked key; 🚫 the fall-back on an unrecognised value (§10 entry 26, which now touches `OBS-35`). `LogLevelBindingTests` (`Unit`, `LogLevelBindingTests.cs`): a number or an unknown name fails naming `Logging:Level` and listing `None, Headers, Body` (V1: both binder modes admit them); a section at another path binds the same; a section without the key leaves the default. | + +**Totals.** 15 rows, 13 MUST and 2 SHOULD. 13 ✅ (`CFG-1`, `-2`, `-3`, `-5`, `-6`, `-7`, `-11`, `-14`, `-37`, `-38`, `SEAM-5`, `SEAM-6`, `OBS-35`; of which 5 split: `CFG-2`, `CFG-5`, `CFG-6`, `CFG-7`, `OBS-35`), 2 N/A (`CFG-4`, `CFG-10`), 0 🚫, 0 ⏳: 13 + 2 = 15. No row adds to the *Unsatisfied MUSTs* count: the never-throw +clauses are §10 entry 26 and `CFG-2`'s fall-through is entry 25's judged mechanism. + +## Rows owned elsewhere and non-row hand-offs + +| ID / hand-off (owner) | Disposition | Evidence | +|---|---|---| +| `SEAM-7` (2b) | N/A stays; each client's transport, codec, pipeline and options are keyed singletons created once ("an unresolved state stays re-evaluable" has nothing to apply to) | `KeyedSingletonTests` (`Unit`): 64 concurrent first resolutions run each factory once | +| `TRANSPORT-1` (8b) | ✅; the named-client clause added | `PrimaryHandlerTests` (SystemNet suite, `Unit`), `NamedClientTests` (`Unit`), `NamedClientRedirectWireTests` (`Security`), `transport-1.redirect-not-followed` on the third driver (`Conformance`) | +| `XCUT-10` (10) | ⏳ 10; phase 9's clause ✅ | `NoRetryingHandlerTests` (`Unit`), `ResilienceCompositionTests` (`Integration`) | +| 5a: bind the records | taken by a hand-written reader (a third route: neither binder mode serves, V1) | the reader's tests (`RecordBindingTests`, the grammar tests) | +| 5a (P5a-4): `IValidateOptions` | the reader's failures plus the wiring checks in one validator; no cross-property failure (P9-10) | `ValidationTests` (`Unit`) | +| 5a (P5a-16), 8b (P8b-4): `FromEnvironment` | code-only `UseProxyFromEnvironment()` and `(lookup)`; no configuration switch (P9-15) | `ProxyRegistrationTests`, `NoImplicitEnvironmentReadArchitectureTests` (`Unit`) | +| 5b (P5b-16): V4 | answered by V1 (the binder admits a numeric and an undefined level) | `ScalarGrammarTests`, `LogLevelBindingTests` | +| 6a: `RetryableStatusCodes` | a list key of integers (V1: both binders drop it silently) | `RecordBindingTests` | +| 6a, #94, §6.1, §11 item 24: the resilience guidance | refined: the SDK-side `MaxRetryAttempts = 0` option withdrawn, the composition is `Retry.ShouldHandle` returning `false` (P9-19) | `ResilienceCompositionTests`, `dependency-injection.md`, `retry.md` correction | +| 6b: `AllowedMethods`, `Predicate` | a list key through `Method.Of`; `Predicate` code-only | `RecordBindingTests` | +| 6c: auth registration | `Auth:*`, `UseAuth`, `UseAuthPolicy`, `ConfigureCredentials`; no secret in any failure, `ToString` or log line | `AuthKeyTests`, `AuthRegistrationTests`, `CredentialRedactionTests` (`Unit`) | +| 7a: `ISerde` from `CreateDefaultOptions` | composition, not a reference (P9-17) | `SerdeRegistrationTests` (`Unit`) | +| 7c: nothing | confirmed | (design) | +| 8a: the third driver | built | `DependencyInjectionConformanceTests` (`Conformance`) | +| 8b: the handler constructor, `CreatePrimaryHandler` | built | `PrimaryHandlerTests` | +| 8b: `SystemNetHttpClientOptions` as the binding target | `Transport:*` keys; `Logger` and `TimeProvider` from the container | `TransportBindingTests` | +| 8b (P8b-18), 4a (P4a-13): `CallKey` stamp | not built (P9-20): no reader | `docs/first-release.md` trigger; §11 item 30 correction; phase 11 inbound | +| Phase 1 (S3) | row 9 closed | `NamedClientRedirectWireTests` (`Security`, `Security/NamedClientRedirectWireTests.cs`) | +| 2b: `SEAM-7`'s singleton lifetime | cited | `KeyedSingletonTests` | + +**`Security`.** One class is added (`NamedClientRedirectWireTests`) and none is edited: `git status` over `tests/Dexpace.Sdk.Core.Tests/` and `tests/Dexpace.Sdk.Http.SystemNet.Tests/Security/` is empty, and `Dexpace.Sdk.Core` and `Dexpace.Sdk.Serialization.SystemTextJson` (source and `PublicAPI.Unshipped.txt`) are untouched. + +## Styleguide audit groups + +`docs/styleguide/csharp-aspnetcore/01-host-and-configuration.md` and `02-dependency-injection.md`, audited for the package. **Conforming:** keyed services for several implementations of one role (2.6); no service locator in the package (factories resolve from the root provider); `ValidateOnStart` with an accessor choice stated +(`IOptionsMonitor.Get(name)` only, no change callback subscribed, 1.5); lifetimes are singletons; `ValidateScopes` and `ValidateOnBuild` are exercised by `ProviderValidationTests` (2.5); no `Environment.GetEnvironmentVariable` in the package. **Departures, recorded:** (1) 1.3 ("the Options pattern is the only sanctioned reader"): the package reads the section itself +(P9-4), a `Conflicts` note in `docs/knowledge/notes/configuration.md`; (2) 2.7 ("a feature extension returns the collection so calls chain"): `AddDexpaceClient` returns a `DexpaceClientBuilder` (the `IHttpClientBuilder` precedent; `builder.Services` chains back), same note. + +## Deviations from the plan and the design + +1. **One working tree, no commits.** The four stacked PRs, their branches and commits were not made (the instruction was to leave everything as working-tree changes); the task order was the plan's. +2. **No `TransportSnapshot.cs`.** `DexpaceClientState` holds a `SystemNetHttpClientOptions` (the logger and clock left at their defaults and replaced by the container) and the pooled-connection lifetime directly; the extra record carried nothing the two did not. +3. **Per-name registrations are plain `AddSingleton` factories**, not `TryAddEnumerable`: the setup and the validator are one instance per client name, constructed over that name's registration, so there is no implementation type to deduplicate on. +4. **`KeyTable` gained a `Groups` list and the reader a `Touched` set** (the plan had a `CodeOnly` list and `HandlerLevel` only): the nested records (`Retry`, `Redirect`, `Logging`, `Proxy`, the three credentials) are covered by their children's keys, and `KeyTableTests.The_reader_asks_for_exactly_the_keys_of_the_table` holds the reader to the table so a key cannot be read and not accepted. +5. **`BaseAddress` and every credential failure carry fixed rule text**, not the accessor's message: the record's own message includes the redacted URL (design C says `UrlRedactor`; the plan's test says the failure does not contain the URL, which wins), and an accessor message over a credential could echo it. +6. **`PrimaryHandlerLease.Track` after disposal disposes the late handler at once and keeps every tracked handler** (R7 said a second call disposed the earlier instance); the caller-replaced-primary case is observed through the lease's tracked list. +7. **The lease ordering test uses a tracked stand-in handler and a factory transport** rather than the package's own transport, because the package transport needs a live chain; the real ordering is covered by `Disposing_the_container_releases_the_pooled_connection`. +8. **`AddPolicy`'s stage test uses `PerCall` and `PerAttempt`**: `Diagnostics` is a pillar and refuses a second occupant. +9. **`UseProxyFromEnvironment` called twice keeps the last**, where the design was silent. +10. **R5's `NU1109` risk did not materialise**: the `Microsoft.Extensions.Http.Resilience` 10.10.0 restore succeeded with the transitive pins in place, and the handler accepts a 1 ms `Retry.Delay` (design F23's residual); the three props lines are `Microsoft.Extensions.Http`, `...Configuration.EnvironmentVariables` and `...Http.Resilience`. +11. **`Only_the_borrowed_assertion_is_not_exercised_and_the_driver_says_why`** was added to the third driver's tests; the kit reports `transport-15.borrowed-survives` as the one `NotExercised` assertion. +12. **The `ci.yml` header comment** names the third driver; no step changed. +13. The design's open rulings were taken at their recommended defaults, as the plan says: P9-1, 2, 4, 5, 7, 9, 10, 12, 15, 17, 19, 20, 28 and 29. + +## Gates run + +`dotnet restore Dexpace.Sdk.sln --locked-mode`; `dotnet build Dexpace.Sdk.sln -c Release` (0 warnings, 0 errors: `RS0016`/`RS0017` for the two changed `PublicAPI` files, `RS0030` with the new ban, `MA0051`, `CA2007`); `dotnet format --verify-no-changes`; `dotnet test --solution Dexpace.Sdk.sln -c Release` with coverage (6 857 tests, 0 failed, 22 skipped; the DI +suite is 387 tests (5 skipped: the waived and not-exercised kit rows), 97.5 % line coverage; the gate's aggregate is 97.6 % against 80 %); the coverage gate's self-test; `dotnet pack`, the dependency audit (`5 libraries conform`) and the reproducible-pack check; the NativeAOT smoke publish and run (no trim or AOT warning, `all checks passed`); the tools solution build and tests; `scripts/knowledge verify-structure`; the housekeeping probe. diff --git a/docs/work/mvp/phase9/2026-10-12-phase9-host-integration-design.md b/docs/work/mvp/phase9/2026-10-12-phase9-host-integration-design.md index 3704073..1c160ed 100644 --- a/docs/work/mvp/phase9/2026-10-12-phase9-host-integration-design.md +++ b/docs/work/mvp/phase9/2026-10-12-phase9-host-integration-design.md @@ -886,3 +886,14 @@ finding was checked against the source first. One line each; the plan follows th **Open rulings after validation** (taken at their recommended defaults): P9-1, P9-2, P9-4, P9-5, **P9-7 (now open)**, P9-9 (unknown keys), P9-10, P9-12, P9-15, P9-17, P9-19, P9-20, **P9-28 (new)** and **P9-29 (new)**. Row tally unchanged by the resolution: 15 owned rows, 13 ✅ (5 split), 2 N/A, 0 🚫, 0 ⏳. + +## Correction (2026-10-13): which keyed services fail on invalid configuration + +D and the "Zero candidates" bullet say the client fails the resolution of "each of its keyed services" (and "any of the client's keyed services"). That is true of +every service **the package builds** (the transport, the pipeline, the options, a codec created by a `UseSerde(Func<…>)` factory) and untrue of a caller-supplied +instance: `UseSerde(instance)` and `UseTransport(instance)` are returned as given and never consult the validated state, so resolving the keyed `ISerde` with bad +configuration succeeds. The code stays as built: wrapping a caller's instance in a factory would make the container dispose it, breaking `XCUT-22`. The start-up +failure (`ValidateOnStart`, `IStartupValidator.Validate()`) is unchanged and still reports every failure. `dependency-injection.md` carries the corrected wording. + +A second, same-day finding: a value set on a **group** key (`Transport:Proxy`, `Auth`, `Auth:ApiKey`, `Retry`) failed nothing and was dropped silently, against P9-9. It +now fails as `": a group of keys, not a value (…)"` with a fixed rule text that never quotes the value; an empty or blank value stays absent (P9-7). diff --git a/scripts/ci/dependency-audit.cs b/scripts/ci/dependency-audit.cs index fc181d3..08d1e1b 100644 --- a/scripts/ci/dependency-audit.cs +++ b/scripts/ci/dependency-audit.cs @@ -12,11 +12,14 @@ // - Dexpace.Sdk.Core may reference only the framework, Microsoft.Extensions.Logging.Abstractions (with the // closure its own nuspec requires) and build-only PrivateAssets="all" packages, which never reach either // file; -// - an adapter may reference the same, plus Dexpace.Sdk.Core, plus at most one third-party library; +// - an adapter may reference the same, plus Dexpace.Sdk.Core, plus at most one third-party library, plus the +// first-party Dexpace projects its policy names; +// "at most one third-party library" counts a root package whose closure is one publisher's lock-stepped family +// as one library (design §10 entry 2), and first-party Dexpace packages are not third-party; // - every Microsoft.Extensions.* / System.* package is band-matched to the target framework's major version; // - every package targets exactly the floor of roadmap decision D1 (net10.0). // A new library must be added to the policies table below, which is where a reviewer sees its one third-party library -// named. Exit 0 when every library conforms, 1 on any violation, 2 on a usage or input error. +// and its first-party projects named. Exit 0 when every library conforms, 1 on any violation, 2 on a usage or input error. #:property RestorePackagesWithLockFile=false #:property PublishAot=false @@ -34,14 +37,17 @@ const string TargetFramework = "net10.0"; const int FrameworkMajor = 10; -// The one place an adapter's third-party library is named. SystemTextJson takes none: System.Text.Json is in -// the shared framework, so the adapter's "one library" is the runtime's own (design §9.2). -var policies = new Dictionary(StringComparer.Ordinal) +// The one place an adapter's third-party library and first-party projects are named. SystemTextJson takes no third-party +// library: System.Text.Json is in the shared framework, so the adapter's "one library" is the runtime's own (design §9.2). +// The DI package's one third-party root is Microsoft.Extensions.Http, whose closure is the hosting family (design §10 +// entry 2, phase 9 P9-3), and it references the reference transport, a first-party project. +var policies = new Dictionary(StringComparer.Ordinal) { - [Core] = null, - ["Dexpace.Sdk.Http.SystemNet"] = null, - ["Dexpace.Sdk.Serialization.SystemTextJson"] = null, - ["Dexpace.Sdk.Conformance"] = null, + [Core] = new(null, []), + ["Dexpace.Sdk.Http.SystemNet"] = new(null, []), + ["Dexpace.Sdk.Serialization.SystemTextJson"] = new(null, []), + ["Dexpace.Sdk.Conformance"] = new(null, []), + ["Dexpace.Sdk.Extensions.DependencyInjection"] = new("Microsoft.Extensions.Http", ["Dexpace.Sdk.Http.SystemNet"]), }; if (args.Length != 2) @@ -57,7 +63,7 @@ foreach (var library in libraries) { - if (!policies.TryGetValue(library, out var thirdParty)) + if (!policies.TryGetValue(library, out var policy)) { violations.Add($"{library}: no dependency policy; add it to the policies table in scripts/ci/dependency-audit.cs"); continue; @@ -70,8 +76,8 @@ return 2; } - AuditDepsJson(library, thirdParty, depsJson, violations); - if (!AuditNuspec(library, thirdParty, packageDir, violations)) + AuditDepsJson(library, policy, depsJson, violations); + if (!AuditNuspec(library, policy, packageDir, violations)) { return 2; } @@ -87,7 +93,7 @@ : $"dependency-audit: {violations.Count} violation(s)"); return violations.Count == 0 ? 0 : 1; -void AuditDepsJson(string library, string? thirdParty, string path, List found) +void AuditDepsJson(string library, Policy policy, string path, List found) { using var document = JsonDocument.Parse(File.ReadAllText(path)); var root = document.RootElement; @@ -111,12 +117,17 @@ void AuditDepsJson(string library, string? thirdParty, string path, List StringComparer.Ordinal); var allowedPackages = Closure(graph, Facade); - if (thirdParty is not null) + if (policy.ThirdParty is not null) { - allowedPackages.UnionWith(Closure(graph, thirdParty)); + allowedPackages.UnionWith(Closure(graph, policy.ThirdParty)); } var allowedProjects = library == Core ? new HashSet(StringComparer.Ordinal) { Core } : Closure(graph, Core); + foreach (var project in policy.Projects) + { + allowedProjects.UnionWith(Closure(graph, project)); + } + allowedProjects.Add(library); var packages = kinds.Where(kind => kind.Value.Type == "package").ToList(); @@ -138,7 +149,7 @@ void AuditDepsJson(string library, string? thirdParty, string path, List { if (!allowedProjects.Contains(name)) { - found.Add($"{library}: references project {name}; a library references at most {Core}"); + found.Add($"{library}: references project {name}; a library references at most {Core} and the projects its policy names"); } } @@ -148,7 +159,7 @@ void AuditDepsJson(string library, string? thirdParty, string path, List } } -bool AuditNuspec(string library, string? thirdParty, string directory, List found) +bool AuditNuspec(string library, Policy policy, string directory, List found) { var nupkg = Directory.Exists(directory) ? Directory.GetFiles(directory, $"{library}.*.nupkg") @@ -178,9 +189,10 @@ bool AuditNuspec(string library, string? thirdParty, string directory, List(StringComparer.Ordinal) { library == Core ? Facade : Core }; - if (thirdParty is not null) + expected.UnionWith(policy.Projects); + if (policy.ThirdParty is not null) { - expected.Add(thirdParty); + expected.Add(policy.ThirdParty); } var optional = library == Core ? [] : new HashSet(StringComparer.Ordinal) { Facade }; @@ -195,9 +207,12 @@ bool AuditNuspec(string library, string? thirdParty, string directory, List 0 ? $" (plus optionally {Facade})" : string.Empty)}"); } - if (dependencies.TryGetValue(Core, out var coreVersion) && coreVersion != version) + foreach (var dexpace in policy.Projects.Append(Core)) { - found.Add($"{library}: depends on {Core} {coreVersion}, not its own lockstep version {version} (design §2.3)"); + if (dependencies.TryGetValue(dexpace, out var dexpaceVersion) && dexpaceVersion != version) + { + found.Add($"{library}: depends on {dexpace} {dexpaceVersion}, not its own lockstep version {version} (design §2.3)"); + } } if (dependencies.TryGetValue(Facade, out var facadeVersion) && !facadeVersion.StartsWith($"{FrameworkMajor}.", StringComparison.Ordinal)) @@ -226,3 +241,7 @@ static HashSet Closure(Dictionary> graph, string st return seen; } + +// A library's allowance beyond core and the facade: its one third-party root package (null for none) and the first-party +// projects it may reference. +sealed record Policy(string? ThirdParty, string[] Projects); diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/ClientConfiguration.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/ClientConfiguration.cs new file mode 100644 index 0000000..09a0203 --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/ClientConfiguration.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Http.SystemNet; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// What the reader built from a client's section: the records, the handler's pooled-connection lifetime and every failure (P9-9). +/// The client options. +/// The transport options, without logger or clock; carries the explicit proxy. +/// The primary handler's pooled-connection lifetime; infinite for none (P9-13). +/// The credentials read from Auth:*, or when no such key is present. +/// Every key that failed, in table order. +internal sealed record ClientConfiguration( + DexpaceClientOptions Options, + SystemNetHttpClientOptions Transport, + TimeSpan PooledConnectionLifetime, + AuthCredentials? Credentials, + IReadOnlyList Failures) +{ + /// The default pooled-connection lifetime: the factory's own rotation interval (P9-13). + internal static readonly TimeSpan s_defaultPooledConnectionLifetime = TimeSpan.FromMinutes(2); +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigSection.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigSection.cs new file mode 100644 index 0000000..b56c38f --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigSection.cs @@ -0,0 +1,149 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Microsoft.Extensions.Configuration; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// +/// A client's configuration section as the reader sees it: a lookup that trims ASCII whitespace and treats an empty or whitespace-only value +/// as absent (CFG-2, P9-7), a failure collector, and the list reader. The one place a key is turned into text (CFG-38). +/// +internal sealed class ConfigSection(IConfigurationSection? section, List failures) +{ + private readonly HashSet _touched = new(StringComparer.OrdinalIgnoreCase); + + /// The keys the reader asked for, so a test can hold the reader to the key table. + internal IReadOnlyCollection Touched => _touched; + + /// The section's configuration path. + internal string Path => section?.Path ?? string.Empty; + + /// The ASCII-trimmed value of , or when it is absent, empty or whitespace-only. + /// A key relative to the section. + /// The value, or . + internal string? Text(string key) + { + _touched.Add(key); + if (section?[key] is not { } raw) + { + return null; + } + + var trimmed = ScalarGrammar.AsciiTrim(raw); + return trimmed.Length == 0 ? null : trimmed; + } + + /// The children of a list key, in the configuration's order. + private List Children(string key) => + section is null ? [] : [.. section.GetSection(key).GetChildren()]; + + /// True when has a value or child keys; the proxy and credential readers build only what was asked for. + /// A key relative to the section. + /// Whether the key is present. + internal bool Has(string key) => Text(key) is not null || Children(key).Exists(static c => c.Value is not null || c.GetChildren().Any()); + + /// Records a failure for . + /// A key relative to the section. + /// The rule it broke. + internal void Fail(string key, string rule) => failures.Add(new ConfigurationFailure(FullPath(key), rule)); + + /// The full configuration path of . + /// A key relative to the section. + /// The section's path plus the key. + internal string FullPath(string key) => Path.Length == 0 ? key : Path + ":" + key; + + /// Reads through ; a parse failure is recorded under the key. + internal bool TryRead(string key, Parser parse, out T value) + { + value = default!; + if (Text(key) is not { } text) + { + return false; + } + + if (parse(text, out value, out var rule)) + { + return true; + } + + Fail(key, rule ?? "invalid value"); + return false; + } + + /// + /// Reads a list key: child keys (Key:0, Key:1, in the configuration's order) or one comma-separated scalar, never both; + /// none is the empty list. Items parse with ; the list replaces the record's default (V1). + /// + internal bool TryReadList(string key, Parser element, out List items) + { + items = []; + var children = Children(key); + var scalar = Text(key); + if (children.Count > 0 && scalar is not null) + { + Fail(key, "give child keys or one comma-separated value, not both"); + return false; + } + + List raw; + if (children.Count > 0) + { + raw = []; + foreach (var child in children) + { + var text = child.Value is null ? string.Empty : ScalarGrammar.AsciiTrim(child.Value); + if (text.Length == 0) + { + Fail(key, "a list item is empty"); + return false; + } + + raw.Add(text); + } + } + else if (scalar is null) + { + return false; + } + else if (!ScalarGrammar.TrySplitList(scalar, out raw, out var splitRule)) + { + Fail(key, splitRule!); + return false; + } + + return ParseItems(key, raw, element, items); + } + + private bool ParseItems(string key, List raw, Parser element, List items) + { + for (var i = 0; i < raw.Count; i++) + { + if (!element(raw[i], out var item, out var rule)) + { + Fail(key, "item " + i.ToString(System.Globalization.CultureInfo.InvariantCulture) + ": " + rule); + return false; + } + + items.Add(item); + } + + return true; + } + + /// The accessor-rejection text, safe to show: the first line, without the parameter suffix the runtime appends. + /// The accessor's rejection. + /// The rule text. + internal static string Sanitise(ArgumentException exception) + { + var message = exception.Message; + var newline = message.IndexOf('\n', StringComparison.Ordinal); + if (newline >= 0) + { + message = message[..newline].TrimEnd('\r'); + } + + var suffix = message.LastIndexOf(" (Parameter '", StringComparison.Ordinal); + return suffix >= 0 ? message[..suffix] : message; + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationFailure.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationFailure.cs new file mode 100644 index 0000000..19df23d --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationFailure.cs @@ -0,0 +1,17 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// +/// One configuration failure: the configuration path of the key and the rule it broke (P9-9). It never carries the configured value, a +/// URL or a credential (XCUT-18, XCUT-19); an accessor's numeric range message may echo the rejected number (P9-28). +/// +/// The key's full configuration path (the section's path plus the key), or empty for a failure about the section. +/// The rule the key broke. +internal readonly record struct ConfigurationFailure(string Path, string Rule) +{ + /// Formats the failure as "<path>: <rule>". + /// The failure's text. + public override string ToString() => Path.Length == 0 ? Rule : Path + ": " + Rule; +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Auth.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Auth.cs new file mode 100644 index 0000000..05e12c6 --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Auth.cs @@ -0,0 +1,125 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Http.Common; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +internal static partial class ConfigurationReader +{ + private const string AuthRequired = "required when any key of this credential is present"; + + // P9-18: Auth:* feeds the redacting credentials. No rule here quotes a configured value, so every credential failure carries + // fixed text and never an accessor's message (a key or password could appear in one). AuthCredentials.Token is code-only. + private static AuthCredentials? ReadAuth(ConfigSection section) + { + var apiKey = ReadApiKey(section, out var apiKeyPresent); + var basic = ReadUsernamePassword(section, "Auth:Basic:Username", "Auth:Basic:Password", static (u, p) => new BasicCredential(u, p), colonForbidden: true, out var basicPresent); + var digest = ReadUsernamePassword(section, "Auth:Digest:Username", "Auth:Digest:Password", static (u, p) => new DigestCredential(u, p), colonForbidden: false, out var digestPresent); + var marginPresent = section.Has("Auth:TokenRefreshMargin"); + if (!(apiKeyPresent || basicPresent || digestPresent || marginPresent)) + { + return null; + } + + var binder = new RecordBinder( + section, + new AuthCredentials { ApiKey = apiKey, Basic = basic as BasicCredential, Digest = digest as DigestCredential }); + binder.Duration("Auth:TokenRefreshMargin", static (r, v) => r with { TokenRefreshMargin = v }); + return binder.Value; + } + + private static ApiKeyCredential? ReadApiKey(ConfigSection section, out bool present) + { + var key = section.Text("Auth:ApiKey:Key"); + var header = section.Text("Auth:ApiKey:HeaderName"); + var scheme = section.Text("Auth:ApiKey:Scheme"); + present = key is not null || header is not null || scheme is not null; + if (!present) + { + return null; + } + + var ok = true; + HttpHeaderName? name = null; + if (key is null) + { + section.Fail("Auth:ApiKey:Key", AuthRequired); + ok = false; + } + + if (header is not null && !TryHeaderName(header, out name)) + { + section.Fail("Auth:ApiKey:HeaderName", "not a valid HTTP header name"); + ok = false; + } + + if (scheme is not null && scheme.Any(char.IsWhiteSpace)) + { + section.Fail("Auth:ApiKey:Scheme", "must not contain whitespace"); + ok = false; + } + + return ok ? Build(section, "Auth:ApiKey:Key", () => new ApiKeyCredential(key!, name, scheme)) : null; + } + + private static object? ReadUsernamePassword( + ConfigSection section, + string usernameKey, + string passwordKey, + Func create, + bool colonForbidden, + out bool present) + { + var username = section.Text(usernameKey); + var password = section.Text(passwordKey); + present = username is not null || password is not null; + if (!present) + { + return null; + } + + if (username is null || password is null) + { + section.Fail(username is null ? usernameKey : passwordKey, AuthRequired); + return null; + } + + if (colonForbidden && username.Contains(':', StringComparison.Ordinal)) + { + section.Fail(usernameKey, "must not contain a colon"); + return null; + } + + return Build(section, passwordKey, () => create(username, password)); + } + + private static T? Build(ConfigSection section, string key, Func create) + where T : class + { + try + { + return create(); + } + catch (ArgumentException) + { + section.Fail(key, "is not a valid credential value"); + return null; + } + } + + private static bool TryHeaderName(string text, out HttpHeaderName? name) + { + try + { + name = HttpHeaderName.Of(text); + return true; + } + catch (ArgumentException) + { + name = null; + return false; + } + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Client.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Client.cs new file mode 100644 index 0000000..653e860 --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Client.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Common; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +internal static partial class ConfigurationReader +{ + private const string BaseAddressRule = "not an absolute http or https URI without a fragment"; + + private static DexpaceClientOptions ReadClient(ConfigSection section) + { + var binder = new RecordBinder(section, new DexpaceClientOptions()); + + // The record's accessor would echo the (redacted) URL; the rule here names the requirement only. + binder.Bind("BaseAddress", ScalarGrammar.TryParseAbsoluteUri, static (r, v) => r with { BaseAddress = v }, BaseAddressRule); + + // An accessor checks only for null, so a control character would pass start-up and fail every call (F15). + binder.Bind("UserAgent", ParseUserAgent, static (r, v) => r with { UserAgent = v }); + binder.OptionalDuration("OverallTimeout", static (r, v) => r with { OverallTimeout = v }); + binder.OptionalDuration("AttemptTimeout", static (r, v) => r with { AttemptTimeout = v }); + return binder.Value with + { + Retry = ReadRetry(section), + Redirect = ReadRedirect(section), + Logging = ReadLogging(section), + }; + } + + private static bool ParseUserAgent(string text, out string value, out string? rule) + { + value = text; + rule = HttpHeaderSyntax.IsValidOutboundValue(text) ? null : "must be a valid header value (printable ASCII, no control characters)"; + return rule is null; + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Logging.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Logging.cs new file mode 100644 index 0000000..89a89e2 --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Logging.cs @@ -0,0 +1,23 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +internal static partial class ConfigurationReader +{ + // OBS-35: the level is read by name only, from the caller's section; a number or an unknown name fails naming the key. The three + // lists replace the record's defaults (V1: the binder appended). A name that is not a token never matches, so none is validated. + private static HttpLoggingOptions ReadLogging(ConfigSection section) + { + var binder = new RecordBinder(section, new HttpLoggingOptions()); + binder.EnumName("Logging:Level", static (r, v) => r with { Level = v }); + binder.Int("Logging:BodyPreviewSize", static (r, v) => r with { BodyPreviewSize = v }); + binder.Items("Logging:AllowedHeaderNames", ScalarGrammar.TryParseString, static (r, v) => r with { AllowedHeaderNames = v }); + binder.Items("Logging:AllowedQueryParameters", ScalarGrammar.TryParseString, static (r, v) => r with { AllowedQueryParameters = v }); + binder.Items("Logging:UrlValuedHeaderNames", ScalarGrammar.TryParseString, static (r, v) => r with { UrlValuedHeaderNames = v }); + binder.Bool("Logging:OmitDisallowedHeaders", static (r, v) => r with { OmitDisallowedHeaders = v }); + return binder.Value; + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Proxy.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Proxy.cs new file mode 100644 index 0000000..4245254 --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Proxy.cs @@ -0,0 +1,94 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +internal static partial class ConfigurationReader +{ + private const string ProxyHostKey = "Transport:Proxy:Host"; + private const string ProxyPortKey = "Transport:Proxy:Port"; + private const string ProxyRequired = "required when any proxy key is present"; + + // CFG-24's first layer (P9-15): explicit keys build a ProxyOptions, only when one of them is present. Nothing here reads the + // environment, and no key switches that read on (CFG-28). + private static ProxyOptions? ReadProxy(ConfigSection section) + { + string[] keys = + [ + "Transport:Proxy:Type", ProxyHostKey, ProxyPortKey, "Transport:Proxy:NonProxyHosts", + "Transport:Proxy:UserName", "Transport:Proxy:Password", "Transport:Proxy:BypassAll", + ]; + + // Every key is asked for (no short-circuit) so the reader's key set is stable. + var present = keys.Select(section.Has).ToList().Contains(true); + if (!present) + { + return null; + } + + var hostOk = TryReadHost(section, out var host); + var portOk = TryReadPort(section, out var port); + var binder = new RecordBinder(section, new ProxyOptions { Host = hostOk ? host : "placeholder.invalid", Port = portOk ? port : 1 }); + binder.EnumName("Transport:Proxy:Type", static (r, v) => r with { Type = v }); + binder.Items( + "Transport:Proxy:NonProxyHosts", + ScalarGrammar.TryParseString, + static (r, v) => r with { NonProxyHosts = v }, + "not a valid bypass pattern"); + binder.Str("Transport:Proxy:UserName", static (r, v) => r with { UserName = v }); + binder.Str("Transport:Proxy:Password", static (r, v) => r with { Password = v }); + binder.Bool("Transport:Proxy:BypassAll", static (r, v) => r with { BypassAll = v }); + return hostOk && portOk ? binder.Value : null; + } + + private static bool TryReadHost(ConfigSection section, out string host) + { + host = string.Empty; + if (section.Text(ProxyHostKey) is not { } text) + { + section.Fail(ProxyHostKey, ProxyRequired); + return false; + } + + try + { + _ = new ProxyOptions { Host = text, Port = 1 }; + host = text; + return true; + } + catch (ArgumentException ex) + { + section.Fail(ProxyHostKey, ConfigSection.Sanitise(ex)); + return false; + } + } + + private static bool TryReadPort(ConfigSection section, out int port) + { + port = 0; + if (section.Text(ProxyPortKey) is null) + { + section.Fail(ProxyPortKey, ProxyRequired); + return false; + } + + if (!section.TryRead(ProxyPortKey, ScalarGrammar.TryParseInt32, out var parsed)) + { + return false; + } + + try + { + _ = new ProxyOptions { Host = "placeholder.invalid", Port = parsed }; + port = parsed; + return true; + } + catch (ArgumentException ex) + { + section.Fail(ProxyPortKey, ConfigSection.Sanitise(ex)); + return false; + } + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Redirect.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Redirect.cs new file mode 100644 index 0000000..36ee76b --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Redirect.cs @@ -0,0 +1,21 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Common; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +internal static partial class ConfigurationReader +{ + // Predicate is code-only (a delegate); AllowedMethods replaces the default set (V1). + private static RedirectOptions ReadRedirect(ConfigSection section) + { + var binder = new RecordBinder(section, new RedirectOptions()); + binder.Int("Redirect:MaxRedirects", static (r, v) => r with { MaxRedirects = v }); + binder.Bool("Redirect:AllowHttpsToHttpDowngrade", static (r, v) => r with { AllowHttpsToHttpDowngrade = v }); + binder.Bool("Redirect:FollowSeeOther", static (r, v) => r with { FollowSeeOther = v }); + binder.Methods("Redirect:AllowedMethods", static (r, v) => r with { AllowedMethods = new HashSet(v) }); + return binder.Value; + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Retry.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Retry.cs new file mode 100644 index 0000000..7b920c9 --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Retry.cs @@ -0,0 +1,24 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +internal static partial class ConfigurationReader +{ + private static RetryOptions ReadRetry(ConfigSection section) + { + var binder = new RecordBinder(section, new RetryOptions()); + binder.Int("Retry:MaxRetryAttempts", static (r, v) => r with { MaxRetryAttempts = v }); + binder.Duration("Retry:BaseDelay", static (r, v) => r with { BaseDelay = v }); + binder.Duration("Retry:MaxDelay", static (r, v) => r with { MaxDelay = v }); + binder.OptionalDuration("Retry:FixedDelay", static (r, v) => r with { FixedDelay = v }); + binder.Number("Retry:Multiplier", static (r, v) => r with { Multiplier = v }); + binder.Number("Retry:Jitter", static (r, v) => r with { Jitter = v }); + binder.Bool("Retry:HonorRetryAfter", static (r, v) => r with { HonorRetryAfter = v }); + binder.Items("Retry:RetryableStatusCodes", ScalarGrammar.TryParseInt32, static (r, v) => r with { RetryableStatusCodes = new HashSet(v) }); + binder.Str("Retry:AttemptHeaderName", static (r, v) => r with { AttemptHeaderName = v }); + return binder.Value; + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Transport.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Transport.cs new file mode 100644 index 0000000..4b5f386 --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.Transport.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Http.SystemNet; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +internal static partial class ConfigurationReader +{ + private const string PooledLifetimeKey = "Transport:PooledConnectionLifetime"; + + // Logger and TimeProvider come from the container, never from configuration (design: Transport:* keys). + private static (SystemNetHttpClientOptions Options, TimeSpan PooledConnectionLifetime) ReadTransport(ConfigSection section) + { + var binder = new RecordBinder(section, new SystemNetHttpClientOptions()); + binder.OptionalDuration("Transport:Timeout", static (r, v) => r with { Timeout = v }); + binder.EnumName("Transport:HeaderDropLogging", static (r, v) => r with { HeaderDropLogging = v }); + binder.Long("Transport:ResendBufferLimit", static (r, v) => r with { ResendBufferLimit = v }); + var proxy = ReadProxy(section); + var options = proxy is null ? binder.Value : binder.Value with { Proxy = proxy }; + return (options, ReadPooledLifetime(section)); + } + + // The primary handler's own setting (P9-13): a duration, or none for an infinite lifetime; the default is two minutes. + private static TimeSpan ReadPooledLifetime(ConfigSection section) + { + if (!section.TryRead(PooledLifetimeKey, ScalarGrammar.TryParseOptionalDuration, out var parsed)) + { + return ClientConfiguration.s_defaultPooledConnectionLifetime; + } + + if (parsed is null) + { + return Timeout.InfiniteTimeSpan; + } + + if (parsed.Value <= TimeSpan.Zero) + { + section.Fail(PooledLifetimeKey, "must be greater than zero, or none for an infinite lifetime"); + return ClientConfiguration.s_defaultPooledConnectionLifetime; + } + + return parsed.Value; + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.cs new file mode 100644 index 0000000..c893dcf --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/ConfigurationReader.cs @@ -0,0 +1,99 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Http.SystemNet; +using Microsoft.Extensions.Configuration; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// +/// Reads a client's into the option records (CFG-1, CFG-2, CFG-5 to CFG-7, CFG-38, OBS-35; P9-4). A +/// hand-written reader, because neither mode of the configuration binder can serve (phase 9 V1): it walks the keys of the +/// , converts each with the package's own grammar and builds each record with with expressions. It never stops +/// at the first failure and never quotes a configured value. +/// +internal static partial class ConfigurationReader +{ + internal const string RootRule = "pass the client's own section, not the configuration root"; + + /// Reads ; a section binds nothing and yields the defaults. + /// The client's section, or . + /// The records, the handler setting and every failure. + internal static ClientConfiguration Read(IConfigurationSection? section) => Read(section, out _); + + /// Reads and reports the keys the reader asked for. + /// The client's section, or . + /// The keys the reader asked for. + /// The records, the handler setting and every failure. + internal static ClientConfiguration Read(IConfigurationSection? section, out IReadOnlyCollection touched) + { + var failures = new List(); + if (section is { Path.Length: 0 }) + { + failures.Add(new ConfigurationFailure(string.Empty, RootRule)); + touched = []; + return Defaults(failures); + } + + var reader = new ConfigSection(section, failures); + var options = ReadClient(reader); + var (transport, lifetime) = ReadTransport(reader); + var credentials = ReadAuth(reader); + if (section is not null) + { + CheckUnknownKeys(section, failures); + } + + touched = reader.Touched; + return new ClientConfiguration(options, transport, lifetime, credentials, failures); + } + + private static ClientConfiguration Defaults(List failures) => + new(new DexpaceClientOptions(), new SystemNetHttpClientOptions(), ClientConfiguration.s_defaultPooledConnectionLifetime, null, failures); + + // A key under the section that is not in the table fails naming the key in the configuration's own spelling (P9-9, R9, R10). + private static void CheckUnknownKeys(IConfigurationSection section, List failures) + { + foreach (var child in section.GetChildren()) + { + Walk(child, child.Key, failures, section.Path); + } + } + + private static void Walk(IConfigurationSection node, string relative, List failures, string sectionPath) + { + if (KeyTable.IsKey(relative, out var isList)) + { + // A list key owns its indexed children; any other key has no children, so one that does is unknown below it. + if (!isList) + { + WalkChildren(node, relative, failures, sectionPath); + } + + return; + } + + if (KeyTable.IsGroup(relative)) + { + // A group holds keys, never a value; one set there would be dropped silently. The rule text is fixed: the value may be a credential. An empty one is absent (P9-7). + if (node.Value is { } value && ScalarGrammar.AsciiTrim(value).Length > 0) + { + failures.Add(new ConfigurationFailure(sectionPath + ":" + relative, "a group of keys, not a value (set a key beneath it, e.g. " + relative + ":...)")); + } + + WalkChildren(node, relative, failures, sectionPath); + return; + } + + failures.Add(new ConfigurationFailure(sectionPath + ":" + relative, "unknown key")); + } + + private static void WalkChildren(IConfigurationSection node, string relative, List failures, string sectionPath) + { + foreach (var child in node.GetChildren()) + { + Walk(child, relative + ":" + child.Key, failures, sectionPath); + } + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/Dexpace.Sdk.Extensions.DependencyInjection.csproj b/src/Dexpace.Sdk.Extensions.DependencyInjection/Dexpace.Sdk.Extensions.DependencyInjection.csproj new file mode 100644 index 0000000..5146c8c --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/Dexpace.Sdk.Extensions.DependencyInjection.csproj @@ -0,0 +1,31 @@ + + + + Dexpace.Sdk.Extensions.DependencyInjection + Dexpace.Sdk.Extensions.DependencyInjection + Dexpace.Sdk.Extensions.DependencyInjection + + Host integration for the dexpace SDK: registers named clients (pipeline, transport, codec and options) as keyed + services over Microsoft.Extensions.DependencyInjection and reads their configuration from an IConfigurationSection. + + http;sdk;dependency-injection;configuration;dexpace + true + README.md + + + + + + + + + + + + + + + + + diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientBuilder.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientBuilder.cs new file mode 100644 index 0000000..ffe33d9 --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientBuilder.cs @@ -0,0 +1,235 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Serialization; +using Dexpace.Sdk.Http.SystemNet; +using Microsoft.Extensions.DependencyInjection; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// +/// Configures one named client registered by +/// (CFG-1, CFG-37, SEAM-5, SEAM-6; P9-5, P9-6). Every method returns the builder, and chains back to the collection. +/// +/// +/// A sealed class rather than an interface, so a third-party adapter extends it with extension methods. The transforms are applied after the +/// configuration values, in call order (CFG-1's fourth tier): the option records are immutable, so a transform returns a modified copy. +/// +public sealed class DexpaceClientBuilder +{ + private readonly DexpaceClientRegistration _registration; + + internal DexpaceClientBuilder(DexpaceClientRegistration registration, IHttpClientBuilder httpClient) + { + _registration = registration; + HttpClient = httpClient; + } + + /// The client's name, which is also its service key. + public string Name => _registration.Name; + + /// The collection the client was registered into. + public IServiceCollection Services => _registration.Services; + + /// + /// The IHttpClientBuilder of the client's handler chain, so AddHttpMessageHandler and AddStandardResilienceHandler compose + /// underneath the transport. ConfigureHttpClient has no effect: the package uses the handler and never CreateClient. The + /// package registers no retrying handler; to add the standard resilience handler without a second retry engine, set + /// Retry.ShouldHandle to return (XCUT-10). + /// + public IHttpClientBuilder HttpClient { get; } + + /// Applies to the client options after the configuration values, in call order (CFG-1). + /// A transform returning the modified options; returning fails validation naming the client. + /// This builder. + /// is . + public DexpaceClientBuilder Configure(Func configure) + { + ArgumentNullException.ThrowIfNull(configure); + _registration.OptionsTransforms.Add(configure); + return this; + } + + /// Applies to the transport options after the configuration values, in call order. Setting Proxy counts as an explicit proxy source. + /// A transform returning the modified options; the container supplies the logger and the clock. + /// This builder. + /// is . + public DexpaceClientBuilder ConfigureTransport(Func configure) + { + ArgumentNullException.ThrowIfNull(configure); + _registration.TransportTransforms.Add(configure); + return this; + } + + /// Applies to the credentials read from Auth:*, with the container available, for what configuration cannot hold (a TokenCredential). + /// A transform from the container and the configured credentials to the credentials to use. + /// This builder. + /// is . + public DexpaceClientBuilder ConfigureCredentials(Func configure) + { + ArgumentNullException.ThrowIfNull(configure); + _registration.CredentialTransforms.Add(configure); + return this; + } + + /// + /// Resolves the proxy from the process environment (HTTPS_PROXY, HTTP_PROXY, NO_PROXY) when the client's transport is first + /// resolved (CFG-28, P9-15). The one explicit environment read; nothing else in the package reads the environment, and no configuration value + /// turns it on. A second proxy source (explicit Transport:Proxy:* keys) fails validation. + /// + /// This builder. + public DexpaceClientBuilder UseProxyFromEnvironment() + { + _registration.EnvironmentProxy = static logger => ProxyOptions.FromEnvironment(logger); + return this; + } + + /// + /// Resolves the proxy through when the client's transport is first resolved, for example + /// key => configuration[key] to serve HTTPS_PROXY from any configuration provider (CFG-11, CFG-28). + /// + /// Returns the value of a variable name, or . + /// This builder. + /// is . + public DexpaceClientBuilder UseProxyFromEnvironment(Func lookup) + { + ArgumentNullException.ThrowIfNull(lookup); + _registration.EnvironmentProxy = logger => ProxyOptions.FromEnvironment(lookup, logger); + return this; + } + + /// + /// Installs as the client's transport, replacing the package's default (SEAM-5). The container never disposes an + /// instance it did not create (XCUT-22). Installing the same instance again is a no-op; a different one throws (SEAM-6). + /// + /// The transport. + /// This builder. + /// is . + /// A different transport is already installed for this client. + public DexpaceClientBuilder UseTransport(IAsyncHttpClient transport) + { + ArgumentNullException.ThrowIfNull(transport); + _registration.Transport.Install( + _registration, + transport, + transport.GetType().FullName ?? transport.GetType().Name, + ServiceDescriptor.KeyedSingleton(Name, transport)); + return this; + } + + /// + /// Installs the transport builds, replacing the package's default (SEAM-5). The factory runs on the root provider and the + /// container disposes what it returns. The same delegate again is a no-op; a different one throws (SEAM-6). + /// + /// Builds the transport from the container. + /// This builder. + /// is . + /// A different transport is already installed for this client. + public DexpaceClientBuilder UseTransport(Func factory) + { + ArgumentNullException.ThrowIfNull(factory); + _registration.Transport.Install( + _registration, + factory, + ImplementationNames.Describe(factory), + ServiceDescriptor.KeyedSingleton(Name, (provider, _) => factory(provider))); + return this; + } + + /// + /// Installs as the client's codec (SEAM-5). A codec is required: the package has no default, so a client without one fails + /// validation with an install hint. The container never disposes an instance it did not create. + /// + /// The codec. + /// This builder. + /// is . + /// A different codec is already installed for this client. + public DexpaceClientBuilder UseSerde(ISerde serde) + { + ArgumentNullException.ThrowIfNull(serde); + _registration.Serde.Install( + _registration, + serde, + serde.GetType().FullName ?? serde.GetType().Name, + ServiceDescriptor.KeyedSingleton(Name, serde)); + return this; + } + + /// Installs the codec builds (SEAM-5). The factory runs on the root provider and the container disposes what it returns. + /// Builds the codec from the container. + /// This builder. + /// is . + /// A different codec is already installed for this client. + public DexpaceClientBuilder UseSerde(Func factory) + { + ArgumentNullException.ThrowIfNull(factory); + _registration.Serde.Install( + _registration, + factory, + ImplementationNames.Describe(factory), + ServiceDescriptor.KeyedSingleton(Name, (provider, _) => factory(provider))); + return this; + } + + /// + /// Installs a MultiSchemeAuthPolicy over and the credentials read from Auth:* (P9-18). The descriptor + /// is what a generated SDK owns. Cannot be combined with . + /// + /// The client's auth requirements. + /// This builder. + /// is . + /// was called, or a different descriptor is already installed. + public DexpaceClientBuilder UseAuth(AuthDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(descriptor); + if (_registration.AuthPolicyFactory is not null) + { + throw new InvalidOperationException("Client '" + Name + "' already installs an auth policy with UseAuthPolicy; UseAuth and UseAuthPolicy cannot be combined (SEAM-6)."); + } + + if (_registration.AuthDescriptor is { } incumbent && !incumbent.Equals(descriptor)) + { + throw new InvalidOperationException("Client '" + Name + "' already has a different auth descriptor (SEAM-6)."); + } + + _registration.AuthDescriptor = descriptor; + return this; + } + + /// Installs the auth policy builds (for example a ChallengeAuthPolicy). Cannot be combined with . + /// Builds the policy from the container. + /// This builder. + /// is . + /// was called, or a different policy factory is already installed. + public DexpaceClientBuilder UseAuthPolicy(Func factory) + { + ArgumentNullException.ThrowIfNull(factory); + if (_registration.AuthDescriptor is not null) + { + throw new InvalidOperationException("Client '" + Name + "' already installs an auth policy with UseAuth; UseAuth and UseAuthPolicy cannot be combined (SEAM-6)."); + } + + if (_registration.AuthPolicyFactory is { } incumbent && !incumbent.Equals(factory)) + { + throw new InvalidOperationException("Client '" + Name + "' already has a different auth policy factory (SEAM-6)."); + } + + _registration.AuthPolicyFactory = factory; + return this; + } + + /// Adds a policy built by to the client's pipeline; the stage sort places it (PIPE-35). + /// Builds the policy from the container. + /// This builder. + /// is . + public DexpaceClientBuilder AddPolicy(Func factory) + { + ArgumentNullException.ThrowIfNull(factory); + _registration.Policies.Add(factory); + return this; + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientRegistration.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientRegistration.cs new file mode 100644 index 0000000..dd6bb5e --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientRegistration.cs @@ -0,0 +1,64 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Http.SystemNet; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// +/// What the builder records for one named client: its section, its transforms and the collection it registered into (P9-5, P9-6). It exposes +/// no removal member: a transform is code, and a record has no removal (CFG-10). +/// +/// The client's name, which is also its key. +/// The collection the client registered into; the validator scans it for duplicate keyed descriptors (SEAM-5). +/// The client's configuration section, or . +internal sealed class DexpaceClientRegistration(string name, IServiceCollection services, IConfigurationSection? section) +{ + /// The client's name. + internal string Name { get; } = name; + + /// The collection registered into. + internal IServiceCollection Services { get; } = services; + + /// The client's section, or . + internal IConfigurationSection? Section { get; } = section; + + /// The Configure transforms, in call order (CFG-1's fourth tier). + internal List> OptionsTransforms { get; } = []; + + /// The ConfigureTransport transforms, in call order. + internal List> TransportTransforms { get; } = []; + + /// The ConfigureCredentials transforms, in call order. + internal List> CredentialTransforms { get; } = []; + + /// The transport's slot: the package's SystemNet default, or the explicit install that replaced it (SEAM-5, SEAM-6). + internal ServiceSlot Transport { get; } = new("transport"); + + /// The codec's slot: there is no default, so it starts empty (SEAM-5). + internal ServiceSlot Serde { get; } = new("codec"); + + /// The auth descriptor the package builds a MultiSchemeAuthPolicy from, or . + internal AuthDescriptor? AuthDescriptor { get; set; } + + /// The factory of a caller's own auth policy, or . + internal Func? AuthPolicyFactory { get; set; } + + /// The caller's extra policies, in call order (PIPE-35). + internal List> Policies { get; } = []; + + /// + /// Resolves the proxy the caller asked for from the environment, or when none was asked for (CFG-28, P9-15). Set only + /// by the two UseProxyFromEnvironment overloads; resolved when the primary handler is built, never at registration. + /// + internal Func? EnvironmentProxy { get; set; } + + /// The builder this registration belongs to, so a second AddDexpaceClient for the name returns it. + internal DexpaceClientBuilder? Builder { get; set; } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientRegistrations.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientRegistrations.cs new file mode 100644 index 0000000..772d855 --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientRegistrations.cs @@ -0,0 +1,30 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// The registration of a client's options state: the setup, the validator and ValidateOnStart (P9-11; R3, R12). +internal static class DexpaceClientRegistrations +{ + /// + /// Registers the named options of with the setup, the validator and ValidateOnStart, and no + /// IOptionsChangeTokenSource: the snapshot is taken once, at first resolution, so a reload never reaches a built client. + /// + /// The collection. + /// The client's name. + /// The client's section, or . + /// The registration the builder records into. + internal static DexpaceClientRegistration AddClientState(IServiceCollection services, string name, IConfigurationSection? section) + { + var registration = new DexpaceClientRegistration(name, services, section); + services.AddKeyedSingleton(name, registration); + services.AddSingleton>(provider => new DexpaceClientStateSetup(registration, provider)); + services.AddSingleton>(new DexpaceClientValidator(registration)); + services.AddOptions(name).ValidateOnStart(); + return registration; + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientState.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientState.cs new file mode 100644 index 0000000..64d1389 --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientState.cs @@ -0,0 +1,36 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Http.SystemNet; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// +/// The options-pattern TOptions of one named client (P9-11): the snapshot the reader and the transforms produced, and the failures to +/// report. Internal and mutable by design; the public surface is the immutable records inside it. The public parameterless constructor is +/// what OptionsFactory needs to create it under trimming (R4). +/// +internal sealed class DexpaceClientState +{ + /// Creates the state the options factory fills in. + public DexpaceClientState() + { + } + + /// The client options: reader output, then the Configure transforms in call order. + internal DexpaceClientOptions Options { get; set; } = new(); + + /// The transport options, without logger or clock; the container supplies those. + internal SystemNetHttpClientOptions Transport { get; set; } = new(); + + /// The primary handler's pooled-connection lifetime; infinite for none. + internal TimeSpan PooledConnectionLifetime { get; set; } = ClientConfiguration.s_defaultPooledConnectionLifetime; + + /// The credentials from Auth:* and the credential transforms, or when there are none. + internal AuthCredentials? Credentials { get; set; } + + /// Every failure the reader and the transforms found, as "<path>: <rule>". + internal List Failures { get; } = []; +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientStateSetup.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientStateSetup.cs new file mode 100644 index 0000000..bf3197d --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientStateSetup.cs @@ -0,0 +1,71 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Errors; +using Microsoft.Extensions.Options; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// +/// Fills a client's : the reader over the section, then the transforms in call order (CFG-1, P9-6). A transform +/// that returns `null` or throws is a failure naming the client; its message is never quoted, because it can carry a value. +/// +internal sealed class DexpaceClientStateSetup(DexpaceClientRegistration registration, IServiceProvider services) : IConfigureNamedOptions +{ + /// + public void Configure(string? name, DexpaceClientState options) + { + ArgumentNullException.ThrowIfNull(options); + if (!string.Equals(name, registration.Name, StringComparison.Ordinal)) + { + return; + } + + var configuration = ConfigurationReader.Read(registration.Section); + options.Failures.AddRange(configuration.Failures.Select(static failure => failure.ToString())); + options.Options = Transform(options, configuration.Options, registration.OptionsTransforms, "Configure", static (transform, value) => transform(value)); + options.Transport = Transform(options, configuration.Transport, registration.TransportTransforms, "ConfigureTransport", static (transform, value) => transform(value)); + options.PooledConnectionLifetime = configuration.PooledConnectionLifetime; + options.Credentials = TransformCredentials(options, configuration.Credentials); + } + + /// + public void Configure(DexpaceClientState options) => Configure(Options.DefaultName, options); + + private AuthCredentials? TransformCredentials(DexpaceClientState state, AuthCredentials? configured) + { + if (registration.CredentialTransforms.Count == 0) + { + return configured; + } + + return Transform(state, configured ?? new AuthCredentials(), registration.CredentialTransforms, "ConfigureCredentials", (transform, value) => transform(services, value)); + } + + private T Transform(DexpaceClientState state, T start, List transforms, string method, Func apply) + where T : class + { + var current = start; + foreach (var transform in transforms) + { + try + { + var result = apply(transform, current); + if (result is null) + { + state.Failures.Add(registration.Name + ": " + method + " returned null"); + continue; + } + + current = result; + } + catch (Exception ex) when (!ExceptionFacts.IsFatal(ex)) + { + state.Failures.Add(registration.Name + ": " + method + " threw " + ex.GetType().Name); + } + } + + return current; + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientValidator.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientValidator.cs new file mode 100644 index 0000000..8a5d6b0 --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceClientValidator.cs @@ -0,0 +1,81 @@ +// 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.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// +/// The one validator of a named client (P9-11): it reports every failure the reader and the transforms found, all at once, as an +/// at host start, or at first resolution with no host (V4). The wiring checks of registration +/// (SEAM-5, the proxy sources, the auth rules) join the same list. +/// +internal sealed class DexpaceClientValidator(DexpaceClientRegistration registration) : IValidateOptions +{ + /// + public ValidateOptionsResult Validate(string? name, DexpaceClientState options) + { + ArgumentNullException.ThrowIfNull(options); + if (!string.Equals(name, registration.Name, StringComparison.Ordinal)) + { + return ValidateOptionsResult.Skip; + } + + var failures = new List(options.Failures); + CheckSingleRegistration(failures); + CheckProxySources(options, failures); + CheckAuth(options, failures); + return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); + } + + // SEAM-5: exactly one transport and one codec per client. The collection is complete once the provider is built, and + // GetRequiredKeyedService would silently resolve the last of several (V4, section 11 item 8). + private void CheckSingleRegistration(List failures) + { + CheckRole("IAsyncHttpClient", "UseTransport", failures); + CheckRole("ISerde", "UseSerde", failures); + } + + private void CheckRole(string role, string installer, List failures) + { + var candidates = registration.Services + .Where(d => d.ServiceType == typeof(TService) && d.IsKeyedService && Equals(d.ServiceKey, registration.Name)) + .ToList(); + if (candidates.Count == 0) + { + failures.Add("client '" + registration.Name + "' has no " + role + ": call " + installer + "(...) on AddDexpaceClient(\"" + registration.Name + "\")"); + } + else if (candidates.Count > 1) + { + failures.Add( + "client '" + registration.Name + "' has " + candidates.Count.ToString(System.Globalization.CultureInfo.InvariantCulture) + " " + role + + " registrations (" + string.Join("; ", candidates.Select(Describe)) + "); register exactly one"); + } + } + + private string Describe(ServiceDescriptor descriptor) => + ReferenceEquals(descriptor, registration.Transport.Descriptor) && registration.Transport.Description is { } transport ? transport + : ReferenceEquals(descriptor, registration.Serde.Descriptor) && registration.Serde.Description is { } serde ? serde + : ImplementationNames.Describe(descriptor); + + // P9-15: at most one proxy source per client; ConfigureTransport setting Proxy counts as an explicit one. + private void CheckProxySources(DexpaceClientState state, List failures) + { + if (state.Transport.Proxy is not null && registration.EnvironmentProxy is not null) + { + failures.Add(registration.Name + ": two proxy sources: explicit proxy settings and UseProxyFromEnvironment; use one"); + } + } + + // P9-18: credentials that are silently not sent are the misconfiguration nobody sees. + private void CheckAuth(DexpaceClientState state, List failures) + { + if (state.Credentials is not null && registration.AuthDescriptor is null && registration.AuthPolicyFactory is null) + { + failures.Add(registration.Name + ": credentials are configured but nothing sends them: call UseAuth(descriptor) or UseAuthPolicy(...)"); + } + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceConfigurationKeys.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceConfigurationKeys.cs new file mode 100644 index 0000000..a1ccf5a --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceConfigurationKeys.cs @@ -0,0 +1,30 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// +/// The configuration keys the SDK publishes (CFG-14). They are published and never read: the section of a client is always the caller's +/// argument to AddDexpaceClient, so no key is baked in (OBS-35), and nothing in the package consults the environment on its own +/// (CFG-28). +/// +public static class DexpaceConfigurationKeys +{ + /// The conventional name of the configuration section that holds an SDK client's settings. A suggestion for the caller's own section, never read by the package (OBS-35). + public const string SectionName = "Dexpace"; + + /// The key, relative to a client's section, of the retry budget (RetryOptions.MaxRetryAttempts). + public const string MaxRetryAttempts = "Retry:MaxRetryAttempts"; + + /// The key, relative to a client's section, of the HTTP log level (HttpLoggingOptions.Level), read by name: None, Headers or Body (OBS-35). + public const string LogLevel = "Logging:Level"; + + /// The name of the environment variable that carries the proxy for plain HTTP requests, as ProxyOptions.FromEnvironment reads it. + public const string HttpProxy = "HTTP_PROXY"; + + /// The name of the environment variable that carries the proxy for HTTPS requests, as ProxyOptions.FromEnvironment reads it. + public const string HttpsProxy = "HTTPS_PROXY"; + + /// The name of the environment variable that lists the hosts that bypass the proxy, as ProxyOptions.FromEnvironment reads it. + public const string NoProxy = "NO_PROXY"; +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceServiceCollectionExtensions.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceServiceCollectionExtensions.cs new file mode 100644 index 0000000..361e3b9 --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/DexpaceServiceCollectionExtensions.cs @@ -0,0 +1,77 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// Registers named dexpace clients with a container (CFG-37, SEAM-5, SEAM-6; P9-5). +public static class DexpaceServiceCollectionExtensions +{ + /// + /// Registers the client with the defaults of every option record. Call + /// and the other builder methods to configure it; resolve it as the keyed services HttpPipeline, IAsyncHttpClient, ISerde + /// and DexpaceClientOptions under . + /// + /// The collection to register into. + /// The client's name, which is also its service key. Case-sensitive. + /// The builder; chains back to the collection. + /// or is . + /// is blank. + /// The name is already registered with a different configuration section. + public static DexpaceClientBuilder AddDexpaceClient(this IServiceCollection services, string name) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return AddClient(services, name, section: null); + } + + /// + /// Registers the client , reading its settings from (CFG-1 to CFG-7, OBS-35). The keys + /// are relative to the section, case-insensitive and :-separated; a value that does not parse, a record that rejects it and an unknown + /// key each fail the host's start naming the key and never the value. The section is a snapshot taken at first resolution: a reload does not reach + /// a built client. + /// + /// The collection to register into. + /// The client's name, which is also its service key. Case-sensitive. + /// + /// The client's own section, for example configuration.GetSection("Dexpace:GitHub"). Never the configuration root: a root holds every + /// process variable, so it could never boot. + /// + /// The builder; chains back to the collection. + /// , or is . + /// is blank, or is the configuration root. + /// The name is already registered with a different configuration section. + public static DexpaceClientBuilder AddDexpaceClient(this IServiceCollection services, string name, IConfigurationSection configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(configuration); + if (configuration.Path.Length == 0) + { + throw new ArgumentException(ConfigurationReader.RootRule, nameof(configuration)); + } + + return AddClient(services, name, configuration); + } + + private static DexpaceClientBuilder AddClient(IServiceCollection services, string name, IConfigurationSection? section) + { + // SEAM-6 applied to the client: one name registers once; the same call again returns the builder, a different one fails. + var existing = services.FirstOrDefault(d => d.ServiceType == typeof(DexpaceClientRegistration) && Equals(d.ServiceKey, name)); + if (existing?.KeyedImplementationInstance is DexpaceClientRegistration registered) + { + return ReferenceEquals(registered.Section, section) + ? registered.Builder! + : throw new InvalidOperationException( + "The client '" + name + "' is already registered with a different configuration section (SEAM-6). Register a client once, or use another name."); + } + + var registration = DexpaceClientRegistrations.AddClientState(services, name, section); + NamedClient.AddKeyedServices(registration); + var builder = new DexpaceClientBuilder(registration, NamedClient.AddHandlerChain(registration)); + registration.Builder = builder; + return builder; + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/DurationGrammar.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/DurationGrammar.cs new file mode 100644 index 0000000..9790b1b --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/DurationGrammar.cs @@ -0,0 +1,208 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// +/// The duration grammar of CFG-7 (P9-8, P9-29): ISO-8601 day-time durations (PnDTnHnMn.nS, a fraction of at most seven digits +/// on the seconds), a number with a unit (250ms, 3s, 2m, 1h, 1d) and whole milliseconds. Hand-written +/// scanners over ASCII, so no culture, no regular expression and no exception reaches the caller; a failure carries a rule that +/// describes the grammar and never the input. +/// +internal static class DurationGrammar +{ + internal const string Rule = + "not a duration: use ISO-8601 such as PT5S or P1DT2H (no years, months or weeks), a number with a unit such as 250ms, 3s, 2m, 1h or 1d, " + + "or whole milliseconds; a duration is never negative and fits in a TimeSpan"; + + private const int MaxFractionDigits = 7; + + /// Parses (ASCII-trimmed first) as a duration. + /// The configured text. + /// The duration when the method returns . + /// The grammar's rule text when it returns ; never the input. + /// when is a duration. + internal static bool TryParse(string text, out TimeSpan value, out string? rule) + { + ArgumentNullException.ThrowIfNull(text); + value = default; + rule = Rule; + var trimmed = ScalarGrammar.AsciiTrim(text); + if (trimmed.Length == 0) + { + return false; + } + + UInt128 ticks; + var parsed = trimmed[0] is 'P' or 'p' ? TryParseIso(trimmed, out ticks) : TryParsePlain(trimmed, out ticks); + if (!parsed || ticks > (UInt128)long.MaxValue) + { + return false; + } + + value = TimeSpan.FromTicks((long)ticks); + rule = null; + return true; + } + + private static bool TryParseIso(string text, out UInt128 ticks) + { + ticks = 0; + var upper = ScalarGrammar.AsciiUpper(text); + var index = 1; + var any = false; + if (TryReadDigits(upper, ref index, out var days, out _) && index < upper.Length && upper[index] == 'D') + { + ticks += (UInt128)days * (ulong)TimeSpan.TicksPerDay; + index++; + any = true; + } + else if (index != 1) + { + // Digits that are not followed by D: a year, month or week designator, or a stray character. + return false; + } + + if (index == upper.Length) + { + return any; + } + + return upper[index] == 'T' && TryParseTime(upper, index + 1, ref ticks); + } + + // The part after T: [nH][nM][n[.f]S], in that order, at least one component, nothing left over. + private static bool TryParseTime(string text, int index, ref UInt128 ticks) + { + var any = false; + var stage = 0; + while (index < text.Length) + { + if (!TryReadDigits(text, ref index, out var whole, out _) || index >= text.Length) + { + return false; + } + + UInt128 fractionTicks = 0; + if (text[index] == '.') + { + index++; + if (!TryReadFraction(text, ref index, out var digits, out var count) || index >= text.Length || text[index] != 'S') + { + return false; + } + + fractionTicks = (UInt128)digits * Pow10(MaxFractionDigits - count); + } + + var designator = text[index++]; + var order = designator switch { 'H' => 1, 'M' => 2, 'S' => 3, _ => 0 }; + if (order <= stage) + { + return false; + } + + stage = order; + var unit = designator switch { 'H' => (ulong)TimeSpan.TicksPerHour, 'M' => (ulong)TimeSpan.TicksPerMinute, _ => (ulong)TimeSpan.TicksPerSecond }; + ticks += ((UInt128)whole * unit) + fractionTicks; + any = true; + } + + return any; + } + + // [ ], or digits alone as milliseconds. + private static bool TryParsePlain(string text, out UInt128 ticks) + { + ticks = 0; + var index = 0; + if (!TryReadDigits(text, ref index, out var whole, out _)) + { + return false; + } + + var fractionDigits = 0; + ulong fraction = 0; + if (index < text.Length && text[index] == '.') + { + index++; + if (!TryReadFraction(text, ref index, out fraction, out fractionDigits)) + { + return false; + } + } + + if (index == text.Length) + { + ticks = (UInt128)whole * (ulong)TimeSpan.TicksPerMillisecond; + return fractionDigits == 0; + } + + if (text[index] == ' ') + { + index++; + } + + var unitTicks = ScalarGrammar.AsciiLower(text[index..]) switch + { + "ms" => (ulong)TimeSpan.TicksPerMillisecond, + "s" => (ulong)TimeSpan.TicksPerSecond, + "m" => (ulong)TimeSpan.TicksPerMinute, + "h" => (ulong)TimeSpan.TicksPerHour, + "d" => (ulong)TimeSpan.TicksPerDay, + _ => 0UL, + }; + if (unitTicks == 0) + { + return false; + } + + // The fraction of one unit, in ticks, must be exact: a duration finer than a tick fails rather than truncating. + var scaled = (UInt128)fraction * unitTicks; + var divisor = Pow10(fractionDigits); + if (scaled % divisor != 0) + { + return false; + } + + ticks = ((UInt128)whole * unitTicks) + (scaled / divisor); + return true; + } + + private static UInt128 Pow10(int exponent) + { + UInt128 result = 1; + for (var i = 0; i < exponent; i++) + { + result *= 10; + } + + return result; + } + + // ASCII digits only; false when there is none or the value does not fit a ulong. + private static bool TryReadDigits(string text, ref int index, out ulong value, out int count) + { + value = 0; + var start = index; + while (index < text.Length && text[index] is >= '0' and <= '9') + { + var digit = (ulong)(text[index] - '0'); + if (value > (ulong.MaxValue - digit) / 10) + { + count = index - start; + return false; + } + + value = (value * 10) + digit; + index++; + } + + count = index - start; + return count > 0; + } + + // One to seven digits after the point: the tick resolution (a longer fraction is a failure, not a silent truncation). + private static bool TryReadFraction(string text, ref int index, out ulong digits, out int count) => + TryReadDigits(text, ref index, out digits, out count) && count <= MaxFractionDigits; +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/GlobalUsings.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/GlobalUsings.cs new file mode 100644 index 0000000..c706251 --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/GlobalUsings.cs @@ -0,0 +1,10 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// The project's curated global usings: ImplicitUsings is off (Directory.Build.props), so this file is the whole +// implicit set, and any other namespace is imported at the top of the file that uses it. +global using System; +global using System.Collections.Generic; +global using System.Linq; +global using System.Threading; +global using System.Threading.Tasks; diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/ImplementationNames.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/ImplementationNames.cs new file mode 100644 index 0000000..9822c2c --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/ImplementationNames.cs @@ -0,0 +1,43 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Microsoft.Extensions.DependencyInjection; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// Names the implementation behind a keyed descriptor for a failure message (SEAM-5): an instance's type, an implementation type, or a factory's declaring type and method. +internal static class ImplementationNames +{ + /// Describes . + /// A keyed descriptor. + /// The implementation's name. + internal static string Describe(ServiceDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(descriptor); + if (!descriptor.IsKeyedService) + { + return descriptor.ServiceType.FullName ?? descriptor.ServiceType.Name; + } + + if (descriptor.KeyedImplementationInstance is { } instance) + { + return instance.GetType().FullName ?? instance.GetType().Name; + } + + if (descriptor.KeyedImplementationType is { } type) + { + return type.FullName ?? type.Name; + } + + return descriptor.KeyedImplementationFactory is { } factory ? Describe(factory) : "an unknown implementation"; + } + + /// Names a factory delegate by its declaring type and method. + /// The delegate. + /// The declaring type and method. + internal static string Describe(Delegate factory) + { + ArgumentNullException.ThrowIfNull(factory); + return "a factory (" + factory.Method.DeclaringType?.FullName + "." + factory.Method.Name + ")"; + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/KeyTable.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/KeyTable.cs new file mode 100644 index 0000000..cbd12ee --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/KeyTable.cs @@ -0,0 +1,117 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Http.SystemNet; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// One key the reader accepts, and the member it feeds. +/// The key relative to the client's section, :-separated and case-insensitive. +/// The type whose member the key feeds, or for a handler-level key. +/// The property (or, for the credential classes, the constructor parameter) the key feeds. +/// True when the key is a list (child keys or one comma-separated value). +internal sealed record KeyEntry(string Key, Type? Target, string? Member, bool IsList = false); + +/// +/// The keys the reader accepts (P9-4, P9-9): one table, shared by the unknown-key pass and the drift guard. A key that is not here +/// fails the boot naming it; a record member that is neither here nor on a code-only list fails a test (design C). +/// +internal static class KeyTable +{ + /// Every accepted key. + internal static IReadOnlyList Entries { get; } = + [ + new("BaseAddress", typeof(DexpaceClientOptions), nameof(DexpaceClientOptions.BaseAddress)), + new("UserAgent", typeof(DexpaceClientOptions), nameof(DexpaceClientOptions.UserAgent)), + new("OverallTimeout", typeof(DexpaceClientOptions), nameof(DexpaceClientOptions.OverallTimeout)), + new("AttemptTimeout", typeof(DexpaceClientOptions), nameof(DexpaceClientOptions.AttemptTimeout)), + new("Retry:MaxRetryAttempts", typeof(RetryOptions), nameof(RetryOptions.MaxRetryAttempts)), + new("Retry:BaseDelay", typeof(RetryOptions), nameof(RetryOptions.BaseDelay)), + new("Retry:MaxDelay", typeof(RetryOptions), nameof(RetryOptions.MaxDelay)), + new("Retry:FixedDelay", typeof(RetryOptions), nameof(RetryOptions.FixedDelay)), + new("Retry:Multiplier", typeof(RetryOptions), nameof(RetryOptions.Multiplier)), + new("Retry:Jitter", typeof(RetryOptions), nameof(RetryOptions.Jitter)), + new("Retry:HonorRetryAfter", typeof(RetryOptions), nameof(RetryOptions.HonorRetryAfter)), + new("Retry:RetryableStatusCodes", typeof(RetryOptions), nameof(RetryOptions.RetryableStatusCodes), IsList: true), + new("Retry:AttemptHeaderName", typeof(RetryOptions), nameof(RetryOptions.AttemptHeaderName)), + new("Redirect:MaxRedirects", typeof(RedirectOptions), nameof(RedirectOptions.MaxRedirects)), + new("Redirect:AllowHttpsToHttpDowngrade", typeof(RedirectOptions), nameof(RedirectOptions.AllowHttpsToHttpDowngrade)), + new("Redirect:FollowSeeOther", typeof(RedirectOptions), nameof(RedirectOptions.FollowSeeOther)), + new("Redirect:AllowedMethods", typeof(RedirectOptions), nameof(RedirectOptions.AllowedMethods), IsList: true), + new("Logging:Level", typeof(HttpLoggingOptions), nameof(HttpLoggingOptions.Level)), + new("Logging:BodyPreviewSize", typeof(HttpLoggingOptions), nameof(HttpLoggingOptions.BodyPreviewSize)), + new("Logging:AllowedHeaderNames", typeof(HttpLoggingOptions), nameof(HttpLoggingOptions.AllowedHeaderNames), IsList: true), + new("Logging:AllowedQueryParameters", typeof(HttpLoggingOptions), nameof(HttpLoggingOptions.AllowedQueryParameters), IsList: true), + new("Logging:UrlValuedHeaderNames", typeof(HttpLoggingOptions), nameof(HttpLoggingOptions.UrlValuedHeaderNames), IsList: true), + new("Logging:OmitDisallowedHeaders", typeof(HttpLoggingOptions), nameof(HttpLoggingOptions.OmitDisallowedHeaders)), + new("Transport:Timeout", typeof(SystemNetHttpClientOptions), nameof(SystemNetHttpClientOptions.Timeout)), + new("Transport:HeaderDropLogging", typeof(SystemNetHttpClientOptions), nameof(SystemNetHttpClientOptions.HeaderDropLogging)), + new("Transport:ResendBufferLimit", typeof(SystemNetHttpClientOptions), nameof(SystemNetHttpClientOptions.ResendBufferLimit)), + new("Transport:PooledConnectionLifetime", null, null), + new("Transport:Proxy:Type", typeof(ProxyOptions), nameof(ProxyOptions.Type)), + new("Transport:Proxy:Host", typeof(ProxyOptions), nameof(ProxyOptions.Host)), + new("Transport:Proxy:Port", typeof(ProxyOptions), nameof(ProxyOptions.Port)), + new("Transport:Proxy:NonProxyHosts", typeof(ProxyOptions), nameof(ProxyOptions.NonProxyHosts), IsList: true), + new("Transport:Proxy:UserName", typeof(ProxyOptions), nameof(ProxyOptions.UserName)), + new("Transport:Proxy:Password", typeof(ProxyOptions), nameof(ProxyOptions.Password)), + new("Transport:Proxy:BypassAll", typeof(ProxyOptions), nameof(ProxyOptions.BypassAll)), + new("Auth:ApiKey:Key", typeof(ApiKeyCredential), "key"), + new("Auth:ApiKey:HeaderName", typeof(ApiKeyCredential), "header"), + new("Auth:ApiKey:Scheme", typeof(ApiKeyCredential), "scheme"), + new("Auth:Basic:Username", typeof(BasicCredential), "username"), + new("Auth:Basic:Password", typeof(BasicCredential), "password"), + new("Auth:Digest:Username", typeof(DigestCredential), "username"), + new("Auth:Digest:Password", typeof(DigestCredential), "password"), + new("Auth:TokenRefreshMargin", typeof(AuthCredentials), nameof(AuthCredentials.TokenRefreshMargin)), + ]; + + /// + /// Members that are code-only: configuration can never feed them (a delegate, a logger, a clock, a credentials hook, a container service), + /// so the drift guard does not ask for a key. + /// + internal static IReadOnlyList<(Type Type, string Member)> CodeOnly { get; } = + [ + (typeof(RedirectOptions), nameof(RedirectOptions.Predicate)), + (typeof(SystemNetHttpClientOptions), nameof(SystemNetHttpClientOptions.Logger)), + (typeof(SystemNetHttpClientOptions), nameof(SystemNetHttpClientOptions.TimeProvider)), + (typeof(ProxyOptions), nameof(ProxyOptions.ChallengeCredentials)), + (typeof(AuthCredentials), nameof(AuthCredentials.Token)), + ]; + + /// Members that hold a nested record or credential whose own members the keys cover (their group of keys is the member). + internal static IReadOnlyList<(Type Type, string Member)> Groups { get; } = + [ + (typeof(DexpaceClientOptions), nameof(DexpaceClientOptions.Retry)), + (typeof(DexpaceClientOptions), nameof(DexpaceClientOptions.Redirect)), + (typeof(DexpaceClientOptions), nameof(DexpaceClientOptions.Logging)), + (typeof(SystemNetHttpClientOptions), nameof(SystemNetHttpClientOptions.Proxy)), + (typeof(AuthCredentials), nameof(AuthCredentials.ApiKey)), + (typeof(AuthCredentials), nameof(AuthCredentials.Basic)), + (typeof(AuthCredentials), nameof(AuthCredentials.Digest)), + ]; + + /// The keys whose configuration is read by the primary handler, not by a record (the handler-level category). + internal static IReadOnlyList HandlerLevel { get; } = ["Transport:PooledConnectionLifetime"]; + + /// The prefixes of the keys, for the unknown-key pass: a child that leads to a key is not unknown. + internal static bool IsKey(string fullKey, out bool isList) + { + foreach (var entry in Entries) + { + if (string.Equals(entry.Key, fullKey, StringComparison.OrdinalIgnoreCase)) + { + isList = entry.IsList; + return true; + } + } + + isList = false; + return false; + } + + /// True when some key lies below (a group such as Retry or Transport:Proxy). + internal static bool IsGroup(string prefix) => + Entries.Any(entry => entry.Key.StartsWith(prefix + ":", StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/NamedClient.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/NamedClient.cs new file mode 100644 index 0000000..fe3ba19 --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/NamedClient.cs @@ -0,0 +1,110 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Net.Http; +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; +using Dexpace.Sdk.Http.SystemNet; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// +/// The pieces of one named client, built from the container (P9-5, P9-12, P9-13, P9-14). Every factory reads the validated +/// first, so a client that fails validation fails the resolution of each of its keyed services (V4, design D). +/// +internal static class NamedClient +{ + private const string HandlerNamePrefix = "Dexpace."; + private const string PipelineCategory = "Dexpace.Sdk"; + private const string TransportCategory = "Dexpace.Sdk.Http.SystemNet"; + + /// Registers the named HttpClient whose handler chain is the transport's (E): a redirect-off primary, no factory loggers, an infinite lifetime. + internal static IHttpClientBuilder AddHandlerChain(DexpaceClientRegistration registration) + { + var name = registration.Name; + return registration.Services + .AddHttpClient(HandlerNamePrefix + name) + .ConfigurePrimaryHttpMessageHandler(provider => CreatePrimary(provider, registration)) + .RemoveAllLoggers() + .SetHandlerLifetime(Timeout.InfiniteTimeSpan); + } + + /// Registers the keyed lease, default transport, options and pipeline of the client. + internal static void AddKeyedServices(DexpaceClientRegistration registration) + { + var name = registration.Name; + var services = registration.Services; + services.AddKeyedSingleton(name, static (_, _) => new PrimaryHandlerLease()); + var transport = ServiceDescriptor.KeyedSingleton(name, (provider, _) => CreateTransport(provider, registration)); + services.Add(transport); + registration.Transport.Descriptor = transport; + services.AddKeyedSingleton(name, (provider, _) => StateOf(provider, name).Options); + services.AddKeyedSingleton(name, (provider, _) => CreatePipeline(provider, registration)); + } + + private static DexpaceClientState StateOf(IServiceProvider provider, string name) => + provider.GetRequiredService>().Get(name); + + private static ILogger LoggerFor(IServiceProvider provider, string category) => + provider.GetRequiredService().CreateLogger(category); + + private static TimeProvider ClockOf(IServiceProvider provider) => provider.GetService() ?? TimeProvider.System; + + private static SocketsHttpHandler CreatePrimary(IServiceProvider provider, DexpaceClientRegistration registration) + { + var state = StateOf(provider, registration.Name); + var logger = LoggerFor(provider, TransportCategory); + + // CFG-28: the environment is read only when the caller asked, and only here, when the primary is built (P9-15). + var proxy = registration.EnvironmentProxy is { } environment ? environment(logger) : state.Transport.Proxy; + var handler = SystemNetHttpClient.CreatePrimaryHandler(state.Transport with { Proxy = proxy, Logger = logger, TimeProvider = ClockOf(provider) }); + handler.PooledConnectionLifetime = state.PooledConnectionLifetime; + provider.GetRequiredKeyedService(registration.Name).Track(handler); + return handler; + } + + private static SystemNetHttpClient CreateTransport(IServiceProvider provider, DexpaceClientRegistration registration) + { + // The lease first: it is created, so disposed, after the transport (V8); then the validated state; then the chain. + _ = provider.GetRequiredKeyedService(registration.Name); + var state = StateOf(provider, registration.Name); + var chain = provider.GetRequiredService().CreateHandler(HandlerNamePrefix + registration.Name); + var options = state.Transport with { Proxy = null, Logger = LoggerFor(provider, TransportCategory), TimeProvider = ClockOf(provider) }; + return new SystemNetHttpClient(chain, options); + } + + private static HttpPipeline CreatePipeline(IServiceProvider provider, DexpaceClientRegistration registration) + { + var state = StateOf(provider, registration.Name); + var transport = provider.GetRequiredKeyedService(registration.Name); + var clock = ClockOf(provider); + var auth = CreateAuthPolicy(provider, registration, state, clock); +#pragma warning disable CA2000 // The seed pipeline owns nothing (PIPE-27): it is flattened into the builder and dropped, never disposed. + var seed = DexpacePipeline.CreateDefault(transport, auth, LoggerFor(provider, PipelineCategory), clock); +#pragma warning restore CA2000 + var builder = PipelineBuilder.Flatten(seed); + foreach (var policy in registration.Policies) + { + builder.Add(policy(provider)); + } + + return builder.Build(transport, state.Options); + } + + private static HttpPipelinePolicy? CreateAuthPolicy(IServiceProvider provider, DexpaceClientRegistration registration, DexpaceClientState state, TimeProvider clock) + { + if (registration.AuthPolicyFactory is { } factory) + { + return factory(provider); + } + + return registration.AuthDescriptor is { } descriptor + ? new MultiSchemeAuthPolicy(descriptor, state.Credentials ?? new Dexpace.Sdk.Core.Auth.AuthCredentials(), clock) + : null; + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/PrimaryHandlerLease.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/PrimaryHandlerLease.cs new file mode 100644 index 0000000..eb43650 --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/PrimaryHandlerLease.cs @@ -0,0 +1,74 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// +/// Releases the primary handler the package created for a named client when the container is disposed (P9-13, V8). The factory owns the +/// handler chain and never releases a handler whose lifetime is infinite, so without the lease the pooled connections outlive the container. +/// It is a keyed singleton factory, resolved before the transport, so the container disposes it after the transport. The caller's +/// DelegatingHandlers are the factory's and are never touched (XCUT-22). +/// +internal sealed class PrimaryHandlerLease : IDisposable +{ + private readonly List _tracked = []; + private readonly Lock _gate = new(); + private bool _disposed; + + /// The handlers the lease holds, for a test to observe. + internal IReadOnlyList Tracked + { + get + { + lock (_gate) + { + return [.. _tracked]; + } + } + } + + /// Takes ownership of , built by the package; a second call keeps both and disposes both (R7). + /// The primary handler the package built. + internal void Track(IDisposable handler) + { + ArgumentNullException.ThrowIfNull(handler); + var disposeNow = false; + lock (_gate) + { + if (_disposed) + { + disposeNow = true; + } + else + { + _tracked.Add(handler); + } + } + + if (disposeNow) + { + handler.Dispose(); + } + } + + /// + public void Dispose() + { + List release; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + release = [.. _tracked]; + } + + foreach (var handler in release) + { + handler.Dispose(); + } + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/PublicAPI.Shipped.txt b/src/Dexpace.Sdk.Extensions.DependencyInjection/PublicAPI.Shipped.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/PublicAPI.Unshipped.txt b/src/Dexpace.Sdk.Extensions.DependencyInjection/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..addae09 --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/PublicAPI.Unshipped.txt @@ -0,0 +1,27 @@ +#nullable enable +Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder +Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder.AddPolicy(System.Func! factory) -> Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder! +Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder.Configure(System.Func! configure) -> Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder! +Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder.ConfigureCredentials(System.Func! configure) -> Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder! +Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder.ConfigureTransport(System.Func! configure) -> Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder! +Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder.HttpClient.get -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! +Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder.Name.get -> string! +Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder.Services.get -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder.UseAuth(Dexpace.Sdk.Core.Auth.AuthDescriptor! descriptor) -> Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder! +Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder.UseAuthPolicy(System.Func! factory) -> Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder! +Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder.UseProxyFromEnvironment() -> Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder! +Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder.UseProxyFromEnvironment(System.Func! lookup) -> Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder! +Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder.UseSerde(Dexpace.Sdk.Core.Serialization.ISerde! serde) -> Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder! +Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder.UseSerde(System.Func! factory) -> Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder! +Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder.UseTransport(Dexpace.Sdk.Core.Client.IAsyncHttpClient! transport) -> Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder! +Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder.UseTransport(System.Func! factory) -> Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder! +Dexpace.Sdk.Extensions.DependencyInjection.DexpaceConfigurationKeys +Dexpace.Sdk.Extensions.DependencyInjection.DexpaceServiceCollectionExtensions +const Dexpace.Sdk.Extensions.DependencyInjection.DexpaceConfigurationKeys.HttpProxy = "HTTP_PROXY" -> string! +const Dexpace.Sdk.Extensions.DependencyInjection.DexpaceConfigurationKeys.HttpsProxy = "HTTPS_PROXY" -> string! +const Dexpace.Sdk.Extensions.DependencyInjection.DexpaceConfigurationKeys.LogLevel = "Logging:Level" -> string! +const Dexpace.Sdk.Extensions.DependencyInjection.DexpaceConfigurationKeys.MaxRetryAttempts = "Retry:MaxRetryAttempts" -> string! +const Dexpace.Sdk.Extensions.DependencyInjection.DexpaceConfigurationKeys.NoProxy = "NO_PROXY" -> string! +const Dexpace.Sdk.Extensions.DependencyInjection.DexpaceConfigurationKeys.SectionName = "Dexpace" -> string! +static Dexpace.Sdk.Extensions.DependencyInjection.DexpaceServiceCollectionExtensions.AddDexpaceClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name) -> Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder! +static Dexpace.Sdk.Extensions.DependencyInjection.DexpaceServiceCollectionExtensions.AddDexpaceClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, Microsoft.Extensions.Configuration.IConfigurationSection! configuration) -> Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder! diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/README.md b/src/Dexpace.Sdk.Extensions.DependencyInjection/README.md new file mode 100644 index 0000000..eb45c4f --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/README.md @@ -0,0 +1,50 @@ +# Dexpace.Sdk.Extensions.DependencyInjection + +Host integration for the dexpace .NET SDK: named clients registered as keyed services over `Microsoft.Extensions.DependencyInjection`, configured from an +`IConfigurationSection`. It references [`Dexpace.Sdk.Core`](https://www.nuget.org/packages/Dexpace.Sdk.Core), the reference transport +[`Dexpace.Sdk.Http.SystemNet`](https://www.nuget.org/packages/Dexpace.Sdk.Http.SystemNet) and `Microsoft.Extensions.Http`, and no codec. + +> **Pre-release.** Nothing is published yet, and the public API may change before 1.0. Targets `net10.0`. + +```csharp +services.AddDexpaceClient("github", builder.Configuration.GetSection("Dexpace:GitHub")) + .UseSerde(_ => new SystemTextJsonSerde(SystemTextJsonSerde.CreateDefaultOptions(GitHubJsonContext.Default))) + .UseAuth(GitHubAuth.Descriptor) + .Configure(o => o with { Redirect = o.Redirect with { Predicate = GitHubRedirects.Allow } }); + +public sealed class GitHubClient([FromKeyedServices("github")] HttpPipeline pipeline, [FromKeyedServices("github")] ISerde serde) { /* ... */ } +``` + +## What you get + +- **Four keyed singletons per client**, under its name: `HttpPipeline`, `IAsyncHttpClient`, `ISerde` and `DexpaceClientOptions`. `AddDexpaceClient` returns a sealed `DexpaceClientBuilder` (`Configure`, + `ConfigureTransport`, `ConfigureCredentials`, `UseTransport`, `UseSerde`, `UseAuth`, `UseAuthPolicy`, `AddPolicy`, `UseProxyFromEnvironment`, and `HttpClient`, the `IHttpClientBuilder` of the named client). +- **A codec is required**: there is no default. A client without one fails validation with `client 'github' has no ISerde: call UseSerde(...) ...`. +- **Settings from the section you pass** (never the configuration root). Keys are relative to it, case-insensitive and `:`-separated (`Dexpace__Retry__MaxRetryAttempts` as an environment variable): + `BaseAddress`, `UserAgent`, `OverallTimeout`, `AttemptTimeout`; `Retry:*`, `Redirect:*`, `Logging:*`, `Transport:*` (including `Transport:Proxy:*` and `Transport:PooledConnectionLifetime`) and `Auth:*`. + The full table is in [`docs/sdk-documentation/dependency-injection.md`](https://github.com/dexpace/dotnet-sdk/blob/main/docs/sdk-documentation/dependency-injection.md). +- **Strict grammars**: base-10 integers, `true`/`false`, durations (`PT5S`, `250ms`, `3s`, `1500` as milliseconds, `none`), enums by name, lists that replace the defaults. An empty value is absent (the record default applies, and it + silently shadows a lower provider's value). +- **Failures at start, naming the key and never the value.** Every bad value, rejected setting and unknown key is reported at once as `": "` through `ValidateOnStart` (or `IStartupValidator.Validate()` with no host, or the first + resolution). +- **No reload**: the snapshot is taken at first resolution. + +## The named client + +The transport is built over `IHttpClientFactory` with a redirect-off primary handler (`SystemNetHttpClient.CreatePrimaryHandler`), the factory's own loggers removed (the SDK's redacted `http.request`/`http.response` events are the +one HTTP log stream), an infinite handler lifetime with `Transport:PooledConnectionLifetime` (default 2 minutes) doing the rotation, and a container-owned lease that releases the primary when the container is disposed. Dispose +the provider with `DisposeAsync`. `HttpClient.ConfigureHttpClient(...)` has no effect. + +## Proxy + +Explicit `Transport:Proxy:*` keys, **or** `UseProxyFromEnvironment()` / `UseProxyFromEnvironment(key => configuration[key])` in code. No configuration value turns the environment read on (`CFG-28`); two sources fail validation. + +## One retry layer + +The package registers no retrying handler. Compose the standard resilience handler without a second retry engine like this (the SDK's own `MaxRetryAttempts = 0` would still leave the handler re-sending request bodies, and the +handler-side `MaxRetryAttempts = 0` is refused by its own validation): + +```csharp +services.AddDexpaceClient("github", section) + .HttpClient.AddStandardResilienceHandler(o => o.Retry.ShouldHandle = _ => ValueTask.FromResult(false)); +``` diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/RecordBinder.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/RecordBinder.cs new file mode 100644 index 0000000..c2a4742 --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/RecordBinder.cs @@ -0,0 +1,94 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Http.Common; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// +/// Builds one immutable record member by member (P9-4, P9-9): each key is parsed, applied with a with transform, and an accessor's +/// rejection becomes a failure under that key, so one boot reports every bad key. +/// +/// The record being built. +internal sealed class RecordBinder(ConfigSection section, TRecord start) + where TRecord : class +{ + /// The record so far. + internal TRecord Value { get; private set; } = start; + + /// Binds an integer key. + internal void Int(string key, Func set) => Bind(key, ScalarGrammar.TryParseInt32, set); + + /// Binds a 64-bit integer key. + internal void Long(string key, Func set) => Bind(key, ScalarGrammar.TryParseInt64, set); + + /// Binds a boolean key. + internal void Bool(string key, Func set) => Bind(key, ScalarGrammar.TryParseBoolean, set); + + /// Binds a decimal key. + internal void Number(string key, Func set) => Bind(key, ScalarGrammar.TryParseDouble, set); + + /// Binds a string key. + internal void Str(string key, Func set) => Bind(key, ScalarGrammar.TryParseString, set); + + /// Binds a duration key. + internal void Duration(string key, Func set) => Bind(key, ScalarGrammar.TryParseDuration, set); + + /// Binds a duration key that admits none. + internal void OptionalDuration(string key, Func set) => Bind(key, ScalarGrammar.TryParseOptionalDuration, set); + + /// Binds an enum key, by name. + internal void EnumName(string key, Func set) + where TEnum : struct, System.Enum => Bind(key, ScalarGrammar.TryParseEnum, set); + + /// Binds a key through its own parser; replaces the accessor's text where it could echo the value. + internal void Bind(string key, Parser parse, Func set, string? fixedRule = null) + { + if (!section.TryRead(key, parse, out var parsed)) + { + return; + } + + Apply(key, parsed, set, fixedRule); + } + + /// Binds a list key; the list replaces the record's default. + internal void Items(string key, Parser element, Func, TRecord> set, string? fixedRule = null) + { + if (section.TryReadList(key, element, out var items)) + { + Apply(key, items, set, fixedRule); + } + } + + /// Binds a list of HTTP method tokens through , which never echoes its input (HTTP-20). + internal void Methods(string key, Func, TRecord> set) => Items(key, ParseMethod, set); + + private void Apply(string key, T parsed, Func set, string? fixedRule) + { + try + { + Value = set(Value, parsed); + } + catch (ArgumentException ex) + { + section.Fail(key, fixedRule ?? ConfigSection.Sanitise(ex)); + } + } + + private static bool ParseMethod(string text, out Method value, out string? rule) + { + try + { + value = Method.Of(text); + rule = null; + return true; + } + catch (ArgumentException) + { + value = null!; + rule = "not an HTTP method token"; + return false; + } + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/ScalarGrammar.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/ScalarGrammar.cs new file mode 100644 index 0000000..eabb59b --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/ScalarGrammar.cs @@ -0,0 +1,236 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Globalization; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// A parser from configured text to a value: with the value, or with a rule. +/// The parsed type. +/// The ASCII-trimmed, non-empty text. +/// The value on success. +/// The rule text on failure; never the input. +/// Whether the text parsed. +internal delegate bool Parser(string text, out T value, out string? rule); + +/// +/// The scalar grammars of the configuration reader (CFG-5, CFG-6, OBS-35; P9-8, P9-27). Every parser returns a rule on failure and +/// never throws; no rule quotes the input, because a configured value can be a secret (XCUT-19). Nothing reads the culture. +/// +internal static class ScalarGrammar +{ + /// ASCII whitespace: space, tab, line feed, vertical tab, form feed and carriage return (P9-27). + private static readonly char[] s_whitespace = [' ', '\t', '\n', '\u000B', '\f', '\r']; + + /// Trims ASCII whitespace from both ends. + /// The text. + /// The trimmed text. + internal static string AsciiTrim(string text) + { + ArgumentNullException.ThrowIfNull(text); + return text.Trim(s_whitespace); + } + + /// Upper-cases ASCII letters only, so no culture or special casing rule can turn a non-ASCII letter into an ASCII one. + /// The text. + /// The text with a to z upper-cased. + internal static string AsciiUpper(string text) => string.Create(text.Length, text, static (span, source) => + { + for (var i = 0; i < span.Length; i++) + { + var c = source[i]; + span[i] = c is >= 'a' and <= 'z' ? (char)(c - 32) : c; + } + }); + + /// Lower-cases ASCII letters only. + /// The text. + /// The text with A to Z lower-cased. + internal static string AsciiLower(string text) => string.Create(text.Length, text, static (span, source) => + { + for (var i = 0; i < span.Length; i++) + { + var c = source[i]; + span[i] = c is >= 'A' and <= 'Z' ? (char)(c + 32) : c; + } + }); + + /// CFG-5: base-10, [+-]?[0-9]+ in ASCII, in the range of . + internal static bool TryParseInt32(string text, out int value, out string? rule) + { + value = 0; + rule = "not a base-10 integer that fits a 32-bit integer (digits only, an optional sign, no separators, exponent or fraction)"; + if (!IsSignedDigits(text) || !int.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out value)) + { + return false; + } + + rule = null; + return true; + } + + /// CFG-5: base-10, [+-]?[0-9]+ in ASCII, in the range of . + internal static bool TryParseInt64(string text, out long value, out string? rule) + { + value = 0; + rule = "not a base-10 integer that fits a 64-bit integer (digits only, an optional sign, no separators, exponent or fraction)"; + if (!IsSignedDigits(text) || !long.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out value)) + { + return false; + } + + rule = null; + return true; + } + + /// CFG-6: only true or false, case-insensitive (the caller has ASCII-trimmed). + internal static bool TryParseBoolean(string text, out bool value, out string? rule) + { + value = false; + if (string.Equals(AsciiLower(text), "true", StringComparison.Ordinal)) + { + value = true; + rule = null; + return true; + } + + if (string.Equals(AsciiLower(text), "false", StringComparison.Ordinal)) + { + rule = null; + return true; + } + + rule = "not a boolean: only true or false, in any case (not 1, 0, yes, no, on or off)"; + return false; + } + + /// A finite number in invariant floating-point notation; the record's own range applies after. + internal static bool TryParseDouble(string text, out double value, out string? rule) + { + value = 0; + rule = "not a finite decimal number in invariant notation (for example 1.5)"; + if (!double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value) || !double.IsFinite(value)) + { + return false; + } + + rule = null; + return true; + } + + /// A duration (CFG-7). + internal static bool TryParseDuration(string text, out TimeSpan value, out string? rule) => + DurationGrammar.TryParse(text, out value, out rule); + + /// A duration, or none (any case) for . + internal static bool TryParseOptionalDuration(string text, out TimeSpan? value, out string? rule) + { + if (string.Equals(AsciiLower(text), "none", StringComparison.Ordinal)) + { + value = null; + rule = null; + return true; + } + + var parsed = DurationGrammar.TryParse(text, out var duration, out rule); + value = parsed ? duration : null; + if (!parsed) + { + rule += "; or none"; + } + + return parsed; + } + + /// An enum by name only, case-insensitive (OBS-35): a number, an undefined value or any other name fails and the rule lists the names. + internal static bool TryParseEnum(string text, out TEnum value, out string? rule) + where TEnum : struct, Enum + { + value = default; + foreach (var name in Enum.GetNames()) + { + if (string.Equals(name, text, StringComparison.OrdinalIgnoreCase) && !text.Any(static c => c > '\u007F')) + { + value = Enum.Parse(name); + rule = null; + return true; + } + } + + rule = "not one of the names " + string.Join(", ", Enum.GetNames()) + " (by name only, in any case)"; + return false; + } + + /// An absolute URI; the record checks scheme and fragment. + internal static bool TryParseAbsoluteUri(string text, out Uri value, out string? rule) + { + if (Uri.TryCreate(text, UriKind.Absolute, out var uri)) + { + value = uri; + rule = null; + return true; + } + + value = null!; + rule = "not an absolute URI"; + return false; + } + + /// Any non-empty string. + internal static bool TryParseString(string text, out string value, out string? rule) + { + value = text; + rule = null; + return true; + } + + /// + /// Splits a scalar list on commas, trimming each item; an empty item is a failure, never silently dropped. none is the empty list. + /// + /// The ASCII-trimmed, non-empty text. + /// The items. + /// The failure's rule. + /// Whether the text split cleanly. + internal static bool TrySplitList(string text, out List items, out string? rule) + { + items = []; + rule = null; + if (string.Equals(AsciiLower(text), "none", StringComparison.Ordinal)) + { + return true; + } + + foreach (var part in text.Split(',')) + { + var item = AsciiTrim(part); + if (item.Length == 0) + { + rule = "a list item is empty (a stray or trailing comma)"; + return false; + } + + items.Add(item); + } + + return true; + } + + private static bool IsSignedDigits(string text) + { + var start = text.Length > 0 && text[0] is '+' or '-' ? 1 : 0; + if (text.Length == start) + { + return false; + } + + for (var i = start; i < text.Length; i++) + { + if (text[i] is < '0' or > '9') + { + return false; + } + } + + return true; + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/ServiceSlot.cs b/src/Dexpace.Sdk.Extensions.DependencyInjection/ServiceSlot.cs new file mode 100644 index 0000000..9f491a3 --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/ServiceSlot.cs @@ -0,0 +1,49 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Microsoft.Extensions.DependencyInjection; + +namespace Dexpace.Sdk.Extensions.DependencyInjection; + +/// +/// One role of a named client (the transport or the codec): the descriptor in the collection and the explicit install that won it (SEAM-5, +/// SEAM-6; P9-16). An explicit install replaces the package's default descriptor; the same install again is a no-op and a different one throws +/// at the call, naming the client and both implementations. +/// +internal sealed class ServiceSlot(string role) +{ + /// The descriptor currently registered for the role, or when none is. + internal ServiceDescriptor? Descriptor { get; set; } + + /// The instance or delegate of the explicit install; while the default stands. + internal object? Identity { get; private set; } + + /// How the explicit install is named in a message. + internal string? Description { get; private set; } + + /// Installs as the role's registration. + internal void Install(DexpaceClientRegistration registration, object identity, string description, ServiceDescriptor descriptor) + { + if (Identity is not null) + { + if (ReferenceEquals(Identity, identity) || (Identity is Delegate existing && existing.Equals(identity))) + { + return; + } + + throw new InvalidOperationException( + "Client '" + registration.Name + "' already has " + role + " " + Description + "; a second, different " + role + " (" + + description + ") cannot be installed (SEAM-6). Install one " + role + " per client."); + } + + if (Descriptor is not null) + { + registration.Services.Remove(Descriptor); + } + + registration.Services.Add(descriptor); + Descriptor = descriptor; + Identity = identity; + Description = description; + } +} diff --git a/src/Dexpace.Sdk.Extensions.DependencyInjection/packages.lock.json b/src/Dexpace.Sdk.Extensions.DependencyInjection/packages.lock.json new file mode 100644 index 0000000..919555c --- /dev/null +++ b/src/Dexpace.Sdk.Extensions.DependencyInjection/packages.lock.json @@ -0,0 +1,160 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.294, )", + "resolved": "3.0.294", + "contentHash": "j78ENG75OozcuL0OPSIjvgLaYI4F5nS8mpK02PlXCCCXj8A4CAIfpKw27ZPvG4k8TweGqParV8Dwm2VAwBBmgg==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "W4kJGezNIKLzo0Ak5FAQDFvkMf2U7DtGL4THmHyRSApfKsKt5V+eX/bU0ZLKAt/uf9Bb2o1bi0YDKj/GRB/vYQ==" + }, + "Microsoft.Extensions.Http": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "x/fnoUGgmIUjSgLR1p6Mpi/vXc1Xe0xDPJnggPp23I6vwM/kY4nxyOjo8i6G5KPVw+esJM+rJsqnHbdjasvCBw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Diagnostics": "10.0.12", + "Microsoft.Extensions.Logging": "10.0.12", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "e3IPP32CRNL031VZJAUTlCTG0YN7WFh4mN3fsSHTDQCJB+3+f0jGycv4fXk3rrftaY3B85XrQaj7sRthrOsavg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dAgIf1TOr8KLs+aBRIbXUZBjHoSH4rDG8+XkX/Q6AZwkQdMA0+yPDKTHsieeXdZDfpOpZVHzOnuAb5Z2nX3KsA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.12", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "amx0O/R4dReqKlC3T740+CHpX8D8tAIV0f5kAPfFi4XK9jYo9BSepGt9/rWfvvCoSMaZ1+XvnqPp/Vv2fmTdAg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.12", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.12", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.12" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "fMp91qlRlMOuHfMyp7LuAnOS+MOeWTF1obJmyFcs4nZCXu6pr+QB1AbFJcl2qpBltBrqF/Y7+Rn9IuNcSuctig==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.12", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "rqpu4qj5WE9x1IHGXSIgHBKi7IUlQaHyp4aXCYIanG2OghlUMFZpZTgExaXwcvmLAJHsxKQWMPpc7D2WIbCVtA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Configuration.Binder": "10.0.12", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "dexpace.sdk.core": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )" + } + }, + "dexpace.sdk.http.systemnet": { + "type": "Project", + "dependencies": { + "Dexpace.Sdk.Core": "[0.0.1-alpha.1, )" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + } + } + } +} \ No newline at end of file diff --git a/src/Dexpace.Sdk.Http.SystemNet/PublicAPI.Unshipped.txt b/src/Dexpace.Sdk.Http.SystemNet/PublicAPI.Unshipped.txt index 7f573a3..55b257b 100644 --- a/src/Dexpace.Sdk.Http.SystemNet/PublicAPI.Unshipped.txt +++ b/src/Dexpace.Sdk.Http.SystemNet/PublicAPI.Unshipped.txt @@ -35,5 +35,6 @@ Dexpace.Sdk.Http.SystemNet.SystemNetHttpClientOptions.Timeout.init -> void override Dexpace.Sdk.Http.SystemNet.SystemNetHttpClientOptions.Equals(object? obj) -> bool override Dexpace.Sdk.Http.SystemNet.SystemNetHttpClientOptions.GetHashCode() -> int override Dexpace.Sdk.Http.SystemNet.SystemNetHttpClientOptions.ToString() -> string! +static Dexpace.Sdk.Http.SystemNet.SystemNetHttpClient.CreatePrimaryHandler(Dexpace.Sdk.Http.SystemNet.SystemNetHttpClientOptions! options) -> System.Net.Http.SocketsHttpHandler! static Dexpace.Sdk.Http.SystemNet.SystemNetHttpClientOptions.operator !=(Dexpace.Sdk.Http.SystemNet.SystemNetHttpClientOptions? left, Dexpace.Sdk.Http.SystemNet.SystemNetHttpClientOptions? right) -> bool static Dexpace.Sdk.Http.SystemNet.SystemNetHttpClientOptions.operator ==(Dexpace.Sdk.Http.SystemNet.SystemNetHttpClientOptions? left, Dexpace.Sdk.Http.SystemNet.SystemNetHttpClientOptions? right) -> bool diff --git a/src/Dexpace.Sdk.Http.SystemNet/README.md b/src/Dexpace.Sdk.Http.SystemNet/README.md index 2aed531..1c1aecf 100644 --- a/src/Dexpace.Sdk.Http.SystemNet/README.md +++ b/src/Dexpace.Sdk.Http.SystemNet/README.md @@ -19,6 +19,10 @@ What a constructor takes says who owns what: A handler whose primary `SocketsHttpHandler`/`HttpClientHandler` follows redirects (`AllowAutoRedirect` defaults to `true`) is rejected with `ArgumentException`: the SDK's `RedirectPolicy` is the only redirect authority. Set `AllowAutoRedirect = false`. +`SystemNetHttpClient.CreatePrimaryHandler(SystemNetHttpClientOptions)` returns the `SocketsHttpHandler` the owned construction builds (no redirects, direct unless a proxy is set, server credentials +unset), for a primary handler in an `IHttpClientFactory` chain; the caller owns it, and the handler constructor then takes the chain with `Proxy` left `null`. +[`Dexpace.Sdk.Extensions.DependencyInjection`](https://www.nuget.org/packages/Dexpace.Sdk.Extensions.DependencyInjection) builds a named client that way. + ## Behaviour - **Streaming.** Response bodies are delivered as live streams (`HttpCompletionOption.ResponseHeadersRead`), diff --git a/src/Dexpace.Sdk.Http.SystemNet/SystemNetHttpClient.cs b/src/Dexpace.Sdk.Http.SystemNet/SystemNetHttpClient.cs index 0d6352c..7ff82ca 100644 --- a/src/Dexpace.Sdk.Http.SystemNet/SystemNetHttpClient.cs +++ b/src/Dexpace.Sdk.Http.SystemNet/SystemNetHttpClient.cs @@ -308,10 +308,6 @@ private SystemNetHttpClient(SystemHttpClient client, Construction construction, options.ResendBufferLimit, options.TimeProvider, ResendCapable: construction != Construction.Owned || CarriesProxyCredentials(options.Proxy)); - if (construction == Construction.Owned && options.Proxy is { } proxy) - { - ProxyWarnings.Emit(options.Logger, proxy); - } } // What built the native client decides whether it can re-send a request behind the transport's back (TRANSPORT-18). @@ -490,12 +486,41 @@ private Response AdaptGuarded(HttpResponseMessage native, Uri sentUri, Request r // options the handler goes direct (UseProxy = false, Proxy = null), so the runtime's process-wide default proxy, which reads // HTTP(S)_PROXY and NO_PROXY, is never consulted (CFG-28, P8b-4); with one, an adapter over ProxyOptions is installed. // The handler's own Credentials stay unset, so a proxy credential can only answer a 407 from that proxy (TRANSPORT-30). -#pragma warning disable RS0030 // The transport's owned-client factory: the one sanctioned construction (styleguide 13.8, P8b-3). - private static SystemHttpClient CreateOwnedClient(SystemNetHttpClientOptions options, Action? configure) + /// + /// Builds the SocketsHttpHandler the owned construction uses, so a factory-built handler chain starts from the same + /// invariants: it never follows a redirect (TRANSPORT-1), it goes direct unless + /// is set (CFG-28), and its server Credentials stay unset so a proxy credential can only answer a 407 (TRANSPORT-30). + /// + /// + /// The transport's settings. Only and + /// are read: the logger receives the TRANSPORT-30 warnings for a proxy setting the native client cannot honour. + /// + /// A new handler, owned by the caller. + /// + /// The caller owns the returned handler and must dispose it. Pass it as the primary handler of a chain whose outermost handler is given + /// to , with a + /// whose is : that constructor refuses a proxy (TRANSPORT-15), + /// because a handler you own keeps its own. + /// + /// is . + public static SocketsHttpHandler CreatePrimaryHandler(SystemNetHttpClientOptions options) { ArgumentNullException.ThrowIfNull(options); var proxy = options.Proxy is { } configured ? new ProxyOptionsWebProxy(configured) : null; var handler = new SocketsHttpHandler { AllowAutoRedirect = false, UseProxy = proxy is not null, Proxy = proxy }; + if (options.Proxy is { } warned) + { + ProxyWarnings.Emit(options.Logger, warned); + } + + return handler; + } + +#pragma warning disable RS0030 // The transport's owned-client factory: the one sanctioned construction (styleguide 13.8, P8b-3). + private static SystemHttpClient CreateOwnedClient(SystemNetHttpClientOptions options, Action? configure) + { + ArgumentNullException.ThrowIfNull(options); + var handler = CreatePrimaryHandler(options); configure?.Invoke(handler); return new(handler) { Timeout = Timeout.InfiniteTimeSpan }; } diff --git a/tests/Dexpace.Sdk.AotSmoke/Dexpace.Sdk.AotSmoke.csproj b/tests/Dexpace.Sdk.AotSmoke/Dexpace.Sdk.AotSmoke.csproj index 06e3504..fcda437 100644 --- a/tests/Dexpace.Sdk.AotSmoke/Dexpace.Sdk.AotSmoke.csproj +++ b/tests/Dexpace.Sdk.AotSmoke/Dexpace.Sdk.AotSmoke.csproj @@ -3,7 +3,8 @@ Exe @@ -30,6 +31,7 @@ + diff --git a/tests/Dexpace.Sdk.AotSmoke/SmokeChecks.DependencyInjection.cs b/tests/Dexpace.Sdk.AotSmoke/SmokeChecks.DependencyInjection.cs new file mode 100644 index 0000000..11c17b7 --- /dev/null +++ b/tests/Dexpace.Sdk.AotSmoke/SmokeChecks.DependencyInjection.cs @@ -0,0 +1,91 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using Dexpace.Sdk.Conformance.Wire; +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.Pipeline; +using Dexpace.Sdk.Core.Serialization; +using Dexpace.Sdk.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Dexpace.Sdk.AotSmoke; + +// Phase 9: the DI package. The configuration reader is plain code (no binder, no reflection), the factories are lambdas and the +// enum names come from Enum.GetNames(), so registration, validation and the named client's transport must survive trimming and +// NativeAOT (P9-21, V7). The client registers a stub codec because a client without one fails validation (design D). +internal static partial class SmokeChecks +{ + private static async Task CheckPhase9Async() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Smoke:Retry:BaseDelay"] = "250ms", + ["Smoke:Logging:Level"] = "Headers", + ["Smoke:Redirect:AllowedMethods"] = "GET, HEAD", + ["Smoke:Retry:RetryableStatusCodes"] = "429, 503", + }) + .Build(); + var services = new ServiceCollection(); + _ = services.AddDexpaceClient("smoke", configuration.GetSection("Smoke")).UseSerde(new StubSerde()); + await using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }); + + provider.GetRequiredService().Validate(); + var options = provider.GetRequiredKeyedService("smoke"); + Expect(options.Retry.BaseDelay == TimeSpan.FromMilliseconds(250), "The DI reader parses a shorthand duration"); + Expect(options.Logging.Level == HttpLogLevel.Headers, "The DI reader parses a log level by name"); + Expect(options.Redirect.AllowedMethods.Count == 2 && options.Redirect.AllowedMethods.Contains(Method.Head), "The DI reader parses a method list"); + Expect(options.Retry.RetryableStatusCodes.Count == 2 && options.Retry.RetryableStatusCodes.Contains(503), "The DI reader parses a status list"); + Expect(provider.GetRequiredKeyedService("smoke") is not null, "The keyed pipeline resolves"); + + await using var server = LoopbackServer.Start(LoopbackResponse.Redirect(302, "http://127.0.0.1:9/never")); + var transport = provider.GetRequiredKeyedService("smoke"); + using var response = await transport.ExecuteAsync(Request.Get(server.Url("/start").AbsoluteUri), CancellationToken.None); + Expect(response.Status.Code == 302, "The named client's transport returns the 302 unchanged (TRANSPORT-1)"); + + var bad = new ServiceCollection(); + _ = bad.AddDexpaceClient("bad", new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Bad:Retry:MaxRetryAttempts"] = "abc" }).Build().GetSection("Bad")) + .UseSerde(new StubSerde()); + await using var badProvider = bad.BuildServiceProvider(); + var failure = Record(() => badProvider.GetRequiredService().Validate()); + Expect(failure is not null, "A bad value fails validation with OptionsValidationException"); + Expect(failure!.Message.Contains("Retry:MaxRetryAttempts", StringComparison.Ordinal) && !failure.Message.Contains("abc", StringComparison.Ordinal), "The failure names the key and never the value"); + } + + private static OptionsValidationException? Record(Action action) + { + try + { + action(); + return null; + } + catch (OptionsValidationException ex) + { + return ex; + } + } + + // A codec that does nothing: the check is about registration, not about wire formats. + private sealed class StubSerde : ISerde + { + public MediaType DefaultMediaType => CommonMediaTypes.ApplicationJson; + + public ValueTask SerializeAsync(Stream destination, T value, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + + public ValueTask DeserializeAsync(Stream source, CancellationToken cancellationToken = default) => ValueTask.FromResult(default); + + public void Serialize(IBufferWriter destination, T value) + { + } + + public T? Deserialize(ReadOnlySpan utf8) => default; + } +} diff --git a/tests/Dexpace.Sdk.AotSmoke/SmokeChecks.cs b/tests/Dexpace.Sdk.AotSmoke/SmokeChecks.cs index d94f9d5..4595498 100644 --- a/tests/Dexpace.Sdk.AotSmoke/SmokeChecks.cs +++ b/tests/Dexpace.Sdk.AotSmoke/SmokeChecks.cs @@ -68,6 +68,7 @@ public static async Task RunAllAsync() await CheckPhase7cPaginationAsync(); await CheckPhase8aConformanceAsync(); await CheckPhase8bAsync(); + await CheckPhase9Async(); } catch (SmokeFailureException failure) { diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/AddDexpaceClientTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/AddDexpaceClientTests.cs new file mode 100644 index 0000000..fd8c7c8 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/AddDexpaceClientTests.cs @@ -0,0 +1,87 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// CFG-37, CFG-1, SEAM-6 (P9-5): the registration entry point, one registration per name. +[Trait("Category", "Unit")] +public sealed class AddDexpaceClientTests +{ + [Fact] + public void AddDexpaceClient_returns_a_builder_for_the_name() + { + var services = new ServiceCollection(); + + var builder = services.AddDexpaceClient("github"); + + Assert.Equal("github", builder.Name); + Assert.Same(services, builder.Services); + Assert.NotNull(builder.HttpClient); + Assert.Equal("Dexpace.github", builder.HttpClient.Name); + } + + [Fact] + public void The_section_overload_takes_a_section_and_rejects_the_root() + { + var services = new ServiceCollection(); + var root = Config.Root(("PATH", "/usr/bin")); + + var exception = Assert.Throws(() => services.AddDexpaceClient("github", root.GetSection(string.Empty))); + + Assert.Contains("not the configuration root", exception.Message, StringComparison.Ordinal); + Assert.Equal("github", services.AddDexpaceClient("github", Config.Section()).Name); + } + + [Fact] + public void A_second_call_for_the_same_name_returns_the_builder_with_the_same_section_and_throws_otherwise() + { + var services = new ServiceCollection(); + var section = Config.Section(); + var first = services.AddDexpaceClient("github", section); + + Assert.Same(first, services.AddDexpaceClient("github", section)); + var exception = Assert.Throws(() => services.AddDexpaceClient("github", Config.Section())); + Assert.Contains("'github'", exception.Message, StringComparison.Ordinal); + Assert.Throws(() => services.AddDexpaceClient("github")); + + var bare = new ServiceCollection(); + Assert.Same(bare.AddDexpaceClient("a"), bare.AddDexpaceClient("a")); + } + + [Fact] + public void Two_names_are_independent_and_case_sensitive() + { + var services = new ServiceCollection(); + + var lower = services.AddDexpaceClient("github"); + var upper = services.AddDexpaceClient("GitHub"); + + Assert.NotSame(lower, upper); + } + + [Fact] + public void Configure_and_ConfigureTransport_record_their_transforms_in_call_order() + { + var services = new ServiceCollection(); + var builder = services.AddDexpaceClient("github"); + var registration = (DexpaceClientRegistration)services.Single(d => d.ServiceType == typeof(DexpaceClientRegistration)).KeyedImplementationInstance!; + + Func first = static o => o; + Func second = static o => o; + Assert.Same(builder, builder.Configure(first).Configure(second)); + + Assert.Equal([first, second], registration.OptionsTransforms); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void A_blank_name_is_rejected(string name) + { + Assert.Throws(() => new ServiceCollection().AddDexpaceClient(name)); + Assert.Throws(() => new ServiceCollection().AddDexpaceClient(null!)); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Architecture/IlCallScanner.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Architecture/IlCallScanner.cs new file mode 100644 index 0000000..3daaa6b --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Architecture/IlCallScanner.cs @@ -0,0 +1,170 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Reflection; +using System.Reflection.Emit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests.Architecture; + +/// One call site found in a method body: the method that contains it and the method it calls. +/// The method or constructor whose IL holds the call. +/// The method the call, callvirt, newobj, ldftn or ldvirtftn targets. +internal sealed record IlCall(MethodBase CallerMethod, MethodBase Callee) +{ + /// The type that declares ; a compiler-generated state machine for an async method. + public Type Caller => CallerMethod.DeclaringType!; + + /// The call as Type.Method -> Type.Callee, for an assertion message. + public override string ToString() => + $"{Caller.FullName}.{CallerMethod.Name} -> {Callee.DeclaringType?.FullName}.{Callee.Name}"; +} + +/// One string literal loaded (ldstr) in a method body. +/// The method or constructor whose IL holds the load. +/// The literal. +internal sealed record IlString(MethodBase CallerMethod, string Value); + +/// +/// A test-only IL walker (phase 8b, CFG-28 and TRANSPORT-9 architecture pins): it lists every call site in every method +/// and constructor body of an assembly's types, so a test can assert that a banned or unwanted callee has no caller. It +/// reads MethodBody.GetILAsByteArray(), decodes opcodes from a table built once from and +/// resolves a callee token with Module.ResolveMethod. Reflection lives here and never in src/. +/// +internal static class IlCallScanner +{ + private const BindingFlags AllMembers = + BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + + private static readonly OpCode[] s_oneByte = new OpCode[256]; + private static readonly OpCode[] s_twoByte = new OpCode[256]; + + static IlCallScanner() + { + foreach (var field in typeof(OpCodes).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (field.GetValue(null) is not OpCode opCode) + { + continue; + } + + var value = (ushort)opCode.Value; + if (opCode.Size == 1) + { + s_oneByte[value & 0xFF] = opCode; + } + else + { + s_twoByte[value & 0xFF] = opCode; + } + } + } + + /// + /// Every call site in every type of (nested and compiler-generated types included), except the types a + /// coverage tool injects: coverlet adds a Coverlet.Core.Instrumentation.Tracker type to the instrumented assembly whose + /// own code reads the environment, which is not the library's. + /// + /// The assembly to scan. + public static IReadOnlyList FindCalls(Assembly assembly) => + FindCalls(assembly.GetTypes().Where(type => type.FullName?.StartsWith("Coverlet.Core.Instrumentation", StringComparison.Ordinal) != true)); + + /// Every string literal loaded in every method body of (phase 9: the SectionName "published, not read" scan). + /// The assembly to scan. + public static IReadOnlyList FindStrings(Assembly assembly) + { + var strings = new List(); + foreach (var type in assembly.GetTypes().Where(type => type.FullName?.StartsWith("Coverlet.Core.Instrumentation", StringComparison.Ordinal) != true)) + { + foreach (var member in type.GetMethods(AllMembers).Cast().Concat(type.GetConstructors(AllMembers))) + { + ScanBody(member, null, [], strings); + } + } + + return strings; + } + + /// Every call site in the given types' declared methods and constructors. + /// The types to scan. + public static IReadOnlyList FindCalls(IEnumerable types) + { + var calls = new List(); + foreach (var type in types) + { + var typeArguments = type.IsGenericTypeDefinition ? type.GetGenericArguments() : null; + foreach (var member in type.GetMethods(AllMembers).Cast().Concat(type.GetConstructors(AllMembers))) + { + ScanBody(member, typeArguments, calls, null); + } + } + + return calls; + } + + private static void ScanBody(MethodBase member, Type[]? typeArguments, List calls, List? strings) + { + if (member.GetMethodBody()?.GetILAsByteArray() is not { } il) + { + return; + } + + var methodArguments = member.IsGenericMethodDefinition ? member.GetGenericArguments() : null; + var position = 0; + while (position < il.Length) + { + OpCode opCode; + if (il[position] == 0xFE) + { + opCode = s_twoByte[il[position + 1]]; + position += 2; + } + else + { + opCode = s_oneByte[il[position]]; + position++; + } + + if (strings is not null && opCode == OpCodes.Ldstr) + { + strings.Add(new IlString(member, member.Module.ResolveString(BitConverter.ToInt32(il, position)))); + } + else if (strings is null && IsCall(opCode)) + { + var callee = Resolve(member.Module, BitConverter.ToInt32(il, position), typeArguments, methodArguments); + if (callee is not null) + { + calls.Add(new IlCall(member, callee)); + } + } + + position += OperandSize(opCode, il, position); + } + } + + private static bool IsCall(OpCode opCode) => + opCode == OpCodes.Call || opCode == OpCodes.Callvirt || opCode == OpCodes.Newobj || opCode == OpCodes.Ldftn || opCode == OpCodes.Ldvirtftn; + + private static MethodBase? Resolve(Module module, int token, Type[]? typeArguments, Type[]? methodArguments) + { + try + { + return module.ResolveMethod(token, typeArguments, methodArguments); + } + catch (Exception ex) when (ex is ArgumentException or MissingMemberException or BadImageFormatException or NotSupportedException) + { + // A token the module cannot resolve in this generic context is skipped, never thrown: the scan is best effort + // over the callees that matter, and every banned callee is a non-generic runtime method. + return null; + } + } + + private static int OperandSize(OpCode opCode, byte[] il, int position) => opCode.OperandType switch + { + OperandType.InlineNone => 0, + OperandType.ShortInlineBrTarget or OperandType.ShortInlineI or OperandType.ShortInlineVar => 1, + OperandType.InlineVar => 2, + OperandType.InlineI8 or OperandType.InlineR => 8, + OperandType.InlineSwitch => 4 + (4 * BitConverter.ToInt32(il, position)), + _ => 4, + }; +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Architecture/NoImplicitEnvironmentReadArchitectureTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Architecture/NoImplicitEnvironmentReadArchitectureTests.cs new file mode 100644 index 0000000..56af691 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Architecture/NoImplicitEnvironmentReadArchitectureTests.cs @@ -0,0 +1,111 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Net; +using System.Reflection; +using Dexpace.Sdk.Core.Configuration; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests.Architecture; + +/// +/// CFG-28, CFG-11 (P9-15): in the DI package, ProxyOptions.FromEnvironment is called only from the two UseProxyFromEnvironment code paths, the +/// process environment is read from nowhere, and no configuration switch can turn the environment read on. An IL scan over every method body of the +/// assembly: the second layer behind the RS0030 bans, and the only one that sees a call a pragma suppressed. +/// +[Trait("Category", "Unit")] +public sealed class NoImplicitEnvironmentReadArchitectureTests +{ + [Fact] + public void FromEnvironment_is_called_only_from_the_two_UseProxyFromEnvironment_paths_and_nothing_reads_the_environment() + { + var offenders = IlCallScanner.FindCalls(typeof(DexpaceClientBuilder).Assembly) + .Where(call => IsForbiddenCallee(call.Callee)) + .Where(call => !IsPermitted(call)) + .Select(call => call.ToString()) + .ToList(); + + Assert.Empty(offenders); + } + + [Fact] + public void The_two_permitted_call_sites_are_still_there() + { + // If the overloads were rewritten, the scan above would pass vacuously for the wrong reason. + var permitted = IlCallScanner.FindCalls(typeof(DexpaceClientBuilder).Assembly) + .Where(call => IsForbiddenCallee(call.Callee)) + .Where(IsPermitted) + .ToList(); + + Assert.Equal(2, permitted.Count); + Assert.Equal(2, permitted.Select(static c => c.Callee.ToString()).Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public void The_scanner_finds_a_planted_call() + { + var planted = IlCallScanner.FindCalls(typeof(PlantedCalls).GetNestedTypes(BindingFlags.NonPublic | BindingFlags.Public).Append(typeof(PlantedCalls))) + .Where(call => IsForbiddenCallee(call.Callee)) + .Select(call => call.CallerMethod.Name + "|" + call.Callee.Name) + .ToList(); + + Assert.Contains("ReadsEnvironment|GetEnvironmentVariable", planted); + Assert.Contains("ReadsDefaultProxy|get_DefaultProxy", planted); + Assert.Contains("ReadsFromEnvironment|FromEnvironment", planted); + Assert.Contains("ReadsDefaultWebProxy|get_DefaultWebProxy", planted); + Assert.Contains(planted, static entry => entry.StartsWith("MoveNext|GetEnvironmentVariable", StringComparison.Ordinal)); + } + + [Fact] + public void The_scan_would_catch_a_configuration_switch() + { + // P9-15's open half has a standing guard: a type that calls FromEnvironment from a method named Read... fails the permitted-caller rule. + var planted = IlCallScanner.FindCalls([typeof(PlantedSwitch)]) + .Where(call => IsForbiddenCallee(call.Callee)) + .ToList(); + + Assert.NotEmpty(planted); + Assert.All(planted, static call => Assert.False(IsPermitted(call))); + } + + private static bool IsPermitted(IlCall call) => + call.Callee.DeclaringType == typeof(ProxyOptions) + && call.Callee.Name == nameof(ProxyOptions.FromEnvironment) + && call.Caller.FullName?.StartsWith("Dexpace.Sdk.Extensions.DependencyInjection.DexpaceClientBuilder", StringComparison.Ordinal) == true + && call.CallerMethod.Name.Contains("UseProxyFromEnvironment", StringComparison.Ordinal); + + private static bool IsForbiddenCallee(MethodBase callee) => (callee.DeclaringType?.FullName, callee.Name) switch + { + ("System.Environment", "GetEnvironmentVariable" or "GetEnvironmentVariables") => true, + ("Dexpace.Sdk.Core.Configuration.ProxyOptions", "FromEnvironment") => true, + ("System.Net.Http.HttpClient", "get_DefaultProxy" or "set_DefaultProxy") => true, + ("System.Net.WebRequest", "get_DefaultWebProxy" or "set_DefaultWebProxy") => true, + _ => false, + }; + + // Callees the scanner must see; never called at runtime. + internal static class PlantedCalls + { + internal static string? ReadsEnvironment() => Environment.GetEnvironmentVariable("X"); + + internal static IWebProxy ReadsDefaultProxy() => HttpClient.DefaultProxy; + + internal static ProxyOptions? ReadsFromEnvironment() => ProxyOptions.FromEnvironment(); + +#pragma warning disable SYSLIB0014 // WebRequest is obsolete; the scan must still see a read of its process-wide default. + internal static IWebProxy? ReadsDefaultWebProxy() => WebRequest.DefaultWebProxy; +#pragma warning restore SYSLIB0014 + + internal static async Task ReadsInsideAnAsyncStateMachine() + { + await Task.Yield(); + return Environment.GetEnvironmentVariable("Y"); + } + } + + // A configuration switch in the shape P9-15 forbids: a reader that turns the environment read on from a value. + internal static class PlantedSwitch + { + internal static ProxyOptions? ReadProxy(bool fromEnvironment) => fromEnvironment ? ProxyOptions.FromEnvironment() : null; + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/AsyncOnlyTransport.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/AsyncOnlyTransport.cs new file mode 100644 index 0000000..e647cc5 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/AsyncOnlyTransport.cs @@ -0,0 +1,23 @@ +// 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.Request; +using Dexpace.Sdk.Core.Http.Response; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// A transport that is IAsyncDisposable only, as IAsyncHttpClient allows: a synchronous provider dispose throws for it (design G, F21). +internal sealed class AsyncOnlyTransport : IAsyncHttpClient +{ + internal int Disposals { get; private set; } + + public Task ExecuteAsync(Request request, RequestOptions options, CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public ValueTask DisposeAsync() + { + Disposals++; + return ValueTask.CompletedTask; + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/AuthKeyTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/AuthKeyTests.cs new file mode 100644 index 0000000..5d7b31a --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/AuthKeyTests.cs @@ -0,0 +1,73 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// P9-18, XCUT-18, XCUT-19: the Auth:* keys feed the redacting credentials, and no failure carries a value. +[Trait("Category", "Unit")] +public sealed class AuthKeyTests +{ + [Fact] + public void The_credential_keys_bind() + { + var configuration = Config.Read( + ("Auth:ApiKey:Key", "k-123"), + ("Auth:ApiKey:HeaderName", "X-Api-Key"), + ("Auth:ApiKey:Scheme", "Token"), + ("Auth:Basic:Username", "alice"), + ("Auth:Basic:Password", "pw"), + ("Auth:Digest:Username", "bob"), + ("Auth:Digest:Password", "pw2"), + ("Auth:TokenRefreshMargin", "1m")); + + Assert.Empty(configuration.Failures); + var credentials = Assert.IsType(configuration.Credentials); + Assert.Equal("k-123", credentials.ApiKey!.Key); + Assert.Equal("X-Api-Key", credentials.ApiKey.HeaderName.Original); + Assert.Equal("Token", credentials.ApiKey.Scheme); + Assert.Equal("alice", credentials.Basic!.Username); + Assert.Equal("bob", credentials.Digest!.Username); + Assert.Equal(TimeSpan.FromMinutes(1), credentials.TokenRefreshMargin); + } + + [Fact] + public void No_auth_key_means_no_credentials() + { + Assert.Null(Config.Read().Credentials); + } + + [Fact] + public void Each_credential_requires_its_mandatory_members() + { + Assert.EndsWith("Auth:Basic:Password: required when any key of this credential is present", Assert.Single(Config.Failures(Config.Read(("Auth:Basic:Username", "alice")))), StringComparison.Ordinal); + Assert.EndsWith("Auth:Digest:Username: required when any key of this credential is present", Assert.Single(Config.Failures(Config.Read(("Auth:Digest:Password", "pw")))), StringComparison.Ordinal); + Assert.EndsWith("Auth:ApiKey:Key: required when any key of this credential is present", Assert.Single(Config.Failures(Config.Read(("Auth:ApiKey:HeaderName", "X-Key")))), StringComparison.Ordinal); + } + + [Fact] + public void The_token_is_code_only() + { + Assert.Contains("unknown key", Assert.Single(Config.Failures(Config.Read(("Auth:Token", "x")))), StringComparison.Ordinal); + } + + [Fact] + public void A_bad_credential_fails_with_fixed_text_and_the_secrets_are_redacted_everywhere() + { + var configuration = Config.Read( + ("Auth:ApiKey:Key", "s3cr3t-key"), + ("Auth:ApiKey:HeaderName", "bad header"), + ("Auth:Basic:Username", "a:b"), + ("Auth:Basic:Password", "s3cr3t-pass"), + ("Auth:Digest:Username", "bob"), + ("Auth:Digest:Password", "s3cr3t-digest")); + + var failures = string.Join('|', Config.Failures(configuration)); + Assert.Contains("Auth:ApiKey:HeaderName", failures, StringComparison.Ordinal); + Assert.Contains("Auth:Basic:Username", failures, StringComparison.Ordinal); + Assert.DoesNotContain("s3cr3t", failures, StringComparison.Ordinal); + Assert.DoesNotContain("s3cr3t", configuration.Credentials!.ToString(), StringComparison.Ordinal); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/AuthRegistrationTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/AuthRegistrationTests.cs new file mode 100644 index 0000000..5d998d1 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/AuthRegistrationTests.cs @@ -0,0 +1,124 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; +using Dexpace.Sdk.TestSupport.Transports; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// P9-18: UseAuth, UseAuthPolicy and ConfigureCredentials over the redacting credentials. +[Trait("Category", "Unit")] +public sealed class AuthRegistrationTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static readonly AuthDescriptor s_apiKey = new(new AuthRequirement(AuthScheme.ApiKey)); + + [Fact] + public async Task Auth_keys_and_UseAuth_install_a_policy_that_sends_the_configured_api_key() + { + var scripted = new ScriptedTransport(TestResponses.Create(Status.FromCode(200))); + var services = new ServiceCollection(); + _ = TestClients.Add(services, TestClients.Name, ("Auth:ApiKey:Key", "k-123"), ("Auth:ApiKey:HeaderName", "X-Api-Key")) + .UseTransport(scripted) + .UseAuth(s_apiKey); + await using var provider = services.BuildServiceProvider(); + + using var response = await provider.GetRequiredKeyedService(TestClients.Name).ExecuteAsync(Request.Get("https://api.test/x"), Ct); + + Assert.Equal("k-123", scripted.LastRequest!.Headers.Get("X-Api-Key")); + } + + [Fact] + public async Task ConfigureCredentials_adds_what_configuration_cannot_hold_after_the_configured_values() + { + var scripted = new ScriptedTransport(TestResponses.Create(Status.FromCode(200))); + var services = new ServiceCollection(); + services.AddSingleton(new StaticToken()); + _ = TestClients.Add(services, TestClients.Name, ("Auth:ApiKey:Key", "k-123")) + .UseTransport(scripted) + .ConfigureCredentials(static (sp, credentials) => credentials with { Token = sp.GetRequiredService() }) + .UseAuth(new AuthDescriptor(new AuthRequirement(AuthScheme.OAuth2))); + await using var provider = services.BuildServiceProvider(); + + using var response = await provider.GetRequiredKeyedService(TestClients.Name).ExecuteAsync(Request.Get("https://api.test/x"), Ct); + + Assert.Equal("Bearer tok-1", scripted.LastRequest!.Headers.Get("Authorization")); + } + + [Fact] + public async Task UseAuthPolicy_installs_any_other_policy() + { + var scripted = new ScriptedTransport(TestResponses.Create(Status.FromCode(200))); + var services = new ServiceCollection(); + _ = TestClients.Add(services).UseTransport(scripted).UseAuthPolicy(static _ => new ApiKeyAuthPolicy(new ApiKeyCredential("custom-key"))); + await using var provider = services.BuildServiceProvider(); + + using var response = await provider.GetRequiredKeyedService(TestClients.Name).ExecuteAsync(Request.Get("https://api.test/x"), Ct); + + Assert.Equal("custom-key", scripted.LastRequest!.Headers.Get("Authorization")); + } + + [Fact] + public async Task Credentials_with_neither_UseAuth_nor_UseAuthPolicy_fail_validation_naming_the_client() + { + var services = new ServiceCollection(); + _ = TestClients.Add(services, TestClients.Name, ("Auth:ApiKey:Key", "k-123")).UseTransport(new ScriptedTransport()); + await using var provider = services.BuildServiceProvider(); + + var exception = Assert.Throws(provider.GetRequiredService().Validate); + + var failure = Assert.Single(exception.Failures); + Assert.StartsWith("github: credentials are configured but nothing sends them", failure, StringComparison.Ordinal); + Assert.DoesNotContain("k-123", failure, StringComparison.Ordinal); + } + + [Fact] + public void UseAuth_and_UseAuthPolicy_together_throw_at_the_second_call() + { + var builder = TestClients.Add(new ServiceCollection()).UseAuth(s_apiKey); + Assert.Throws(() => builder.UseAuthPolicy(static _ => new ApiKeyAuthPolicy(new ApiKeyCredential("k")))); + + var other = TestClients.Add(new ServiceCollection()).UseAuthPolicy(static _ => new ApiKeyAuthPolicy(new ApiKeyCredential("k"))); + Assert.Throws(() => other.UseAuth(s_apiKey)); + } + + [Fact] + public async Task UseAuth_with_no_credentials_is_legal_and_sends_nothing() + { + var scripted = new ScriptedTransport(TestResponses.Create(Status.FromCode(200))); + var services = new ServiceCollection(); + _ = TestClients.Add(services).UseTransport(scripted).UseAuth(new AuthDescriptor(AuthRequirement.NoAuth)); + await using var provider = services.BuildServiceProvider(); + + using var response = await provider.GetRequiredKeyedService(TestClients.Name).ExecuteAsync(Request.Get("https://api.test/x"), Ct); + + Assert.Null(scripted.LastRequest!.Headers.Get("Authorization")); + } + + [Fact] + public async Task A_ConfigureCredentials_returning_null_fails_naming_the_client() + { + var services = new ServiceCollection(); + _ = TestClients.Add(services).UseTransport(new ScriptedTransport()).ConfigureCredentials(static (_, _) => null!).UseAuth(s_apiKey); + await using var provider = services.BuildServiceProvider(); + + var exception = Assert.Throws(provider.GetRequiredService().Validate); + + Assert.Contains("github: ConfigureCredentials returned null", exception.Failures); + } + + private sealed class StaticToken : TokenCredential + { + public override ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default) => + ValueTask.FromResult(new AccessToken("tok-1")); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/BooleanGrammarTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/BooleanGrammarTests.cs new file mode 100644 index 0000000..ce4a9a2 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/BooleanGrammarTests.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// CFG-6 (P9-27, P9-29): only true and false in any case, after the reader's ASCII trim; the eight tokens of the Ruby table fail. +[Trait("Category", "Unit")] +public sealed class BooleanGrammarTests +{ + [Theory] + [InlineData("true", true)] + [InlineData("TRUE", true)] + [InlineData("False", false)] + [InlineData("FaLsE", false)] + public void Accepts_the_two_tokens_in_any_case(string text, bool expected) + { + Assert.True(ScalarGrammar.TryParseBoolean(text, out var value, out _)); + Assert.Equal(expected, value); + } + + [Theory] + [InlineData("1")] + [InlineData("0")] + [InlineData("yes")] + [InlineData("no")] + [InlineData("on")] + [InlineData("off")] + [InlineData("t")] + [InlineData("f")] + [InlineData("truee")] + [InlineData("tru")] + [InlineData("")] + public void Rejects_everything_else(string text) + { + Assert.False(ScalarGrammar.TryParseBoolean(text, out _, out var rule)); + Assert.Contains("only true or false", rule, StringComparison.Ordinal); + } + + [Fact] + public void A_boolean_with_surrounding_whitespace_is_read_after_the_reader_trims_it() + { + // Ruby does not trim booleans; P9-27 does, in the reader (recorded as a section 11 resolution, P9-29). + var configuration = Config.Read(("Retry:HonorRetryAfter", " false ")); + + Assert.Empty(configuration.Failures); + Assert.False(configuration.Options.Retry.HonorRetryAfter); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ChainSpy.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ChainSpy.cs new file mode 100644 index 0000000..bc8334d --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ChainSpy.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// A delegating handler a test adds through HttpClient.AddHttpMessageHandler to read the chain below it and to see what reaches it. +internal sealed class ChainSpy : DelegatingHandler +{ + private int _disposals; + + /// How many times the factory or the container disposed this handler. + internal int Disposals => Volatile.Read(ref _disposals); + + /// The requests that reached this handler. + internal List Seen { get; } = []; + + /// The handlers below this one, in order, ending with the primary. + internal IReadOnlyList Below() + { + var chain = new List(); + for (var handler = InnerHandler; handler is not null; handler = (handler as DelegatingHandler)?.InnerHandler) + { + chain.Add(handler); + } + + return chain; + } + + /// The primary handler at the bottom of the chain. + internal HttpMessageHandler Primary => Below()[^1]; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + lock (Seen) + { + Seen.Add(request); + } + + return base.SendAsync(request, cancellationToken); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + Interlocked.Increment(ref _disposals); + } + + base.Dispose(disposing); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Config.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Config.cs new file mode 100644 index 0000000..10efe36 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Config.cs @@ -0,0 +1,39 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Microsoft.Extensions.Configuration; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// In-memory configuration for the suite: no test reads the real environment but EnvironmentNormalizationTests (CFG-11). +internal static class Config +{ + /// The path every test section lives at. + internal const string SectionPath = "Dexpace:GitHub"; + + /// A root over one in-memory provider holding as given. + internal static IConfigurationRoot Root(params (string Key, string? Value)[] values) => + new ConfigurationBuilder().AddInMemoryCollection(values.Select(static v => new KeyValuePair(v.Key, v.Value))).Build(); + + /// The client section at holding , keyed relative to the section. + internal static IConfigurationSection Section(params (string Key, string? Value)[] values) => + Root([.. values.Select(static v => (SectionPath + ":" + v.Key, v.Value))]).GetSection(SectionPath); + + /// A section over several in-memory providers, the last the highest (appsettings, environment, an override). + internal static IConfigurationSection Layered(params (string Key, string? Value)[][] layers) + { + var builder = new ConfigurationBuilder(); + foreach (var layer in layers) + { + builder.AddInMemoryCollection(layer.Select(static v => new KeyValuePair(SectionPath + ":" + v.Key, v.Value))); + } + + return builder.Build().GetSection(SectionPath); + } + + /// Reads and returns the reader's result. + internal static ClientConfiguration Read(params (string Key, string? Value)[] values) => ConfigurationReader.Read(Section(values)); + + /// The failure texts of a read, in order. + internal static string[] Failures(ClientConfiguration configuration) => [.. configuration.Failures.Select(static f => f.ToString())]; +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ConfigurationPrecedenceTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ConfigurationPrecedenceTests.cs new file mode 100644 index 0000000..e0204c2 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ConfigurationPrecedenceTests.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.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// CFG-1 (section 10 entry 25) and CFG-10: the record initializer, the caller's providers, then the Configure transform; there is no removal. +[Trait("Category", "Unit")] +public sealed class ConfigurationPrecedenceTests +{ + private static readonly (string, string?)[] s_appsettings = [("UserAgent", "appsettings")]; + private static readonly (string, string?)[] s_environment = [("UserAgent", "environment")]; + private static readonly (string, string?)[] s_override = [("UserAgent", "override")]; + + [Fact] + public void Removing_each_provider_from_the_top_resolves_the_next() + { + Assert.Equal("override", UserAgent(s_appsettings, s_environment, s_override)); + Assert.Equal("environment", UserAgent(s_appsettings, s_environment)); + Assert.Equal("appsettings", UserAgent(s_appsettings)); + Assert.Equal(new DexpaceClientOptions().UserAgent, UserAgent()); + } + + [Fact] + public void The_Configure_transform_is_applied_last_in_call_order() + { + var services = StateHost.WithClient( + registration => + { + registration.OptionsTransforms.Add(static o => o with { UserAgent = "a" }); + registration.OptionsTransforms.Add(static o => o with { UserAgent = "b" }); + }, + ("UserAgent", "from-configuration")); + + Assert.Equal("b", services.Get().Options.UserAgent); + } + + [Fact] + public void A_transform_overrides_a_configuration_value_and_removing_it_resolves_the_configuration() + { + var with = StateHost.WithClient(r => r.OptionsTransforms.Add(static o => o with { UserAgent = "code" }), ("UserAgent", "config")); + var without = StateHost.WithClient(static _ => { }, ("UserAgent", "config")); + + Assert.Equal("code", with.Get().Options.UserAgent); + Assert.Equal("config", without.Get().Options.UserAgent); + } + + [Fact] + public void A_transform_returning_null_fails_naming_the_client() + { + var provider = StateHost.WithClient(r => r.OptionsTransforms.Add(static _ => null!)); + + var exception = Assert.Throws(() => provider.Get()); + + Assert.Equal("github: Configure returned null", Assert.Single(exception.Failures)); + } + + [Fact] + public void A_transform_that_throws_is_a_failure_naming_the_client_and_never_the_message() + { + var provider = StateHost.WithClient(r => r.OptionsTransforms.Add(static _ => throw new InvalidOperationException("s3cr3t"))); + + var exception = Assert.Throws(() => provider.Get()); + + var failure = Assert.Single(exception.Failures); + Assert.StartsWith("github: Configure threw InvalidOperationException", failure, StringComparison.Ordinal); + Assert.DoesNotContain("s3cr3t", failure, StringComparison.Ordinal); + } + + [Fact] + public void Precedence_has_no_removal_operation() + { + // CFG-10 (N/A, vacuous): a transform is code and a record has no removal, so the registration exposes nothing to remove with. + var members = typeof(DexpaceClientRegistration).GetMembers(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + + Assert.DoesNotContain(members, static m => m.Name.StartsWith("Remove", StringComparison.Ordinal) || m.Name.StartsWith("Clear", StringComparison.Ordinal)); + } + + private static string UserAgent(params (string, string?)[][] layers) => ConfigurationReader.Read(Config.Layered(layers)).Options.UserAgent; +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ConfigurationSeamTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ConfigurationSeamTests.cs new file mode 100644 index 0000000..5836a52 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ConfigurationSeamTests.cs @@ -0,0 +1,52 @@ +// 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 Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// CFG-11: the seam is a lookup, and the recording lookup observes every proxy variable the resolver reads and no other. +[Trait("Category", "Unit")] +public sealed class ConfigurationSeamTests +{ + [Fact] + public async Task The_lookup_observes_exactly_the_proxy_variables_the_resolver_reads() + { + var asked = new List(); + var services = new ServiceCollection(); + _ = TestClients.Add(services).UseProxyFromEnvironment(key => + { + asked.Add(key); + return null; + }); + await using var provider = services.BuildServiceProvider(); + + _ = provider.GetRequiredKeyedService(TestClients.Name); + + // 5a's resolver: the upper- and lower-case spellings of the two proxy variables; NO_PROXY and GATEWAY_INTERFACE only once a proxy was found. + Assert.Equal( + new HashSet { "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy" }, + asked.ToHashSet(StringComparer.Ordinal)); + } + + [Fact] + public async Task When_a_proxy_is_found_the_additional_reads_are_the_documented_ones() + { + var asked = new List(); + var services = new ServiceCollection(); + _ = TestClients.Add(services).UseProxyFromEnvironment(key => + { + asked.Add(key); + return key == "HTTPS_PROXY" ? "http://proxy.test:3128" : null; + }); + await using var provider = services.BuildServiceProvider(); + + _ = provider.GetRequiredKeyedService(TestClients.Name); + + var universe = new HashSet { "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "NO_PROXY", "no_proxy" }; + Assert.Subset(universe, asked.ToHashSet(StringComparer.Ordinal)); + Assert.Contains("NO_PROXY", asked); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/DependencyInjectionConformanceTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/DependencyInjectionConformanceTests.cs new file mode 100644 index 0000000..15e7ef1 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/DependencyInjectionConformanceTests.cs @@ -0,0 +1,100 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests.Conformance; + +/// +/// The transport conformance kit driven against the transport AddDexpaceClient builds (phase 9, P9-22; the third driver beside the two SystemNet constructions +/// and the raw-socket client): one case per (assertion, face), plus the whole suite as a report. The waivers are the two permanent assertion-scoped decisions +/// (design section 10 entries 32 and 33); the kit fails the run if a waiver is left after its clause starts to pass. transport-1.redirect-not-followed +/// passing on both faces is the proof the 8a hand-off names, and transport-15.owned-released is what the container-owned lease earns (V8). +/// +[Trait("Category", "Conformance")] +public sealed class DependencyInjectionConformanceTests +{ + public static TheoryData Rows() + { + var rows = new TheoryData(); + foreach (var assertion in TransportSuite.Assertions) + { + foreach (var face in assertion.Faces) + { + rows.Add(assertion.Name, face); + } + } + + return rows; + } + + [Theory] + [MemberData(nameof(Rows))] + public async Task Assertion_holds(string name, TransportFace face) + { + var assertion = TransportSuite.Assertions.Single(a => a.Name == name); + var result = await TransportSuite.RunAsync(DependencyInjectionSubject.Create(), assertion, face, DependencyInjectionSubject.Options, TestContext.Current.CancellationToken); + + if (result.Status == ConformanceStatus.Passed) + { + return; + } + + if (result.Status is ConformanceStatus.Failed or ConformanceStatus.Errored) + { + Assert.Fail(result.Detail); + } + else + { + Assert.Skip($"{result.Status}: {result.Detail}"); + } + } + + [Fact] + public async Task The_whole_suite_is_green_and_its_report_is_written() + { + var report = await TransportSuite.RunAllAsync(DependencyInjectionSubject.Create(), DependencyInjectionSubject.Options, TestContext.Current.CancellationToken); + + TestContext.Current.TestOutputHelper?.WriteLine(report.ToString()); + Assert.True(report.IsGreen, report.ToString()); + } + + [Theory] + [InlineData(TransportFace.Async)] + [InlineData(TransportFace.Blocking)] + public async Task The_redirect_is_not_followed_on_both_faces(TransportFace face) + { + var assertion = TransportSuite.Assertions.Single(static a => a.Name == "transport-1.redirect-not-followed"); + + var result = await TransportSuite.RunAsync(DependencyInjectionSubject.Create(), assertion, face, DependencyInjectionSubject.Options, TestContext.Current.CancellationToken); + + Assert.Equal(ConformanceStatus.Passed, result.Status); + } + + [Fact] + public void Only_the_two_recorded_permanent_decisions_remain() + { + var waivers = DependencyInjectionSubject.Options.Waivers; + + Assert.DoesNotContain(waivers, static waiver => waiver.Owner is not null); + Assert.Equal( + ["TRANSPORT-14|transport-14.name-malformed-dropped", "TRANSPORT-2|transport-2.bodyless-not-resent"], + waivers.Select(static waiver => $"{waiver.RequirementId}|{waiver.Assertion}").Order(StringComparer.Ordinal)); + Assert.All(waivers, static waiver => Assert.Null(waiver.Face)); + } + + [Fact] + public void Only_known_waiver_targets_remain() => Assert.All( + DependencyInjectionSubject.Options.Waivers.Where(static waiver => waiver.Assertion is not null), + waiver => Assert.Contains(TransportSuite.Assertions, a => a.Name == waiver.Assertion && a.RequirementIds.Contains(waiver.RequirementId))); + + [Fact] + public async Task Only_the_borrowed_assertion_is_not_exercised_and_the_driver_says_why() + { + var report = await TransportSuite.RunAllAsync(DependencyInjectionSubject.Create(), DependencyInjectionSubject.Options, TestContext.Current.CancellationToken); + + var notExercised = report.Results.Where(static r => r.Status == ConformanceStatus.NotExercised).Select(static r => r.Assertion.Name).Distinct().ToList(); + Assert.Equal(["transport-15.borrowed-survives"], notExercised); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/DependencyInjectionSubject.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/DependencyInjectionSubject.cs new file mode 100644 index 0000000..38f4b85 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/DependencyInjectionSubject.cs @@ -0,0 +1,108 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Globalization; +using Dexpace.Sdk.Conformance; +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.Http.SystemNet.Tests.Conformance; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests.Conformance; + +/// +/// The transport AddDexpaceClient builds from IHttpClientFactory as a conformance subject (phase 9, P9-22; the 8a hand-off): a fresh container per +/// transport, the kit's logger forwarded through a single-logger factory, the hooks added as handlers of the named client and the proxy hook through the +/// Transport:Proxy:* keys, so CreatePrimaryHandler's proxy path is exercised. CreateBorrowed is not supplied: the package never borrows a +/// client, so transport-15.borrowed-survives stays NotExercised. +/// +internal static class DependencyInjectionSubject +{ + private const string Name = "kit"; + + /// The subject, on both faces, with the hooks. + internal static TransportSubject Create() => new() + { + Name = "Dexpace.Sdk.Extensions.DependencyInjection (named client)", + CreateAsync = settings => Build(settings), + CreateBlocking = settings => Build(settings), + CreateWithFaultingAdaptation = settings => Build(settings, static b => b.HttpClient.AddHttpMessageHandler(static () => new FaultingAdaptationHandler())), + CreateWithInternalCancel = InternalCancel, + CreateWithNativeResend = settings => Build(settings, static b => b.HttpClient.AddHttpMessageHandler(static () => new ResendingHandler())), + CreateWithProxy = (settings, proxy) => Build( + settings, + configure: null, + ("Transport:Proxy:Host", proxy.Host), + ("Transport:Proxy:Port", proxy.Port.ToString(CultureInfo.InvariantCulture)), + ("Transport:Proxy:UserName", proxy.UserName), + ("Transport:Proxy:Password", proxy.Password)), + AfterDispose = AfterDisposeBehavior.ThrowsObjectDisposedException, + }; + + /// The kit's bounds and the two permanent assertion-scoped waivers of the SystemNet drivers (design section 10 entries 32 and 33), each turning stale if its assertion passes. + internal static TransportSuiteOptions Options { get; } = new() + { + AssertionTimeout = TimeSpan.FromSeconds(20), + ReleaseTimeout = TimeSpan.FromSeconds(5), + Waivers = + [ + new("TRANSPORT-2", "SocketsHttpHandler has no public switch for its own connection-failure retry: a request with no content whose connection ends before any response byte is re-sent up to three more times. The transport closes the clause where it can: a body-less POST, PUT, PATCH or extension method is given a zero-length content (Content-Length: 0, byte for byte what the native client writes anyway), so the retry cannot fire. A body-less GET, HEAD, OPTIONS, DELETE and TRACE is still re-sent: all are idempotent (RFC 9110 section 9.2.2), which permits the retry, and attaching content to them would break TRANSPORT-26 or RFC 9110 section 8.6. Unmet on that stated domain: design section 10 entry 33") + { + Assertion = "transport-2.bodyless-not-resent", + }, + new("TRANSPORT-14", "SocketsHttpHandler rejects the whole response on a malformed header name (a space, a control byte or a non-ASCII byte) before the adapter sees any header, and has no leniency switch; no handler-level workaround exists short of replacing its HTTP/1.1 parser. Unmet on that stated domain: design section 10 entry 32 (the value and obs-text clauses are met and unwaived)") + { + Assertion = "transport-14.name-malformed-dropped", + }, + ], + }; + + private static InternalCancellation InternalCancel(TransportSettings settings) + { + CancelOnDemandHandler? trigger = null; + var transport = Build(settings, b => b.HttpClient.AddHttpMessageHandler(() => trigger = new CancelOnDemandHandler())); + return new InternalCancellation(transport, () => trigger!.CancelInFlight()); + } + + private static ProviderBackedTransport Build(TransportSettings settings, Action? configure = null, params (string Key, string? Value)[] values) + { + var services = new ServiceCollection(); + var builder = TestClients.Add(services, Name, values.Where(static v => v.Value is not null).ToArray()); + configure?.Invoke(builder); + services.Replace(ServiceDescriptor.Singleton(new SingleLoggerFactory(settings.Logger))); + var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }); + return new ProviderBackedTransport(provider, (Dexpace.Sdk.Http.SystemNet.SystemNetHttpClient)provider.GetRequiredKeyedService(Name)); + } + + /// A transport that disposes its container with itself, so the pooled connections do not outlive the run (P9-13). + private sealed class ProviderBackedTransport(ServiceProvider provider, Dexpace.Sdk.Http.SystemNet.SystemNetHttpClient inner) : IAsyncHttpClient, IHttpClient + { + public Task ExecuteAsync(Request request, RequestOptions options, CancellationToken cancellationToken) => + inner.ExecuteAsync(request, options, cancellationToken); + + public Response Execute(Request request, RequestOptions options, CancellationToken cancellationToken) => + inner.Execute(request, options, cancellationToken); + + public void Dispose() => provider.Dispose(); + + public ValueTask DisposeAsync() => provider.DisposeAsync(); + } + + /// Hands every category the one logger the kit supplies, so its assertions on header-drop and proxy events see them. + private sealed class SingleLoggerFactory(ILogger logger) : ILoggerFactory + { + public void AddProvider(ILoggerProvider provider) + { + } + + public ILogger CreateLogger(string categoryName) => logger; + + public void Dispose() + { + } + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/CredentialRedactionTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/CredentialRedactionTests.cs new file mode 100644 index 0000000..75b0812 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/CredentialRedactionTests.cs @@ -0,0 +1,82 @@ +// 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.Request; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// XCUT-18, XCUT-19 (P9-18): no failure message, no ToString and no log line from a bad Auth:* or proxy configuration contains a configured secret. +[Trait("Category", "Unit")] +public sealed class CredentialRedactionTests +{ + private static readonly string[] s_secrets = ["s3cr3t-key", "s3cr3t-pass", "s3cr3t-digest", "s3cr3t-proxy"]; + + [Fact] + public async Task No_secret_appears_in_any_failure_string_or_log_line_of_a_bad_configuration() + { + using var recorder = new RecordingLoggerProvider(); + var services = new ServiceCollection(); + services.AddLogging(b => b.SetMinimumLevel(LogLevel.Trace).AddProvider(recorder)); + _ = TestClients.Add( + services, + TestClients.Name, + ("Auth:ApiKey:Key", "s3cr3t-key"), + ("Auth:Basic:Username", "alice"), + ("Auth:Basic:Password", "s3cr3t-pass"), + ("Auth:Digest:Username", "bob"), + ("Auth:Digest:Password", "s3cr3t-digest"), + ("Transport:Proxy:Host", "proxy.test"), + ("Transport:Proxy:Port", "not-a-port"), + ("Transport:Proxy:Password", "s3cr3t-proxy"), + ("Retry:MaxRetryAttempts", "abc")); + await using var provider = services.BuildServiceProvider(); + + var startup = Assert.Throws(provider.GetRequiredService().Validate); + var resolution = Assert.Throws(() => provider.GetRequiredKeyedService(TestClients.Name)); + var state = ConfigurationReader.Read(Config.Section( + ("Auth:ApiKey:Key", "s3cr3t-key"), + ("Auth:Basic:Username", "alice"), + ("Auth:Basic:Password", "s3cr3t-pass"), + ("Auth:Digest:Username", "bob"), + ("Auth:Digest:Password", "s3cr3t-digest"))); + + var everything = string.Join( + '\n', + startup.Message, + startup.ToString(), + resolution.Message, + resolution.ToString(), + state.Credentials!.ToString(), + string.Join('\n', recorder.Entries.Select(static e => e.Message)), + string.Join('\n', Config.Failures(state))); + foreach (var secret in s_secrets) + { + Assert.DoesNotContain(secret, everything, StringComparison.Ordinal); + } + + Assert.Contains("Transport:Proxy:Port", startup.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task A_failing_first_call_logs_no_secret() + { + using var recorder = new RecordingLoggerProvider(); + var services = new ServiceCollection(); + services.AddLogging(b => b.SetMinimumLevel(LogLevel.Trace).AddProvider(recorder)); + var scripted = new Dexpace.Sdk.TestSupport.Transports.ScriptedTransport(new InvalidOperationException("boom")); + _ = TestClients.Add(services, TestClients.Name, ("Auth:ApiKey:Key", "s3cr3t-key"), ("Retry:MaxRetryAttempts", "0")) + .UseTransport(scripted) + .UseAuth(new Dexpace.Sdk.Core.Auth.AuthDescriptor(new Dexpace.Sdk.Core.Auth.AuthRequirement(Dexpace.Sdk.Core.Auth.AuthScheme.ApiKey))); + await using var provider = services.BuildServiceProvider(); + var pipeline = provider.GetRequiredKeyedService(TestClients.Name); + + await Assert.ThrowsAnyAsync(async () => await pipeline.ExecuteAsync(Request.Get("https://api.test/x"), TestContext.Current.CancellationToken)); + + Assert.DoesNotContain(recorder.Entries, static e => e.Message.Contains("s3cr3t-key", StringComparison.Ordinal)); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests.csproj b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests.csproj new file mode 100644 index 0000000..890fa43 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests.csproj @@ -0,0 +1,39 @@ + + + + + Dexpace.Sdk.Extensions.DependencyInjection.Tests + Exe + false + true + + false + $(NoWarn);CS1591 + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/DurationGrammarTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/DurationGrammarTests.cs new file mode 100644 index 0000000..09827a9 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/DurationGrammarTests.cs @@ -0,0 +1,156 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// CFG-7 (P9-8, P9-29): the duration grammar; the cases port the sibling SDKs' tables and add V2's four divergences from XmlConvert. +[Trait("Category", "Unit")] +public sealed class DurationGrammarTests +{ + public static TheoryData Iso => new() + { + { "PT5S", 5_000 }, + { "P1D", 86_400_000 }, + { "P1DT2H3M4S", 93_784_000 }, + { "PT0S", 0 }, + { "pt5s", 5_000 }, + { "PT0.5S", 500 }, + { "PT1M", 60_000 }, + { "PT1H30M", 5_400_000 }, + }; + + public static TheoryData Shorthand => new() + { + { "250ms", 250 }, + { "3s", 3_000 }, + { "2m", 120_000 }, + { "1h", 3_600_000 }, + { "1d", 86_400_000 }, + { "250MS", 250 }, + { "250 ms", 250 }, + { "1.5s", 1_500 }, + { "2S", 2_000 }, + { "4h", 14_400_000 }, + { "5d", 432_000_000 }, + }; + + [Theory] + [MemberData(nameof(Iso))] + public void Accepts_the_ISO_8601_forms(string text, long milliseconds) => AssertParses(text, milliseconds); + + [Theory] + [MemberData(nameof(Shorthand))] + public void Accepts_the_shorthand_forms(string text, long milliseconds) => AssertParses(text, milliseconds); + + [Theory] + [InlineData("1500", 1_500)] + [InlineData("0", 0)] + [InlineData("1000", 1_000)] + public void Reads_a_bare_integer_as_milliseconds(string text, long milliseconds) => AssertParses(text, milliseconds); + + [Fact] + public void PT0_0000001S_is_one_tick() + { + Assert.True(DurationGrammar.TryParse("PT0.0000001S", out var value, out _)); + Assert.Equal(TimeSpan.FromTicks(1), value); + } + + [Theory] + [InlineData(" PT5S ", 5_000)] + [InlineData(" 250ms ", 250)] + [InlineData("\t3s\r\n", 3_000)] + public void Tolerates_surrounding_whitespace(string text, long milliseconds) => AssertParses(text, milliseconds); + + [Theory] + [InlineData("-PT5S")] // XmlConvert reads this as minus five seconds + [InlineData("PT-5S")] + [InlineData("P5M")] // XmlConvert reads this as 150 days + [InlineData("P1Y")] // and this as 365 days + [InlineData("P1W")] + [InlineData("P1Y2M")] + [InlineData("PT0.00000001S")] // XmlConvert truncates this to zero + public void Rejects_each_of_V2s_divergences_from_XmlConvert(string text) => AssertRejected(text); + + [Theory] + [InlineData("P")] + [InlineData("PT")] + [InlineData("P1X")] + [InlineData("-3s")] + [InlineData("-1")] + [InlineData("5w")] + [InlineData("soon")] + [InlineData("5x")] + [InlineData("-500ms")] + [InlineData("P1DT")] + [InlineData("PT1.5H")] + [InlineData("PT1S2M")] + [InlineData("1.5")] + [InlineData("1 5s")] + [InlineData("5 s junk")] + [InlineData("+5s")] + [InlineData("")] + [InlineData(" ")] + [InlineData("P99999999D")] + [InlineData("99999999999999999999ms")] + [InlineData("none")] + [InlineData("1,5s")] + [InlineData("1.00000000s")] + public void Rejects_malformed_input(string text) => AssertRejected(text); + + [Fact] + public void The_three_grammars_land_on_one_scale() + { + for (var seconds = 0; seconds <= 100_000; seconds++) + { + Assert.True(DurationGrammar.TryParse("PT" + seconds + "S", out var iso, out _)); + Assert.True(DurationGrammar.TryParse(seconds + "s", out var shorthand, out _)); + Assert.True(DurationGrammar.TryParse((seconds * 1000L).ToString(System.Globalization.CultureInfo.InvariantCulture), out var bare, out _)); + Assert.Equal(iso, shorthand); + Assert.Equal(iso, bare); + } + } + + [Fact] + public void Never_throws_for_an_arbitrary_string_and_never_yields_a_negative_value() + { + var random = new Random(20261012); + const string Alphabet = "PTpt0123456789.DHMSdhmsmn -+xé́٣"; + for (var i = 0; i < 2_000; i++) + { + var text = string.Create(random.Next(0, 12), random, static (span, r) => + { + for (var k = 0; k < span.Length; k++) + { + span[k] = Alphabet[r.Next(Alphabet.Length)]; + } + }); + if (DurationGrammar.TryParse(text, out var value, out var rule)) + { + Assert.True(value >= TimeSpan.Zero); + Assert.Null(rule); + } + } + } + + [Fact] + public void A_failure_never_quotes_the_input() + { + Assert.False(DurationGrammar.TryParse("s3cr3t", out _, out var rule)); + Assert.DoesNotContain("s3cr3t", rule, StringComparison.Ordinal); + } + + private static void AssertParses(string text, long milliseconds) + { + Assert.True(DurationGrammar.TryParse(text, out var value, out var rule), rule); + Assert.Equal(TimeSpan.FromMilliseconds(milliseconds), value); + Assert.Null(rule); + } + + private static void AssertRejected(string text) + { + Assert.False(DurationGrammar.TryParse(text, out _, out var rule)); + Assert.False(string.IsNullOrEmpty(rule)); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/EmptyValueTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/EmptyValueTests.cs new file mode 100644 index 0000000..00ba386 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/EmptyValueTests.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// CFG-2 (P9-7, section 10 entry 25): an empty or whitespace-only value is absent, so the record default applies and a lower provider's value is not consulted. +[Trait("Category", "Unit")] +public sealed class EmptyValueTests +{ + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void An_empty_value_over_a_lower_providers_value_resolves_the_record_default(string empty) + { + var section = Config.Layered([("Retry:MaxRetryAttempts", "5")], [("Retry:MaxRetryAttempts", empty)]); + + var configuration = ConfigurationReader.Read(section); + + Assert.Empty(configuration.Failures); + Assert.Equal(new RetryOptions().MaxRetryAttempts, configuration.Options.Retry.MaxRetryAttempts); + } + + [Fact] + public void Every_kind_of_key_treats_empty_as_absent() + { + var higher = new[] + { + ("UserAgent", ""), + ("Retry:MaxRetryAttempts", ""), + ("Retry:BaseDelay", " "), + ("Retry:RetryableStatusCodes", ""), + }; + var lower = new[] + { + ("UserAgent", "lower-agent"), + ("Retry:MaxRetryAttempts", "7"), + ("Retry:BaseDelay", "9s"), + ("Retry:RetryableStatusCodes", "500"), + }; + + var configuration = ConfigurationReader.Read(Config.Layered([.. lower], [.. higher])); + + Assert.Empty(configuration.Failures); + var defaults = new DexpaceClientOptions(); + Assert.Equal(defaults.UserAgent, configuration.Options.UserAgent); + Assert.NotEqual(string.Empty, configuration.Options.UserAgent); + Assert.Equal(defaults.Retry, configuration.Options.Retry); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/EnvironmentNormalizationTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/EnvironmentNormalizationTests.cs new file mode 100644 index 0000000..c527663 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/EnvironmentNormalizationTests.cs @@ -0,0 +1,110 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// +/// CFG-3, CFG-4, CFG-11 (section 10 entry 25): .NET's own rule maps __ to : case-insensitively, and the reader sits on it. The only +/// suite that reads the real process environment, under a per-test GUID prefix, removed in finally (CFG-11: no other test does). +/// +[Trait("Category", "Integration")] +[Collection("ProcessEnvironment")] +public sealed class EnvironmentNormalizationTests +{ + private static IConfigurationSection SectionFor(string prefix) => + new ConfigurationBuilder().AddEnvironmentVariables(prefix).Build().GetSection(string.Empty); + + [Fact] + public void A_double_underscore_variable_resolves_under_the_colon_key() + { + var prefix = NewPrefix(); + var name = prefix + "Retry__MaxRetryAttempts"; + Environment.SetEnvironmentVariable(name, "4"); + try + { + var configuration = new ConfigurationBuilder().AddEnvironmentVariables(prefix).Build(); + + Assert.Equal("4", configuration["Retry:MaxRetryAttempts"]); + var read = ConfigurationReader.Read(Nested(configuration)); + Assert.Empty(read.Failures); + Assert.Equal(4, read.Options.Retry.MaxRetryAttempts); + } + finally + { + Environment.SetEnvironmentVariable(name, null); + } + + Assert.Null(Environment.GetEnvironmentVariable(name)); + } + + [Fact] + public void A_camel_case_and_an_upper_case_spelling_resolve_alike() + { + // CFG-4 (N/A, vacuous): nothing normalises casing away, so there is no raw accessor to provide. + var prefix = NewPrefix(); + var upper = prefix + "RETRY__MAXRETRYATTEMPTS"; + var lower = NewPrefix() + "retry__maxretryattempts"; + Environment.SetEnvironmentVariable(upper, "5"); + Environment.SetEnvironmentVariable(lower, "6"); + try + { + var first = ConfigurationReader.Read(Nested(new ConfigurationBuilder().AddEnvironmentVariables(prefix).Build())); + var second = ConfigurationReader.Read(Nested(new ConfigurationBuilder().AddEnvironmentVariables(lower[..lower.IndexOf("retry", StringComparison.Ordinal)]).Build())); + + Assert.Equal(5, first.Options.Retry.MaxRetryAttempts); + Assert.Equal(6, second.Options.Retry.MaxRetryAttempts); + } + finally + { + Environment.SetEnvironmentVariable(upper, null); + Environment.SetEnvironmentVariable(lower, null); + } + } + + [Fact] + public void A_variable_outside_the_prefix_is_never_read() + { + var mine = NewPrefix(); + var other = NewPrefix() + "Retry__MaxRetryAttempts"; + Environment.SetEnvironmentVariable(other, "9"); + try + { + var configuration = new ConfigurationBuilder().AddEnvironmentVariables(mine).Build(); + + Assert.Null(configuration["Retry:MaxRetryAttempts"]); + Assert.Empty(ConfigurationReader.Read(Nested(configuration)).Failures); + } + finally + { + Environment.SetEnvironmentVariable(other, null); + } + } + + [Fact] + public void The_production_default_delegates_to_the_environment_and_leaves_no_residue() + { + // CFG-11: AddEnvironmentVariables is what an unconfigured host reads; a variable removed afterwards is gone. + var prefix = NewPrefix(); + var name = prefix + "UserAgent"; + Environment.SetEnvironmentVariable(name, "from-env"); + var seen = new ConfigurationBuilder().AddEnvironmentVariables(prefix).Build()["UserAgent"]; + Environment.SetEnvironmentVariable(name, null); + var after = new ConfigurationBuilder().AddEnvironmentVariables(prefix).Build()["UserAgent"]; + + Assert.Equal("from-env", seen); + Assert.Null(after); + _ = SectionFor(prefix); + } + + // The reader needs a section with a non-empty path; the environment provider's keys sit at the root, so nest them under one. + private static IConfigurationSection Nested(IConfigurationRoot environment) + { + var values = environment.AsEnumerable().Where(static p => p.Value is not null).Select(static p => ($"{Config.SectionPath}:{p.Key}", p.Value)); + return Config.Root([.. values]).GetSection(Config.SectionPath); + } + + private static string NewPrefix() => "DEXPACE_TEST_" + Guid.NewGuid().ToString("N") + "__"; +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/GlobalUsings.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/GlobalUsings.cs new file mode 100644 index 0000000..9e313ce --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/GlobalUsings.cs @@ -0,0 +1,13 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// The project's curated global usings: ImplicitUsings is off (Directory.Build.props), so this file is the whole +// implicit set, and any other namespace is imported at the top of the file that uses it. +global using System; +global using System.Collections.Generic; +global using System.IO; +global using System.Linq; +global using System.Net.Http; +global using System.Threading; +global using System.Threading.Tasks; +global using Dexpace.Sdk.Core.Client; diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/IntegerGrammarTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/IntegerGrammarTests.cs new file mode 100644 index 0000000..c7bdc8a --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/IntegerGrammarTests.cs @@ -0,0 +1,62 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// CFG-5 (P9-8, P9-26): base-10 integers; ports the sibling SDKs' tables, with 1_000 rejected where Ruby tolerates it. +[Trait("Category", "Unit")] +public sealed class IntegerGrammarTests +{ + [Theory] + [InlineData("010", 10)] + [InlineData("-5", -5)] + [InlineData("+3", 3)] + [InlineData("0", 0)] + [InlineData("-0", 0)] + [InlineData("2147483647", int.MaxValue)] + [InlineData("-2147483648", int.MinValue)] + public void Parses_decimal_integers(string text, int expected) + { + Assert.True(ScalarGrammar.TryParseInt32(text, out var value, out var rule)); + Assert.Equal(expected, value); + Assert.Null(rule); + } + + [Theory] + [InlineData("1_000")] + [InlineData("0x10")] + [InlineData("1e3")] + [InlineData("1.5")] + [InlineData("5x")] + [InlineData("٣")] + [InlineData("2")] + [InlineData("2147483648")] + [InlineData("")] + [InlineData("+")] + [InlineData("-")] + [InlineData("1 000")] + [InlineData(" 5 ")] + public void Rejects_everything_else(string text) + { + Assert.False(ScalarGrammar.TryParseInt32(text, out _, out var rule)); + Assert.Contains("32-bit", rule, StringComparison.Ordinal); + } + + [Fact] + public void The_long_target_has_the_long_range_and_the_rule_names_the_type() + { + Assert.True(ScalarGrammar.TryParseInt64("9223372036854775807", out var value, out _)); + Assert.Equal(long.MaxValue, value); + Assert.False(ScalarGrammar.TryParseInt64("9223372036854775808", out _, out var rule)); + Assert.Contains("64-bit", rule, StringComparison.Ordinal); + } + + [Fact] + public void A_failure_never_quotes_the_input() + { + Assert.False(ScalarGrammar.TryParseInt32("s3cr3t", out _, out var rule)); + Assert.DoesNotContain("s3cr3t", rule, StringComparison.Ordinal); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/KeyConstantsTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/KeyConstantsTests.cs new file mode 100644 index 0000000..6a41076 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/KeyConstantsTests.cs @@ -0,0 +1,89 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Reflection; +using Dexpace.Sdk.Core.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// CFG-14 (P9-23): the published keys are exact, name what ProxyOptions.FromEnvironment reads, and are never read by the package. +[Trait("Category", "Unit")] +public sealed class KeyConstantsTests +{ + [Fact] + public void The_published_values_are_exact_and_the_set_is_closed() + { + Assert.Equal("Dexpace", DexpaceConfigurationKeys.SectionName); + Assert.Equal("Retry:MaxRetryAttempts", DexpaceConfigurationKeys.MaxRetryAttempts); + Assert.Equal("Logging:Level", DexpaceConfigurationKeys.LogLevel); + Assert.Equal("HTTP_PROXY", DexpaceConfigurationKeys.HttpProxy); + Assert.Equal("HTTPS_PROXY", DexpaceConfigurationKeys.HttpsProxy); + Assert.Equal("NO_PROXY", DexpaceConfigurationKeys.NoProxy); + + var constants = typeof(DexpaceConfigurationKeys).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly); + Assert.Equal(6, constants.Length); + Assert.All(constants, static f => Assert.True(f.IsLiteral)); + } + + [Fact] + public void The_proxy_names_are_what_ProxyOptions_FromEnvironment_reads() + { + var asked = new List(); + _ = ProxyOptions.FromEnvironment( + key => + { + asked.Add(key); + return null; + }, + NullLogger.Instance); + + // NO_PROXY is consulted only once a proxy was found. + _ = ProxyOptions.FromEnvironment( + key => + { + asked.Add(key); + return key == "HTTPS_PROXY" ? "http://proxy.test:3128" : null; + }, + NullLogger.Instance); + + Assert.Contains(DexpaceConfigurationKeys.HttpProxy, asked); + Assert.Contains(DexpaceConfigurationKeys.HttpsProxy, asked); + Assert.Contains(DexpaceConfigurationKeys.NoProxy, asked); + } + + [Fact] + public void The_section_name_is_not_a_key_the_key_table_expects() + { + // Published, not read: the table's keys are all relative to the caller's section, and none is the conventional section name. + Assert.DoesNotContain(KeyTable.Entries, static e => string.Equals(e.Key, DexpaceConfigurationKeys.SectionName, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void No_member_of_the_package_reads_SectionName() + { + // A const is inlined, so the scan is over the string literals loaded in the package: the section name is published, never read, and no key is + // baked in (OBS-35). The literal appears nowhere in the package's own code. + var offenders = Architecture.IlCallScanner.FindStrings(typeof(DexpaceClientBuilder).Assembly) + .Where(static s => string.Equals(s.Value, DexpaceConfigurationKeys.SectionName, StringComparison.Ordinal)) + .Select(static s => s.CallerMethod.DeclaringType?.Name + "." + s.CallerMethod.Name) + .ToList(); + + Assert.Empty(offenders); + } + + [Fact] + public void The_scanner_finds_a_planted_ldstr() + { + var planted = Architecture.IlCallScanner.FindStrings(typeof(KeyConstantsTests).Assembly) + .Where(static s => s.CallerMethod.DeclaringType == typeof(Planted) && s.Value == "Dexpace"); + + Assert.NotEmpty(planted); + } + + private static class Planted + { + internal static string Reads() => "Dexpace"; + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/KeyTableTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/KeyTableTests.cs new file mode 100644 index 0000000..82dfa16 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/KeyTableTests.cs @@ -0,0 +1,133 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Reflection; +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Http.SystemNet; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// +/// CFG-38's drift guard (design C, R1/R2): a member added to an option record, or a key added without a member, fails here, not in a +/// deployment. Reflection is a test-only use. +/// +[Trait("Category", "Unit")] +public sealed class KeyTableTests +{ + private static readonly Type[] s_records = + [ + typeof(DexpaceClientOptions), typeof(RetryOptions), typeof(RedirectOptions), typeof(HttpLoggingOptions), + typeof(SystemNetHttpClientOptions), typeof(ProxyOptions), typeof(AuthCredentials), + ]; + + private static readonly Type[] s_credentials = [typeof(ApiKeyCredential), typeof(BasicCredential), typeof(DigestCredential)]; + + [Fact] + public void Every_public_init_property_is_in_the_key_table_or_on_the_code_only_list() + { + var missing = Missing(s_records, s_credentials, KeyTable.Entries, KeyTable.CodeOnly, KeyTable.Groups); + + Assert.True(missing.Count == 0, "missing from the key table: " + string.Join(", ", missing)); + } + + [Fact] + public void Every_key_maps_to_a_real_member_or_is_handler_level() + { + foreach (var entry in KeyTable.Entries) + { + if (KeyTable.HandlerLevel.Contains(entry.Key, StringComparer.OrdinalIgnoreCase)) + { + Assert.Null(entry.Target); + continue; + } + + var target = Assert.IsAssignableFrom(entry.Target); + var exists = s_credentials.Contains(target) + ? target.GetConstructors().Any(c => c.GetParameters().Any(p => p.Name == entry.Member)) + : target.GetProperty(entry.Member!) is not null; + Assert.True(exists, entry.Key + " maps to no member"); + } + } + + [Fact] + public void A_new_member_would_fail_the_guard() + { + // The guard's own pin: a scratch record with one more init property, and a scratch credential class with one more constructor + // parameter, are fed to the same check and reported. + var missing = Missing([typeof(ScratchRecord)], [typeof(ScratchCredential)], KeyTable.Entries, KeyTable.CodeOnly, KeyTable.Groups); + + Assert.Contains(nameof(ScratchRecord) + "." + nameof(ScratchRecord.Extra), missing); + Assert.Contains(nameof(ScratchCredential) + ".extra", missing); + } + + [Fact] + public void Every_key_is_documented_once() + { + var keys = KeyTable.Entries.Select(static e => e.Key).ToList(); + + Assert.Equal(keys.Count, keys.Distinct(StringComparer.OrdinalIgnoreCase).Count()); + } + + [Fact] + public void The_reader_asks_for_exactly_the_keys_of_the_table() + { + _ = ConfigurationReader.Read(Config.Section(), out var touched); + + Assert.Equal( + KeyTable.Entries.Select(static e => e.Key).Order(StringComparer.OrdinalIgnoreCase), + touched.Order(StringComparer.OrdinalIgnoreCase), + StringComparer.OrdinalIgnoreCase); + } + + private static List Missing( + Type[] records, + Type[] credentials, + IReadOnlyList entries, + IReadOnlyList<(Type Type, string Member)> codeOnly, + IReadOnlyList<(Type Type, string Member)> groups) + { + var missing = new List(); + foreach (var type in records) + { + foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(IsInit)) + { + var covered = entries.Any(e => e.Target == type && e.Member == property.Name) + || codeOnly.Contains((type, property.Name)) + || groups.Contains((type, property.Name)); + if (!covered) + { + missing.Add(type.Name + "." + property.Name); + } + } + } + + foreach (var type in credentials) + { + foreach (var parameter in type.GetConstructors().SelectMany(static c => c.GetParameters())) + { + if (!entries.Any(e => e.Target == type && e.Member == parameter.Name)) + { + missing.Add(type.Name + "." + parameter.Name); + } + } + } + + return missing; + } + + private static bool IsInit(PropertyInfo property) => + property.SetMethod is { } setter + && setter.ReturnParameter.GetRequiredCustomModifiers().Any(static m => m.FullName == "System.Runtime.CompilerServices.IsExternalInit"); + + private sealed record ScratchRecord + { + public int Extra { get; init; } + } + + private sealed class ScratchCredential(string extra) + { + public string Extra { get; } = extra; + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/KeyedSingletonTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/KeyedSingletonTests.cs new file mode 100644 index 0000000..577cea0 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/KeyedSingletonTests.cs @@ -0,0 +1,74 @@ +// 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.Pipeline; +using Dexpace.Sdk.Core.Serialization; +using Dexpace.Sdk.TestSupport.Transports; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// +/// SEAM-7 (the singleton lifetime of a built container): 64 concurrent first resolutions run each factory once. "An unresolved state stays +/// re-evaluable" has nothing to apply to, because a built container gains no registrations; SEAM-7 stays N/A with this test cited. +/// +[Trait("Category", "Unit")] +public sealed class KeyedSingletonTests +{ + private const int Threads = 64; + + [Fact] + public async Task Sixty_four_concurrent_first_resolutions_run_each_factory_once_and_share_one_instance() + { + var transportBuilds = 0; + var serdeBuilds = 0; + var services = new ServiceCollection(); + _ = services.AddDexpaceClient(TestClients.Name, Config.Section()) + .UseTransport(_ => + { + Interlocked.Increment(ref transportBuilds); + return new ScriptedTransport(); + }) + .UseSerde(_ => + { + Interlocked.Increment(ref serdeBuilds); + return StubSerde.Instance; + }); + await using var provider = services.BuildServiceProvider(); + + var transports = await Race(() => provider.GetRequiredKeyedService(TestClients.Name)); + var serdes = await Race(() => provider.GetRequiredKeyedService(TestClients.Name)); + var pipelines = await Race(() => provider.GetRequiredKeyedService(TestClients.Name)); + var options = await Race(() => provider.GetRequiredKeyedService(TestClients.Name)); + + Assert.Equal(1, transportBuilds); + Assert.Equal(1, serdeBuilds); + Assert.Single(transports.Distinct()); + Assert.Single(serdes.Distinct()); + Assert.Single(pipelines.Distinct()); + Assert.Single(options.Distinct()); + } + + // Dedicated threads released together: a pool-starving barrier would deadlock (the 8b finding). + private static async Task> Race(Func resolve) + where T : class + { + using var start = new ManualResetEventSlim(false); + var tasks = Enumerable.Range(0, Threads) + .Select(_ => Task.Factory.StartNew( + () => + { + start.Wait(); + return resolve(); + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default)) + .ToList(); + start.Set(); + return [.. await Task.WhenAll(tasks)]; + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/LogLevelBindingTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/LogLevelBindingTests.cs new file mode 100644 index 0000000..e766510 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/LogLevelBindingTests.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// OBS-35 (section 10 entry 26, P9-23): the log level is read by name only, from the caller's section, with no baked key. +[Trait("Category", "Unit")] +public sealed class LogLevelBindingTests +{ + [Theory] + [InlineData("Body", HttpLogLevel.Body)] + [InlineData("body", HttpLogLevel.Body)] + [InlineData(" Headers ", HttpLogLevel.Headers)] + [InlineData("none", HttpLogLevel.None)] + public void A_name_binds_in_any_case(string value, HttpLogLevel expected) + { + Assert.Equal(expected, Config.Read(("Logging:Level", value)).Options.Logging.Level); + } + + [Theory] + [InlineData("2")] + [InlineData("7")] + [InlineData("Verbose")] + public void A_number_or_an_unknown_name_fails_naming_the_key_and_listing_the_names(string value) + { + // V1: the binder admits both a numeric and an undefined level; this reader does not. + var failure = Assert.Single(Config.Failures(Config.Read(("Logging:Level", value)))); + + Assert.StartsWith(Config.SectionPath + ":Logging:Level: ", failure, StringComparison.Ordinal); + Assert.Contains("None, Headers, Body", failure, StringComparison.Ordinal); + } + + [Fact] + public void The_section_is_always_the_callers_argument() + { + var other = Config.Root(("Acme:Http:Logging:Level", "Body")).GetSection("Acme:Http"); + Assert.Equal(HttpLogLevel.Body, ConfigurationReader.Read(other).Options.Logging.Level); + + var unrelated = Config.Root(("Dexpace:Logging:Level", "Body")).GetSection("Acme:Http"); + Assert.Equal(HttpLogLevel.None, ConfigurationReader.Read(unrelated).Options.Logging.Level); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/NamedClientTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/NamedClientTests.cs new file mode 100644 index 0000000..305f82b --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/NamedClientTests.cs @@ -0,0 +1,129 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Wire; +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Http.SystemNet; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// TRANSPORT-1, OBS-11 (P9-12, P9-13, P9-14; V3, V9): the named client's handler chain, its primary and what the package takes out of the factory's defaults. +[Trait("Category", "Unit")] +public sealed class NamedClientTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static (ServiceCollection Services, DexpaceClientBuilder Builder, ChainSpy Spy) Client(params (string, string?)[] values) + { + var services = new ServiceCollection(); + var builder = TestClients.Add(services, TestClients.Name, values); + var spy = new ChainSpy(); + builder.HttpClient.AddHttpMessageHandler(() => spy); + return (services, builder, spy); + } + + [Fact] + public async Task The_keyed_transport_is_a_SystemNetHttpClient_over_a_redirect_off_primary() + { + var (services, _, spy) = Client(); + await using var provider = services.BuildServiceProvider(); + + var transport = provider.GetRequiredKeyedService(TestClients.Name); + + Assert.IsType(transport); + var primary = Assert.IsType(spy.Primary); + Assert.False(primary.AllowAutoRedirect); + Assert.False(primary.UseProxy); + } + + [Fact] + public async Task The_package_removes_the_factorys_loggers_so_the_SDKs_events_are_the_one_HTTP_log_stream() + { + using var recorder = new RecordingLoggerProvider(); + var (services, _, _) = Client(); + services.AddLogging(b => b.SetMinimumLevel(LogLevel.Trace).AddProvider(recorder)); + await using var provider = services.BuildServiceProvider(); + await using var server = LoopbackServer.Start(LoopbackResponse.Ok("hi")); + var transport = provider.GetRequiredKeyedService(TestClients.Name); + + await using var response = await transport.ExecuteAsync(Request.Get(server.Url("/secret-path/token123?x=1").AbsoluteUri), Ct); + + Assert.Equal(200, response.Status.Code); + Assert.DoesNotContain(recorder.Entries, e => e.Category.StartsWith("System.Net.Http.HttpClient", StringComparison.Ordinal)); + Assert.DoesNotContain(recorder.Entries, e => e.Message.Contains("token123", StringComparison.Ordinal)); + } + + [Fact] + public async Task The_handler_lifetime_is_infinite() + { + var (services, _, _) = Client(); + await using var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Get("Dexpace.github"); + + Assert.Equal(Timeout.InfiniteTimeSpan, options.HandlerLifetime); + } + + [Theory] + [InlineData(null, 120)] + [InlineData("10s", 10)] + [InlineData("none", -1)] + public async Task The_pooled_connection_lifetime_is_the_configured_value_and_two_minutes_by_default(string? configured, int seconds) + { + var (services, _, spy) = configured is null ? Client() : Client(("Transport:PooledConnectionLifetime", configured)); + await using var provider = services.BuildServiceProvider(); + + _ = provider.GetRequiredKeyedService(TestClients.Name); + + var primary = Assert.IsType(spy.Primary); + Assert.Equal(seconds < 0 ? Timeout.InfiniteTimeSpan : TimeSpan.FromSeconds(seconds), primary.PooledConnectionLifetime); + } + + [Fact] + public async Task ConfigureHttpClient_is_ignored_because_the_package_uses_the_handler_and_never_CreateClient() + { + var (services, builder, spy) = Client(); + builder.HttpClient.ConfigureHttpClient(c => + { + c.BaseAddress = new Uri("http://ignored.invalid/"); + c.DefaultRequestHeaders.Add("X-Extra", "should-not-be-sent"); + }); + services.AddSingleton(_ => throw new InvalidOperationException("CreateClient must never be the package's route")); + await using var provider = services.BuildServiceProvider(); + await using var server = LoopbackServer.Start(LoopbackResponse.Ok("hi")); + var transport = provider.GetRequiredKeyedService(TestClients.Name); + + await using var response = await transport.ExecuteAsync(Request.Get(server.Url("/x").AbsoluteUri), Ct); + + Assert.Equal(200, response.Status.Code); + Assert.Empty(Assert.Single(server.Requests).HeaderValues("x-extra")); + Assert.Single(spy.Seen); + } + + [Fact] + public async Task A_substituted_redirect_following_primary_is_refused_at_first_resolution() + { + var (services, builder, _) = Client(); + builder.HttpClient.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler()); + await using var provider = services.BuildServiceProvider(); + + var exception = Assert.Throws(() => provider.GetRequiredKeyedService(TestClients.Name)); + + Assert.Contains("AllowAutoRedirect", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task A_client_with_a_bad_configuration_fails_the_resolution_of_its_transport() + { + var (services, _, _) = Client(("Retry:MaxRetryAttempts", "abc")); + await using var provider = services.BuildServiceProvider(); + + Assert.Throws(() => provider.GetRequiredKeyedService(TestClients.Name)); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/NamedPipelineTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/NamedPipelineTests.cs new file mode 100644 index 0000000..8b922f6 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/NamedPipelineTests.cs @@ -0,0 +1,129 @@ +// 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 Dexpace.Sdk.TestSupport.Pipeline; +using Dexpace.Sdk.TestSupport.Transports; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using Common = Dexpace.Sdk.Core.Http.Common; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// PIPE-27, PIPE-35, CFG-8 (P9-5; V3): the keyed pipeline is the default policies over the keyed transport with the bound options. +[Trait("Category", "Unit")] +public sealed class NamedPipelineTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task The_keyed_pipeline_runs_the_default_policies_with_the_bound_options() + { + var scripted = new ScriptedTransport( + TestResponses.Create(Status.FromCode(503)), + TestResponses.Create(Status.FromCode(200))); + var services = new ServiceCollection(); + _ = TestClients.Add( + services, + TestClients.Name, + ("Retry:MaxRetryAttempts", "1"), + ("Retry:BaseDelay", "1ms"), + ("Retry:Jitter", "0")) + .UseTransport(scripted); + await using var provider = services.BuildServiceProvider(); + var pipeline = provider.GetRequiredKeyedService(TestClients.Name); + + using var response = await pipeline.ExecuteAsync(Request.Get("https://api.test/x"), Ct); + + Assert.Equal(200, response.Status.Code); + Assert.Equal(2, scripted.CallCount); + } + + [Fact] + public async Task AddPolicy_places_a_policy_by_stage_and_keeps_call_order_within_a_stage() + { + var log = new List(); + var scripted = new ScriptedTransport(TestResponses.Create(Status.FromCode(200))); + var services = new ServiceCollection(); + _ = TestClients.Add(services) + .UseTransport(scripted) + .AddPolicy(_ => new ProbePolicy("attempt", PipelineStage.PerAttempt, log)) + .AddPolicy(_ => new ProbePolicy("call-1", PipelineStage.PerCall, log)) + .AddPolicy(_ => new ProbePolicy("call-2", PipelineStage.PerCall, log)); + await using var provider = services.BuildServiceProvider(); + var pipeline = provider.GetRequiredKeyedService(TestClients.Name); + + using var response = await pipeline.ExecuteAsync(Request.Get("https://api.test/x"), Ct); + + int At(string name) => log.FindIndex(e => e.Contains(name, StringComparison.Ordinal)); + Assert.True(At("call-1") >= 0 && At("call-1") < At("call-2")); + Assert.True(At("call-2") < At("attempt")); + } + + [Fact] + public async Task The_keyed_options_is_the_snapshot_the_pipeline_was_built_with_and_stays_immutable() + { + var services = new ServiceCollection(); + _ = TestClients.Add(services, TestClients.Name, ("UserAgent", "acme/1"), ("Redirect:MaxRedirects", "5")).UseTransport(new ScriptedTransport()); + await using var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredKeyedService(TestClients.Name); + _ = provider.GetRequiredKeyedService(TestClients.Name); + + Assert.Equal("acme/1", options.UserAgent); + var changed = options with { UserAgent = "other" }; + Assert.Equal("acme/1", provider.GetRequiredKeyedService(TestClients.Name).UserAgent); + Assert.Equal("other", changed.UserAgent); + } + + [Fact] + public async Task The_pipeline_does_not_own_the_transport() + { + using var scripted = new ScriptedTransport(); + var services = new ServiceCollection(); + _ = TestClients.Add(services).UseTransport(_ => scripted); + var provider = services.BuildServiceProvider(); + var pipeline = provider.GetRequiredKeyedService(TestClients.Name); + + await pipeline.DisposeAsync(); + Assert.False(scripted.IsDisposed); + + await provider.DisposeAsync(); + Assert.True(scripted.IsDisposed); + } + + [Fact] + public async Task A_factory_cannot_take_a_scoped_service_because_it_runs_on_the_root_provider() + { + var services = new ServiceCollection(); + services.AddScoped(); + _ = services.AddDexpaceClient(TestClients.Name, Config.Section()) + .UseSerde(sp => new ScopedSerde(sp.GetRequiredService())); + await using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + + Assert.Throws(() => provider.GetRequiredKeyedService(TestClients.Name)); + } + + internal sealed class ScopedThing; + + private sealed class ScopedSerde(ScopedThing thing) : Dexpace.Sdk.Core.Serialization.ISerde + { + public ScopedThing Thing { get; } = thing; + + public Common.MediaType DefaultMediaType => Common.CommonMediaTypes.ApplicationJson; + + public ValueTask SerializeAsync(Stream destination, T value, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + + public ValueTask DeserializeAsync(Stream source, CancellationToken cancellationToken = default) => ValueTask.FromResult(default); + + public void Serialize(System.Buffers.IBufferWriter destination, T value) + { + } + + public T? Deserialize(ReadOnlySpan utf8) => default; + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/NoRetryingHandlerTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/NoRetryingHandlerTests.cs new file mode 100644 index 0000000..ad122f2 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/NoRetryingHandlerTests.cs @@ -0,0 +1,42 @@ +// 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 Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// XCUT-10's DI clause (P9-19): the package adds no retrying handler and no handler at all besides the primary. +[Trait("Category", "Unit")] +public sealed class NoRetryingHandlerTests +{ + [Fact] + public async Task The_chain_holds_nothing_the_package_added_besides_the_primary() + { + var services = new ServiceCollection(); + var builder = TestClients.Add(services); + using var spy = new ChainSpy(); + builder.HttpClient.AddHttpMessageHandler(() => spy); + await using var provider = services.BuildServiceProvider(); + + _ = provider.GetRequiredKeyedService(TestClients.Name); + + // Below the test's own handler: only the SocketsHttpHandler. Above it, only the factory's lifetime tracking handler. + Assert.Single(spy.Below()); + Assert.IsType(spy.Primary); + } + + [Fact] + public void The_package_registers_no_resilience_or_Polly_service() + { + var services = new ServiceCollection(); + _ = TestClients.Add(services); + + Assert.DoesNotContain( + services, + d => (d.ServiceType.Assembly.GetName().Name ?? string.Empty).Contains("Resilience", StringComparison.Ordinal) + || (d.ServiceType.Assembly.GetName().Name ?? string.Empty).Contains("Polly", StringComparison.Ordinal) + || (d.ServiceType.FullName ?? string.Empty).Contains("Resilience", StringComparison.Ordinal)); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/NullGuardTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/NullGuardTests.cs new file mode 100644 index 0000000..58d7c41 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/NullGuardTests.cs @@ -0,0 +1,100 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Reflection; +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Serialization; +using Dexpace.Sdk.Http.SystemNet; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// +/// CFG-37: every public method of the extension class and the builder rejects a null argument, by reflection over the public surface (a test-only use of +/// reflection). The test fails when a public parameter type has no example value, so a new method cannot silently skip the guard. +/// +[Trait("Category", "Unit")] +public sealed class NullGuardTests +{ + private static readonly Dictionary> s_examples = new() + { + [typeof(IServiceCollection)] = static () => new ServiceCollection(), + [typeof(string)] = static () => "github", + [typeof(IConfigurationSection)] = static () => Config.Section(), + [typeof(Func)] = static () => (Func)(static o => o), + [typeof(Func)] = static () => (Func)(static o => o), + [typeof(Func)] = static () => (Func)(static (_, c) => c), + [typeof(Func)] = static () => (Func)(static _ => null), + [typeof(IAsyncHttpClient)] = static () => new Dexpace.Sdk.TestSupport.Transports.ScriptedTransport(), + [typeof(Func)] = static () => (Func)(static _ => new Dexpace.Sdk.TestSupport.Transports.ScriptedTransport()), + [typeof(ISerde)] = static () => StubSerde.Instance, + [typeof(Func)] = static () => (Func)(static _ => StubSerde.Instance), + [typeof(AuthDescriptor)] = static () => new AuthDescriptor(AuthRequirement.NoAuth), + [typeof(Func)] = static () => (Func)(static _ => new Dexpace.Sdk.Core.Pipeline.Policies.IdempotencyPolicy()), + }; + + public static TheoryData Cases() + { + var data = new TheoryData(); + foreach (var method in PublicMethods()) + { + var parameters = method.GetParameters(); + for (var i = 0; i < parameters.Length; i++) + { + if (!parameters[i].ParameterType.IsValueType) + { + data.Add(Key(method), i); + } + } + } + + return data; + } + + [Fact] + public void Every_public_parameter_type_has_an_example_value() + { + var missing = PublicMethods() + .SelectMany(static m => m.GetParameters()) + .Select(static p => p.ParameterType) + .Where(static t => !s_examples.ContainsKey(t)) + .Distinct() + .ToList(); + + Assert.True(missing.Count == 0, "no example value for: " + string.Join(", ", missing)); + } + + [Theory] + [MemberData(nameof(Cases))] + public void A_null_argument_throws_ArgumentNullException_naming_the_parameter(string key, int index) + { + var method = PublicMethods().Single(m => Key(m) == key); + var target = method.IsStatic ? null : new ServiceCollection().AddDexpaceClient("github"); + var arguments = method.GetParameters().Select(static p => s_examples[p.ParameterType]()).ToArray(); + arguments[index] = null!; + + var exception = Assert.Throws(() => method.Invoke(target, arguments)); + + var inner = Assert.IsType(exception.InnerException); + Assert.Equal(method.GetParameters()[index].Name, inner.ParamName); + } + + [Fact] + public void A_blank_name_is_an_ArgumentException() + { + Assert.Throws(() => new ServiceCollection().AddDexpaceClient(" ", Config.Section())); + } + + private static IEnumerable PublicMethods() => + new[] { typeof(DexpaceServiceCollectionExtensions), typeof(DexpaceClientBuilder) } + .SelectMany(static t => t.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + .Where(static m => !m.IsSpecialName); + + private static string Key(MethodInfo method) => + method.DeclaringType!.Name + "." + method.Name + "(" + string.Join(",", method.GetParameters().Select(static p => p.ParameterType.Name)) + ")"; +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/PrimaryHandlerLeaseTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/PrimaryHandlerLeaseTests.cs new file mode 100644 index 0000000..7f72d3e --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/PrimaryHandlerLeaseTests.cs @@ -0,0 +1,153 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Wire; +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Http.Request; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// TRANSPORT-15, XCUT-22 (P9-13, V8): the container-owned lease releases the primary the package built, and only that. +[Trait("Category", "Integration")] +public sealed class PrimaryHandlerLeaseTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task Disposing_the_container_releases_the_pooled_connection() + { + await using var server = LoopbackServer.Start(LoopbackResponse.Ok("pooled", keepAlive: true)); + var services = new ServiceCollection(); + _ = TestClients.Add(services); + var provider = services.BuildServiceProvider(); + var transport = provider.GetRequiredKeyedService(TestClients.Name); + using (var response = await transport.ExecuteAsync(Request.Get(server.Url("/owned").AbsoluteUri), Ct)) + { + _ = await response.Body.ReadAsStringAsync(Ct); + } + + await provider.DisposeAsync(); + + // Without the lease the factory's infinite lifetime leaves the idle connection open and this wait never completes (V8). + using var bound = CancellationTokenSource.CreateLinkedTokenSource(Ct); + bound.CancelAfter(TimeSpan.FromSeconds(30)); + await server.WaitForConnectionReleasedAsync(0, bound.Token); + } + + [Fact] + public async Task A_callers_DelegatingHandler_is_not_disposed_by_the_container() + { + var services = new ServiceCollection(); + var builder = TestClients.Add(services); + using var spy = new ChainSpy(); + builder.HttpClient.AddHttpMessageHandler(() => spy); + var provider = services.BuildServiceProvider(); + _ = provider.GetRequiredKeyedService(TestClients.Name); + + await provider.DisposeAsync(); + + Assert.Equal(0, spy.Disposals); + } + + [Fact] + public async Task A_replaced_primary_is_the_callers_and_only_the_discarded_instance_is_released() + { + var services = new ServiceCollection(); + var builder = TestClients.Add(services); + using var replacement = new CountingHandler(); + builder.HttpClient.ConfigurePrimaryHttpMessageHandler(() => replacement); + var provider = services.BuildServiceProvider(); + + // The package's own primary was built and discarded by the factory only if its delegate ran; resolving the lease shows what it holds. + var lease = provider.GetRequiredKeyedService(TestClients.Name); + _ = provider.GetRequiredKeyedService(TestClients.Name); + var discarded = Assert.IsType(Assert.Single(lease.Tracked)); + Assert.NotSame(replacement, discarded); + await provider.DisposeAsync(); + + Assert.Equal(0, replacement.Disposals); + _ = Assert.Throws(() => discarded.PooledConnectionLifetime = TimeSpan.FromMinutes(1)); + } + + [Fact] + public async Task A_provider_built_but_never_resolved_disposes_nothing_and_throws_nothing() + { + var services = new ServiceCollection(); + _ = TestClients.Add(services); + var provider = services.BuildServiceProvider(); + + await provider.DisposeAsync(); + } + + [Fact] + public void The_lease_disposes_what_it_tracks_once_and_a_late_track_disposes_at_once() + { + var lease = new PrimaryHandlerLease(); + var first = new CountingHandler(); + lease.Track(first); + + lease.Dispose(); + lease.Dispose(); + var late = new CountingHandler(); + lease.Track(late); + + Assert.Equal(1, first.Disposals); + Assert.Equal(1, late.Disposals); + } + + [Fact] + public async Task The_lease_is_created_before_and_disposed_after_the_transport() + { + var order = new List(); + var services = new ServiceCollection(); + var builder = TestClients.Add(services); + _ = builder.UseTransport(_ => new OrderedTransport(order)); + var provider = services.BuildServiceProvider(); + var lease = provider.GetRequiredKeyedService(TestClients.Name); + lease.Track(new OrderedHandler(order)); + _ = provider.GetRequiredKeyedService(TestClients.Name); + + await provider.DisposeAsync(); + + Assert.Equal(["transport", "lease-handler"], order); + } + + private sealed class CountingHandler : HttpMessageHandler + { + private int _disposals; + + internal int Disposals => Volatile.Read(ref _disposals); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage()); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + Interlocked.Increment(ref _disposals); + } + + base.Dispose(disposing); + } + } + + private sealed class OrderedHandler(List order) : IDisposable + { + public void Dispose() => order.Add("lease-handler"); + } + + private sealed class OrderedTransport(List order) : IAsyncHttpClient + { + public Task ExecuteAsync(Request request, RequestOptions options, CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public ValueTask DisposeAsync() + { + order.Add("transport"); + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ProcessEnvironmentDefinition.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ProcessEnvironmentDefinition.cs new file mode 100644 index 0000000..6930034 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ProcessEnvironmentDefinition.cs @@ -0,0 +1,10 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// Serialises the classes that touch the real process environment. +[CollectionDefinition("ProcessEnvironment")] +public sealed class ProcessEnvironmentDefinition; diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ProviderValidationTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ProviderValidationTests.cs new file mode 100644 index 0000000..dff36de --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ProviderValidationTests.cs @@ -0,0 +1,81 @@ +// 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.Pipeline; +using Dexpace.Sdk.Core.Serialization; +using Dexpace.Sdk.TestSupport.Transports; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// The roadmap card's provider test (styleguide 2.5; SEAM-5, NFR-8; V4): ValidateScopes and ValidateOnBuild over every keyed registration, root and scope. +[Trait("Category", "Unit")] +public sealed class ProviderValidationTests +{ + private static readonly ServiceProviderOptions s_strict = new() { ValidateScopes = true, ValidateOnBuild = true }; + + [Fact] + public async Task Every_keyed_registration_resolves_from_the_root_and_from_a_scope() + { + var services = new ServiceCollection(); + _ = TestClients.Add(services, "first"); + _ = TestClients.Add(services, "second").UseTransport(new ScriptedTransport()); + await using var provider = services.BuildServiceProvider(s_strict); + await using var scope = provider.CreateAsyncScope(); + + foreach (var name in new[] { "first", "second" }) + { + foreach (var source in new IServiceProvider[] { provider, scope.ServiceProvider }) + { + var keyed = (IKeyedServiceProvider)source; + Assert.NotNull(keyed.GetRequiredKeyedService(name)); + Assert.NotNull(keyed.GetRequiredKeyedService(name)); + Assert.NotNull(keyed.GetRequiredKeyedService(name)); + Assert.NotNull(keyed.GetRequiredKeyedService(name)); + } + } + } + + [Fact] + public async Task A_factory_that_captures_a_scoped_service_fails_under_ValidateScopes() + { + var services = new ServiceCollection(); + services.AddScoped(); + _ = services.AddDexpaceClient(TestClients.Name, Config.Section()).UseSerde(sp => new CapturingSerde(sp.GetRequiredService())); + await using var provider = services.BuildServiceProvider(s_strict); + + Assert.Throws(() => provider.GetRequiredKeyedService(TestClients.Name)); + } + + [Fact] + public async Task ValidateOnBuild_does_not_run_options_validation_so_the_failure_arrives_at_start_or_first_resolution() + { + var services = new ServiceCollection(); + _ = TestClients.Add(services, TestClients.Name, ("Retry:MaxRetryAttempts", "abc")); + await using var provider = services.BuildServiceProvider(s_strict); + + // V4: building the provider did not throw; the validator is what fails. + Assert.Throws(provider.GetRequiredService().Validate); + } + + private sealed class CapturingSerde(NamedPipelineTests.ScopedThing thing) : ISerde + { + public NamedPipelineTests.ScopedThing Thing { get; } = thing; + + public Dexpace.Sdk.Core.Http.Common.MediaType DefaultMediaType => Dexpace.Sdk.Core.Http.Common.CommonMediaTypes.ApplicationJson; + + public ValueTask SerializeAsync(Stream destination, T value, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + + public ValueTask DeserializeAsync(Stream source, CancellationToken cancellationToken = default) => ValueTask.FromResult(default); + + public void Serialize(System.Buffers.IBufferWriter destination, T value) + { + } + + public T? Deserialize(ReadOnlySpan utf8) => default; + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ProxyKeyTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ProxyKeyTests.cs new file mode 100644 index 0000000..0b3959d --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ProxyKeyTests.cs @@ -0,0 +1,91 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// CFG-28, CFG-24's first layer (P9-15): the explicit Transport:Proxy:* keys, and no key that reads the environment. +[Trait("Category", "Unit")] +public sealed class ProxyKeyTests +{ + [Fact] + public void The_seven_keys_build_a_ProxyOptions() + { + var configuration = Config.Read( + ("Transport:Proxy:Type", "socks5"), + ("Transport:Proxy:Host", "proxy.test"), + ("Transport:Proxy:Port", "1080"), + ("Transport:Proxy:NonProxyHosts", "*.internal, localhost"), + ("Transport:Proxy:UserName", "alice"), + ("Transport:Proxy:Password", "s3cr3t"), + ("Transport:Proxy:BypassAll", "false")); + + Assert.Empty(configuration.Failures); + var proxy = Assert.IsType(configuration.Transport.Proxy); + Assert.Equal(ProxyType.Socks5, proxy.Type); + Assert.Equal("proxy.test", proxy.Host); + Assert.Equal(1080, proxy.Port); + Assert.Equal(["*.internal", "localhost"], proxy.NonProxyHosts); + Assert.Equal("alice", proxy.UserName); + Assert.False(proxy.BypassAll); + Assert.DoesNotContain("s3cr3t", proxy.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void No_proxy_key_means_no_proxy() + { + Assert.Null(Config.Read().Transport.Proxy); + } + + [Theory] + [InlineData("Transport:Proxy:Port", "Transport:Proxy:Host")] + [InlineData("Transport:Proxy:Host", "Transport:Proxy:Port")] + public void Any_proxy_key_requires_host_and_port(string present, string missing) + { + var configuration = Config.Read((present, present.EndsWith("Port", StringComparison.Ordinal) ? "8080" : "proxy.test")); + + var failure = Assert.Single(Config.Failures(configuration)); + Assert.Equal(Config.SectionPath + ":" + missing + ": required when any proxy key is present", failure); + Assert.Null(configuration.Transport.Proxy); + } + + [Theory] + [InlineData("0x10")] + [InlineData("70000")] + [InlineData("-1")] + public void A_bad_port_fails_under_its_key(string port) + { + var configuration = Config.Read(("Transport:Proxy:Host", "proxy.test"), ("Transport:Proxy:Port", port)); + + Assert.Contains("Transport:Proxy:Port", Assert.Single(Config.Failures(configuration)), StringComparison.Ordinal); + } + + [Fact] + public void A_bad_host_fails_without_the_value_and_a_bad_pattern_without_the_pattern() + { + var host = Assert.Single(Config.Failures(Config.Read(("Transport:Proxy:Host", "has/slash-s3cr3t"), ("Transport:Proxy:Port", "1")))); + Assert.DoesNotContain("s3cr3t", host, StringComparison.Ordinal); + } + + [Fact] + public void No_key_turns_the_environment_read_on() + { + // P9-15: a value that makes the package read the environment is the implicit read P8b-4 forbids by another route. + var configuration = Config.Read(("Transport:Proxy:FromEnvironment", "true")); + + Assert.Equal(Config.SectionPath + ":Transport:Proxy:FromEnvironment: unknown key", Assert.Single(Config.Failures(configuration))); + } + + [Fact] + public void The_password_never_appears_in_a_failure() + { + var configuration = Config.Read( + ("Transport:Proxy:Host", "proxy.test"), + ("Transport:Proxy:Port", "abc"), + ("Transport:Proxy:Password", "s3cr3t")); + + Assert.DoesNotContain("s3cr3t", string.Join('|', Config.Failures(configuration)), StringComparison.Ordinal); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ProxyRegistrationTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ProxyRegistrationTests.cs new file mode 100644 index 0000000..d8b12b0 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ProxyRegistrationTests.cs @@ -0,0 +1,111 @@ +// 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 Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// CFG-11, CFG-28 (P9-15): explicit keys or an explicit UseProxyFromEnvironment, never both, never a configuration switch, never at registration. +[Trait("Category", "Unit")] +public sealed class ProxyRegistrationTests +{ + private static readonly Uri s_destination = new("http://example.test/"); + + private static (ServiceCollection Services, DexpaceClientBuilder Builder, ChainSpy Spy) Client(params (string, string?)[] values) + { + var services = new ServiceCollection(); + var builder = TestClients.Add(services, TestClients.Name, values); + var spy = new ChainSpy(); + builder.HttpClient.AddHttpMessageHandler(() => spy); + return (services, builder, spy); + } + + [Fact] + public async Task Explicit_keys_reach_the_primary_handler() + { + var (services, _, spy) = Client(("Transport:Proxy:Host", "proxy.test"), ("Transport:Proxy:Port", "3128")); + await using var provider = services.BuildServiceProvider(); + + _ = provider.GetRequiredKeyedService(TestClients.Name); + + var primary = Assert.IsType(spy.Primary); + Assert.True(primary.UseProxy); + Assert.Equal("http://proxy.test:3128/", primary.Proxy!.GetProxy(s_destination)!.AbsoluteUri); + } + + [Fact] + public async Task UseProxyFromEnvironment_with_a_lookup_resolves_lazily_through_the_callers_lookup() + { + var asked = new List(); + var (services, builder, spy) = Client(); + _ = builder.UseProxyFromEnvironment(key => + { + asked.Add(key); + return key == "HTTPS_PROXY" ? "http://envproxy.test:3128" : null; + }); + Assert.Empty(asked); + await using var provider = services.BuildServiceProvider(); + Assert.Empty(asked); + + _ = provider.GetRequiredKeyedService(TestClients.Name); + + Assert.NotEmpty(asked); + var primary = Assert.IsType(spy.Primary); + Assert.Equal("http://envproxy.test:3128/", primary.Proxy!.GetProxy(s_destination)!.AbsoluteUri); + } + + [Fact] + public async Task The_documented_IConfiguration_form_serves_HTTPS_PROXY_from_any_provider() + { + var configuration = Config.Root(("HTTPS_PROXY", "http://fromconfig.test:8080")); + var (services, builder, spy) = Client(); + _ = builder.UseProxyFromEnvironment(key => configuration[key]); + await using var provider = services.BuildServiceProvider(); + + _ = provider.GetRequiredKeyedService(TestClients.Name); + + Assert.Equal("http://fromconfig.test:8080/", Assert.IsType(spy.Primary).Proxy!.GetProxy(s_destination)!.AbsoluteUri); + } + + [Fact] + public async Task Two_proxy_sources_fail_validation() + { + var (services, builder, _) = Client(("Transport:Proxy:Host", "proxy.test"), ("Transport:Proxy:Port", "3128")); + _ = builder.UseProxyFromEnvironment(static _ => null); + await using var provider = services.BuildServiceProvider(); + + var exception = Assert.Throws(provider.GetRequiredService().Validate); + + Assert.Contains("two proxy sources", Assert.Single(exception.Failures), StringComparison.Ordinal); + } + + [Fact] + public async Task ConfigureTransport_setting_Proxy_counts_as_an_explicit_source() + { + var (services, builder, _) = Client(); + _ = builder + .ConfigureTransport(static o => o with { Proxy = new Dexpace.Sdk.Core.Configuration.ProxyOptions { Host = "proxy.test", Port = 1 } }) + .UseProxyFromEnvironment(static _ => null); + await using var provider = services.BuildServiceProvider(); + + var exception = Assert.Throws(provider.GetRequiredService().Validate); + + Assert.Contains("two proxy sources", Assert.Single(exception.Failures), StringComparison.Ordinal); + } + + [Fact] + public async Task A_client_with_no_proxy_source_goes_direct() + { + var (services, _, spy) = Client(); + await using var provider = services.BuildServiceProvider(); + + _ = provider.GetRequiredKeyedService(TestClients.Name); + + var primary = Assert.IsType(spy.Primary); + Assert.False(primary.UseProxy); + Assert.Null(primary.Proxy); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/RecordBindingTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/RecordBindingTests.cs new file mode 100644 index 0000000..711be15 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/RecordBindingTests.cs @@ -0,0 +1,188 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Common; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// CFG-5, CFG-6, CFG-7 (P9-4, P9-8, P9-9): one row per key of the client, retry, redirect and logging records, with a bad value for each kind. +[Trait("Category", "Unit")] +public sealed class RecordBindingTests +{ + private static ClientConfiguration Ok(params (string, string?)[] values) + { + var configuration = Config.Read(values); + Assert.Empty(configuration.Failures); + return configuration; + } + + private static string Fail(params (string, string?)[] values) => Assert.Single(Config.Failures(Config.Read(values))); + + [Fact] + public void BaseAddress_binds_an_absolute_http_URI() + { + Assert.Equal(new Uri("https://api.test/"), Ok(("BaseAddress", "https://api.test/")).Options.BaseAddress); + } + + [Theory] + [InlineData("not a uri")] + [InlineData("/relative")] + [InlineData("ftp://host/")] + [InlineData("https://user:s3cr3t@api.test/p?token=s3cr3t#frag")] + public void A_bad_BaseAddress_fails_without_the_URL(string value) + { + var failure = Fail(("BaseAddress", value)); + + Assert.StartsWith(Config.SectionPath + ":BaseAddress: ", failure, StringComparison.Ordinal); + Assert.DoesNotContain("api.test", failure, StringComparison.Ordinal); + Assert.DoesNotContain("s3cr3t", failure, StringComparison.Ordinal); + } + + [Fact] + public void UserAgent_binds_and_a_control_character_fails_without_the_value() + { + Assert.Equal("acme/1.0", Ok(("UserAgent", "acme/1.0")).Options.UserAgent); + + var failure = Fail(("UserAgent", "bad\r\nX-Injected: s3cr3t")); + + Assert.Contains("UserAgent", failure, StringComparison.Ordinal); + Assert.DoesNotContain("s3cr3t", failure, StringComparison.Ordinal); + } + + [Theory] + [InlineData("PT30S", 30)] + [InlineData("30s", 30)] + public void The_two_timeouts_bind_durations(string text, int seconds) + { + var options = Ok(("OverallTimeout", text), ("AttemptTimeout", text)).Options; + + Assert.Equal(TimeSpan.FromSeconds(seconds), options.OverallTimeout); + Assert.Equal(TimeSpan.FromSeconds(seconds), options.AttemptTimeout); + } + + [Fact] + public void None_clears_a_nullable_timeout_and_zero_and_negatives_fail_through_the_record() + { + Assert.Null(Ok(("OverallTimeout", "none")).Options.OverallTimeout); + Assert.Contains("OverallTimeout", Fail(("OverallTimeout", "0")), StringComparison.Ordinal); + Assert.Contains("AttemptTimeout", Fail(("AttemptTimeout", "-1s")), StringComparison.Ordinal); + Assert.Contains("greater than zero", Fail(("AttemptTimeout", "50d")), StringComparison.Ordinal); + } + + [Fact] + public void Retry_keys_bind() + { + var retry = Ok( + ("Retry:MaxRetryAttempts", "3"), + ("Retry:BaseDelay", "250ms"), + ("Retry:MaxDelay", "PT10S"), + ("Retry:FixedDelay", "1s"), + ("Retry:Multiplier", "1.5"), + ("Retry:Jitter", "0.1"), + ("Retry:HonorRetryAfter", "false"), + ("Retry:AttemptHeaderName", "X-Attempt")).Options.Retry; + + Assert.Equal(3, retry.MaxRetryAttempts); + Assert.Equal(TimeSpan.FromMilliseconds(250), retry.BaseDelay); + Assert.Equal(TimeSpan.FromSeconds(10), retry.MaxDelay); + Assert.Equal(TimeSpan.FromSeconds(1), retry.FixedDelay); + Assert.Equal(1.5, retry.Multiplier); + Assert.Equal(0.1, retry.Jitter); + Assert.False(retry.HonorRetryAfter); + Assert.Equal("X-Attempt", retry.AttemptHeaderName); + Assert.Null(Ok(("Retry:FixedDelay", "none")).Options.Retry.FixedDelay); + } + + [Fact] + public void A_negative_retry_budget_is_the_records_rejection_reported_under_the_key() + { + // CFG-5: the negative is parsed and handed to the record, whose accessor decides. + var failure = Fail(("Retry:MaxRetryAttempts", "-1")); + + Assert.StartsWith(Config.SectionPath + ":Retry:MaxRetryAttempts: ", failure, StringComparison.Ordinal); + Assert.Contains("non-negative", failure, StringComparison.Ordinal); + Assert.Contains("32-bit", Fail(("Retry:MaxRetryAttempts", "2147483648")), StringComparison.Ordinal); + } + + [Theory] + [InlineData("Retry:Multiplier", "0.5")] + [InlineData("Retry:Jitter", "2")] + [InlineData("Retry:BaseDelay", "abc")] + [InlineData("Retry:HonorRetryAfter", "yes")] + [InlineData("Retry:AttemptHeaderName", "bad name")] + [InlineData("Retry:RetryableStatusCodes", "99")] + [InlineData("Retry:RetryableStatusCodes", "600")] + public void A_bad_retry_value_fails_under_its_key(string key, string value) + { + var failure = Fail((key, value)); + + Assert.StartsWith(Config.SectionPath + ":" + key + ": ", failure, StringComparison.Ordinal); + } + + [Fact] + public void Retryable_status_codes_replace_the_default_in_both_list_forms_and_none_is_empty() + { + Assert.Equal(new HashSet { 429, 503 }, Ok(("Retry:RetryableStatusCodes", "429, 503")).Options.Retry.RetryableStatusCodes); + Assert.Equal( + new HashSet { 502 }, + Ok(("Retry:RetryableStatusCodes:0", "502")).Options.Retry.RetryableStatusCodes); + Assert.Empty(Ok(("Retry:RetryableStatusCodes", "none")).Options.Retry.RetryableStatusCodes); + } + + [Fact] + public void Redirect_keys_bind_and_the_methods_replace_the_default_through_Method_Of() + { + var redirect = Ok( + ("Redirect:MaxRedirects", "5"), + ("Redirect:AllowHttpsToHttpDowngrade", "true"), + ("Redirect:FollowSeeOther", "true"), + ("Redirect:AllowedMethods", "GET, HEAD, POST")).Options.Redirect; + + Assert.Equal(5, redirect.MaxRedirects); + Assert.True(redirect.AllowHttpsToHttpDowngrade); + Assert.True(redirect.FollowSeeOther); + Assert.Equal(new HashSet { Method.Get, Method.Head, Method.Post }, redirect.AllowedMethods); + Assert.Empty(Ok(("Redirect:AllowedMethods", "none")).Options.Redirect.AllowedMethods); + } + + [Fact] + public void A_method_token_with_a_space_fails_without_the_token() + { + var failure = Fail(("Redirect:AllowedMethods", "GET, S3CR3T VERB")); + + Assert.Contains("item 1", failure, StringComparison.Ordinal); + Assert.DoesNotContain("S3CR3T", failure, StringComparison.Ordinal); + } + + [Fact] + public void Redirect_predicate_is_code_only() + { + Assert.Equal(Config.SectionPath + ":Redirect:Predicate: unknown key", Fail(("Redirect:Predicate", "x"))); + } + + [Fact] + public void Logging_keys_bind_and_the_lists_replace_rather_than_append() + { + var logging = Ok( + ("Logging:BodyPreviewSize", "100"), + ("Logging:AllowedHeaderNames", "x-one, x-two"), + ("Logging:AllowedQueryParameters:0", "page"), + ("Logging:UrlValuedHeaderNames", "none"), + ("Logging:OmitDisallowedHeaders", "true")).Options.Logging; + + Assert.Equal(100, logging.BodyPreviewSize); + Assert.Equal(["x-one", "x-two"], logging.AllowedHeaderNames); + Assert.Equal(["page"], logging.AllowedQueryParameters); + Assert.Empty(logging.UrlValuedHeaderNames); + Assert.True(logging.OmitDisallowedHeaders); + Assert.DoesNotContain("content-type", logging.AllowedHeaderNames); + } + + [Fact] + public void A_negative_body_preview_fails_through_the_record() + { + Assert.Contains("Logging:BodyPreviewSize", Fail(("Logging:BodyPreviewSize", "-1")), StringComparison.Ordinal); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/RecordingLoggerProvider.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/RecordingLoggerProvider.cs new file mode 100644 index 0000000..2a70220 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/RecordingLoggerProvider.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Microsoft.Extensions.Logging; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// Records the category and message of every log entry the container's logging produces. +internal sealed class RecordingLoggerProvider : ILoggerProvider +{ + private readonly List<(string Category, LogLevel Level, string Message)> _entries = []; + + internal IReadOnlyList<(string Category, LogLevel Level, string Message)> Entries + { + get + { + lock (_entries) + { + return [.. _entries]; + } + } + } + + public ILogger CreateLogger(string categoryName) => new Recorder(categoryName, this); + + public void Dispose() + { + } + + private sealed class Recorder(string category, RecordingLoggerProvider owner) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (owner._entries) + { + owner._entries.Add((category, logLevel, formatter(state, exception))); + } + } + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ResilienceCompositionTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ResilienceCompositionTests.cs new file mode 100644 index 0000000..693f218 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ResilienceCompositionTests.cs @@ -0,0 +1,113 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Wire; +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Pipeline; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http.Resilience; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// +/// XCUT-10 (P9-19, V6; section 11 item 24): one retry layer. Over a loopback server that answers 503, the documented composition (the standard resilience handler +/// with Retry.ShouldHandle returning ) sends once per SDK attempt, the standard handler's default arrangement multiplies the +/// attempts and re-sends a POST body behind the SDK's gate, and the handler-side MaxRetryAttempts = 0 that the old guidance implied is refused by the handler's own validation. +/// +[Trait("Category", "Integration")] +public sealed class ResilienceCompositionTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static LoopbackServer Unavailable() => LoopbackServer.Start(static _ => LoopbackResponse.Status(503, "Service Unavailable")); + + private static Request Post(Uri url, RequestBody body) => Request.Post(url.AbsoluteUri, body); + + private static ServiceProvider Provider(Action compose, int attempts) + { + var services = new ServiceCollection(); + var builder = TestClients.Add( + services, + TestClients.Name, + ("Retry:MaxRetryAttempts", attempts.ToString(System.Globalization.CultureInfo.InvariantCulture)), + ("Retry:BaseDelay", "1ms"), + ("Retry:MaxDelay", "1ms"), + ("Retry:Jitter", "0")); + compose(builder); + return services.BuildServiceProvider(); + } + + [Fact] + public async Task The_documented_composition_sends_once_per_SDK_attempt() + { + await using var server = Unavailable(); + await using var provider = Provider(static b => b.HttpClient.AddStandardResilienceHandler(static o => o.Retry.ShouldHandle = static _ => ValueTask.FromResult(false)), attempts: 2); + var pipeline = provider.GetRequiredKeyedService(TestClients.Name); + + using var replayable = await pipeline.ExecuteAsync(Post(server.Url("/replay"), RequestBody.FromString("payload")), Ct); + + // MaxRetryAttempts + 1: the SDK is the one retry layer, so the handler adds no re-send. + Assert.Equal(503, replayable.Status.Code); + Assert.Equal(3, server.Requests.Count); + } + + [Fact] + public async Task A_single_use_body_reaches_the_server_once_behind_the_composition() + { + await using var server = Unavailable(); + await using var provider = Provider(static b => b.HttpClient.AddStandardResilienceHandler(static o => o.Retry.ShouldHandle = static _ => ValueTask.FromResult(false)), attempts: 2); + var pipeline = provider.GetRequiredKeyedService(TestClients.Name); + using var stream = new NonSeekableStream(System.Text.Encoding.UTF8.GetBytes("payload")); + + using var response = await pipeline.ExecuteAsync(Post(server.Url("/once"), RequestBody.FromStream(stream)), Ct); + + Assert.Equal(503, response.Status.Code); + Assert.Single(server.Requests); + } + + [Fact] + public async Task The_rate_limiter_timeouts_and_circuit_breaker_are_still_in_place() + { + await using var provider = Provider(static b => b.HttpClient.AddStandardResilienceHandler(static o => o.Retry.ShouldHandle = static _ => ValueTask.FromResult(false)), attempts: 0); + _ = provider.GetRequiredKeyedService(TestClients.Name); + + var options = provider.GetRequiredService>().Get("Dexpace.github-standard"); + + Assert.Equal(TimeSpan.FromSeconds(10), options.AttemptTimeout.Timeout); + Assert.Equal(TimeSpan.FromSeconds(30), options.TotalRequestTimeout.Timeout); + Assert.True(options.CircuitBreaker.FailureRatio > 0); + Assert.NotNull(options.RateLimiter); + } + + [Fact] + public async Task The_standard_handlers_default_arrangement_multiplies_the_attempts_and_resends_a_POST_body() + { + // The regression the guidance exists for (XCUT-10(b), section 11 item 24): the SDK is told to make one attempt (MaxRetryAttempts = 0, the option the + // old guidance offered) and the handler's own default retry still sends the POST body again, behind the SDK's re-send gate. + await using var server = Unavailable(); + await using var provider = Provider(static b => b.HttpClient.AddStandardResilienceHandler(static o => o.Retry.Delay = TimeSpan.FromMilliseconds(1)), attempts: 0); + var pipeline = provider.GetRequiredKeyedService(TestClients.Name); + + using var response = await pipeline.ExecuteAsync(Post(server.Url("/multiplied"), RequestBody.FromString("payload")), Ct); + + Assert.True(server.Requests.Count > 1, "the handler's default retry re-sent the body: " + server.Requests.Count); + } + + [Fact] + public async Task MaxRetryAttempts_zero_is_refused_by_the_standard_handlers_own_validation() + { + await using var provider = Provider(static b => b.HttpClient.AddStandardResilienceHandler(static o => o.Retry.MaxRetryAttempts = 0), attempts: 0); + + var failure = Assert.ThrowsAny(() => provider.GetRequiredKeyedService(TestClients.Name)); + + Assert.Contains("MaxRetryAttempts", failure.ToString(), StringComparison.Ordinal); + } + + private sealed class NonSeekableStream(byte[] bytes) : MemoryStream(bytes) + { + public override bool CanSeek => false; + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ScalarGrammarTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ScalarGrammarTests.cs new file mode 100644 index 0000000..be769e2 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ScalarGrammarTests.cs @@ -0,0 +1,150 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Globalization; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Http.SystemNet; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// P9-8: the decimal, enum and list grammars (V1's mis-conversions answered). +[Trait("Category", "Unit")] +public sealed class ScalarGrammarTests +{ + [Theory] + [InlineData("0.5", 0.5)] + [InlineData("2", 2.0)] + [InlineData("-1", -1.0)] + [InlineData("1e2", 100.0)] + public void Decimals_parse_in_invariant_notation(string text, double expected) + { + Assert.True(ScalarGrammar.TryParseDouble(text, out var value, out _)); + Assert.Equal(expected, value); + } + + [Theory] + [InlineData("NaN")] + [InlineData("Infinity")] + [InlineData("-Infinity")] + [InlineData("1,5")] + [InlineData("abc")] + public void Non_finite_and_non_invariant_decimals_fail(string text) => Assert.False(ScalarGrammar.TryParseDouble(text, out _, out _)); + + [Fact] + public void The_decimal_parse_ignores_the_current_culture() + { + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + Assert.True(ScalarGrammar.TryParseDouble("1.5", out var value, out _)); + Assert.Equal(1.5, value); + Assert.False(ScalarGrammar.TryParseDouble("1,5", out _, out _)); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + [Theory] + [InlineData("Body")] + [InlineData("body")] + [InlineData(" BODY ")] + public void An_enum_is_read_by_name_in_any_case_after_trimming(string text) + { + Assert.True(ScalarGrammar.TryParseEnum(ScalarGrammar.AsciiTrim(text), out var value, out _)); + Assert.Equal(HttpLogLevel.Body, value); + } + + [Theory] + [InlineData("2")] + [InlineData("7")] + [InlineData("-1")] + [InlineData("Verbose")] + public void A_number_an_undefined_value_or_an_unknown_name_fails_and_the_rule_lists_the_names(string text) + { + // V1: the binder admits "2" and the undefined 7; this reader does not (OBS-35). + Assert.False(ScalarGrammar.TryParseEnum(text, out _, out var rule)); + Assert.Contains("None, Headers, Body", rule, StringComparison.Ordinal); + Assert.DoesNotContain(text, rule!.Replace("None, Headers, Body", string.Empty, StringComparison.Ordinal), StringComparison.Ordinal); + } + + [Fact] + public void The_same_rule_holds_for_the_other_two_enums() + { + Assert.True(ScalarGrammar.TryParseEnum("every", out var drop, out _)); + Assert.Equal(HeaderDropLogging.Every, drop); + Assert.True(ScalarGrammar.TryParseEnum("socks5", out var proxy, out _)); + Assert.Equal(ProxyType.Socks5, proxy); + Assert.False(ScalarGrammar.TryParseEnum("1", out _, out _)); + Assert.False(ScalarGrammar.TryParseEnum("9", out _, out _)); + } + + [Fact] + public void A_scalar_list_splits_on_commas_and_trims_each_item() + { + Assert.True(ScalarGrammar.TrySplitList("a, b ,c", out var items, out _)); + Assert.Equal(["a", "b", "c"], items); + } + + [Fact] + public void None_is_the_empty_list() + { + Assert.True(ScalarGrammar.TrySplitList("NONE", out var items, out _)); + Assert.Empty(items); + } + + [Theory] + [InlineData("a,")] + [InlineData(",a")] + [InlineData("a,,b")] + public void A_stray_comma_fails_instead_of_dropping_an_item(string text) => Assert.False(ScalarGrammar.TrySplitList(text, out _, out _)); + + [Fact] + public void Children_form_a_list_in_the_configurations_order_and_both_forms_together_fail() + { + var children = Config.Read(("Retry:RetryableStatusCodes:0", "503"), ("Retry:RetryableStatusCodes:1", "429")); + Assert.Empty(children.Failures); + Assert.Equal(new HashSet { 503, 429 }, children.Options.Retry.RetryableStatusCodes); + + var both = Config.Read(("Retry:RetryableStatusCodes", "503"), ("Retry:RetryableStatusCodes:0", "429")); + Assert.Contains("not both", Assert.Single(Config.Failures(both)), StringComparison.Ordinal); + } + + [Fact] + public void A_list_item_that_fails_names_the_index_and_never_the_value() + { + var configuration = Config.Read(("Retry:RetryableStatusCodes", "503, s3cr3t")); + + var failure = Assert.Single(Config.Failures(configuration)); + Assert.Contains("item 1", failure, StringComparison.Ordinal); + Assert.DoesNotContain("s3cr3t", failure, StringComparison.Ordinal); + } + + [Fact] + public void Every_failure_is_value_free() + { + const string Marker = "s3cr3t-value"; + string?[] rules = + [ + Rule(ScalarGrammar.TryParseInt32(Marker, out _, out var a), a), + Rule(ScalarGrammar.TryParseInt64(Marker, out _, out var b), b), + Rule(ScalarGrammar.TryParseBoolean(Marker, out _, out var c), c), + Rule(ScalarGrammar.TryParseDouble(Marker, out _, out var d), d), + Rule(ScalarGrammar.TryParseDuration(Marker, out _, out var e), e), + Rule(ScalarGrammar.TryParseOptionalDuration(Marker, out _, out var f), f), + Rule(ScalarGrammar.TryParseEnum(Marker, out _, out var g), g), + Rule(ScalarGrammar.TryParseAbsoluteUri(Marker, out _, out var h), h), + ]; + + Assert.All(rules, rule => Assert.DoesNotContain(Marker, rule, StringComparison.Ordinal)); + } + + private static string? Rule(bool parsed, string? rule) + { + Assert.False(parsed); + return rule; + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Security/NamedClientRedirectWireTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Security/NamedClientRedirectWireTests.cs new file mode 100644 index 0000000..46bed3e --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Security/NamedClientRedirectWireTests.cs @@ -0,0 +1,110 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Wire; +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Pipeline; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests.Security; + +/// +/// Roadmap phase 1, S3, row 9 (TRANSPORT-1, REDIR-7, REDIR-8, REDIR-9, XCUT-17; P9-24): the DI package configures each named client's primary handler +/// with AllowAutoRedirect = false, so a 3xx comes back to the SDK's RedirectPolicy and no credential crosses an origin on the runtime's +/// say-so. Two loopback servers on different ports are two origins; every assertion reads the raw bytes each one received. Permanent (roadmap constraint 5): +/// never delete or loosen it. The phase-1 classes (RedirectWireTests and the like) are untouched. +/// +[Trait("Category", "Security")] +public sealed class NamedClientRedirectWireTests +{ + private static readonly string[] s_credentialHeaders = ["authorization", "cookie", "proxy-authorization"]; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static Request WithCredentials(Uri url) => + Request.Create( + Method.Get, + url.AbsoluteUri, + new Headers.Builder() + .Add("Authorization", "Bearer seed-secret") + .Add("Cookie", "session=seed-secret") + .Add("Proxy-Authorization", "Basic c2VlZDpzZWNyZXQ=") + .Add("X-Pass", "kept") + .Build()); + + private static void AssertCarriesNoCredential(RecordedRequest request) + { + foreach (var name in s_credentialHeaders) + { + Assert.Empty(request.HeaderValues(name)); + } + + Assert.DoesNotContain("seed-secret", request.RawText, StringComparison.Ordinal); + Assert.DoesNotContain("c2VlZDpzZWNyZXQ=", request.RawText, StringComparison.Ordinal); + } + + [Fact] + public async Task The_named_client_returns_the_3xx_and_never_contacts_the_target() + { + await using var target = LoopbackServer.Start(LoopbackResponse.Ok("followed")); + await using var origin = LoopbackServer.Start(LoopbackResponse.Redirect(302, target.Url("/landing").AbsoluteUri)); + var services = new ServiceCollection(); + _ = TestClients.Add(services, "kit"); + await using var provider = services.BuildServiceProvider(); + var transport = provider.GetRequiredKeyedService("kit"); + + await using var response = await transport.ExecuteAsync(WithCredentials(origin.Url("/start")), Ct); + + Assert.Equal(302, response.Status.Code); + Assert.Equal(target.Url("/landing").AbsoluteUri, response.Headers.Get("Location")); + Assert.Single(origin.Requests); + Assert.Empty(target.Requests); + } + + [Fact] + public async Task Through_the_pipeline_a_cross_origin_hop_carries_no_credential_and_a_same_origin_hop_loses_authorization() + { + await using var target = LoopbackServer.Start(LoopbackResponse.Ok("landed")); + await using var origin = LoopbackServer.Start(LoopbackResponse.Redirect(302, target.Url("/landing").AbsoluteUri)); + await using var same = LoopbackServer.Start(LoopbackResponse.Redirect(302, "/next"), LoopbackResponse.Ok("next")); + var services = new ServiceCollection(); + _ = TestClients.Add(services, "kit", ("Redirect:MaxRedirects", "3"), ("Redirect:AllowedMethods", "GET")); + await using var provider = services.BuildServiceProvider(); + var pipeline = provider.GetRequiredKeyedService("kit"); + + using var cross = await pipeline.ExecuteAsync(WithCredentials(origin.Url("/start")), Ct); + using var inside = await pipeline.ExecuteAsync(WithCredentials(same.Url("/start")), Ct); + + Assert.Equal(200, cross.Status.Code); + Assert.Equal("Bearer seed-secret", Assert.Single(origin.Requests).Header("Authorization")); + var hop = Assert.Single(target.Requests); + Assert.Equal("GET /landing HTTP/1.1", hop.RequestLine); + AssertCarriesNoCredential(hop); + Assert.Equal("kept", hop.Header("X-Pass")); + + Assert.Equal(200, inside.Status.Code); + var sameHop = same.Requests[1]; + Assert.Empty(sameHop.HeaderValues("Authorization")); + Assert.Equal("session=seed-secret", sameHop.Header("Cookie")); + } + + [Fact] + public async Task A_substituted_redirect_following_primary_fails_the_resolution_and_contacts_neither_origin() + { + await using var target = LoopbackServer.Start(LoopbackResponse.Ok("followed")); + await using var origin = LoopbackServer.Start(LoopbackResponse.Redirect(302, target.Url("/landing").AbsoluteUri)); + var services = new ServiceCollection(); + var builder = TestClients.Add(services, "kit"); + _ = builder.HttpClient.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { UseProxy = false, AllowAutoRedirect = true }); + await using var provider = services.BuildServiceProvider(); + + var failure = Assert.Throws(() => provider.GetRequiredKeyedService("kit")); + + Assert.Contains("AllowAutoRedirect", failure.Message, StringComparison.Ordinal); + Assert.Empty(origin.Requests); + Assert.Empty(target.Requests); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/SerdeRegistrationTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/SerdeRegistrationTests.cs new file mode 100644 index 0000000..3b9b3a9 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/SerdeRegistrationTests.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text.Json.Serialization; +using Dexpace.Sdk.Core.Serialization; +using Dexpace.Sdk.Serialization.SystemTextJson; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// A model with a Tristate member, so the round trip proves the codec wires it on the options copy. +/// A tri-state string. +/// A plain number. +public sealed record Gadget(Tristate Name, int Size); + +/// The source-generated context the 7a composition is documented over. +[JsonSerializable(typeof(Gadget))] +public sealed partial class GadgetJsonContext : JsonSerializerContext; + +/// P9-17 (the 7a hand-off): the codec is composed by the caller, not referenced by the package. +[Trait("Category", "Unit")] +public sealed class SerdeRegistrationTests +{ + [Fact] + public async Task CreateDefaultOptions_with_a_context_round_trips_through_the_registered_serde() + { + var services = new ServiceCollection(); + _ = services.AddDexpaceClient(TestClients.Name, Config.Section()) + .UseSerde(_ => new SystemTextJsonSerde(SystemTextJsonSerde.CreateDefaultOptions(GadgetJsonContext.Default))); + await using var provider = services.BuildServiceProvider(); + var serde = provider.GetRequiredKeyedService(TestClients.Name); + var original = new Gadget(Tristate.Null, 3); + + using var stream = new MemoryStream(); + await serde.SerializeAsync(stream, original, TestContext.Current.CancellationToken); + stream.Position = 0; + var json = System.Text.Encoding.UTF8.GetString(stream.ToArray()); + stream.Position = 0; + var back = await serde.DeserializeAsync(stream, TestContext.Current.CancellationToken); + + Assert.Equal("""{"name":null,"size":3}""", json); + Assert.Equal(original, back); + } + + [Fact] + public void The_package_does_not_reference_the_codec() + { + var references = typeof(DexpaceClientBuilder).Assembly.GetReferencedAssemblies().Select(static a => a.Name); + + Assert.DoesNotContain("Dexpace.Sdk.Serialization.SystemTextJson", references); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/SingleRegistrationTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/SingleRegistrationTests.cs new file mode 100644 index 0000000..b742c84 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/SingleRegistrationTests.cs @@ -0,0 +1,152 @@ +// 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.Serialization; +using Dexpace.Sdk.TestSupport.Transports; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// SEAM-5, SEAM-6, XCUT-22, XCUT-23 (P9-16; V4): explicit installs replace the default, a different second install throws at the call, duplicates fail at start. +[Trait("Category", "Unit")] +public sealed class SingleRegistrationTests +{ + private static int Count(IServiceCollection services) => + services.Count(static d => d.ServiceType == typeof(T) && Equals(d.ServiceKey, TestClients.Name)); + + [Fact] + public async Task An_explicit_transport_instance_replaces_the_default_and_is_never_disposed_by_the_container() + { + var services = new ServiceCollection(); + var scripted = new ScriptedTransport(); + _ = TestClients.Add(services).UseTransport(scripted); + var provider = services.BuildServiceProvider(); + + var resolved = provider.GetRequiredKeyedService(TestClients.Name); + var lease = provider.GetRequiredKeyedService(TestClients.Name); + await provider.DisposeAsync(); + + Assert.Same(scripted, resolved); + Assert.Empty(lease.Tracked); + Assert.False(scripted.IsDisposed); + Assert.Equal(1, Count(services)); + } + + [Fact] + public async Task An_explicit_transport_factory_is_a_factory_descriptor_and_the_container_disposes_what_it_returns() + { + var services = new ServiceCollection(); + var scripted = new ScriptedTransport(); + _ = TestClients.Add(services).UseTransport(_ => scripted); + var provider = services.BuildServiceProvider(); + + Assert.Same(scripted, provider.GetRequiredKeyedService(TestClients.Name)); + await provider.DisposeAsync(); + + Assert.True(scripted.IsDisposed); + } + + [Fact] + public void A_synchronous_dispose_throws_for_an_async_only_transport() + { + var services = new ServiceCollection(); + _ = TestClients.Add(services).UseTransport(_ => new AsyncOnlyTransport()); + var provider = services.BuildServiceProvider(); + _ = provider.GetRequiredKeyedService(TestClients.Name); + + Assert.Throws(provider.Dispose); + } + + [Fact] + public async Task UseSerde_installs_an_instance_or_a_factory_the_same_way() + { + var services = new ServiceCollection(); + var serde = new Dexpace.Sdk.TestSupport.Serialization.BytesSerde(Dexpace.Sdk.Core.Http.Common.CommonMediaTypes.ApplicationJson, []); + _ = services.AddDexpaceClient(TestClients.Name, Config.Section()).UseSerde(serde); + var provider = services.BuildServiceProvider(); + + Assert.Same(serde, provider.GetRequiredKeyedService(TestClients.Name)); + await provider.DisposeAsync(); + } + + [Fact] + public async Task No_codec_fails_validation_with_the_install_hint_and_fails_every_keyed_resolution() + { + var services = new ServiceCollection(); + _ = services.AddDexpaceClient(TestClients.Name, Config.Section()); + await using var provider = services.BuildServiceProvider(); + + var exception = Assert.Throws(provider.GetRequiredService().Validate); + + Assert.Equal( + "client 'github' has no ISerde: call UseSerde(...) on AddDexpaceClient(\"github\")", + Assert.Single(exception.Failures)); + Assert.Throws(() => provider.GetRequiredKeyedService(TestClients.Name)); + } + + [Fact] + public void The_same_instance_or_the_same_delegate_again_is_a_no_op() + { + var services = new ServiceCollection(); + var scripted = new ScriptedTransport(); + Func factory = _ => scripted; + var builder = TestClients.Add(services); + + _ = builder.UseTransport(scripted).UseTransport(scripted); + Assert.Equal(1, Count(services)); + _ = builder.UseSerde(StubSerde.Instance); + Assert.Equal(1, Count(services)); + + var other = new ServiceCollection(); + _ = TestClients.Add(other).UseTransport(factory).UseTransport(factory); + Assert.Equal(1, Count(other)); + } + + [Fact] + public void A_different_second_install_throws_at_the_call_naming_the_client_and_both_implementations() + { + var services = new ServiceCollection(); + var builder = TestClients.Add(services).UseTransport(new ScriptedTransport()); + + var transport = Assert.Throws(() => builder.UseTransport(new AsyncOnlyTransport())); + var codec = Assert.Throws(() => builder.UseSerde(new Dexpace.Sdk.TestSupport.Serialization.BytesSerde(Dexpace.Sdk.Core.Http.Common.CommonMediaTypes.ApplicationJson, []))); + + Assert.Contains("'github'", transport.Message, StringComparison.Ordinal); + Assert.Contains(nameof(ScriptedTransport), transport.Message, StringComparison.Ordinal); + Assert.Contains(nameof(AsyncOnlyTransport), transport.Message, StringComparison.Ordinal); + Assert.Contains("'github'", codec.Message, StringComparison.Ordinal); + Assert.Contains("BytesSerde", codec.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task A_raw_duplicate_keyed_registration_fails_at_start_listing_every_implementation() + { + var services = new ServiceCollection(); + _ = TestClients.Add(services).UseTransport(new ScriptedTransport()); + services.AddKeyedSingleton(TestClients.Name, new AsyncOnlyTransport()); + services.AddKeyedSingleton(TestClients.Name, (_, _) => StubSerde.Instance); + await using var provider = services.BuildServiceProvider(); + + // V4: GetRequiredKeyedService would silently resolve the last registration of each; the validator lists them. + var exception = Assert.Throws(provider.GetRequiredService().Validate); + + Assert.Equal(2, exception.Failures.Count()); + Assert.Contains(exception.Failures, f => f.Contains(nameof(ScriptedTransport), StringComparison.Ordinal) && f.Contains(nameof(AsyncOnlyTransport), StringComparison.Ordinal)); + Assert.Contains(exception.Failures, f => f.Contains("ISerde", StringComparison.Ordinal) && f.Contains("a factory", StringComparison.Ordinal) && f.Contains("BytesSerde", StringComparison.Ordinal)); + } + + [Fact] + public async Task A_raw_descriptor_under_another_clients_key_is_not_counted() + { + var services = new ServiceCollection(); + _ = TestClients.Add(services).UseTransport(new ScriptedTransport()); + services.AddKeyedSingleton("other", new AsyncOnlyTransport()); + services.AddKeyedSingleton("other", StubSerde.Instance); + await using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService().Validate(); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/StateHost.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/StateHost.cs new file mode 100644 index 0000000..343d49c --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/StateHost.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// A provider over one client's options state, registered through the internal seam AddClientState (R3). +internal sealed class StateHost +{ + /// The name every test client uses. + internal const string Name = "github"; + + private StateHost(ServiceProvider provider, DexpaceClientRegistration registration) + { + Provider = provider; + Registration = registration; + } + + internal ServiceProvider Provider { get; } + + internal DexpaceClientRegistration Registration { get; } + + /// Builds a provider, lets edit the registration, and keeps the provider alive for the test. + internal static StateHost WithClient(Action configure, params (string Key, string? Value)[] values) + { + var services = new ServiceCollection(); + var registration = Register(services, Name, values.Length == 0 ? null : Config.Section(values)); + configure(registration); + return new StateHost(services.BuildServiceProvider(), registration); + } + + /// + /// Registers a client's options state through the internal seam and gives it a stub transport and codec descriptor, because the validator now fails a client + /// with no codec (F1) and these tests are not about registration. + /// + internal static DexpaceClientRegistration Register(IServiceCollection services, string name, Microsoft.Extensions.Configuration.IConfigurationSection? section) + { + var registration = DexpaceClientRegistrations.AddClientState(services, name, section); + services.AddKeyedSingleton(name, StubSerde.Instance); + services.AddKeyedSingleton(name, new Dexpace.Sdk.TestSupport.Transports.ScriptedTransport()); + return registration; + } + + /// Resolves the validated state; throws when validation fails. + internal DexpaceClientState Get() => Provider.GetRequiredService>().Get(Name); +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/StubSerde.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/StubSerde.cs new file mode 100644 index 0000000..15a70e8 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/StubSerde.cs @@ -0,0 +1,14 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Serialization; +using Dexpace.Sdk.TestSupport.Serialization; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// The codec every test client installs: a client with no codec fails validation and every keyed resolution (design D), so tests that are not about the codec go through . +internal static class StubSerde +{ + internal static ISerde Instance { get; } = new BytesSerde(CommonMediaTypes.ApplicationJson, []); +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/TestCategoryTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/TestCategoryTests.cs new file mode 100644 index 0000000..e975f36 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/TestCategoryTests.cs @@ -0,0 +1,16 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.TestSupport; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +// Roadmap constraint 4, design §9.3: every test carries a category, so a --filter-trait run selects it. +[Trait("Category", "Unit")] +public sealed class TestCategoryTests +{ + [Fact] + public void Every_test_carries_a_design_category() => + Assert.Empty(TestCategories.FindUncategorized(typeof(TestCategoryTests).Assembly)); +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/TestClients.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/TestClients.cs new file mode 100644 index 0000000..48c8615 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/TestClients.cs @@ -0,0 +1,26 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Microsoft.Extensions.DependencyInjection; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// The one place a test registers a client: a codec stub is installed here, once (F1). +internal static class TestClients +{ + /// The name every test client uses. + internal const string Name = "github"; + + /// Registers over an in-memory section holding and installs the stub codec. + internal static DexpaceClientBuilder Add(IServiceCollection services, string name = Name, params (string Key, string? Value)[] values) => + services.AddDexpaceClient(name, Config.Section(values)).UseSerde(StubSerde.Instance); + + /// A provider with scope and build validation on, over one client. + internal static ServiceProvider Provider(Action? configure = null, params (string Key, string? Value)[] values) + { + var services = new ServiceCollection(); + var builder = Add(services, Name, values); + configure?.Invoke(builder); + return services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/TransportBindingTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/TransportBindingTests.cs new file mode 100644 index 0000000..dd71d3b --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/TransportBindingTests.cs @@ -0,0 +1,56 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Http.SystemNet; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// P9-8, P9-13: the Transport:* keys of SystemNetHttpClientOptions and the primary handler's pooled-connection lifetime. +[Trait("Category", "Unit")] +public sealed class TransportBindingTests +{ + [Fact] + public void Timeout_binds_a_duration_and_none_means_no_transport_default() + { + Assert.Equal(TimeSpan.FromSeconds(10), Config.Read(("Transport:Timeout", "PT10S")).Transport.Timeout); + Assert.Null(Config.Read(("Transport:Timeout", "none")).Transport.Timeout); + Assert.Equal(new SystemNetHttpClientOptions().Timeout, Config.Read().Transport.Timeout); + } + + [Fact] + public void An_out_of_range_timeout_fails_through_the_record() + { + Assert.Contains("Transport:Timeout", Assert.Single(Config.Failures(Config.Read(("Transport:Timeout", "60d")))), StringComparison.Ordinal); + } + + [Fact] + public void HeaderDropLogging_is_read_by_name_and_a_number_fails() + { + Assert.Equal(HeaderDropLogging.Every, Config.Read(("Transport:HeaderDropLogging", "every")).Transport.HeaderDropLogging); + Assert.Single(Config.Failures(Config.Read(("Transport:HeaderDropLogging", "1")))); + } + + [Fact] + public void ResendBufferLimit_binds_a_long_and_a_negative_fails_through_the_record() + { + Assert.Equal(5_000_000_000L, Config.Read(("Transport:ResendBufferLimit", "5000000000")).Transport.ResendBufferLimit); + Assert.Contains("Transport:ResendBufferLimit", Assert.Single(Config.Failures(Config.Read(("Transport:ResendBufferLimit", "-1")))), StringComparison.Ordinal); + } + + [Fact] + public void PooledConnectionLifetime_defaults_to_two_minutes_binds_a_duration_and_none_is_infinite() + { + Assert.Equal(TimeSpan.FromMinutes(2), Config.Read().PooledConnectionLifetime); + Assert.Equal(TimeSpan.FromSeconds(10), Config.Read(("Transport:PooledConnectionLifetime", "10s")).PooledConnectionLifetime); + Assert.Equal(Timeout.InfiniteTimeSpan, Config.Read(("Transport:PooledConnectionLifetime", "none")).PooledConnectionLifetime); + Assert.Single(Config.Failures(Config.Read(("Transport:PooledConnectionLifetime", "0s")))); + } + + [Fact] + public void Logger_and_clock_are_never_read_from_configuration() + { + Assert.Contains("unknown key", Assert.Single(Config.Failures(Config.Read(("Transport:Logger", "x")))), StringComparison.Ordinal); + Assert.Contains("unknown key", Assert.Single(Config.Failures(Config.Read(("Transport:TimeProvider", "x")))), StringComparison.Ordinal); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/TypedLookupTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/TypedLookupTests.cs new file mode 100644 index 0000000..b9bafd1 --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/TypedLookupTests.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// CFG-38: every typed value reaches its parser through the one reader over the one IConfiguration. +[Trait("Category", "Unit")] +public sealed class TypedLookupTests +{ + [Fact] + public void An_integer_a_boolean_and_a_duration_supplied_only_by_a_lower_provider_resolve() + { + var section = Config.Layered( + [("Retry:MaxRetryAttempts", "4"), ("Retry:HonorRetryAfter", "false"), ("Retry:BaseDelay", "PT3S")], + [("UserAgent", "higher")]); + + var configuration = ConfigurationReader.Read(section); + + Assert.Empty(configuration.Failures); + Assert.Equal(4, configuration.Options.Retry.MaxRetryAttempts); + Assert.False(configuration.Options.Retry.HonorRetryAfter); + Assert.Equal(TimeSpan.FromSeconds(3), configuration.Options.Retry.BaseDelay); + Assert.Equal("higher", configuration.Options.UserAgent); + } + + [Fact] + public void The_record_default_applies_only_when_no_provider_has_the_key() + { + var configuration = Config.Read(); + + Assert.Equal(new Dexpace.Sdk.Core.Configuration.RetryOptions(), configuration.Options.Retry); + } + + [Fact] + public void A_differently_cased_key_resolves_alike() + { + var configuration = Config.Read(("retry:maxretryattempts", "6"), ("RETRY:HONORRETRYAFTER", "false")); + + Assert.Empty(configuration.Failures); + Assert.Equal(6, configuration.Options.Retry.MaxRetryAttempts); + Assert.False(configuration.Options.Retry.HonorRetryAfter); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ValidationTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ValidationTests.cs new file mode 100644 index 0000000..f1aebaf --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ValidationTests.cs @@ -0,0 +1,197 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests; + +/// P9-9, P9-10, P9-11, P9-28 (V4): every failure reported at once as "<path>: <rule>", value-free, at start or at first resolution. +[Trait("Category", "Unit")] +public sealed class ValidationTests +{ + [Fact] + public void Every_failure_is_reported_at_once() + { + var host = StateHost.WithClient( + static _ => { }, + ("Retry:MaxRetryAttempts", "abc"), + ("Retry:HonorRetryAfter", "yes"), + ("Retry:Jitter", "7")); + + var exception = Assert.Throws(() => host.Get()); + + Assert.Equal(3, exception.Failures.Count()); + Assert.All(exception.Failures, f => Assert.StartsWith(Config.SectionPath + ":Retry:", f, StringComparison.Ordinal)); + } + + [Fact] + public void No_configured_string_url_or_credential_is_quoted() + { + var host = StateHost.WithClient( + static _ => { }, + ("BaseAddress", "https://u:s3cr3t@host.test/s3cr3t?x=s3cr3t#s3cr3t"), + ("Retry:AttemptHeaderName", "s3cr3t header"), + ("Redirect:AllowedMethods", "s3cr3t verb"), + ("Auth:Basic:Username", "s3cr3t"), + ("Transport:Proxy:Host", "proxy.test"), + ("Transport:Proxy:Port", "s3cr3t"), + ("Transport:Proxy:Password", "s3cr3t")); + + var exception = Assert.Throws(() => host.Get()); + + Assert.DoesNotContain("s3cr3t", exception.Message, StringComparison.Ordinal); + Assert.DoesNotContain("s3cr3t", exception.ToString(), StringComparison.Ordinal); + Assert.All(exception.Failures, f => Assert.DoesNotContain("s3cr3t", f, StringComparison.Ordinal)); + } + + [Fact] + public void A_numeric_accessor_rejection_may_echo_the_number_and_nothing_else() + { + // P9-28: the BCL's range helpers print the rejected number; no numeric option carries a secret. + var configuration = Config.Read(("Retry:MaxRetryAttempts", "-7"), ("UserAgent", "s3cr3t\u0001")); + + var failures = Config.Failures(configuration); + Assert.Equal(2, failures.Length); + Assert.Contains("-7", failures.Single(static f => f.Contains("MaxRetryAttempts", StringComparison.Ordinal)), StringComparison.Ordinal); + Assert.DoesNotContain("s3cr3t", string.Join('|', failures), StringComparison.Ordinal); + } + + [Fact] + public void Unknown_keys_fail_naming_the_key_in_the_configurations_spelling() + { + var host = StateHost.WithClient(static _ => { }, ("Retry:MaxRetries", "3"), ("Wat", "1"), ("RETRY:maxretryattempts", "2")); + + var exception = Assert.Throws(() => host.Get()); + + Assert.Equal(2, exception.Failures.Count()); + Assert.Contains(Config.SectionPath + ":Retry:MaxRetries: unknown key", exception.Failures); + Assert.Contains(Config.SectionPath + ":Wat: unknown key", exception.Failures); + } + + [Fact] + public void A_key_under_the_wrong_parent_or_below_a_scalar_is_unknown() + { + Assert.Single(Config.Failures(Config.Read(("Transport:Retry:MaxRetryAttempts", "1")))); + Assert.Single(Config.Failures(Config.Read(("Retry:MaxRetryAttempts:Extra", "1")))); + } + + [Theory] + [InlineData("Transport:Proxy", "http://user:s3cr3tpw@proxy:8080")] + [InlineData("Auth:ApiKey", "s3cr3tpw")] + [InlineData("Auth", "Bearer s3cr3tpw")] + [InlineData("Retry", "s3cr3tpw")] + public void A_value_on_a_group_key_fails_without_quoting_it(string key, string value) + { + var failure = Assert.Single(Config.Failures(Config.Read((key, value)))); + + Assert.Contains(key + ": a group of keys, not a value", failure, StringComparison.Ordinal); + Assert.DoesNotContain("s3cr3tpw", failure, StringComparison.Ordinal); + } + + [Fact] + public void An_empty_value_on_a_group_key_is_absent_not_a_failure() + { + Assert.Empty(Config.Failures(Config.Read(("Retry", ""), ("Auth", " ")))); + } + + [Fact] + public void A_null_section_binds_nothing() + { + var configuration = ConfigurationReader.Read(null); + + Assert.Empty(configuration.Failures); + Assert.Equal(new Dexpace.Sdk.Core.Configuration.RetryOptions(), configuration.Options.Retry); + } + + [Fact] + public void A_root_section_is_rejected_with_a_pointed_message_and_reads_nothing() + { + var root = Config.Root(("PATH", "/usr/bin"), ("Retry:MaxRetryAttempts", "9")).GetSection(string.Empty); + + var configuration = ConfigurationReader.Read(root); + + Assert.Equal("pass the client's own section, not the configuration root", Assert.Single(Config.Failures(configuration))); + Assert.Equal(new Dexpace.Sdk.Core.Configuration.RetryOptions(), configuration.Options.Retry); + } + + [Fact] + public void A_host_less_provider_fails_at_first_resolution() + { + var host = StateHost.WithClient(static _ => { }, ("Retry:MaxRetryAttempts", "abc")); + + Assert.Throws(() => host.Provider.GetRequiredService>().Get(StateHost.Name)); + } + + [Fact] + public void IStartupValidator_runs_the_named_validators() + { + // R12: this is the service the hosting startup calls, so "fails at host start" is the same call. + var host = StateHost.WithClient(static _ => { }, ("Retry:MaxRetryAttempts", "abc")); + + var validator = host.Provider.GetRequiredService(); + + var exception = Assert.Throws(validator.Validate); + Assert.Contains("Retry:MaxRetryAttempts", Assert.Single(exception.Failures), StringComparison.Ordinal); + } + + [Fact] + public void Two_names_validate_independently() + { + var services = new ServiceCollection(); + _ = StateHost.Register(services, "a", Config.Section(("Retry:MaxRetryAttempts", "abc"))); + _ = StateHost.Register(services, "b", Config.Section(("Retry:MaxRetryAttempts", "2"))); + using var provider = services.BuildServiceProvider(); + var monitor = provider.GetRequiredService>(); + + Assert.Throws(() => monitor.Get("a")); + Assert.Equal(2, monitor.Get("b").Options.Retry.MaxRetryAttempts); + } + + [Fact] + public void A_valid_client_validates_clean_and_once_per_name_registration_adds_one_validator_start() + { + var host = StateHost.WithClient(static _ => { }, ("Retry:MaxRetryAttempts", "2")); + + host.Provider.GetRequiredService().Validate(); + + Assert.Single(host.Provider.GetServices>()); + } + + [Fact] + public void Cross_property_combinations_the_records_accept_do_not_fail() + { + // P9-10: BaseDelay above MaxDelay caps at MaxDelay (6a); an AttemptTimeout above OverallTimeout never fires; a transport timeout + // below AttemptTimeout bounds the attempt at the transport. Failing them only under DI would give one record two validities. + var host = StateHost.WithClient( + static _ => { }, + ("Retry:BaseDelay", "10s"), + ("Retry:MaxDelay", "1s"), + ("OverallTimeout", "5s"), + ("AttemptTimeout", "10s"), + ("Transport:Timeout", "1s")); + + var state = host.Get(); + + Assert.Equal(TimeSpan.FromSeconds(10), state.Options.Retry.BaseDelay); + Assert.Equal(TimeSpan.FromSeconds(1), state.Transport.Timeout); + } + + [Fact] + public void No_reload_reaches_a_resolved_state_and_no_change_token_source_is_registered() + { + var root = Config.Root((Config.SectionPath + ":UserAgent", "first")); + var services = new ServiceCollection(); + _ = StateHost.Register(services, StateHost.Name, root.GetSection(Config.SectionPath)); + using var provider = services.BuildServiceProvider(); + var monitor = provider.GetRequiredService>(); + Assert.Equal("first", monitor.Get(StateHost.Name).Options.UserAgent); + + root[Config.SectionPath + ":UserAgent"] = "second"; + root.Reload(); + + Assert.Equal("first", monitor.Get(StateHost.Name).Options.UserAgent); + Assert.Empty(provider.GetServices>()); + } +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/packages.lock.json b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/packages.lock.json new file mode 100644 index 0000000..098aadc --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/packages.lock.json @@ -0,0 +1,550 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "coverlet.MTP": { + "type": "Direct", + "requested": "[10.1.0, )", + "resolved": "10.1.0", + "contentHash": "FbW1D7OFFUmITgKzYhasyaX0m/oQzuU3ab4ooOURngztKK/QulCT/YuADhRDSGrE79S2dVoEQPgNkg2qBz29tg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.12", + "Microsoft.Extensions.Configuration.Json": "10.0.12", + "Microsoft.Extensions.DependencyInjection": "10.0.12", + "Microsoft.Testing.Platform": "2.4.1" + } + }, + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.294, )", + "resolved": "3.0.294", + "contentHash": "j78ENG75OozcuL0OPSIjvgLaYI4F5nS8mpK02PlXCCCXj8A4CAIfpKw27ZPvG4k8TweGqParV8Dwm2VAwBBmgg==" + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "OIFhLxKAdvSrZuecX177ZkN4AcAB6OLlbS8y0MQYL68512aMOZI8cJLb8OeuYe2X+gu5uMqbbCxhzAmSp+Q2ow==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.12", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Http.Resilience": { + "type": "Direct", + "requested": "[10.10.0, )", + "resolved": "10.10.0", + "contentHash": "KZw4jCU4RxHwjOXS7BUal3CnaupEJdGkJ9tPzE1Y2CR5x6IHMuxc6vNNWDiFIlnMa1TKGkta/Dfz3DQiFF1fxQ==", + "dependencies": { + "Microsoft.Extensions.Http.Diagnostics": "10.10.0", + "Microsoft.Extensions.ObjectPool": "10.0.12", + "Microsoft.Extensions.Resilience": "10.10.0" + } + }, + "Microsoft.Testing.Extensions.HangDump": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "ViQa60PnKgnHsWI66CGPeYv71RSs1e1e6XJgNbP+aD+uaJMJ6jn6t+6/14OVvPC9luVtJwqWyvdJW942mSxQHg==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.607501", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.TrxReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "KGAvJKRqhod45ecH4L1cCKIjGrzziUcLo3L4hRlfOg/Ww2Q7MnA30qHY2rnKzGyLVWf91tc2VleGe8c33FmPaQ==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "xunit.v3": { + "type": "Direct", + "requested": "[4.0.1, )", + "resolved": "4.0.1", + "contentHash": "KpDYf6jFjYfOchuRJOAXPOpwN03hLE1AF4kWzCUSnhr8JZMiGH4BT1YidCcm6BE1H4E90GyJzuKoCwXbg9PXmA==", + "dependencies": { + "xunit.v3.mtp-v2": "[4.0.1]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.607501", + "contentHash": "17Yxzao41A1oZZ5lCCAnnXOy9up5i/GVEGazBjJAUZ4UISsNAotUt6h7zvCDgfKIC46CD7jszgLzLZoscSIJQA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.4" + } + }, + "Microsoft.Extensions.AmbientMetadata.Application": { + "type": "Transitive", + "resolved": "10.10.0", + "contentHash": "Sa4xD2ab5yNRJYt4piS05C9b5Gpd1G3TZpvVJFgHyqLYdRThUNLcLHpREI4yjkmElCjHXoPcF+SHUim9cZ8cvQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.12", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.12", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.12" + } + }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "10.10.0", + "contentHash": "Sh3ujDRJpCMNY4x+43ljCROt4qBOoWfsL8l816RPYZS1S+Tw277r5002rbB15Sh6hFcCT8mGYBO7NtpfSYrQ0Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.ObjectPool": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "e3IPP32CRNL031VZJAUTlCTG0YN7WFh4mN3fsSHTDQCJB+3+f0jGycv4fXk3rrftaY3B85XrQaj7sRthrOsavg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dAgIf1TOr8KLs+aBRIbXUZBjHoSH4rDG8+XkX/Q6AZwkQdMA0+yPDKTHsieeXdZDfpOpZVHzOnuAb5Z2nX3KsA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.12", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "PNPS7yYH9U2M0dExn0tMkQFVtE53xTlCs/dgTfsWTTFyCF6qwGT3VrTgm2nnnkSRlSmvM9/J+n9xCpyzd9xTpQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.12", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.12", + "Microsoft.Extensions.FileProviders.Physical": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "H4u2ZeIhjmRQvbHj7S7U4MK1kXb0khx3UtOWcir32VHczdFyIVF0lvHew/VVHmCF7Uk0QseAUaO7HyFKOCSiKA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.12", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.12", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + }, + "Microsoft.Extensions.DependencyInjection.AutoActivation": { + "type": "Transitive", + "resolved": "10.10.0", + "contentHash": "AJ2lFnzzJkN2ZRvfPBKQ5k3EJFkhmsm1tEhWzK58KsxU+WK3GVOh/5WHUhQ56TWjnxGZCGgr0+U1QUuJ4rJVuA==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "amx0O/R4dReqKlC3T740+CHpX8D8tAIV0f5kAPfFi4XK9jYo9BSepGt9/rWfvvCoSMaZ1+XvnqPp/Vv2fmTdAg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.12", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.12", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.12" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "fMp91qlRlMOuHfMyp7LuAnOS+MOeWTF1obJmyFcs4nZCXu6pr+QB1AbFJcl2qpBltBrqF/Y7+Rn9IuNcSuctig==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + }, + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { + "type": "Transitive", + "resolved": "10.10.0", + "contentHash": "/6C1XjuRYRXst3QUoFnQdL0Q2ZihDlxr8PQ4jei8MyjVRZEGFg+le6N90d/CdpdgIbDz/h8JIKY1psACYDhPRQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "iQseV6HixWzEYQH4i++84ToIroXnM5jC1TJF4EqBz3lWNG1HBTMYDLkRb6Wm3wk5V6WqSfl7NukO/LYsloGyfg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "3cY+wo2+Qe65BVwShhr2fFqZv5Ns73h3kX4mpW3WvSgfT3AWCk0hKArKW4TF7Dg3mvbJdA6RhWPW7FR+PS5/1w==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.12", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "45cV+UeI0chPAIXrx9Wv7K4PJXuuIR2pnOur5MOAo6kOFclBHSLLDrvGAxMfWRMtUvpv/kQZkxlgaUN56jwzSQ==" + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "ZlIxIJtWmrph0Ikj0svSaTkZ+d+ZYpTAhazpf1b3aoFRmlNnNWwkcvqnqT9yKHjKvfBRthvUrkq11OxfItOhpg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.12", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.12", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Http.Diagnostics": { + "type": "Transitive", + "resolved": "10.10.0", + "contentHash": "yt9uQJSwh3hJag5NSDGEyWFbPKHaYem0w17TlSgv533NP8Kn56c5si870grJWGSL7aQyeUqxfKvDZxZpFaQUhw==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.12", + "Microsoft.Extensions.Telemetry": "10.10.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.12", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "CLHWfKQZWFE6fSzRXfjd4UlGveckkfPtwx/p5yF/o3hEkMSzGsl7MSxNAkI19hjw0rprBqrQC09Dwy6Lh6kweA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.12", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Configuration.Binder": "10.0.12", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Logging": "10.0.12", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.12" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "opBULzn9F7ZOL8EoFuqM2QjI6x9AG04DFNdnU2ky/kI4aUzbiXEfuy9jvCuu3D/ieccuDHTlalyC62esPD8wGg==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "rqpu4qj5WE9x1IHGXSIgHBKi7IUlQaHyp4aXCYIanG2OghlUMFZpZTgExaXwcvmLAJHsxKQWMPpc7D2WIbCVtA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Configuration.Binder": "10.0.12", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "Microsoft.Extensions.Resilience": { + "type": "Transitive", + "resolved": "10.10.0", + "contentHash": "aYIiuCSqsIqpaqya2JchArkaWibMhiXTNj4+wkjv0s7A/Uy1dvc4MsCPTOtvOU3ffz0+vYRHi7GHcXqynZfdbQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics": "10.0.12", + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.10.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.12", + "Microsoft.Extensions.Telemetry.Abstractions": "10.10.0", + "Polly.Extensions": "8.4.2", + "Polly.RateLimiting": "8.4.2" + } + }, + "Microsoft.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "10.10.0", + "contentHash": "GeJofTliWSVoYAeTfRbnp+TaOUl502yZIcZ+qHFiyOoAIzwTk12eKypnSR9uN2oiHgGW32Y0XN1i+VaQizl0IQ==", + "dependencies": { + "Microsoft.Extensions.AmbientMetadata.Application": "10.10.0", + "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.10.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.12", + "Microsoft.Extensions.ObjectPool": "10.0.12", + "Microsoft.Extensions.Telemetry.Abstractions": "10.10.0" + } + }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "10.10.0", + "contentHash": "I76SAZ95I5lYh4whVZvLpKhpvHI1o21FfGqiASU85GtIVA4ULv+TVIE5+swKhnC63+xI4XjCcIWa9LbyR3pFNw==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "10.10.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12", + "Microsoft.Extensions.ObjectPool": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "JeP1RFqBa11fWmBk8xEfZcMKr4rxWSyI6OZ+659V069CaMkTEOQBW2UdSSeNz3absOsygcn7JJkzerC4LGnZ9w==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "tDxLLic2IfeChbyo8oeGZj/eBdFpPe3Dp80hUoIOefSCqqMHbsob+lX1TfFWV75PJCPouxj2zz0zzPHZxiM4nQ==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "3nW9NyhN1BnHRF994DCOOUQCqVA9pDT+gPpbuhI/xUntZnGnXO4ySovKxFlFuoT/48pnI74LK/DeKVlve1L3VQ==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "qr5M6h16YHMJLFDcWELFVMMpGte2BUmveBZKT5YoBV+bmuJRPu9bv/Zqke4yQuOEKRNxoAETrG5jr+/6Rnr3Hg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g==" + }, + "Polly.Extensions": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Polly.Core": "8.4.2" + } + }, + "Polly.RateLimiting": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", + "dependencies": { + "Polly.Core": "8.4.2", + "System.Threading.RateLimiting": "8.0.0" + } + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "X7QXEcZQGz0G/HL4HUyK+aAvNa/IMGbOCnFIq4jD/Evktq12xANKwzOUr7b08vCmC1LXu/47qHWOdjm3KfaJ0A==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "nC7d3cY06Oo7hkdwWPZkBR0Ud75xNhgx0P7i0GmMMZC3puCGlc4szFiT30biH03IN/eDnr8h+nv3A1UD17bk/A==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Qf25TVdadDQYf9zVxSd7L9RNQhuP0jCUHmo96XTmIKLxGefylIEV63jRbXBPFzdXmV09TCdcurk+AQ9LiWS2Hg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "7qfTfrIfS2wybpSVyRLmhXHbSudD2eNw66LfukixmKsRCTcfvOrLsx0qGL/lObcEh3Z2F4SIXgoFnLIiB8yfjQ==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.4.0", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.0", + "Microsoft.Testing.Platform": "2.4.0", + "Microsoft.Testing.Platform.MSBuild": "2.4.0", + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.inproc.console": "[4.0.1]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "J0d5OfFcp920nZxCRIvU14+TZhiSPQR0wvqEMZ3MRiJLPu1VmYlcRNkmcSW0i1DwkB81gAHc+fHUQf/cU64MYg==", + "dependencies": { + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.mtp-v2": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "s88KiWwDDYgOWV3A+ViJiqCe9cLU/Rt6Gb5TfOC7Abv/HKSoj4IHVPcOQk7jPt7HhZHHEts5IuaVpB30eO5B1w==", + "dependencies": { + "xunit.analyzers": "2.1.0", + "xunit.v3.assert": "[4.0.1]", + "xunit.v3.core.mtp-v2": "[4.0.1]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "p9AyfBpj2e5Iws8B57SbLiSngg7mEdvegHInKR4T2cjt2mis9h8htVgCnmu//agZO4zri6p6VQOj5EkHHTy3gA==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "System.Security.AccessControl": "[6.0.1]", + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Hqwfd6ehMIhPmVWUQ2dgmknzuLFTWeyp8ES1q3D4YR5bQVyiXDcaIoaFwqAz2zgLr49WaW4Mz7VVncP7u/Q8/Q==", + "dependencies": { + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.common": "[4.0.1]" + } + }, + "dexpace.sdk.conformance": { + "type": "Project", + "dependencies": { + "Dexpace.Sdk.Core": "[0.0.1-alpha.1, )" + } + }, + "dexpace.sdk.core": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )" + } + }, + "dexpace.sdk.extensions.dependencyinjection": { + "type": "Project", + "dependencies": { + "Dexpace.Sdk.Core": "[0.0.1-alpha.1, )", + "Dexpace.Sdk.Http.SystemNet": "[0.0.1-alpha.1, )", + "Microsoft.Extensions.Http": "[10.0.12, )" + } + }, + "dexpace.sdk.http.systemnet": { + "type": "Project", + "dependencies": { + "Dexpace.Sdk.Core": "[0.0.1-alpha.1, )" + } + }, + "dexpace.sdk.serialization.systemtextjson": { + "type": "Project", + "dependencies": { + "Dexpace.Sdk.Core": "[0.0.1-alpha.1, )" + } + }, + "dexpace.sdk.testsupport": { + "type": "Project", + "dependencies": { + "Dexpace.Sdk.Core": "[0.0.1-alpha.1, )", + "Microsoft.Extensions.TimeProvider.Testing": "[10.10.0, )" + } + }, + "Microsoft.Extensions.Http": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "x/fnoUGgmIUjSgLR1p6Mpi/vXc1Xe0xDPJnggPp23I6vwM/kY4nxyOjo8i6G5KPVw+esJM+rJsqnHbdjasvCBw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Diagnostics": "10.0.12", + "Microsoft.Extensions.Logging": "10.0.12", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.TimeProvider.Testing": { + "type": "CentralTransitive", + "requested": "[10.10.0, )", + "resolved": "10.10.0", + "contentHash": "8duZdxuO3Oo8tlyQXVq1drXee6GJC9DjUU97Pq3YaIqf92jrAHsWxbbRKwvjQI48Mer3IANwUKF0elqvHeY4Rw==" + } + } + } +} \ No newline at end of file diff --git a/tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/Handlers/CancelOnDemandHandler.cs b/tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/Handlers/CancelOnDemandHandler.cs new file mode 100644 index 0000000..fcf93ae --- /dev/null +++ b/tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/Handlers/CancelOnDemandHandler.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Net.Http; + +namespace Dexpace.Sdk.Http.SystemNet.Tests.Conformance; + +/// +/// Cancels every call in flight through it on demand, from inside the handler chain, with a source that is neither the caller's token nor a deadline. +/// Shared by linked source with the DI suite (phase 9, task 4.1): the parameterless constructor leaves InnerHandler for IHttpClientFactory to assign. +/// +internal sealed class CancelOnDemandHandler : DelegatingHandler +{ + private readonly CancellationTokenSource _cancel = new(); + + public CancelOnDemandHandler() + { + } + + public CancelOnDemandHandler(HttpMessageHandler inner) + : base(inner) + { + } + + internal void CancelInFlight() => _cancel.Cancel(); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancel.Token); + return await base.SendAsync(request, linked.Token); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _cancel.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/Handlers/ExplodingContent.cs b/tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/Handlers/ExplodingContent.cs new file mode 100644 index 0000000..372defe --- /dev/null +++ b/tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/Handlers/ExplodingContent.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Net; +using System.Net.Http; + +namespace Dexpace.Sdk.Http.SystemNet.Tests.Conformance; + +/// Content whose length computation throws, so adapting a response that carries it fails after the response is live; it disposes the original content, which owns the connection. +internal sealed class ExplodingContent(HttpContent original) : HttpContent +{ + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => Task.CompletedTask; + + protected override bool TryComputeLength(out long length) => throw new InvalidOperationException("adaptation failed"); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + original.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/Handlers/FaultingAdaptationHandler.cs b/tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/Handlers/FaultingAdaptationHandler.cs new file mode 100644 index 0000000..691c1f7 --- /dev/null +++ b/tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/Handlers/FaultingAdaptationHandler.cs @@ -0,0 +1,31 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Net.Http; + +namespace Dexpace.Sdk.Http.SystemNet.Tests.Conformance; + +/// +/// Takes the live response from the inner handler and swaps its content for one that fails the length computation the +/// adapter does, so adapting it throws after the response is live. The failing content disposes the original content, which +/// owns the connection, so a transport that disposes the response on a failed adaptation releases it (TRANSPORT-22). +/// Shared by linked source with the DI suite (phase 9, task 4.1). +/// +internal sealed class FaultingAdaptationHandler : DelegatingHandler +{ + public FaultingAdaptationHandler() + { + } + + public FaultingAdaptationHandler(HttpMessageHandler inner) + : base(inner) + { + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var live = await base.SendAsync(request, cancellationToken); + live.Content = new ExplodingContent(live.Content); + return live; + } +} diff --git a/tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/Handlers/ResendingHandler.cs b/tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/Handlers/ResendingHandler.cs new file mode 100644 index 0000000..8f0de72 --- /dev/null +++ b/tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/Handlers/ResendingHandler.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Net.Http; + +namespace Dexpace.Sdk.Http.SystemNet.Tests.Conformance; + +/// Sends every request twice through the inner handler, the native re-send of design section 11 item 18. Shared by linked source with the DI suite (phase 9, task 4.1). +internal sealed class ResendingHandler : DelegatingHandler +{ + public ResendingHandler() + { + } + + public ResendingHandler(HttpMessageHandler inner) + : base(inner) + { + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + using var first = await base.SendAsync(request, cancellationToken); + return await base.SendAsync(request, cancellationToken); + } +} diff --git a/tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/SystemNetSubject.cs b/tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/SystemNetSubject.cs index 217c835..3670dfd 100644 --- a/tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/SystemNetSubject.cs +++ b/tests/Dexpace.Sdk.Http.SystemNet.Tests/Conformance/SystemNetSubject.cs @@ -192,70 +192,4 @@ public async ValueTask DisposeAsync() chain.Dispose(); } } - - /// Cancels every call in flight through it on demand, from inside the handler chain, with a source that is neither the caller's token nor a deadline. - private sealed class CancelOnDemandHandler(HttpMessageHandler inner) : DelegatingHandler(inner) - { - private readonly CancellationTokenSource _cancel = new(); - - internal void CancelInFlight() => _cancel.Cancel(); - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancel.Token); - return await base.SendAsync(request, linked.Token); - } - - protected override void Dispose(bool disposing) - { - if (disposing) - { - _cancel.Dispose(); - } - - base.Dispose(disposing); - } - } - - /// - /// Takes the live response from the inner handler and swaps its content for one that fails the length computation the - /// adapter does, so adapting it throws after the response is live. The failing content disposes the original content, which - /// owns the connection, so a transport that disposes the response on a failed adaptation releases it (TRANSPORT-22). - /// - private sealed class FaultingAdaptationHandler(HttpMessageHandler inner) : DelegatingHandler(inner) - { - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var live = await base.SendAsync(request, cancellationToken); - live.Content = new ExplodingContent(live.Content); - return live; - } - } - - private sealed class ExplodingContent(HttpContent original) : HttpContent - { - protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => Task.CompletedTask; - - protected override bool TryComputeLength(out long length) => throw new InvalidOperationException("adaptation failed"); - - protected override void Dispose(bool disposing) - { - if (disposing) - { - original.Dispose(); - } - - base.Dispose(disposing); - } - } - - /// Sends every request twice through the inner handler, the native re-send of design section 11 item 18. - private sealed class ResendingHandler(HttpMessageHandler inner) : DelegatingHandler(inner) - { - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - using var first = await base.SendAsync(request, cancellationToken); - return await base.SendAsync(request, cancellationToken); - } - } } diff --git a/tests/Dexpace.Sdk.Http.SystemNet.Tests/PrimaryHandlerTests.cs b/tests/Dexpace.Sdk.Http.SystemNet.Tests/PrimaryHandlerTests.cs new file mode 100644 index 0000000..c1fc731 --- /dev/null +++ b/tests/Dexpace.Sdk.Http.SystemNet.Tests/PrimaryHandlerTests.cs @@ -0,0 +1,103 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Net; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Diagnostics; +using Dexpace.Sdk.TestSupport.Diagnostics; +using Xunit; + +namespace Dexpace.Sdk.Http.SystemNet.Tests; + +/// +/// TRANSPORT-1, TRANSPORT-15, TRANSPORT-30, CFG-28 (phase 9, P9-12, R6): , +/// the one place the owned-handler invariants are built, shared by the owned construction and a factory-built chain. +/// +[Trait("Category", "Unit")] +public sealed class PrimaryHandlerTests +{ + private static readonly Uri s_destination = new("http://example.test/"); + + [Fact] + public void CreatePrimaryHandler_goes_direct_and_never_follows_redirects() + { + using var handler = SystemNetHttpClient.CreatePrimaryHandler(new SystemNetHttpClientOptions()); + + Assert.False(handler.AllowAutoRedirect); + Assert.False(handler.UseProxy); + Assert.Null(handler.Proxy); + Assert.Null(handler.Credentials); + } + + [Fact] + public void CreatePrimaryHandler_installs_the_proxy_adapter_and_leaves_server_credentials_unset() + { + var options = new SystemNetHttpClientOptions + { + Proxy = new ProxyOptions { Host = "proxy.test", Port = 3128, UserName = "u", Password = "p" }, + }; + + using var handler = SystemNetHttpClient.CreatePrimaryHandler(options); + + Assert.True(handler.UseProxy); + Assert.NotNull(handler.Proxy); + Assert.Equal("http://proxy.test:3128/", handler.Proxy!.GetProxy(s_destination)!.AbsoluteUri); + Assert.NotNull(handler.Proxy.Credentials!.GetCredential(handler.Proxy.GetProxy(s_destination)!, "Basic")); + Assert.Null(handler.Proxy.Credentials.GetCredential(handler.Proxy.GetProxy(s_destination)!, "NTLM")); + Assert.Null(handler.Credentials); + } + + [Fact] + public void CreatePrimaryHandler_warns_once_through_the_options_logger() + { + var logger = new RecordingLogger(); + var options = new SystemNetHttpClientOptions + { + Logger = logger, + Proxy = new ProxyOptions { Type = ProxyType.Socks4, Host = "proxy.test", Port = 1080, UserName = "u", Password = "p" }, + }; + + using (SystemNetHttpClient.CreatePrimaryHandler(options)) + { + } + + Assert.Equal(DexpaceLogEvents.TransportProxyFeatureUnsupportedId, Assert.Single(logger.Entries).EventId.Id); + } + + [Fact] + public void The_owned_construction_still_warns_exactly_once() + { + // R6's pin: the warning moved into CreatePrimaryHandler, it did not double. + var logger = new RecordingLogger(); + var options = new SystemNetHttpClientOptions + { + Logger = logger, + Proxy = new ProxyOptions { Type = ProxyType.Socks4, Host = "proxy.test", Port = 1080, UserName = "u", Password = "p" }, + }; + + using (new SystemNetHttpClient(options)) + { + } + + Assert.Equal(DexpaceLogEvents.TransportProxyFeatureUnsupportedId, Assert.Single(logger.Entries).EventId.Id); + } + + [Fact] + public void The_returned_handler_is_accepted_by_the_handler_constructor_and_never_disposed_by_it() + { + using var handler = SystemNetHttpClient.CreatePrimaryHandler(new SystemNetHttpClientOptions()); + + using (new SystemNetHttpClient(handler, new SystemNetHttpClientOptions())) + { + } + + // TRANSPORT-15: the handler is the caller's; a disposed SocketsHttpHandler would throw on a property write. + handler.PooledConnectionLifetime = TimeSpan.FromMinutes(2); + } + + [Fact] + public void CreatePrimaryHandler_rejects_null() + { + Assert.Throws("options", () => SystemNetHttpClient.CreatePrimaryHandler(null!)); + } +} diff --git a/tests/Dexpace.Sdk.Http.SystemNet.Tests/SystemNetHttpClientSurfaceTests.cs b/tests/Dexpace.Sdk.Http.SystemNet.Tests/SystemNetHttpClientSurfaceTests.cs index 2ca6d06..6c8a890 100644 --- a/tests/Dexpace.Sdk.Http.SystemNet.Tests/SystemNetHttpClientSurfaceTests.cs +++ b/tests/Dexpace.Sdk.Http.SystemNet.Tests/SystemNetHttpClientSurfaceTests.cs @@ -30,4 +30,14 @@ public void The_implementation_declares_no_defaults_on_the_SPI_members() Assert.All(member.GetParameters(), p => Assert.False(p.HasDefaultValue, $"{member.Name}({p.Name}) declares a default")); } } + + [Fact] + public void The_public_surface_gains_exactly_CreatePrimaryHandler() + { + var method = typeof(SystemNetHttpClient).GetMethod("CreatePrimaryHandler", BindingFlags.Public | BindingFlags.Static); + + Assert.NotNull(method); + Assert.Equal(typeof(System.Net.Http.SocketsHttpHandler), method!.ReturnType); + Assert.Equal(typeof(SystemNetHttpClientOptions), Assert.Single(method.GetParameters()).ParameterType); + } }