diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f1574b6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,51 @@ +root = true + +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE in the repository root for details. +# +# Single source of truth for formatting and analyzer severities. The build runs with +# TreatWarningsAsErrors and AnalysisLevel=latest-recommended (see Directory.Build.props), so any +# rule left at warning/error must be satisfied by the code. Rules dialled down below are +# intentional design choices, each annotated with its rationale. + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space + +[*.{cs,csx}] +indent_size = 4 +max_line_length = 120 + +# Prefer modern C# idioms (these are the conventions the code already follows). +csharp_style_namespace_declarations = file_scoped:warning +csharp_prefer_braces = true:warning +csharp_using_directive_placement = outside_namespace:warning +dotnet_sort_system_directives_first = true +csharp_style_var_for_built_in_types = false:suggestion + +# --- Intentional analyzer exemptions --------------------------------------------------------- + +# HTTP field names, media-type tokens, and protocol identifiers have a canonical *lower-case* +# form (RFC 7230 §3.2). Lowercasing is correct here; ToUpperInvariant would be wrong. +dotnet_diagnostic.CA1308.severity = none + +# The SDK deliberately accepts string URLs at its ergonomic entry points (e.g. Request.Get). +# A System.Uri overload is also provided for callers that prefer it. +dotnet_diagnostic.CA1054.severity = none +dotnet_diagnostic.CA1055.severity = none +dotnet_diagnostic.CA1056.severity = none + +# Argument null-checks are applied where they matter via ArgumentNullException.ThrowIfNull; +# CA1062's blanket requirement on every public parameter is noise for this surface. +dotnet_diagnostic.CA1062.severity = none + +# ConfigureAwait(false) is applied in library code where it matters, but `await using` / `await +# foreach` emit implicit awaits the rule cannot see; the SDK does not depend on capturing context. +dotnet_diagnostic.CA2007.severity = none + +[tests/**/*.cs] +# Test method names use Pascal_Snake casing by convention (e.g. Method_DoesThing_WhenCondition). +dotnet_diagnostic.CA1707.severity = none diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..719333d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + build: + name: build & test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + # The .NET 8 runtime is needed to execute the net8.0 test target; the pinned + # SDK (net10) comes from global.json and drives the build. + dotnet-version: 8.0.x + global-json-file: global.json + + - name: Restore + run: dotnet restore + + # TreatWarningsAsErrors + AnalysisLevel=latest-recommended make the build itself the lint gate. + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: >- + dotnet test --configuration Release --no-build + --collect:"XPlat Code Coverage" + --logger "trx;LogFileName=test-results.trx" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: "**/test-results.trx" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad40f27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Build output +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +artifacts/ + +# .NET / Rider / VS +.vs/ +*.user +*.suo +*.userprefs +.idea/ +*.DotSettings.user + +# Test / coverage +[Tt]est[Rr]esults/ +coverage/ +*.coverage +*.trx +*.cobertura.xml + +# NuGet +*.nupkg +*.snupkg +.nuget/ +project.lock.json + +# OS +.DS_Store +Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d313f4a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +All notable changes to this project are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial repository structure: `Dexpace.Sdk.sln`, central build/package configuration + (`Directory.Build.props`, `Directory.Packages.props`, `.editorconfig`, `global.json`), + `.gitignore`, and a GitHub Actions CI workflow (build + test). +- `Dexpace.Sdk.Core` foundation slice: + - `Http/Common`: `Method`, `Protocol` (+ wire-form conversions), `MediaType` (+ `CommonMediaTypes`), + `HttpHeaderName` (+ well-known names), and the immutable case-insensitive `Headers` multimap. + - `Http/Request`: `Request` and the `RequestBody` abstraction (bytes / string / stream factories, + replayability). + - `Http/Response`: `Response`, the `ResponseBody` abstraction, and `Status` (+ well-known codes). + - `Client`: `IHttpClient` / `IAsyncHttpClient` transport SPIs and sync/async bridges. + - `Errors`: the `SdkException` hierarchy. +- `Dexpace.Sdk.Http.SystemNet`: reference transport adapting `System.Net.Http.HttpClient` to the SPI. +- `Dexpace.Sdk.Core.Tests`: xUnit coverage for media types, headers, methods, statuses, bodies, + request building, and the transport. + +[Unreleased]: https://github.com/dexpace/dotnet-sdk/commits/main diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7def816 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,133 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository + +The .NET counterpart to [`dexpace/java-sdk`](https://github.com/dexpace/java-sdk) and +[`dexpace/python-sdk`](https://github.com/dexpace/python-sdk). The architecture follows the same +shape (immutable HTTP models, transport SPI, body abstractions, typed errors) but the public API +uses .NET idioms — `record` / `readonly record struct` instead of builder objects, `interface` +instead of Kotlin `fun interface` / Python `Protocol`, `IDisposable` / `IAsyncDisposable` instead +of `AutoCloseable` / context managers, `Task` as the async contract. The pluggable I/O seam that +exists in the Java SDK (`IoProvider` over Okio) was intentionally **not** ported: .NET's +`System.IO.Stream`, `Memory`, and `IAsyncDisposable` cover the same surface natively, exactly +as the Python port leans on `bytes` / `BinaryIO`. + +## Build & test (from the repository root) + +```bash +dotnet restore +dotnet build --configuration Release # the build IS the lint gate (warnings-as-errors) +dotnet test --configuration Release +dotnet format --verify-no-changes # formatting gate (uses .editorconfig) +``` + +The .NET SDK is pinned in `global.json` (10.0.100, `rollForward: latestFeature`). Library projects +target `net8.0`. + +## Conventions (enforced — match these when adding code) + +- **net8.0 libraries, C# `latest`, `Nullable` + `ImplicitUsings` enabled.** Modern idioms: file-scoped + namespaces, records, `readonly record struct`, pattern matching, `init` accessors, collection + expressions where they fit. +- **`TreatWarningsAsErrors` + `AnalysisLevel=latest-recommended` + `EnforceCodeStyleInBuild`.** The + build is the lint gate. Rule severities live in `.editorconfig`; a handful of analyzer rules are + deliberately dialled down there with a documented rationale (CA1308 lower-casing, CA1054/55/56 + string URLs, CA1062, CA2007) — do not silence others without justification. +- **Immutable models.** `record` / `readonly record struct`; mutate via `with` expressions or `With*` + helpers. No builder-as-object types — object initializers and `with` make them redundant. `Headers` + is the one mutable-builder exception (`Headers.Builder`) for batched edits. +- **Interfaces for SPIs.** `IHttpClient`, `IAsyncHttpClient` are the transport seams. + `Dexpace.Sdk.Core` ships **no** transport; transports adapt one HTTP library each and live in their + own project (`Dexpace.Sdk.Http.*`). +- **Deterministic cleanup.** `Response`, `ResponseBody`, and transports implement `IDisposable` / + `IAsyncDisposable`. Single-use bodies (stream-backed) throw `StreamConsumedException` on a second + read; call `RequestBody.ToReplayableAsync()` before the first send if retries are needed. +- **No runtime dependencies in `core`.** It builds against the BCL only. `SourceLink` is the only + build-time package. Transports may depend on their HTTP library; `core` may not. +- **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` for the test and transport assemblies). +- **Central package versions.** `Directory.Packages.props` is the single source of truth (the + `libs.versions.toml` analog). `PackageReference`s carry no `Version` attribute. +- **MIT license header on every `.cs` file** — the two-line block, src and tests alike: + + ```csharp + // Copyright (c) 2026 dexpace and Omar Aljarrah. + // Licensed under the MIT License. See LICENSE in the repository root for details. + ``` + +- **Commit style:** `chore:` for refactors/cleanup; `feat:` for new features; `fix:` for bug fixes; + `docs:` for documentation-only changes. + +## Repository Layout + +A single solution (`Dexpace.Sdk.sln`) with central build/package configuration at the root. Each +distribution is its own project under `src/`; tests under `tests/`. + +``` +dotnet-sdk/ +├── Dexpace.Sdk.sln +├── Directory.Build.props # shared compiler + package metadata +├── Directory.Packages.props # central package versions +├── .editorconfig # formatting + analyzer severities +├── global.json # pinned .NET SDK +├── nuget.config +├── docs/architecture.md +└── src/ + ├── Dexpace.Sdk.Core/ # toolkit; no transport, BCL-only + │ ├── Http/Common/ # Method, Protocol, MediaType, CommonMediaTypes, + │ │ # HttpHeaderName, Headers + │ ├── Http/Request/ # Request, RequestBody + │ ├── Http/Response/ # Response, ResponseBody, Status + │ ├── Client/ # IHttpClient, IAsyncHttpClient, HttpClientExtensions + │ └── Errors/ # SdkException + ServiceRequest/Response, HttpResponse, + │ # streaming, serialization, pipeline exceptions + └── Dexpace.Sdk.Http.SystemNet/ # reference transport over System.Net.Http.HttpClient +└── tests/ + └── Dexpace.Sdk.Core.Tests/ # xUnit suite (references core + transport) +``` + +## Architecture — Big Picture + +The SDK is an **HTTP-client toolkit, not an HTTP client**. `Dexpace.Sdk.Core` provides abstractions, +models, and (over time) pipelines; consuming libraries plug in a concrete transport via +`IHttpClient` / `IAsyncHttpClient`. + +Layered, bottom-up: + +1. **Bodies** — `RequestBody.WriteToAsync(Stream)` is the outgoing streaming surface; + `ResponseBody.OpenReadAsync` / `ReadAsBytesAsync` / `ReadAsStringAsync` drain the incoming side. + Bytes/string bodies are replayable; stream bodies are single-use. +2. **HTTP value models** (`Http/Common`, `Http/Response/Status`) — immutable, case-insensitive + `Headers` multimap; `MediaType` with quote-aware parse/round-trip; `Method`, `Protocol`, `Status` + value types with well-known instances. +3. **Request / Response** — `Request` is an immutable `record` (absolute `Uri`); `Response` is a + disposable carrier of status/headers/body/protocol. +4. **Transport SPI** (`Client`) — async-first `IAsyncHttpClient` plus a synchronous `IHttpClient`, + with `AsAsync` / `AsBlocking` bridges. +5. **Errors** — `SdkException` roots the hierarchy: `ServiceRequestException` (never sent, retry-safe + on idempotent methods), `ServiceResponseException` (sent, response unreadable), + `HttpResponseException` (4xx/5xx received intact), plus lifecycle/serialization/pipeline failures. + +## Things That Will Bite You + +- **The build is the lint gate.** A missing `///` doc comment on a public member, an unused `using`, + or an unsuppressed analyzer finding fails the build (`TreatWarningsAsErrors`). Build before + declaring done. +- **`Dexpace.Sdk.Core` must stay BCL-only.** Do not add a runtime `PackageReference` to it — model + third-party needs behind an interface and implement them in an adapter project. +- **Single-use bodies throw on second consumption.** `RequestBody.FromStream` / + `ResponseBody.FromStream` raise `StreamConsumedException` the second time. Buffer first + (`ToReplayableAsync`) when retries are in play. +- **Transports are ownership-aware.** A caller-supplied `System.Net.Http.HttpClient` is never disposed + by `SystemNetHttpClient`; only an internally created one is. +- **Central Package Management is on.** Add new dependency versions to `Directory.Packages.props`, and + reference them without a `Version` attribute. + +## Planned (not yet implemented) + +Mirroring the Java/Python ports: pipeline (staged policies — redirect, retry, idempotency, set-date, +client-identity, logging, tracing), context promotion chain, auth (token credentials, bearer/basic, +RFC 7235 challenges), SSE, pagination, webhooks, and instrumentation. See `docs/architecture.md`. diff --git a/Dexpace.Sdk.sln b/Dexpace.Sdk.sln new file mode 100644 index 0000000..bfdf60a --- /dev/null +++ b/Dexpace.Sdk.sln @@ -0,0 +1,101 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{1345587B-8949-4F11-B5B7-5F5508DA63FF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{892AF1FA-5CA2-4CB1-8286-F7BC8E8B6D93}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dexpace.Sdk.Core", "src\Dexpace.Sdk.Core\Dexpace.Sdk.Core.csproj", "{EDB7743D-49B4-4B2C-A3E4-EFB3DE9E2870}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dexpace.Sdk.Http.SystemNet", "src\Dexpace.Sdk.Http.SystemNet\Dexpace.Sdk.Http.SystemNet.csproj", "{EAB2849B-C69B-4787-A35E-4B704224E81F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dexpace.Sdk.Core.Tests", "tests\Dexpace.Sdk.Core.Tests\Dexpace.Sdk.Core.Tests.csproj", "{76FABB12-AA28-40B2-9A76-73CEADD69405}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dexpace.Sdk.Serialization.SystemTextJson", "src\Dexpace.Sdk.Serialization.SystemTextJson\Dexpace.Sdk.Serialization.SystemTextJson.csproj", "{152954B1-C5B3-47C0-804C-6FE77A9E0790}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dexpace.Sdk.Serialization.SystemTextJson.Tests", "tests\Dexpace.Sdk.Serialization.SystemTextJson.Tests\Dexpace.Sdk.Serialization.SystemTextJson.Tests.csproj", "{2B3774D6-EBF7-44D0-AE70-6995A0D13B5B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {EDB7743D-49B4-4B2C-A3E4-EFB3DE9E2870}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EDB7743D-49B4-4B2C-A3E4-EFB3DE9E2870}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EDB7743D-49B4-4B2C-A3E4-EFB3DE9E2870}.Debug|x64.ActiveCfg = Debug|Any CPU + {EDB7743D-49B4-4B2C-A3E4-EFB3DE9E2870}.Debug|x64.Build.0 = Debug|Any CPU + {EDB7743D-49B4-4B2C-A3E4-EFB3DE9E2870}.Debug|x86.ActiveCfg = Debug|Any CPU + {EDB7743D-49B4-4B2C-A3E4-EFB3DE9E2870}.Debug|x86.Build.0 = Debug|Any CPU + {EDB7743D-49B4-4B2C-A3E4-EFB3DE9E2870}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EDB7743D-49B4-4B2C-A3E4-EFB3DE9E2870}.Release|Any CPU.Build.0 = Release|Any CPU + {EDB7743D-49B4-4B2C-A3E4-EFB3DE9E2870}.Release|x64.ActiveCfg = Release|Any CPU + {EDB7743D-49B4-4B2C-A3E4-EFB3DE9E2870}.Release|x64.Build.0 = Release|Any CPU + {EDB7743D-49B4-4B2C-A3E4-EFB3DE9E2870}.Release|x86.ActiveCfg = Release|Any CPU + {EDB7743D-49B4-4B2C-A3E4-EFB3DE9E2870}.Release|x86.Build.0 = Release|Any CPU + {EAB2849B-C69B-4787-A35E-4B704224E81F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EAB2849B-C69B-4787-A35E-4B704224E81F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EAB2849B-C69B-4787-A35E-4B704224E81F}.Debug|x64.ActiveCfg = Debug|Any CPU + {EAB2849B-C69B-4787-A35E-4B704224E81F}.Debug|x64.Build.0 = Debug|Any CPU + {EAB2849B-C69B-4787-A35E-4B704224E81F}.Debug|x86.ActiveCfg = Debug|Any CPU + {EAB2849B-C69B-4787-A35E-4B704224E81F}.Debug|x86.Build.0 = Debug|Any CPU + {EAB2849B-C69B-4787-A35E-4B704224E81F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EAB2849B-C69B-4787-A35E-4B704224E81F}.Release|Any CPU.Build.0 = Release|Any CPU + {EAB2849B-C69B-4787-A35E-4B704224E81F}.Release|x64.ActiveCfg = Release|Any CPU + {EAB2849B-C69B-4787-A35E-4B704224E81F}.Release|x64.Build.0 = Release|Any CPU + {EAB2849B-C69B-4787-A35E-4B704224E81F}.Release|x86.ActiveCfg = Release|Any CPU + {EAB2849B-C69B-4787-A35E-4B704224E81F}.Release|x86.Build.0 = Release|Any CPU + {76FABB12-AA28-40B2-9A76-73CEADD69405}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {76FABB12-AA28-40B2-9A76-73CEADD69405}.Debug|Any CPU.Build.0 = Debug|Any CPU + {76FABB12-AA28-40B2-9A76-73CEADD69405}.Debug|x64.ActiveCfg = Debug|Any CPU + {76FABB12-AA28-40B2-9A76-73CEADD69405}.Debug|x64.Build.0 = Debug|Any CPU + {76FABB12-AA28-40B2-9A76-73CEADD69405}.Debug|x86.ActiveCfg = Debug|Any CPU + {76FABB12-AA28-40B2-9A76-73CEADD69405}.Debug|x86.Build.0 = Debug|Any CPU + {76FABB12-AA28-40B2-9A76-73CEADD69405}.Release|Any CPU.ActiveCfg = Release|Any CPU + {76FABB12-AA28-40B2-9A76-73CEADD69405}.Release|Any CPU.Build.0 = Release|Any CPU + {76FABB12-AA28-40B2-9A76-73CEADD69405}.Release|x64.ActiveCfg = Release|Any CPU + {76FABB12-AA28-40B2-9A76-73CEADD69405}.Release|x64.Build.0 = Release|Any CPU + {76FABB12-AA28-40B2-9A76-73CEADD69405}.Release|x86.ActiveCfg = Release|Any CPU + {76FABB12-AA28-40B2-9A76-73CEADD69405}.Release|x86.Build.0 = Release|Any CPU + {152954B1-C5B3-47C0-804C-6FE77A9E0790}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {152954B1-C5B3-47C0-804C-6FE77A9E0790}.Debug|Any CPU.Build.0 = Debug|Any CPU + {152954B1-C5B3-47C0-804C-6FE77A9E0790}.Debug|x64.ActiveCfg = Debug|Any CPU + {152954B1-C5B3-47C0-804C-6FE77A9E0790}.Debug|x64.Build.0 = Debug|Any CPU + {152954B1-C5B3-47C0-804C-6FE77A9E0790}.Debug|x86.ActiveCfg = Debug|Any CPU + {152954B1-C5B3-47C0-804C-6FE77A9E0790}.Debug|x86.Build.0 = Debug|Any CPU + {152954B1-C5B3-47C0-804C-6FE77A9E0790}.Release|Any CPU.ActiveCfg = Release|Any CPU + {152954B1-C5B3-47C0-804C-6FE77A9E0790}.Release|Any CPU.Build.0 = Release|Any CPU + {152954B1-C5B3-47C0-804C-6FE77A9E0790}.Release|x64.ActiveCfg = Release|Any CPU + {152954B1-C5B3-47C0-804C-6FE77A9E0790}.Release|x64.Build.0 = Release|Any CPU + {152954B1-C5B3-47C0-804C-6FE77A9E0790}.Release|x86.ActiveCfg = Release|Any CPU + {152954B1-C5B3-47C0-804C-6FE77A9E0790}.Release|x86.Build.0 = Release|Any CPU + {2B3774D6-EBF7-44D0-AE70-6995A0D13B5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2B3774D6-EBF7-44D0-AE70-6995A0D13B5B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2B3774D6-EBF7-44D0-AE70-6995A0D13B5B}.Debug|x64.ActiveCfg = Debug|Any CPU + {2B3774D6-EBF7-44D0-AE70-6995A0D13B5B}.Debug|x64.Build.0 = Debug|Any CPU + {2B3774D6-EBF7-44D0-AE70-6995A0D13B5B}.Debug|x86.ActiveCfg = Debug|Any CPU + {2B3774D6-EBF7-44D0-AE70-6995A0D13B5B}.Debug|x86.Build.0 = Debug|Any CPU + {2B3774D6-EBF7-44D0-AE70-6995A0D13B5B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2B3774D6-EBF7-44D0-AE70-6995A0D13B5B}.Release|Any CPU.Build.0 = Release|Any CPU + {2B3774D6-EBF7-44D0-AE70-6995A0D13B5B}.Release|x64.ActiveCfg = Release|Any CPU + {2B3774D6-EBF7-44D0-AE70-6995A0D13B5B}.Release|x64.Build.0 = Release|Any CPU + {2B3774D6-EBF7-44D0-AE70-6995A0D13B5B}.Release|x86.ActiveCfg = Release|Any CPU + {2B3774D6-EBF7-44D0-AE70-6995A0D13B5B}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {EDB7743D-49B4-4B2C-A3E4-EFB3DE9E2870} = {1345587B-8949-4F11-B5B7-5F5508DA63FF} + {EAB2849B-C69B-4787-A35E-4B704224E81F} = {1345587B-8949-4F11-B5B7-5F5508DA63FF} + {76FABB12-AA28-40B2-9A76-73CEADD69405} = {892AF1FA-5CA2-4CB1-8286-F7BC8E8B6D93} + {152954B1-C5B3-47C0-804C-6FE77A9E0790} = {1345587B-8949-4F11-B5B7-5F5508DA63FF} + {2B3774D6-EBF7-44D0-AE70-6995A0D13B5B} = {892AF1FA-5CA2-4CB1-8286-F7BC8E8B6D93} + EndGlobalSection +EndGlobal diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..acb3a0a --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,36 @@ + + + + latest + enable + enable + true + true + latest-recommended + true + true + + + + true + + + + 0.0.1 + alpha.1 + dexpace + dexpace + Copyright (c) 2026 dexpace and Omar Aljarrah + MIT + https://github.com/dexpace/dotnet-sdk + https://github.com/dexpace/dotnet-sdk + git + true + true + + + + true + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..23cfad5 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/README.md b/README.md index 73ce655..4da43a0 100644 --- a/README.md +++ b/README.md @@ -1 +1,91 @@ -# dotnet-sdk \ No newline at end of file +# dexpace .NET SDK + +The .NET counterpart to [`dexpace/java-sdk`](https://github.com/dexpace/java-sdk) and +[`dexpace/python-sdk`](https://github.com/dexpace/python-sdk). It is an **HTTP-client +toolkit, not an HTTP client**: it provides immutable HTTP models, a transport SPI, and (over +time) a staged pipeline, auth, SSE, pagination, and instrumentation. Consuming libraries plug in +a concrete transport via the `IHttpClient` / `IAsyncHttpClient` interfaces. + +The public API follows .NET idioms — `record` and `readonly record struct` for immutable models, +interfaces for SPIs, `Task` / `IAsyncDisposable` for the async-first surface, and +`System.Net.Http` as the reference transport — while keeping the same architectural shape as the +Java and Python ports. + +## Status + +Alpha. This is the **foundation slice**: the multi-project layout plus a working vertical slice of +`Dexpace.Sdk.Core` (HTTP common models, request/response, bodies, the transport SPI, and the +exception hierarchy) and one reference transport. Pipeline, context chain, auth, SSE, pagination, +and instrumentation are planned — see [docs/architecture.md](docs/architecture.md). + +## Layout + +``` +dotnet-sdk/ +├── Dexpace.Sdk.sln +├── Directory.Build.props # shared compiler + package settings (the libs.versions analog) +├── Directory.Packages.props # central package versions (single source of truth) +├── .editorconfig # formatting + analyzer severities +├── global.json # pinned .NET SDK +├── src/ +│ ├── Dexpace.Sdk.Core/ # toolkit; no transport +│ │ ├── Http/Common/ # Method, Protocol, MediaType, HttpHeaderName, Headers +│ │ ├── Http/Request/ # Request, RequestBody +│ │ ├── Http/Response/ # Response, ResponseBody, Status +│ │ ├── Client/ # IHttpClient, IAsyncHttpClient, bridges +│ │ └── Errors/ # SdkException hierarchy +│ └── Dexpace.Sdk.Http.SystemNet/ # reference transport over System.Net.Http.HttpClient +└── tests/ + └── Dexpace.Sdk.Core.Tests/ # xUnit suite +``` + +## Build & test + +```bash +dotnet restore +dotnet build --configuration Release # build IS the lint gate (warnings-as-errors) +dotnet test --configuration Release +``` + +Requires the .NET SDK pinned in `global.json` (10.0.100, rolling forward to the latest feature +band). The library projects target `net8.0`. + +## Quick start + +```csharp +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Http.SystemNet; + +await using var transport = new SystemNetHttpClient(); + +var request = Request.Get("https://api.example.com/health"); +await using var response = await transport.ExecuteAsync(request); + +if (response.IsSuccess) +{ + Console.WriteLine(await response.Body.ReadAsStringAsync()); +} +``` + +## Conventions + +These are enforced by the build (`TreatWarningsAsErrors`, `AnalysisLevel=latest-recommended`, +`EnforceCodeStyleInBuild`) and `.editorconfig`: + +- **net8.0 libraries, C# `latest`, nullable + implicit usings on.** Modern idioms: records, + `readonly record struct`, file-scoped namespaces, pattern matching, `init` accessors. +- **Immutable models.** `record` / `readonly record struct`; mutate via `with` expressions or the + `With*` helpers. No builders-as-objects — C# object initializers and `with` cover it. +- **Interfaces for SPIs.** `IHttpClient`, `IAsyncHttpClient` are the transport seams; `core` ships + no transport of its own. +- **Async-first, deterministic cleanup.** Bodies, responses, and transports implement + `IDisposable` / `IAsyncDisposable`; single-use bodies throw on a second read. +- **No runtime dependencies in `core`.** It builds against the BCL only; transports adapt one HTTP + library each. +- **Narrow public API, fully documented.** Every public member carries a `///` doc comment + (`GenerateDocumentationFile` is on). Implementation helpers are `internal`. +- **MIT license header on every source file.** + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..3d828fd --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,61 @@ +# Architecture + +The dexpace .NET SDK is an **HTTP-client toolkit, not an HTTP client**. `Dexpace.Sdk.Core` +provides abstractions, models, and (over time) pipelines; consuming libraries plug in a concrete +transport via the `IHttpClient` / `IAsyncHttpClient` interfaces. This mirrors the `dexpace/java-sdk` +and `dexpace/python-sdk` ports, translated into .NET idioms. + +## Idiom mapping + +| Concept | Java (Kotlin) | Python | .NET | +|--------------------|--------------------------------|--------------------------------|--------------------------------------------| +| Immutable model | `data class` + `Builder` | `@dataclass(frozen, slots)` | `record` / `readonly record struct` + `with` | +| SPI seam | `fun interface` | `typing.Protocol` | `interface` | +| Resource cleanup | `AutoCloseable` | context manager (`__enter__`) | `IDisposable` / `IAsyncDisposable` | +| Async contract | `CompletableFuture` | `async`/`await` coroutine | `Task` | +| Body streaming | Okio `Source`/`Sink` | `iter_bytes` / `BinaryIO` | `Stream` + `WriteToAsync`/`OpenReadAsync` | +| Single source of truth for deps | `libs.versions.toml` | `pyproject.toml` / `uv.lock` | `Directory.Packages.props` | + +The pluggable I/O seam that exists in the Java SDK (`IoProvider` over Okio) is **not** ported: +.NET's `System.IO.Stream`, `Memory`, and `IAsyncDisposable` cover the same surface natively, +exactly as the Python port leans on `bytes` / `BinaryIO` instead of an Okio analog. + +## Layers (bottom-up) + +1. **Bodies** — `RequestBody` / `ResponseBody` are typed abstractions over outgoing and incoming + payloads. `RequestBody.WriteToAsync(Stream)` is the primary streaming surface; + `ResponseBody.OpenReadAsync()` / `ReadAsBytesAsync()` / `ReadAsStringAsync()` drain the response. + Byte- and string-backed bodies are replayable; stream-backed bodies are single-use and throw + `StreamConsumedException` on a second pass. Call `RequestBody.ToReplayableAsync()` before the + first send when retries are needed. +2. **HTTP value models** (`Http/Common`) — immutable `Method`, `Protocol`, `MediaType`, + `HttpHeaderName`, `Headers`, plus `Status` in `Http/Response`. `Headers` is a case-insensitive + multimap with non-destructive `With` / `Set` / `Without` and a `Builder` for batched edits. +3. **Request / Response** (`Http/Request`, `Http/Response`) — `Request` is an immutable `record` + (method, absolute `Uri`, `Headers`, optional `RequestBody`); `Response` is a disposable carrier + of `Status`, `Headers`, `ResponseBody`, and the negotiated `Protocol`. +4. **Transport SPI** (`Client`) — `IAsyncHttpClient.ExecuteAsync(Request, CancellationToken)` is the + async-first seam; `IHttpClient.Execute(Request)` is the synchronous variant. + `HttpClientExtensions` bridges between them (`AsAsync`, `AsBlocking`). `core` ships no transport. +5. **Errors** (`Errors`) — `SdkException` roots a hierarchy distinguishing the three transport + failure shapes (`ServiceRequestException`, `ServiceResponseException`, `HttpResponseException`) + from body/stream lifecycle, serialization, and pipeline failures. + +## Transports + +`Dexpace.Sdk.Http.SystemNet` adapts `System.Net.Http.HttpClient` to the SPI. It streams response +bodies (`HttpCompletionOption.ResponseHeadersRead`) rather than buffering, translates transport +faults into the SDK exception hierarchy, and is ownership-aware: a caller-supplied `HttpClient` is +never disposed by the adapter. Additional transports (e.g. a gRPC-web or socket-level transport) +would each adapt one library to the same interfaces. + +## Planned (not yet implemented) + +Mirroring the Java/Python ports, the following land in later slices: + +- **Pipeline** — staged policies (redirect, retry, idempotency, set-date, client-identity, logging, + tracing) composed over the transport. +- **Context chain** — dispatch → request → exchange promotion carrying an instrumentation context. +- **Auth** — token credentials, bearer/basic policies, RFC 7235 challenge handling. +- **SSE, pagination, webhooks, instrumentation** — server-sent events, paged iteration, webhook + signature verification, and tracing/metrics abstractions. diff --git a/docs/superpowers/plans/2026-06-14-serde-system-text-json.md b/docs/superpowers/plans/2026-06-14-serde-system-text-json.md new file mode 100644 index 0000000..85a1a26 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-serde-system-text-json.md @@ -0,0 +1,888 @@ +# Serde + System.Text.Json Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the `ISerde` serialization seam in `Core` and a trim/AOT-safe `System.Text.Json` implementation, plus the request/response body conveniences and the typed-error accessor that depend on it. + +**Architecture:** `Core` declares a generic, context-backed `ISerde` (no third-party dependency). A new `Dexpace.Sdk.Serialization.SystemTextJson` package implements it over source-generated `JsonTypeInfo` — no runtime reflection. Body conveniences (`RequestBody.FromValue`, `ResponseBody.ReadValueAsync`) and `HttpResponseException.GetErrorAsync` route through `ISerde`. + +**Tech Stack:** C# (`net8.0` for `Core`; `net8.0;net10.0` for the new package), System.Text.Json (in-box, source generators), xUnit. + +**Scope notes (deliberately excluded):** +- **Strong-naming/signing** and **retargeting the existing libraries to `net8.0;net10.0`** are a separate "conventions" slice — this plan multi-targets and AOT-validates only the *new* package, leaving `Core`/`SystemNet` at `net8.0`. +- **`AddSystemTextJsonSerde` DI helper** lands with the DI integration slice (10). +- **`Optional`/PATCH** is deferred (issue #1). +- The **error-body buffering** that makes `GetErrorAsync` work post-throw is the policies slice (5); here the accessor reads whatever replayable body the exception already carries. + +--- + +## File Structure + +**New files:** +- `src/Dexpace.Sdk.Serialization.SystemTextJson/Dexpace.Sdk.Serialization.SystemTextJson.csproj` — the package. +- `src/Dexpace.Sdk.Serialization.SystemTextJson/SystemTextJsonSerde.cs` — the `ISerde` implementation. +- `src/Dexpace.Sdk.Core/Serialization/ISerde.cs` — the seam. +- `src/Dexpace.Sdk.Core/Serialization/ResponseBodySerdeExtensions.cs` — `ReadValueAsync`. +- `tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests.csproj` +- `tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/TestModels.cs` — sample models + source-gen context. +- `tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/SystemTextJsonSerdeTests.cs` +- `tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/BodyConvenienceTests.cs` + +**Modified files:** +- `Dexpace.Sdk.sln` — add the two new projects. +- `src/Dexpace.Sdk.Core/Http/Request/RequestBody.cs` — add `FromValue`. +- `src/Dexpace.Sdk.Core/Errors/TransportExceptions.cs` — add `HttpResponseException.GetErrorAsync`. + +--- + +## Task 1: Scaffold the System.Text.Json package and its test project + +**Files:** +- Create: `src/Dexpace.Sdk.Serialization.SystemTextJson/Dexpace.Sdk.Serialization.SystemTextJson.csproj` +- Create: `tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests.csproj` +- Modify: `Dexpace.Sdk.sln` + +- [ ] **Step 1: Create the library project file** + +`src/Dexpace.Sdk.Serialization.SystemTextJson/Dexpace.Sdk.Serialization.SystemTextJson.csproj`: + +```xml + + + + net8.0;net10.0 + Dexpace.Sdk.Serialization.SystemTextJson + Dexpace.Sdk.Serialization.SystemTextJson + Dexpace.Sdk.Serialization.SystemTextJson + + System.Text.Json implementation of the Dexpace.Sdk.Core ISerde serialization seam, + built on source-generated JsonSerializerContext metadata for trim- and AOT-safety. + + http;sdk;json;serialization;dexpace + true + true + + + + + + + + + + + + + + + +``` + +> System.Text.Json is in-box on `net8.0`/`net10.0`; no `PackageReference` (and no `Directory.Packages.props` entry) is needed. + +- [ ] **Step 2: Create the test project file** + +`tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests.csproj`: + +```xml + + + + net8.0;net10.0 + Dexpace.Sdk.Serialization.SystemTextJson.Tests + false + true + false + $(NoWarn);CS1591 + + + + + + + + + + + + + + + +``` + +- [ ] **Step 3: Add both projects to the solution** + +Run: +```bash +dotnet sln Dexpace.Sdk.sln add \ + src/Dexpace.Sdk.Serialization.SystemTextJson/Dexpace.Sdk.Serialization.SystemTextJson.csproj \ + tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests.csproj +``` +Expected: `Project ... added to the solution.` (twice) + +- [ ] **Step 4: Verify the solution restores and builds** + +Run: `dotnet build -c Release` +Expected: PASS (the new projects compile as empty assemblies; no warnings). + +- [ ] **Step 5: Commit** + +```bash +git add Dexpace.Sdk.sln src/Dexpace.Sdk.Serialization.SystemTextJson tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests +git commit -m "chore: scaffold Dexpace.Sdk.Serialization.SystemTextJson project" +``` + +--- + +## Task 2: Declare the `ISerde` seam in Core + +**Files:** +- Create: `src/Dexpace.Sdk.Core/Serialization/ISerde.cs` + +- [ ] **Step 1: Write the interface** + +`src/Dexpace.Sdk.Core/Serialization/ISerde.cs`: + +```csharp +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Buffers; +using Dexpace.Sdk.Core.Http.Common; + +namespace Dexpace.Sdk.Core.Serialization; + +/// +/// Serializes values into, and deserializes them out of, request/response payloads. The seam is +/// generic and serializer-agnostic; implementations are responsible for resolving type metadata +/// (a System.Text.Json implementation ships separately). +/// +public interface ISerde +{ + /// The media type stamped on bodies created from values (for example, application/json). + MediaType DefaultMediaType { get; } + + /// Serializes to . + /// The value type. + /// The stream to write to. + /// The value to serialize. + /// A token to cancel the operation. + /// A task that completes when serialization finishes. + /// Serialization failed. + ValueTask SerializeAsync(Stream destination, T value, CancellationToken cancellationToken = default); + + /// Deserializes a value of type from . + /// The target type. + /// The stream to read from. + /// A token to cancel the operation. + /// The deserialized value, or . + /// Deserialization failed. + ValueTask DeserializeAsync(Stream source, CancellationToken cancellationToken = default); + + /// Serializes synchronously to . + /// The value type. + /// The buffer writer to write to. + /// The value to serialize. + /// Serialization failed. + void Serialize(IBufferWriter destination, T value); + + /// Deserializes a value of type from a UTF-8 buffer. + /// The target type. + /// The UTF-8 encoded payload. + /// The deserialized value, or . + /// Deserialization failed. + T? Deserialize(ReadOnlySpan utf8); +} +``` + +- [ ] **Step 2: Build Core** + +Run: `dotnet build -c Release src/Dexpace.Sdk.Core/Dexpace.Sdk.Core.csproj` +Expected: PASS (no missing-doc warnings; the interface is fully documented). + +- [ ] **Step 3: Commit** + +```bash +git add src/Dexpace.Sdk.Core/Serialization/ISerde.cs +git commit -m "feat: add ISerde serialization seam to core" +``` + +--- + +## Task 3: `SystemTextJsonSerde` async round-trip + +**Files:** +- Create: `tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/TestModels.cs` +- Create: `tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/SystemTextJsonSerdeTests.cs` +- Create: `src/Dexpace.Sdk.Serialization.SystemTextJson/SystemTextJsonSerde.cs` + +- [ ] **Step 1: Write the test models and source-gen context** + +`tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/TestModels.cs`: + +```csharp +// 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; + +namespace Dexpace.Sdk.Serialization.SystemTextJson.Tests; + +public sealed record Widget(string Name, int Size); + +public sealed record ApiError(string Code, string Message); + +[JsonSerializable(typeof(Widget))] +[JsonSerializable(typeof(ApiError))] +internal sealed partial class TestJsonContext : JsonSerializerContext; +``` + +- [ ] **Step 2: Write the failing async round-trip test** + +`tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/SystemTextJsonSerdeTests.cs`: + +```csharp +// 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 Xunit; + +namespace Dexpace.Sdk.Serialization.SystemTextJson.Tests; + +public sealed class SystemTextJsonSerdeTests +{ + private static SystemTextJsonSerde Serde() => new(TestJsonContext.Default); + + [Fact] + public async Task SerializeAsync_then_DeserializeAsync_round_trips() + { + var serde = Serde(); + var widget = new Widget("gizmo", 42); + + using var stream = new MemoryStream(); + await serde.SerializeAsync(stream, widget); + stream.Position = 0; + var result = await serde.DeserializeAsync(stream); + + Assert.Equal(widget, result); + } + + [Fact] + public void DefaultMediaType_is_application_json_utf8() + { + Assert.Equal(CommonMediaTypes.ApplicationJsonUtf8, Serde().DefaultMediaType); + } +} +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `dotnet test -c Release tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests` +Expected: FAILS to compile — `The type or namespace name 'SystemTextJsonSerde' could not be found`. + +- [ ] **Step 4: Implement `SystemTextJsonSerde`** + +`src/Dexpace.Sdk.Serialization.SystemTextJson/SystemTextJsonSerde.cs`: + +```csharp +// 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.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Serialization; + +namespace Dexpace.Sdk.Serialization.SystemTextJson; + +/// +/// A implementation backed by System.Text.Json. Type metadata is resolved from +/// a source-generated , keeping serialization trim- and +/// NativeAOT-safe with no runtime reflection. +/// +public sealed class SystemTextJsonSerde : ISerde +{ + private readonly JsonSerializerOptions _options; + + /// Initializes a new instance from explicit options. + /// + /// Options whose is set (typically a + /// source-generated ). + /// + /// The options have no type-info resolver. + public SystemTextJsonSerde(JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + if (options.TypeInfoResolver is null) + { + throw new ArgumentException( + "The JsonSerializerOptions must have a TypeInfoResolver (for example, a source-generated " + + "JsonSerializerContext) for AOT-safe serialization.", + nameof(options)); + } + + options.MakeReadOnly(); + _options = options; + } + + /// Initializes a new instance from a source-generated context. + /// The source-generated serializer context. + public SystemTextJsonSerde(JsonSerializerContext context) + : this((context ?? throw new ArgumentNullException(nameof(context))).Options) + { + } + + /// + public MediaType DefaultMediaType => CommonMediaTypes.ApplicationJsonUtf8; + + /// + public async ValueTask SerializeAsync(Stream destination, T value, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(destination); + var info = GetTypeInfo(forSerialize: true); + await JsonSerializer.SerializeAsync(destination, value, info, cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask DeserializeAsync(Stream source, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + var info = GetTypeInfo(forSerialize: false); + return await JsonSerializer.DeserializeAsync(source, info, cancellationToken).ConfigureAwait(false); + } + + /// + public void Serialize(IBufferWriter destination, T value) => + throw new NotSupportedException("Implemented in a later task."); + + /// + public T? Deserialize(ReadOnlySpan utf8) => + throw new NotSupportedException("Implemented in a later task."); + + private JsonTypeInfo GetTypeInfo(bool forSerialize) + { + if (_options.GetTypeInfo(typeof(T)) is JsonTypeInfo info) + { + return info; + } + + return forSerialize + ? throw new SerializationException(TypeInfoMessage()) + : throw new DeserializationException(TypeInfoMessage()); + } + + private static string TypeInfoMessage() => + $"No JsonTypeInfo is registered for '{typeof(T)}'. Add it to a source-generated " + + "JsonSerializerContext supplied to SystemTextJsonSerde."; +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `dotnet test -c Release tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests` +Expected: PASS (both tests, on `net8.0` and `net10.0`). + +- [ ] **Step 6: Commit** + +```bash +git add src/Dexpace.Sdk.Serialization.SystemTextJson/SystemTextJsonSerde.cs tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/TestModels.cs tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/SystemTextJsonSerdeTests.cs +git commit -m "feat: add SystemTextJsonSerde with async serialize/deserialize" +``` + +--- + +## Task 4: Synchronous serialize/deserialize + +**Files:** +- Modify: `tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/SystemTextJsonSerdeTests.cs` +- Modify: `src/Dexpace.Sdk.Serialization.SystemTextJson/SystemTextJsonSerde.cs:Serialize,Deserialize` + +- [ ] **Step 1: Write the failing sync round-trip test** + +Add to `SystemTextJsonSerdeTests.cs`: + +```csharp + [Fact] + public void Serialize_then_Deserialize_sync_round_trips() + { + var serde = Serde(); + var widget = new Widget("sprocket", 7); + + var buffer = new ArrayBufferWriter(); + serde.Serialize(buffer, widget); + var result = serde.Deserialize(buffer.WrittenSpan); + + Assert.Equal(widget, result); + } +``` + +Add `using System.Buffers;` to the file's usings. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test -c Release tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests --filter "FullyQualifiedName~Serialize_then_Deserialize_sync_round_trips"` +Expected: FAIL — `System.NotSupportedException : Implemented in a later task.` + +- [ ] **Step 3: Implement the sync methods** + +In `SystemTextJsonSerde.cs`, replace the two `NotSupportedException` bodies with: + +```csharp + /// + public void Serialize(IBufferWriter destination, T value) + { + ArgumentNullException.ThrowIfNull(destination); + var info = GetTypeInfo(forSerialize: true); + using var writer = new Utf8JsonWriter(destination); + JsonSerializer.Serialize(writer, value, info); + } + + /// + public T? Deserialize(ReadOnlySpan utf8) + { + var info = GetTypeInfo(forSerialize: false); + return JsonSerializer.Deserialize(utf8, info); + } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test -c Release tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests --filter "FullyQualifiedName~Serialize_then_Deserialize_sync_round_trips"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/Dexpace.Sdk.Serialization.SystemTextJson/SystemTextJsonSerde.cs tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/SystemTextJsonSerdeTests.cs +git commit -m "feat: add synchronous serialize/deserialize to SystemTextJsonSerde" +``` + +--- + +## Task 5: Error mapping (unknown type, malformed JSON) + +**Files:** +- Modify: `tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/SystemTextJsonSerdeTests.cs` +- Modify: `src/Dexpace.Sdk.Serialization.SystemTextJson/SystemTextJsonSerde.cs` (wrap JSON failures) + +- [ ] **Step 1: Write the failing error tests** + +Add to `SystemTextJsonSerdeTests.cs`: + +```csharp + private sealed record Unregistered(string Value); + + [Fact] + public void Deserialize_unknown_type_throws_DeserializationException() + { + var serde = Serde(); + var ex = Assert.Throws( + () => serde.Deserialize("{}"u8)); + Assert.Contains("Unregistered", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void Deserialize_malformed_json_throws_DeserializationException() + { + var serde = Serde(); + Assert.Throws(() => serde.Deserialize("{ not json"u8)); + } +``` + +Add `using Dexpace.Sdk.Core.Errors;` to the file's usings. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test -c Release tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests --filter "FullyQualifiedName~Deserialize_malformed_json_throws_DeserializationException"` +Expected: FAIL — a raw `System.Text.Json.JsonException` is thrown instead of `DeserializationException`. + +(The unknown-type test already passes from Task 3's `GetTypeInfo` mapping; the malformed-json test drives this task.) + +- [ ] **Step 3: Wrap System.Text.Json failures** + +In `SystemTextJsonSerde.cs`, wrap each `JsonSerializer` call so `JsonException` maps to the SDK type. Replace the four method bodies' serializer calls as follows: + +```csharp + /// + public async ValueTask SerializeAsync(Stream destination, T value, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(destination); + var info = GetTypeInfo(forSerialize: true); + try + { + await JsonSerializer.SerializeAsync(destination, value, info, cancellationToken).ConfigureAwait(false); + } + catch (JsonException ex) + { + throw new SerializationException($"Failed to serialize '{typeof(T)}' to JSON.", ex); + } + } + + /// + public async ValueTask DeserializeAsync(Stream source, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + var info = GetTypeInfo(forSerialize: false); + try + { + return await JsonSerializer.DeserializeAsync(source, info, cancellationToken).ConfigureAwait(false); + } + catch (JsonException ex) + { + throw new DeserializationException($"Failed to deserialize JSON to '{typeof(T)}'.", ex); + } + } + + /// + public void Serialize(IBufferWriter destination, T value) + { + ArgumentNullException.ThrowIfNull(destination); + var info = GetTypeInfo(forSerialize: true); + using var writer = new Utf8JsonWriter(destination); + try + { + JsonSerializer.Serialize(writer, value, info); + } + catch (JsonException ex) + { + throw new SerializationException($"Failed to serialize '{typeof(T)}' to JSON.", ex); + } + } + + /// + public T? Deserialize(ReadOnlySpan utf8) + { + var info = GetTypeInfo(forSerialize: false); + try + { + return JsonSerializer.Deserialize(utf8, info); + } + catch (JsonException ex) + { + throw new DeserializationException($"Failed to deserialize JSON to '{typeof(T)}'.", ex); + } + } +``` + +- [ ] **Step 4: Run the error tests to verify they pass** + +Run: `dotnet test -c Release tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests --filter "FullyQualifiedName~Deserialize"` +Expected: PASS (unknown-type and malformed-json both throw the SDK exception types). + +- [ ] **Step 5: Commit** + +```bash +git add src/Dexpace.Sdk.Serialization.SystemTextJson/SystemTextJsonSerde.cs tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/SystemTextJsonSerdeTests.cs +git commit -m "feat: map System.Text.Json failures to SDK serialization exceptions" +``` + +--- + +## Task 6: `RequestBody.FromValue` + +**Files:** +- Create: `tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/BodyConvenienceTests.cs` +- Modify: `src/Dexpace.Sdk.Core/Http/Request/RequestBody.cs` + +- [ ] **Step 1: Write the failing test** + +`tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/BodyConvenienceTests.cs`: + +```csharp +// 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.Http.Request; +using Xunit; + +namespace Dexpace.Sdk.Serialization.SystemTextJson.Tests; + +public sealed class BodyConvenienceTests +{ + private static SystemTextJsonSerde Serde() => new(TestJsonContext.Default); + + [Fact] + public async Task FromValue_produces_replayable_json_body() + { + var body = RequestBody.FromValue(new Widget("gear", 9), Serde()); + + Assert.True(body.IsReplayable); + Assert.Equal(CommonMediaTypes.ApplicationJsonUtf8, body.ContentType); + + using var first = new MemoryStream(); + await body.WriteToAsync(first); + using var second = new MemoryStream(); + await body.WriteToAsync(second); + Assert.Equal(first.ToArray(), second.ToArray()); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test -c Release tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests --filter "FullyQualifiedName~FromValue_produces_replayable_json_body"` +Expected: FAILS to compile — `'RequestBody' does not contain a definition for 'FromValue'`. + +- [ ] **Step 3: Implement `FromValue`** + +In `src/Dexpace.Sdk.Core/Http/Request/RequestBody.cs`, add `using System.Buffers;` and `using Dexpace.Sdk.Core.Serialization;` to the usings, then add this static factory after `FromStream`: + +```csharp + /// + /// Creates a replayable body by serializing with . + /// + /// The value type. + /// The value to serialize. + /// The serializer. + /// The media type, or for the serde's default. + /// A replayable . + public static RequestBody FromValue(T value, ISerde serde, MediaType? contentType = null) + { + ArgumentNullException.ThrowIfNull(serde); + var buffer = new ArrayBufferWriter(); + serde.Serialize(buffer, value); + return FromBytes(buffer.WrittenMemory, contentType ?? serde.DefaultMediaType); + } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test -c Release tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests --filter "FullyQualifiedName~FromValue_produces_replayable_json_body"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/Dexpace.Sdk.Core/Http/Request/RequestBody.cs tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/BodyConvenienceTests.cs +git commit -m "feat: add RequestBody.FromValue serde convenience" +``` + +--- + +## Task 7: `ResponseBody.ReadValueAsync` + +**Files:** +- Create: `src/Dexpace.Sdk.Core/Serialization/ResponseBodySerdeExtensions.cs` +- Modify: `tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/BodyConvenienceTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Add to `BodyConvenienceTests.cs` (and add `using System.Text; using Dexpace.Sdk.Core.Errors; using Dexpace.Sdk.Core.Http.Response; using Dexpace.Sdk.Core.Serialization;` to the usings): + +```csharp + [Fact] + public async Task ReadValueAsync_deserializes_the_body() + { + var json = Encoding.UTF8.GetBytes("""{"name":"bolt","size":3}"""); + var body = ResponseBody.FromBytes(json, CommonMediaTypes.ApplicationJson); + + var widget = await body.ReadValueAsync(Serde()); + + Assert.Equal(new Widget("bolt", 3), widget); + } + + [Fact] + public async Task ReadValueAsync_is_single_use() + { + var json = Encoding.UTF8.GetBytes("""{"name":"bolt","size":3}"""); + var body = ResponseBody.FromBytes(json, CommonMediaTypes.ApplicationJson); + + await body.ReadValueAsync(Serde()); + await Assert.ThrowsAsync( + async () => await body.ReadValueAsync(Serde())); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test -c Release tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests --filter "FullyQualifiedName~ReadValueAsync"` +Expected: FAILS to compile — `'ResponseBody' does not contain a definition for 'ReadValueAsync'`. + +- [ ] **Step 3: Implement the extension** + +`src/Dexpace.Sdk.Core/Serialization/ResponseBodySerdeExtensions.cs`: + +```csharp +// 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.Response; + +namespace Dexpace.Sdk.Core.Serialization; + +/// Serialization conveniences over . +public static class ResponseBodySerdeExtensions +{ + /// Reads and deserializes the body as using . + /// The target type. + /// The response body (read once). + /// The serializer. + /// A token to cancel the read. + /// The deserialized value, or . + /// The body has already been read. + /// Deserialization failed. + public static async ValueTask ReadValueAsync( + this ResponseBody body, ISerde serde, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(body); + ArgumentNullException.ThrowIfNull(serde); + + await using var stream = await body.OpenReadAsync(cancellationToken).ConfigureAwait(false); + return await serde.DeserializeAsync(stream, cancellationToken).ConfigureAwait(false); + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test -c Release tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests --filter "FullyQualifiedName~ReadValueAsync"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/Dexpace.Sdk.Core/Serialization/ResponseBodySerdeExtensions.cs tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/BodyConvenienceTests.cs +git commit -m "feat: add ResponseBody.ReadValueAsync serde convenience" +``` + +--- + +## Task 8: `HttpResponseException.GetErrorAsync` + +**Files:** +- Modify: `src/Dexpace.Sdk.Core/Errors/TransportExceptions.cs` +- Modify: `tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/BodyConvenienceTests.cs` + +> Note: `Response.Body` is `ResponseBody` (never null — empty bodies are buffered). Constructor: `new Response(Status status, Headers? headers = null, ResponseBody? body = null, Protocol protocol = Protocol.Http11)`. Confirmed against `src/Dexpace.Sdk.Core/Http/Response/Response.cs`. + +- [ ] **Step 1: Write the failing tests** + +Add to `BodyConvenienceTests.cs`: + +```csharp + [Fact] + public async Task GetErrorAsync_deserializes_the_buffered_error_body() + { + var json = Encoding.UTF8.GetBytes("""{"code":"rate_limited","message":"slow down"}"""); + var response = new Response(Status.TooManyRequests, Headers.Empty, + ResponseBody.FromBytes(json, CommonMediaTypes.ApplicationJson)); + var ex = new HttpResponseException(response); + + var error = await ex.GetErrorAsync(Serde()); + + Assert.Equal(new ApiError("rate_limited", "slow down"), error); + } + + [Fact] + public async Task GetErrorAsync_throws_when_body_already_consumed() + { + var json = Encoding.UTF8.GetBytes("""{"code":"x","message":"y"}"""); + var response = new Response(Status.BadRequest, Headers.Empty, + ResponseBody.FromBytes(json, CommonMediaTypes.ApplicationJson)); + var ex = new HttpResponseException(response); + + await ex.GetErrorAsync(Serde()); + await Assert.ThrowsAsync( + async () => await ex.GetErrorAsync(Serde())); + } +``` + +Add `using Dexpace.Sdk.Core.Http.Response;` if not already present. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test -c Release tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests --filter "FullyQualifiedName~GetErrorAsync"` +Expected: FAILS to compile — `'HttpResponseException' does not contain a definition for 'GetErrorAsync'`. + +- [ ] **Step 3: Implement `GetErrorAsync`** + +In `src/Dexpace.Sdk.Core/Errors/TransportExceptions.cs`, add `using Dexpace.Sdk.Core.Serialization;` to the usings, then add this method to the `HttpResponseException` class (after the `Status` property): + +```csharp + /// + /// Deserializes the error response body as using . + /// + /// The error model type. + /// The serializer. + /// A token to cancel the read. + /// The deserialized error model (possibly ). + /// The error body has already been consumed. + /// Deserialization failed. + public async ValueTask GetErrorAsync(ISerde serde, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(serde); + try + { + await using var stream = await Response.Body.OpenReadAsync(cancellationToken).ConfigureAwait(false); + return await serde.DeserializeAsync(stream, cancellationToken).ConfigureAwait(false); + } + catch (StreamConsumedException ex) + { + throw new ResponseNotReadException( + "The error response body has already been consumed and cannot be deserialized.", ex); + } + } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test -c Release tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests --filter "FullyQualifiedName~GetErrorAsync"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/Dexpace.Sdk.Core/Errors/TransportExceptions.cs tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/BodyConvenienceTests.cs +git commit -m "feat: add HttpResponseException.GetErrorAsync typed-error accessor" +``` + +--- + +## Task 9: Full verification + +**Files:** none (verification only). + +- [ ] **Step 1: Build the whole solution under the lint gate** + +Run: `dotnet build -c Release` +Expected: PASS — no warnings (warnings are errors), including the trim/AOT analyzer on the new package and the missing-doc gate. + +- [ ] **Step 2: Run the whole test suite** + +Run: `dotnet test -c Release` +Expected: PASS — all existing tests plus the new serde tests, across `net8.0` and `net10.0`. + +- [ ] **Step 3: Verify formatting** + +Run: `dotnet format --verify-no-changes` +Expected: PASS (no diff). If it reports changes, run `dotnet format`, review, and `git commit -m "style: apply dotnet format"`. + +- [ ] **Step 4: Final confirmation** + +Confirm the working tree is clean (`git status`) and the branch contains the serde commits. + +--- + +## Self-Review + +**Spec coverage (against `2026-06-14-serde-slice-design.md`):** +- `ISerde` generic + context-backed seam in Core → Tasks 2–5. ✓ +- `SystemTextJsonSerde`, AOT-safe via source-gen context, unknown-type + JSON-failure mapping → Tasks 3, 5. ✓ +- `DefaultMediaType` → Task 3. ✓ +- `RequestBody.FromValue` (replayable, default media type) → Task 6. ✓ +- `ResponseBody.ReadValueAsync` (single-use) → Task 7. ✓ +- `HttpResponseException.GetErrorAsync` (+ `ResponseNotReadException` on consumed body) → Task 8. ✓ +- New multi-target + AOT-validated project; STJ referenced only there → Tasks 1, 9. ✓ +- Deferred (correctly out of scope): `AddSystemTextJsonSerde` (DI slice), `Optional` (#1), error-body buffering (policies slice), strong-naming + existing-project retargeting (conventions slice). + +**Placeholder scan:** No TBD/TODO. The one `NotSupportedException` is an intentional, committed red-state in Task 3 that Task 4 turns green. ✓ + +**Type consistency:** `ISerde` members (`SerializeAsync`/`DeserializeAsync`/`Serialize`/`Deserialize`/`DefaultMediaType`) are used identically in `SystemTextJsonSerde`, the conveniences, and `GetErrorAsync`. `RequestBody.FromBytes(ReadOnlyMemory, MediaType?)`, `ResponseBody.OpenReadAsync`, `ResponseBody.FromBytes`, `HttpResponseException.Response`, `StreamConsumedException`, `ResponseNotReadException`, `SerializationException`, `DeserializationException`, and `CommonMediaTypes.ApplicationJson(Utf8)` all match the current source. ✓ + +**Verified against source:** the `Response` constructor and the non-nullable `Response.Body` are confirmed in `Response.cs`; `GetErrorAsync` deserializes the body directly without a null check (an empty buffered body surfaces as a `DeserializationException`, which is the intended "no parseable error" signal). diff --git a/docs/superpowers/specs/2026-06-14-auth-slice-design.md b/docs/superpowers/specs/2026-06-14-auth-slice-design.md new file mode 100644 index 0000000..8da2430 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-auth-slice-design.md @@ -0,0 +1,108 @@ +# Auth — slice design + +- **Date:** 2026-06-14 +- **Status:** Approved; ready for implementation planning. +- **Part of:** [.NET SDK Platform Architecture & Build Plan](2026-06-14-dotnet-sdk-platform-design.md) — slice 6. +- **Related:** OmarAlJarrah/dotnet-sdk#2 (deferred RFC 7616 Digest handler). + +## 1. Purpose & scope + +Credential types, token caching and refresh, the auth policies, and RFC 7235 challenge parsing. + +**In scope:** `ApiKeyCredential`, `BasicCredential`, an abstract `TokenCredential` + `AccessToken`, +an in-memory token cache with proactive refresh and single-flight, the three auth policies, and +challenge parsing + a Basic handler. + +**Out of scope:** the Digest (RFC 7616) handler (deferred, #2); the `Azure.Identity` bridge package +(follow-up). + +## 2. Decisions + +- **Vendor-neutral `TokenCredential` in `Core`** — no Azure dependency. An optional + `Dexpace.Sdk.Auth.AzureIdentity` adapter package (follow-up) bridges Azure.Identity credentials. +- **Token cache with proactive refresh** keyed by request context; concurrent refreshes serialized + with `SemaphoreSlim` (no stampede). +- **Auth policies sit at the `Auth` stage** (inside Retry, so a refreshed token is used on retry) and + **withhold credentials on cross-origin redirects**. +- **Challenge parsing + Basic handler in v1; Digest deferred** (#2). + +## 3. Credentials (sketch) + +```csharp +namespace Dexpace.Sdk.Core.Auth; + +public readonly struct AccessToken +{ + public string Token { get; } + public DateTimeOffset ExpiresOn { get; } + public DateTimeOffset? RefreshOn { get; } // proactive-refresh hint +} + +public readonly struct TokenRequestContext // structurally close to Azure.Core's, to ease the bridge +{ + public IReadOnlyList Scopes { get; } + public string? Claims { get; } +} + +public abstract class TokenCredential +{ + public abstract ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default); + public virtual AccessToken GetToken(TokenRequestContext context, CancellationToken ct = default); // sync bridge +} + +public sealed class ApiKeyCredential // header default Authorization; optional scheme prefix +{ + public ApiKeyCredential(string key, HttpHeaderName? header = null, string? scheme = null); +} + +public sealed class BasicCredential +{ + public BasicCredential(string username, string password); +} +``` + +## 4. Token cache & refresh + +```csharp +public sealed class AccessTokenCache // wraps a TokenCredential +{ + public ValueTask GetAsync(TokenRequestContext context, CancellationToken ct = default); +} +``` + +Serves the cached token until `ExpiresOn`; once past `RefreshOn` it refreshes, serializing concurrent +refreshes through a `SemaphoreSlim` so only one network call fires. A refresh failure while a valid +token remains is swallowed (keep serving); a failure with no valid token surfaces. + +## 5. Auth policies (`Auth` stage) + +- **`ApiKeyAuthPolicy`** — stamps the configured header (default `Authorization`, optional scheme + prefix). +- **`BasicAuthPolicy`** — `Authorization: Basic base64(user:password)`. +- **`BearerTokenAuthPolicy`** — resolves a token via `AccessTokenCache`, stamps + `Authorization: Bearer `; on a `401` carrying a challenge, re-acquires once. +- All three **withhold credentials on cross-origin redirect hops**, coordinated with `RedirectPolicy`'s + header stripping. + +## 6. Challenge handling (RFC 7235) + +- `AuthenticationChallenge.Parse` reads `WWW-Authenticate` / `Proxy-Authenticate` (scheme + params + + token68). +- `ChallengeHandler` (abstract) with `BasicChallengeHandler` (RFC 7617); `CompositeChallengeHandler` + selects a handler. Digest joins later (#2). + +## 7. Project & repo changes + +- `Core`: add `Auth/` (`AccessToken`, `TokenRequestContext`, `TokenCredential`, `ApiKeyCredential`, + `BasicCredential`, `AccessTokenCache`, `AuthenticationChallenge`, `ChallengeHandler`, + `BasicChallengeHandler`, `CompositeChallengeHandler`) and `Pipeline/Policies/` + (`ApiKeyAuthPolicy`, `BasicAuthPolicy`, `BearerTokenAuthPolicy`). +- BCL crypto only (`System.Security.Cryptography` for base64/HMAC); no new package dependency. +- Follow-up package: `Dexpace.Sdk.Auth.AzureIdentity`. + +## 8. Open items (resolve during planning) + +- Cross-origin withholding mechanism: a context flag set by `RedirectPolicy` vs. relying on its + header stripping. +- Depth of the `401`-challenge re-acquire flow in v1 (single re-acquire vs. fuller negotiation). +- Whether `ApiKeyCredential` supports thread-safe key rotation in v1. diff --git a/docs/superpowers/specs/2026-06-14-core-policies-slice-design.md b/docs/superpowers/specs/2026-06-14-core-policies-slice-design.md new file mode 100644 index 0000000..48b449a --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-core-policies-slice-design.md @@ -0,0 +1,97 @@ +# Core policies — slice design + +- **Date:** 2026-06-14 +- **Status:** Approved; ready for implementation planning. +- **Part of:** [.NET SDK Platform Architecture & Build Plan](2026-06-14-dotnet-sdk-platform-design.md) — slice 5. + +## 1. Purpose & scope + +The concrete policies that run on the spine, the default pipeline assembly, and the error-response +contract. + +**In scope:** Operation, Redirect, Retry, Idempotency, SetDate, ClientIdentity, and Instrumentation +policies; `Response.EnsureSuccessAsync`; a default-pipeline factory; the finalized stage ordering. + +**Out of scope:** auth credential/challenge policies (slice 6). + +## 2. Error-response contract + +- `HttpPipeline.SendAsync` **returns** the `Response` for any status — success or error. +- `Response.EnsureSuccessAsync(maxErrorBytes = 1 MiB, ct)` throws `HttpResponseException` when the + status is not success, **buffering the bounded error body into the exception** so + `GetErrorAsync` (serde slice) can read it. The cap guards against oversized error pages. +- This keeps the toolkit unopinionated; a future higher-level/generated client owns any + throw-by-default behavior. + +## 3. Finalized stage ordering (refines slice 4's working default) + +``` +Operation = 100 (pillar) once per call +Redirect = 200 (pillar) +PerCall = 250 (non-pillar) once, above Retry — Idempotency, ClientIdentity +Retry = 300 (pillar) +PerAttempt = 400 (non-pillar) per attempt, inside Retry — SetDate +Auth = 500 (pillar) inside Retry → refreshed token used on retry +Diagnostics = 600 (pillar) inside Retry → per-attempt span/metrics/logs +-> transport terminal +``` + +Rationale: anything that must be **stable across attempts** (the idempotency key) sits *above* Retry; +anything that must be **fresh per attempt** (the `Date` header, auth token, per-attempt span) sits +*below* it. Redirect wraps Retry, so each hop runs a full retry loop. + +## 4. Policies + +- **`OperationPolicy`** (Operation) — opens the once-per-call operation `Activity`; applies + `OverallTimeout` via a linked `CancellationTokenSource`. +- **`RedirectPolicy`** (Redirect) — follows 3xx up to `MaxRedirects`. 307/308 preserve method + body; + 301/302/303 become GET and drop the body. Strips `Authorization`/`Cookie` on cross-origin hops; + rejects HTTPS→HTTP unless `AllowHttpsToHttpDowngrade`. Emits the redirect counter. +- **`RetryPolicy`** (Retry) — retries on 408/429/500/502/503/504 and on `ServiceRequestException` / + `ServiceResponseException` for idempotent methods. Exponential backoff with **full jitter**, capped + at `MaxDelay`; honors `Retry-After` (delta-seconds and HTTP-date) when `HonorRetryAfter`. Non- + idempotent methods retry only when the body is replayable and `RetryNonIdempotentWhenReplayable` is + set. Closes the retryable response before sleeping; emits the retry counter. +- **`IdempotencyPolicy`** (PerCall) — sets `Idempotency-Key` for configured methods (default POST), + generating a GUID v4 once and **stashing it in the context property bag** so retries and redirect + hops reuse the same key. +- **`SetDatePolicy`** (PerAttempt) — stamps a fresh RFC 1123 `Date` header each attempt. +- **`ClientIdentityPolicy`** (PerCall) — stamps `User-Agent` from `DexpaceClientOptions.UserAgent`. +- **`InstrumentationPolicy`** (Diagnostics) — per attempt: starts the client-kind `Activity` with + OTel tags, injects `traceparent`/`tracestate` from `Activity.Current`, records the duration + histogram and active-requests counter, and writes redacted structured `ILogger` events. Consolidates + the siblings' separate logging + tracing into one policy (they share timing + redaction). + +## 5. Default pipeline assembly + +```csharp +public static class DexpacePipeline +{ + public static HttpPipeline CreateDefault( + IAsyncHttpClient transport, + DexpaceClientOptions options, + HttpPipelinePolicy? authPolicy = null, + ILogger? logger = null); +} +``` + +Assembles Operation → Redirect → Idempotency → ClientIdentity → Retry → SetDate → (auth, if supplied) +→ Instrumentation → transport. Auth is injected by the auth slice or the DI layer; everything else is +on by default and tunable through `options`. + +## 6. Project & repo changes + +- `Core`: add `Pipeline/Policies/` (`OperationPolicy`, `RedirectPolicy`, `RetryPolicy`, + `IdempotencyPolicy`, `SetDatePolicy`, `ClientIdentityPolicy`, `InstrumentationPolicy`), + `Pipeline/DexpacePipeline.cs`, and `Response.EnsureSuccessAsync`. +- Adds the `PerCall` stage to `PipelineStage`. +- No new dependencies. + +## 7. Open items (resolve during planning) + +- Jitter formula (full vs decorrelated) and whether the retry predicate/backoff are caller-overridable + via `RetryOptions` hooks in this slice or a follow-up. +- Whether `IdempotencyPolicy` also covers PATCH by default. +- Buffering cap default (`1 MiB`) for `EnsureSuccessAsync`. +- Splitting `InstrumentationPolicy` into separate logging/tracing policies if users want to replace + one without the other (leaning: one policy, with toggles). diff --git a/docs/superpowers/specs/2026-06-14-di-integration-slice-design.md b/docs/superpowers/specs/2026-06-14-di-integration-slice-design.md new file mode 100644 index 0000000..c6fe2da --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-di-integration-slice-design.md @@ -0,0 +1,86 @@ +# DI / hosting integration — slice design + +- **Date:** 2026-06-14 +- **Status:** Approved; ready for implementation planning. +- **Part of:** [.NET SDK Platform Architecture & Build Plan](2026-06-14-dotnet-sdk-platform-design.md) — slice 10. + +## 1. Purpose & scope + +Wire the toolkit into a host's DI container the idiomatic way, tying together Options, `ILogger`, +`IHttpClientFactory`, the pipeline, the transport, and the serde. + +**In scope:** the `Dexpace.Sdk.Extensions.DependencyInjection` package — `AddDexpaceClient`, a fluent +builder, options binding + validation, and `IHttpClientFactory` integration. + +**Out of scope:** the per-package serde/transport sugar that lives in those packages. + +## 2. Decisions + +- **`AddDexpaceClient(...)` returns a fluent `IDexpaceClientBuilder`.** +- **Options bound + validated with `ValidateOnStart`** so misconfiguration fails at host startup. +- **`IHttpClientFactory` integration** — `UseHttpClientFactory(name)` constructs the transport from a + named `HttpClient`, so an enterprise's `DelegatingHandler` / Polly chain composes underneath the SDK + pipeline. +- **Registers the `HttpPipeline`** (assembled from the default policies plus any configured auth) as + the injectable entry point; a thin `DexpaceClient` facade over it is optional (open item). + +## 3. Surface (sketch) + +```csharp +public static IDexpaceClientBuilder AddDexpaceClient( + this IServiceCollection services, Action? configure = null); + +public interface IDexpaceClientBuilder +{ + IServiceCollection Services { get; } + IDexpaceClientBuilder BindConfiguration(string sectionName); // env + appsettings + Key Vault + IDexpaceClientBuilder UseTransport(Func factory); + IDexpaceClientBuilder UseHttpClientFactory(string name); // System.Net transport over a named HttpClient + IDexpaceClientBuilder ConfigurePipeline(Action configure); + IDexpaceClientBuilder AddBearerToken(TokenCredential credential, params string[] scopes); + IDexpaceClientBuilder AddApiKey(string key, HttpHeaderName? header = null, string? scheme = null); + IDexpaceClientBuilder AddBasicAuth(string username, string password); +} +``` + +Usage: + +```csharp +services.AddSystemTextJsonSerde(MyJsonContext.Default); // from the STJ package +services.AddDexpaceClient(o => o.Retry.MaxRetryAttempts = 5) + .BindConfiguration("Dexpace") + .UseHttpClientFactory("dexpace") // or .UseSystemNetTransport() from the transport package + .AddBearerToken(credential, "https://api.example.com/.default"); +``` + +## 4. Package dependencies + +- References: `Core`, `Microsoft.Extensions.DependencyInjection.Abstractions`, + `Microsoft.Extensions.Http` (for `IHttpClientFactory`), `Microsoft.Extensions.Options`, + `Microsoft.Extensions.Options.ConfigurationExtensions`. +- The reference transport sugar (`UseSystemNetTransport`) and serde sugar (`AddSystemTextJsonSerde`) + live in their own packages; `UseTransport(...)` keeps the builder usable without referencing any + specific transport. + +## 5. What gets registered + +- `IOptions` (+ `IValidateOptions<>`, `ValidateOnStart`). +- `ISerde` (resolved from whatever serde was registered). +- `IAsyncHttpClient` transport. +- `HttpPipeline` assembled via `DexpacePipeline.CreateDefault` plus configured auth and any + `ConfigurePipeline` edits; `ILogger` injected from the container. + +## 6. Project & repo changes + +- New `src/Dexpace.Sdk.Extensions.DependencyInjection/` (multi-target, AOT-friendly registration — no + reflection-based scanning). +- New `tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/`. +- `Directory.Packages.props`: add the `Microsoft.Extensions.*` versions used here. + +## 7. Open items (resolve during planning) + +- Whether to ship a thin `DexpaceClient` facade (over `HttpPipeline.SendAsync` + pagination helpers) + or register `HttpPipeline` directly as the entry point. +- Exact location of the `UseSystemNetTransport` extension (transport package referencing the builder + interface vs. the DI package referencing the transport). +- Keyed/named multi-client registration (more than one configured Dexpace client per container). diff --git a/docs/superpowers/specs/2026-06-14-dotnet-sdk-platform-design.md b/docs/superpowers/specs/2026-06-14-dotnet-sdk-platform-design.md new file mode 100644 index 0000000..d6317e6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-dotnet-sdk-platform-design.md @@ -0,0 +1,147 @@ +# Dexpace .NET SDK — Platform Architecture & Build Plan + +- **Date:** 2026-06-14 +- **Status:** Platform shape approved. Each subsystem slice gets its own design spec → plan → build. + +## 1. Purpose & guiding principle + +The .NET SDK is the C#/.NET member of the dexpace SDK family, alongside the Java and Python +ports. Like them it is an **HTTP-client toolkit, not an HTTP client**: it provides abstractions, +immutable models, a transport SPI, and a composable request/response pipeline. Consuming libraries +plug in a concrete transport. + +**Guiding principle: the .NET platform leads the design.** The Java and Python SDKs are a +*capability checklist and a map of the architectural seams* — they tell us which subsystems exist +and how they relate — but the public surface and mechanics are designed from scratch in idiomatic +C#. Where a sibling invented an abstraction to fill a gap in its own ecosystem (a bespoke logger, +tracer, meter, or configuration type), the .NET standard equivalent is used instead. We do not port +for the sake of symmetry. + +## 2. Current state + +The foundation (architecture layers 1–5) is complete and shipping: + +- HTTP value models: `Method`, `Protocol`, `MediaType`, `Headers`, `HttpHeaderName`, `Status`. +- `Request` / `RequestBody` and `Response` / `ResponseBody`, with replayable vs. single-use body + semantics and deterministic disposal. +- Transport SPI: `IHttpClient` / `IAsyncHttpClient` with sync↔async bridges. +- A 12-type `SdkException` hierarchy (already anticipating the upper stack: + `PipelineAbortedException`, `SerializationException`, `DeserializationException`). +- `Dexpace.Sdk.Http.SystemNet`, the reference transport over `System.Net.Http.HttpClient`. + +Everything above the transport seam — pipeline, policies, context, auth, SSE, pagination, webhooks, +serde, instrumentation, configuration, and DI integration — is to be built. + +## 3. Cross-cutting decisions + +- **D1 — Native-first.** Optimize for the idioms, semantics, and mechanics of the .NET platform. + The siblings inform *what* to build, not *how*. +- **D2 — `Core` embraces the standard abstraction packages.** `Core` may depend on + `Microsoft.Extensions.Logging.Abstractions` and `System.Diagnostics.DiagnosticSource` — the + near-universal, Microsoft-owned packages that *are* how a .NET library plugs into the ecosystem. + `Core` still ships no transport, no concrete serializer, and no heavy runtime dependencies. The + bespoke `ClientLogger` / `InstrumentationContext` / `Tracer` / `Meter` / `Configuration` types from + the siblings are **not ported** — `Core` uses `ILogger` and `Activity`/`ActivitySource`/`Meter` + directly. Configuration is plain option POCOs in `Core`; the `Microsoft.Extensions.Options` + machinery (`IOptions`, binding, validation) lives in the DI integration package, not `Core` + (see the Options slice). +- **D3 — Transport-agnostic pipeline with native interop.** `IAsyncHttpClient` remains the bottom + seam. The pipeline is a thin, native, transport-agnostic chain above it and owns *SDK-domain* + concerns: auth, idempotency keys, typed-error- and `Retry-After`-aware retry, redirect semantics, + and SDK spans/metrics. *Connection-level* concerns (proxy, mTLS, HTTP/2-3, corporate telemetry + handlers) live in the transport's underlying `HttpClient`, ideally created via `IHttpClientFactory`, + so an enterprise's existing `DelegatingHandler` / Polly chain composes **underneath** the SDK. +- **D4 — Modern multi-target, AOT-safe.** Libraries target `net8.0;net10.0`, fully trim-safe and + NativeAOT-compatible. This makes `System.Text.Json` source generators mandatory and forbids runtime + reflection on hot paths. + +### Native defaults (apply throughout) + +- Async-first: `Task` / `ValueTask`, `CancellationToken` threaded everywhere. Sync remains a thin + `IHttpClient` bridge — kept, not dropped, for sync-only call sites. +- `IAsyncEnumerable` is the surface for streaming (pagination and SSE). +- `System.Text.Json` with source-generated `JsonSerializerContext` — no reflection on hot paths. +- First-class DI (`AddDexpaceClient(...)`), the Options pattern for configuration, and + `IHttpClientFactory` interop for the transport. +- Observability is `Activity` / `ActivitySource` / `Meter` / `ILogger` straight through, following + OpenTelemetry HTTP semantic conventions — no bespoke telemetry types. +- Strong-named and signed assemblies, for enterprises with assembly-load policies. + +## 4. Package topology + +One cohesive `Core` toolkit plus separated implementations, integrations, and transports: + +``` +Microsoft.Extensions.Logging.Abstractions ┐ abstractions only, no heavy deps +System.Diagnostics.DiagnosticSource ┘ (Options machinery lives in the DI package) + ▲ + ┌─────────┴──────────┐ + │ Dexpace.Sdk.Core │ net8.0; net10.0 — the whole toolkit: + │ │ models · bodies · IAsyncHttpClient SPI · errors · + │ │ serde ABSTRACTION · pipeline + policies · context · + │ │ instrumentation · auth · SSE · pagination · webhooks + └─────────┬──────────┘ + ▲ ▲ ▲ +┌──────┴───┐ ┌────┴────────┐ ┌┴──────────────────────────────┐ +│ Http. │ │ Serializa- │ │ Extensions.DependencyInjection │ +│ SystemNet│ │ tion.System │ │ AddDexpaceClient(...) wires │ +│ │ │ TextJson │ │ Options · ILogger · │ +│ │ │ │ │ IHttpClientFactory · pipeline │ +│ │ │ │ │ · transport · serde │ +└──────────┘ └─────────────┘ └────────────────────────────────┘ +``` + +**Why this granularity:** it mirrors how the siblings ship (their `core` already holds +pipeline/auth/SSE/pagination together) and keeps versioning simple. Alternatives considered and set +aside: *minimal* (fold DI into `Core`, rejected because it forces a container on toolkit-only +consumers) and *granular* (a NuGet per subsystem — `Auth`, `Sse`, `Webhooks`, …), which buys +independent release cadence at the cost of coordinating ~8 packages that share dependencies and are +co-designed. The granular split is the one knob worth revisiting if independent versioning becomes a +requirement. + +**Naming scheme:** `Dexpace.Sdk..` (e.g., `Dexpace.Sdk.Http.SystemNet`, +`Dexpace.Sdk.Serialization.SystemTextJson`, `Dexpace.Sdk.Extensions.DependencyInjection`). + +## 5. Build order + +Each slice is a self-contained design spec → implementation plan → build cycle. + +| # | Slice | Depends on | Scope summary | +|----|--------------------------------|------------|---------------| +| 1 | Serde + System.Text.Json | — | Serde seam in `Core`; source-generated STJ impl; typed error bodies; optional/PATCH field handling | +| 2 | Options & configuration | — | `DexpaceClientOptions`, validation, `IConfiguration` binding; supplies pipeline defaults | +| 3 | Instrumentation + context chain| — | OTel-semantic `ActivitySource`/`Meter` naming; dispatch→request→exchange context propagation | +| 4 | Pipeline spine | 3 | Stage model + middleware-style `PipelineDelegate`; builder with insert/replace/remove | +| 5 | Core policies | 2, 3, 4 | redirect · semantic retry · idempotency · set-date · client-identity · logging · tracing | +| 6 | Auth | 4, 5 | key/basic/bearer/named credentials · `TokenCredential` refresh + cache · RFC 7235 challenges | +| 7 | Pagination | 1, 4 | `IAsyncEnumerable`; cursor / page-number / link-header strategies | +| 8 | SSE | 4 | `IAsyncEnumerable` + reconnecting connection — *parallelizable* | +| 9 | Webhooks | 1 | Standard Webhooks HMAC-SHA256 verify / unwrap (BCL crypto) — *parallelizable* | +| 10 | DI / hosting integration | most | `Dexpace.Sdk.Extensions.DependencyInjection`; ties Options, ILogger, IHttpClientFactory, pipeline, transport, serde together | + +## 6. Non-goals (v1) + +- Not a code-generated service client; this is the generic toolkit only. +- Not a reimplementation of connection-level resilience that `Microsoft.Extensions.Http.Resilience` + (Polly v8) already provides — circuit breaking, hedging, and concurrency limiting compose in the + transport's handler chain, not in the SDK pipeline. +- No .NET Framework / `netstandard2.0` support. +- No bespoke logging, tracing, metrics, or configuration abstractions. + +## 7. Open questions, deferred to the relevant slice spec + +- **Serde:** depth of the `ISerde` seam vs. leaning on `System.Text.Json` directly; representation + of optional/absent fields for PATCH (a dedicated `Optional` vs. STJ-native patterns). +- **Context (slice 3):** ambient `AsyncLocal` context vs. an explicit context value threaded through + the pipeline delegate. +- **Pipeline (slice 4):** exact `PipelineDelegate` signature and per-call state model. +- **Errors:** whether to add a generic typed-error exception (`HttpResponseException`) or a + deserialize-on-demand accessor on the existing `HttpResponseException`. + +## 8. Revisions to existing repo conventions (tracked, applied per slice) + +- Revise the CLAUDE.md "`Core` is BCL-only" rule to "no transport, no concrete/heavy deps; the three + standard abstraction packages are permitted" (per D2). +- `Directory.Build.props`: multi-target `net8.0;net10.0`; enable `IsTrimmable`, `IsAotCompatible`, + and the trim/AOT analyzers; add strong-naming. +- `Directory.Packages.props`: add the abstraction package versions referenced by `Core`. diff --git a/docs/superpowers/specs/2026-06-14-instrumentation-context-slice-design.md b/docs/superpowers/specs/2026-06-14-instrumentation-context-slice-design.md new file mode 100644 index 0000000..32ab97b --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-instrumentation-context-slice-design.md @@ -0,0 +1,98 @@ +# Instrumentation + context chain — slice design + +- **Date:** 2026-06-14 +- **Status:** Approved; ready for implementation planning. +- **Part of:** [.NET SDK Platform Architecture & Build Plan](2026-06-14-dotnet-sdk-platform-design.md) — slice 3. + +## 1. Purpose & scope + +Define the diagnostics surface and the per-call context object that the pipeline threads through its +policies. + +**In scope:** the `ActivitySource`/`Meter` declarations and instrument set, span naming + tags +(OpenTelemetry HTTP semantic conventions), `ILogger` event conventions + redaction, and the +`PipelineContext` type. + +**Out of scope:** the pipeline delegate/stages that *thread* the context (slice 4) and the policies +that *emit* the telemetry (slice 5) — defined there, consuming what this slice declares. + +## 2. Decisions + +- **No bespoke telemetry types.** Spans are `Activity` from one `ActivitySource`; metrics are a + `Meter`; logs go through `ILogger`. This is OpenTelemetry-native — collectors pick it up with no + adapters. +- **One source/meter named `"Dexpace.Sdk"`**, version-stamped, declared once in a `Diagnostics` + static class. +- **Explicit per-call `PipelineContext`**, mutable, threaded through the pipeline delegate. Trace + correlation comes from `Activity.Current`; there is no `ContextStore` and no ambient SDK state. +- **Near-zero overhead when unobserved.** `Activity` is created only when the `ActivitySource` has + listeners; otherwise it stays null and the hot path allocates nothing for tracing. + +## 3. `PipelineContext` (sketch) + +```csharp +namespace Dexpace.Sdk.Core.Pipeline; + +public sealed class PipelineContext +{ + public Request Request { get; set; } // current request; policies may replace it (redirect, auth) + public Response? Response { get; internal set; } // populated once the response arrives + public Activity? Activity { get; internal set; } // active SDK span, or null when no listener + public DexpaceClientOptions Options { get; } // per-call snapshot + public CancellationToken CancellationToken { get; } + public int AttemptNumber { get; internal set; } // retry attempt counter + + public T? GetProperty(string key); // typed property bag for cross-policy state + public void SetProperty(string key, T value); +} +``` + +- One instance per call, created by the pipeline at dispatch and passed down the delegate chain. +- Policies read/replace `Request`, stash cross-policy state in the property bag (e.g. the idempotency + policy parks its key for the retry policy to reuse). +- No ambient access. When user code needs call metadata it reads it from the returned `Response` or + the thrown exception, not from hidden state. + +## 4. Diagnostics conventions + +```csharp +namespace Dexpace.Sdk.Core.Diagnostics; + +public static class DexpaceDiagnostics +{ + public static readonly ActivitySource ActivitySource = new("Dexpace.Sdk", AssemblyVersion); + public static readonly Meter Meter = new("Dexpace.Sdk", AssemblyVersion); +} +``` + +**Spans** — client-kind `Activity`; name = HTTP method (low cardinality, per current semconv). +Tags: `http.request.method`, `url.full` (redacted), `url.scheme`, `server.address`, `server.port`, +`http.response.status_code`, `http.request.resend_count`, `error.type`. + +**Metrics** — OTel semantic-convention names where they exist: +- `http.client.request.duration` — `Histogram` (seconds) +- `http.client.active_requests` — `UpDownCounter` +- `dexpace.client.retries` — `Counter` (SDK-specific) +- `dexpace.client.redirects` — `Counter` (SDK-specific) + +**Logging** — `ILogger` with stable `EventId`s and source-generated `LoggerMessage` methods +(zero-alloc, AOT-safe). High-volume events at `Debug`/`Trace`; retries and failures at `Warning`. +Structured fields: method, redacted URL, status, attempt, elapsed. In DI, `ILogger` is injected; +in manual construction, policies accept an `ILogger` defaulting to `NullLogger`. + +**Redaction** — a `UrlRedactor` strips `Authorization`/`Cookie`/`Set-Cookie` and configurable query +parameters before any value is logged or attached as a span tag. + +## 5. Project & repo changes + +- `Core`: add `Pipeline/PipelineContext.cs`, `Diagnostics/DexpaceDiagnostics.cs`, and + `Diagnostics/UrlRedactor.cs`. Uses `System.Diagnostics.DiagnosticSource` and + `Microsoft.Extensions.Logging.Abstractions` — both already `Core` dependencies. +- No new package references. + +## 6. Open items (resolve during planning or in dependent slices) + +- `AttemptNumber` 0- vs 1-based — finalize alongside the retry policy (slice 5). +- Property-bag shape: `string`-keyed typed helpers (above) vs. a small set of strongly-typed slots. +- Confirm span-name policy against the current OTel HTTP semantic conventions at implementation time + (method-only when no route template is available). diff --git a/docs/superpowers/specs/2026-06-14-options-slice-design.md b/docs/superpowers/specs/2026-06-14-options-slice-design.md new file mode 100644 index 0000000..c18d3e8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-options-slice-design.md @@ -0,0 +1,107 @@ +# Options & configuration — slice design + +- **Date:** 2026-06-14 +- **Status:** Approved; ready for implementation planning. +- **Part of:** [.NET SDK Platform Architecture & Build Plan](2026-06-14-dotnet-sdk-platform-design.md) — slice 2. + +## 1. Purpose & scope + +The configuration objects that feed pipeline and policy defaults, exposed the .NET-native way. + +**In scope:** option POCOs in `Core`; their defaults; the DI-side binding and validation glue. + +**Out of scope:** a bespoke env/override reader (the siblings hand-rolled one — `IConfiguration` +covers it); proxy configuration (`HttpClient` honors `HTTP(S)_PROXY`/`NO_PROXY` natively); behavioral +predicates and hooks (retryable-status callbacks, etc.), which belong to the policies slice. + +## 2. Decisions + +- **Plain POCOs in `Core`, grouped per policy.** A root `DexpaceClientOptions` aggregates + cross-cutting settings and the per-policy option objects. Every option type is constructable with + `new` and carries sensible defaults — the pipeline is fully usable without a container. +- **Policies take the POCOs directly, not `IOptions`.** This keeps policy construction trivial in + the DI-less path and keeps `Core` decoupled from the Options package. +- **The `Microsoft.Extensions.Options` machinery lives only in the DI integration package** — + `IOptions`, `IConfiguration` binding, and `IValidateOptions`. This narrows `Core`'s added + dependencies to `Microsoft.Extensions.Logging.Abstractions` + `System.Diagnostics.DiagnosticSource` + (a correction to platform-spec D2, already applied). +- **Naming aligns with the .NET resilience ecosystem.** Retry uses `MaxRetryAttempts` (number of + retries *after* the first send), matching Polly v8 / `Microsoft.Extensions.Http.Resilience`. + +## 3. Option types (sketch) + +```csharp +namespace Dexpace.Sdk.Core.Configuration; + +public sealed class DexpaceClientOptions +{ + public Uri? BaseAddress { get; set; } + public string UserAgent { get; set; } = DefaultUserAgent; // "dexpace-dotnet/" + public TimeSpan? OverallTimeout { get; set; } // whole operation (redirect + retry) + public TimeSpan? AttemptTimeout { get; set; } // per send attempt + public RetryOptions Retry { get; set; } = new(); + public RedirectOptions Redirect { get; set; } = new(); +} + +public sealed class RetryOptions +{ + public int MaxRetryAttempts { get; set; } = 3; // retries after the first send + public TimeSpan BaseDelay { get; set; } = TimeSpan.FromMilliseconds(200); + public TimeSpan MaxDelay { get; set; } = TimeSpan.FromSeconds(30); + public bool HonorRetryAfter { get; set; } = true; + public bool RetryNonIdempotentWhenReplayable { get; set; } = false; +} + +public sealed class RedirectOptions +{ + public int MaxRedirects { get; set; } = 20; + public bool AllowHttpsToHttpDowngrade { get; set; } = false; + public bool StripSensitiveHeadersOnCrossOrigin { get; set; } = true; +} +``` + +Defaults rationale: retry/backoff numbers match common SDK behavior; max-redirects 20 mirrors +browser/`HttpClient` norms; downgrade-to-HTTP and cross-origin header leakage are off by default for +safety. + +## 4. DI-side binding & validation (delivered with the DI slice) + +```csharp +// programmatic +services.AddDexpaceClient(o => +{ + o.BaseAddress = new Uri("https://api.example.com"); + o.Retry.MaxRetryAttempts = 5; +}); + +// or bound from configuration (env vars + appsettings + Key Vault, all via IConfiguration) +services.AddDexpaceClient().BindConfiguration("Dexpace"); +``` + +- An `IValidateOptions` enforces ranges (attempts ≥ 0, delays > 0, + `MaxDelay ≥ BaseDelay`, redirects ≥ 0) and runs with `ValidateOnStart`, so misconfiguration fails + fast at host startup rather than mid-request. +- Configuration keys follow the section shape `Dexpace:Retry:MaxRetryAttempts`, etc. + +## 5. Manual (DI-less) usage + +```csharp +var options = new DexpaceClientOptions { Retry = { MaxRetryAttempts = 5 } }; +// passed straight to the pipeline builder / individual policies — no container required. +``` + +## 6. Project & repo changes + +- `Core`: add `Configuration/DexpaceClientOptions.cs`, `RetryOptions.cs`, `RedirectOptions.cs` — + plain POCOs, no new dependency. +- `Directory.Packages.props`: `Microsoft.Extensions.Options` (and the configuration/binder packages) + referenced only by the DI integration project. +- Platform spec D2 + package graph updated to move Options out of `Core` (done). + +## 7. Open items (resolve during planning or in dependent slices) + +- Final default values for timeouts (per-attempt vs overall) once the retry/redirect policies are + designed (slice 5). +- Whether logging/tracing verbosity toggles live on `DexpaceClientOptions` or on their own + policy-scoped options (leaning toward policy-scoped, defined in the instrumentation/policies + slices). diff --git a/docs/superpowers/specs/2026-06-14-pagination-slice-design.md b/docs/superpowers/specs/2026-06-14-pagination-slice-design.md new file mode 100644 index 0000000..236eba4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-pagination-slice-design.md @@ -0,0 +1,84 @@ +# Pagination — slice design + +- **Date:** 2026-06-14 +- **Status:** Approved; ready for implementation planning. +- **Part of:** [.NET SDK Platform Architecture & Build Plan](2026-06-14-dotnet-sdk-platform-design.md) — slice 7. + +## 1. Purpose & scope + +Item- and page-level async iteration over paginated operations. + +**In scope:** `AsyncPageable`, `Page`, a `Pageable` factory driven by typed selectors, built-in +cursor / page-number / link-header helpers, and a `maxPages` safety cap. + +**Out of scope:** streaming-array deserialization of a single huge response (a later serde add-on). + +## 2. Decisions + +- **Surface mirrors Azure.Core:** `AsyncPageable : IAsyncEnumerable` with an `AsPages()` view — + the idiom .NET consumers already know. +- **Typed selectors, not JSON-path strings.** The caller supplies a strongly-typed page-envelope + model plus delegates to select items and derive the next request — fully AOT / source-gen friendly. +- **Each page request runs through the full `HttpPipeline`** with a fresh `PipelineContext`, so retry, + auth, and telemetry apply per page. +- **`maxPages` cap** guards against runaway iteration. + +## 3. Surface (sketch) + +```csharp +namespace Dexpace.Sdk.Core.Pagination; + +public abstract class AsyncPageable : IAsyncEnumerable +{ + public abstract IAsyncEnumerable> AsPages(int? pageSizeHint = null); + public abstract IAsyncEnumerator GetAsyncEnumerator(CancellationToken ct = default); +} + +public sealed class Page +{ + public IReadOnlyList Values { get; } + public string? ContinuationToken { get; } + public Status Status { get; } + public Headers Headers { get; } +} + +public static class Pageable +{ + public static AsyncPageable Create( + HttpPipeline pipeline, + Request first, + ISerde serde, + DexpaceClientOptions options, + Func> selectItems, + Func nextRequest, // null ends iteration + int? maxPages = null); +} +``` + +## 4. Built-in strategies + +Thin helpers that produce the `nextRequest` delegate: + +- **Cursor** — read a cursor field from `TPage`, set it as a query parameter on the next request. +- **Page number** — increment a page query parameter until a page returns fewer than the page size. +- **Link header** — parse `Link: ; rel="next"` from the `Response` headers (RFC 8288). + +`nextRequest` receives both the deserialized `TPage` and the raw `Response`, so body-driven cursors +and header-driven links are both expressible. + +## 5. Response lifecycle + +The pager deserializes each page through the serde, captures `Status`/`Headers`/values/continuation +into `Page`, then disposes the response. Item enumeration lazily fetches the next page only when +the consumer advances past the current page's items. + +## 6. Project & repo changes + +- `Core`: add `Pagination/` (`AsyncPageable`, `Page`, `Pageable`, the strategy helpers). +- No new dependencies. + +## 7. Open items (resolve during planning) + +- Whether `Page` should optionally expose the raw (already-consumed) `Response` for advanced cases. +- Per-page vs shared `DexpaceClientOptions`/cancellation semantics. +- Page-size-hint plumbing into the first request. diff --git a/docs/superpowers/specs/2026-06-14-pipeline-spine-slice-design.md b/docs/superpowers/specs/2026-06-14-pipeline-spine-slice-design.md new file mode 100644 index 0000000..fa28716 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-pipeline-spine-slice-design.md @@ -0,0 +1,127 @@ +# Pipeline spine — slice design + +- **Date:** 2026-06-14 +- **Status:** Approved; ready for implementation planning. +- **Part of:** [.NET SDK Platform Architecture & Build Plan](2026-06-14-dotnet-sdk-platform-design.md) — slice 4. + +## 1. Purpose & scope + +The composable request/response pipeline — the spine every cross-cutting concern plugs into. + +**In scope:** the `HttpPipelinePolicy` abstraction, the `PipelineRunner` ("next") mechanism, the +named `PipelineStage` model with pillar semantics, the `PipelineBuilder` (type-targeted edits), the +`HttpPipeline` entry point, the fixed terminal transport runner, and the sync bridge. It threads the +`PipelineContext` from slice 3. + +**Out of scope:** the concrete policies (slice 5) and auth (slice 6). + +## 2. Decisions + +- **Object policies with explicit `next`** (Azure.Core `HttpPipelinePolicy` pattern). +- **Named stages with pillar semantics**, streamlined from the siblings; sparse numbering leaves room + to insert. +- **The transport is the fixed terminal** of the chain, not a replaceable policy. +- **Retry/redirect re-invoke `next` in a loop.** Because `PipelineContext` is the single mutable + carrier and the runner is re-entrant from a fixed index, no per-attempt state cloning is needed — + everything *downstream* of the retry/redirect policy re-runs per attempt; everything *upstream* runs + once. Per-call vs. per-attempt is purely a function of position. +- **`ValueTask`-based**, `CancellationToken` carried on the context. + +## 3. Policy abstraction (sketch) + +```csharp +namespace Dexpace.Sdk.Core.Pipeline; + +public abstract class HttpPipelinePolicy +{ + public abstract PipelineStage Stage { get; } + public abstract ValueTask ProcessAsync(PipelineContext context, PipelineRunner next); + public virtual void Process(PipelineContext context, PipelineRunner next); // default: blocks on ProcessAsync +} + +public readonly struct PipelineRunner // the "next" +{ + public ValueTask RunAsync(PipelineContext context); // runs the remaining chain; sets context.Response + public void Run(PipelineContext context); // sync variant +} +``` + +- A policy mutates `context.Request` before calling `next`, then inspects or replaces + `context.Response` after. +- `RunAsync` invokes `policies[index].ProcessAsync(context, new PipelineRunner(policies, index + 1))`; + when `index` reaches the end it calls the transport and sets `context.Response`. +- Retry/redirect: `do { await next.RunAsync(ctx); } while (ShouldRetry(ctx));` — each call re-runs + everything positioned after the policy. + +## 4. Stage model + +```csharp +public enum PipelineStage // sparse; outermost (lowest) → innermost +{ + Operation = 100, // once-per-call: operation span, overall timeout (pillar) + Redirect = 200, // (pillar) + Retry = 300, // (pillar) + PerAttempt = 400, // user policies that run each attempt (set-date, client-identity, idempotency) + Auth = 500, // inside Retry, so a refreshed token is used on retry (pillar) + Diagnostics = 600, // per-attempt logging / tracing / metrics, near the wire (pillar) + // transport terminal runs last +} +``` + +- **Pillar stages admit exactly one policy**; a second is a clear configuration error. +- **`PerAttempt`** (and other non-pillar stages) stack multiple user policies in insertion order. +- Final numeric ordering of Redirect-vs-Retry and Auth/Diagnostics placement is validated when the + concrete policies land (slice 5); this is the working default. + +## 5. Builder + +```csharp +public sealed class PipelineBuilder +{ + public PipelineBuilder Add(HttpPipelinePolicy policy); // placed by Stage + public PipelineBuilder InsertBefore(HttpPipelinePolicy p) where T : HttpPipelinePolicy; + public PipelineBuilder InsertAfter(HttpPipelinePolicy p) where T : HttpPipelinePolicy; + public PipelineBuilder Replace(HttpPipelinePolicy p) where T : HttpPipelinePolicy; + public PipelineBuilder Remove() where T : HttpPipelinePolicy; + public HttpPipeline Build(IAsyncHttpClient transport); +} +``` + +Policies are ordered by `Stage` then insertion order; pillar violations throw with an actionable +message; `Build` captures the transport as the terminal runner. + +## 6. Entry point + +```csharp +public sealed class HttpPipeline +{ + public ValueTask SendAsync(Request request, DexpaceClientOptions options, CancellationToken ct = default); + public Response Send(Request request, DexpaceClientOptions options, CancellationToken ct = default); +} +``` + +`SendAsync` builds the `PipelineContext`, opens the once-per-call `Activity` at the `Operation` stage +(only when the source has listeners), runs the chain through `PipelineRunner`, and returns +`context.Response` or throws the mapped exception. + +## 7. Sync support + +Async-first. The sync `Send` drives `PipelineRunner.Run` and `HttpPipelinePolicy.Process`, whose +default implementation blocks on the async path — so the existing `IHttpClient` seam works end-to-end +without every policy hand-writing a sync variant. + +## 8. Project & repo changes + +- `Core`: add `Pipeline/HttpPipelinePolicy.cs`, `PipelineRunner.cs`, `PipelineStage.cs`, + `PipelineBuilder.cs`, `HttpPipeline.cs` (joining `PipelineContext` from slice 3). +- No new dependencies. + +## 9. Open items (resolve during planning or in slice 5) + +- Redirect-vs-Retry outermost ordering and final Auth/Diagnostics placement. +- `PipelineRunner` as a `readonly struct` (holding the policy array + index + transport) to avoid + per-hop allocation. +- Whether shipped policies implement true sync paths or rely on the blocking bridge for v1 (leaning + blocking bridge, documented). +- Overall/attempt timeout handling at the `Operation` stage via linked `CancellationTokenSource`, + coordinated with `DexpaceClientOptions` timeouts. diff --git a/docs/superpowers/specs/2026-06-14-serde-slice-design.md b/docs/superpowers/specs/2026-06-14-serde-slice-design.md new file mode 100644 index 0000000..9b14674 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-serde-slice-design.md @@ -0,0 +1,149 @@ +# Serde + System.Text.Json — slice design + +- **Date:** 2026-06-14 +- **Status:** Approved; ready for implementation planning. +- **Part of:** [.NET SDK Platform Architecture & Build Plan](2026-06-14-dotnet-sdk-platform-design.md) — slice 1. +- **Related:** OmarAlJarrah/dotnet-sdk#1 (deferred three-state `Optional` / PATCH support). + +## 1. Purpose & scope + +Define the SDK's serialization seam and ship the reference System.Text.Json implementation, plus +the request/response conveniences and the typed-error accessor that depend on it. This slice lands +first because typed error bodies, pagination item parsing, and webhook payload handling all build on +it. + +**In scope:** + +- `ISerde` seam in `Dexpace.Sdk.Core` (no third-party dependency). +- `Dexpace.Sdk.Serialization.SystemTextJson` with `SystemTextJsonSerde` and DI registration. +- `RequestBody.FromValue` / `ResponseBody.ReadValueAsync` conveniences in `Core`. +- `HttpResponseException.GetErrorAsync` deserialize-on-demand accessor. + +**Out of scope:** + +- Three-state optional/PATCH type (`Optional`) — tracked in #1. +- The bounded error-body buffering that makes the error accessor readable after a throw — owned by + the policies slice; this slice only defines the accessor's contract. +- Non-JSON formats and streaming-array deserialization (`IAsyncEnumerable`) — the seam permits + both; pagination revisits streaming. + +## 2. The `ISerde` seam + +```csharp +namespace Dexpace.Sdk.Core.Serialization; + +public interface ISerde +{ + /// Media type stamped on bodies created from values (e.g. application/json). + MediaType DefaultMediaType { get; } + + // Streaming (primary path — matches the existing body model) + ValueTask SerializeAsync(Stream destination, T value, CancellationToken ct = default); + ValueTask DeserializeAsync(Stream source, CancellationToken ct = default); + + // In-memory fast paths + void Serialize(IBufferWriter destination, T value); + T? Deserialize(ReadOnlySpan utf8); +} +``` + +**Semantics:** + +- **UTF-8 JSON** is the assumed wire encoding for the reference implementation; the seam itself is + format-neutral. +- **Failures map to the existing hierarchy:** serialization errors throw `SerializationException`, + deserialization errors throw `DeserializationException` (both already defined). Implementations + wrap their native exceptions. +- **Generic + context-backed:** `T` is resolved to type metadata by the implementation, not by the + seam. The seam exposes no serializer-specific types, preserving agnosticism. + +## 3. System.Text.Json implementation + +```csharp +namespace Dexpace.Sdk.Serialization.SystemTextJson; + +public sealed class SystemTextJsonSerde : ISerde +{ + public SystemTextJsonSerde(JsonSerializerOptions options); + public SystemTextJsonSerde(JsonSerializerContext context); // convenience + public MediaType DefaultMediaType => CommonMediaTypes.ApplicationJsonUtf8; +} +``` + +- **AOT-safe by construction.** Type metadata comes from `JsonSerializerOptions.TypeInfoResolver` + (a source-generated `JsonSerializerContext`). Each call resolves `JsonTypeInfo` from the + resolver. +- **Unknown-type behavior.** With no `JsonTypeInfo` available, throw a `SerializationException` / + `DeserializationException` whose message names the missing type and points to registering it on a + `JsonSerializerContext`. No silent reflection fallback under the default configuration. +- **Optional reflection mode.** A non-AOT convenience may enable STJ's reflection-based resolver for + apps that have not adopted source generation. Off by default to keep AOT behavior honest; surfaces + the standard STJ trim/AOT analyzer warnings when enabled. +- **Lenient reads.** Default options ignore unknown JSON members (forward compatibility), mirroring + the siblings' "payloads can grow without breaking clients." `JsonSerializerDefaults.Web` + (camelCase, case-insensitive) is the default; fully overridable. +- **DI registration.** `services.AddSystemTextJsonSerde(MyJsonContext.Default)` registers `ISerde`. + +## 4. Body conveniences (Core) + +```csharp +// Request side — eager, replayable +public static RequestBody FromValue(T value, ISerde serde, MediaType? contentType = null); + +// Response side — single-use (honors existing body semantics) +public ValueTask ReadValueAsync(this ResponseBody body, ISerde serde, CancellationToken ct = default); +``` + +- `FromValue` serializes eagerly into a pooled buffer via `serde.Serialize`, producing a + **replayable** byte-backed body (retries are free, no re-serialization). Content-type defaults to + `serde.DefaultMediaType`. Trade-off accepted: typed-model payloads are small, so eager buffering is + the pragmatic choice. +- `ReadValueAsync` opens the response stream and delegates to `serde.DeserializeAsync`. + Single-use rules apply — a second read throws `StreamConsumedException`, as today. + +## 5. Typed error bodies + +```csharp +// On HttpResponseException (Core) +public ValueTask GetErrorAsync(ISerde serde, CancellationToken ct = default); +``` + +- Reads the error response body through the serde and returns the caller-chosen `T`. The caller + picks the model; the throw site does not need to know it. +- **Contract / dependency:** the accessor requires `Response.Body` to be replayable. The pipeline + buffers the error body (bounded) before throwing on 4xx/5xx — implemented in the policies slice. If + invoked on a consumed or non-replayable body, the accessor throws `ResponseNotReadException`. + +## 6. Project & repo changes + +- New `src/Dexpace.Sdk.Serialization.SystemTextJson/` (multi-target `net8.0;net10.0`, + `IsTrimmable` / `IsAotCompatible`, references `Core` + `System.Text.Json`). +- New `tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/`. +- `Core`: add `Serialization/ISerde.cs`; add `RequestBody.FromValue`; add the `ResponseBody` + extension; add `HttpResponseException.GetErrorAsync`. No new `Core` dependency (the seam is + BCL-only). +- `Directory.Packages.props`: add `System.Text.Json` (and + `Microsoft.Extensions.DependencyInjection.Abstractions` if the registration helper ships in the STJ + package). +- Apply the platform spec's multi-target + AOT flags to the new projects. + +## 7. Testing + +- Seam contract, via a hand-written fake `ISerde` and the STJ impl: round-trip; context-resolved + types; unknown-type error message; lenient unknown-member reads; `DefaultMediaType`; native + exception → `SerializationException` / `DeserializationException` mapping. +- Conveniences: `FromValue` is replayable and stamps the right content-type; `ReadValueAsync` is + single-use. +- Error accessor: succeeds against a buffered error body; throws `ResponseNotReadException` on a + consumed body. +- AOT: a NativeAOT publish smoke test in CI — compile a small consumer with a source-gen context and + assert no trim/AOT warnings (can follow as a CI task). + +## 8. Open items (resolve during planning) + +- Registration-helper location: the STJ package (referencing + `Microsoft.Extensions.DependencyInjection.Abstractions`) vs. `Extensions.DependencyInjection`. + Leaning toward the STJ package, per the per-package `AddX` convention. +- Final seam namespace (`Dexpace.Sdk.Core.Serialization` proposed). +- Whether the synchronous `IBufferWriter` / `ReadOnlySpan` members are needed in v1 or + wait until a synchronous caller exists (YAGNI check). diff --git a/docs/superpowers/specs/2026-06-14-sse-slice-design.md b/docs/superpowers/specs/2026-06-14-sse-slice-design.md new file mode 100644 index 0000000..afb0e27 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-sse-slice-design.md @@ -0,0 +1,71 @@ +# Server-Sent Events (SSE) — slice design + +- **Date:** 2026-06-14 +- **Status:** Approved; ready for implementation planning. +- **Part of:** [.NET SDK Platform Architecture & Build Plan](2026-06-14-dotnet-sdk-platform-design.md) — slice 8. + +## 1. Purpose & scope + +Read `text/event-stream` responses as a typed async stream, with optional reconnection. + +**In scope:** a WHATWG-compliant `IAsyncEnumerable` parser over a response stream, +and a reconnecting client (Last-Event-ID resume, server `retry:` backoff). + +**Out of scope:** an SSE *server*; transforming events into typed domain models (caller's job, via the +serde). + +## 2. Decisions + +- **Parser yields `IAsyncEnumerable`** over a `Stream` obtained from + `ResponseBody.OpenReadAsync` — single-use streaming, no buffering of the whole body. +- **Ship the reconnecting client too** — resumes with `Last-Event-ID`, honors the server's `retry:` + hint (capped), and bounds reconnect attempts. +- **`TimeProvider` for backoff timing** (BCL, testable) — no bespoke clock. + +## 3. Surface (sketch) + +```csharp +namespace Dexpace.Sdk.Core.ServerSentEvents; + +public sealed record ServerSentEvent(string? Id, string EventType, string Data, TimeSpan? Retry); + +public static class ServerSentEventReader +{ + public static IAsyncEnumerable ReadAsync(Stream stream, CancellationToken ct = default); +} + +public sealed class ServerSentEventStream // reconnecting +{ + public ServerSentEventStream( + HttpPipeline pipeline, Request request, DexpaceClientOptions options, + SseReconnectOptions? reconnect = null, TimeProvider? timeProvider = null); + + public IAsyncEnumerable ReadAsync(CancellationToken ct = default); +} +``` + +## 4. Parsing rules (WHATWG) + +- Line terminators LF / CR / CRLF; UTF-8 with U+FFFD replacement; leading BOM stripped once. +- `data:` fields accumulate, joined by `\n`; a blank line dispatches the event. +- `event:` sets the type (default `"message"`); `id:` is sticky for the connection; `retry:` (ms) + updates the reconnection delay; lines beginning `:` are comments. +- A bounded line buffer guards against unterminated input. + +## 5. Reconnection + +On stream end or transport error, the reconnecting client waits the current `retry` delay (server hint +or configured default, capped), re-issues the request with `Last-Event-ID`, and resumes — up to +`SseReconnectOptions.MaxAttempts`. Cancellation stops promptly. + +## 6. Project & repo changes + +- `Core`: add `ServerSentEvents/` (`ServerSentEvent`, `ServerSentEventReader`, `ServerSentEventStream`, + `SseReconnectOptions`). `CommonMediaTypes.TextEventStream` already exists. +- No new dependencies. + +## 7. Open items (resolve during planning) + +- Default `retry` value and cap when the server sends none. +- Whether to surface raw `:` comments to the consumer or drop them (leaning drop, with an opt-in). +- Backpressure expectations under slow consumers (documented; `IAsyncEnumerable` naturally pulls). diff --git a/docs/superpowers/specs/2026-06-14-webhooks-slice-design.md b/docs/superpowers/specs/2026-06-14-webhooks-slice-design.md new file mode 100644 index 0000000..a7c6c7d --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-webhooks-slice-design.md @@ -0,0 +1,67 @@ +# Webhooks — slice design + +- **Date:** 2026-06-14 +- **Status:** Approved; ready for implementation planning. +- **Part of:** [.NET SDK Platform Architecture & Build Plan](2026-06-14-dotnet-sdk-platform-design.md) — slice 9. + +## 1. Purpose & scope + +Verify inbound webhook signatures and optionally deserialize the verified payload. + +**In scope:** an `IWebhookVerifier` seam, a `StandardWebhooksVerifier` (HMAC-SHA256), and a typed +`Unwrap` convenience. + +**Out of scope:** provider-specific schemes (Stripe/GitHub-style) — the seam exists so they can be +added later without breaking changes. + +## 2. Decisions + +- **Standard Webhooks (HMAC-SHA256) behind an `IWebhookVerifier` interface**, so additional schemes + slot in later. +- **`TimeProvider` for the clock** (BCL, testable) — no bespoke clock type. +- **Constant-time comparison** via `CryptographicOperations.FixedTimeEquals`. +- **Key rotation supported** — multiple signature tokens in the header are each tried. +- **New error type:** `WebhookVerificationException : SdkException`. + +## 3. Surface (sketch) + +```csharp +namespace Dexpace.Sdk.Core.Webhooks; + +public interface IWebhookVerifier +{ + void Verify(Headers headers, ReadOnlySpan body); +} + +public sealed class StandardWebhooksVerifier : IWebhookVerifier +{ + public StandardWebhooksVerifier(string secret, TimeSpan? tolerance = null, TimeProvider? timeProvider = null); + + public void Verify(Headers headers, ReadOnlySpan body); // throws WebhookVerificationException + public T Unwrap(Headers headers, ReadOnlySpan body, ISerde serde); // verify then deserialize +} +``` + +## 4. Verification algorithm (Standard Webhooks) + +- Secret format `whsec_` (prefix optional); decode to key bytes. +- Signed content = `"{id}.{timestamp}.{body}"` from the `webhook-id`, `webhook-timestamp`, and + `webhook-signature` headers. +- Compute HMAC-SHA256, base64-encode, and compare (constant-time) against each space-separated + `v1,` token in `webhook-signature`. +- Reject when the timestamp is outside the tolerance window (default ±5 minutes) — guards replay. +- Any failure throws `WebhookVerificationException`; `Unwrap` additionally surfaces + `DeserializationException` from the serde. + +## 5. Project & repo changes + +- `Core`: add `Webhooks/` (`IWebhookVerifier`, `StandardWebhooksVerifier`) and + `Errors/WebhookVerificationException.cs`. +- BCL crypto only (`HMACSHA256`, `CryptographicOperations`); no new dependency. + +## 6. Open items (resolve during planning) + +- Whether to accept the body as `ReadOnlySpan` only or also `string` (encoding pitfalls argue + for bytes as canonical). +- Header-name constants for the `webhook-*` set. +- Tolerance default and whether it's required. diff --git a/global.json b/global.json new file mode 100644 index 0000000..512142d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..765346e --- /dev/null +++ b/nuget.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Dexpace.Sdk.Core/Client/HttpClientExtensions.cs b/src/Dexpace.Sdk.Core/Client/HttpClientExtensions.cs new file mode 100644 index 0000000..da38652 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Client/HttpClientExtensions.cs @@ -0,0 +1,59 @@ +// 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.Request; +using Dexpace.Sdk.Core.Http.Response; + +namespace Dexpace.Sdk.Core.Client; + +/// +/// Bridges between the synchronous and asynchronous +/// transport SPIs. +/// +public static class HttpClientExtensions +{ + /// + /// Wraps a synchronous transport as an by offloading each + /// blocking call to the thread pool. + /// + /// The synchronous transport to wrap. + /// An async facade over . + public static IAsyncHttpClient AsAsync(this IHttpClient client) + { + ArgumentNullException.ThrowIfNull(client); + return new SyncToAsyncAdapter(client); + } + + /// + /// Wraps an asynchronous transport as an that blocks on the returned + /// task. The blocking wait unwraps transport exceptions so callers see the original failure. + /// + /// The asynchronous transport to wrap. + /// A blocking facade over . + public static IHttpClient AsBlocking(this IAsyncHttpClient client) + { + ArgumentNullException.ThrowIfNull(client); + return new AsyncToSyncAdapter(client); + } + + private sealed class SyncToAsyncAdapter(IHttpClient inner) : IAsyncHttpClient + { + public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) => + Task.Run(() => inner.Execute(request), cancellationToken); + + public ValueTask DisposeAsync() + { + inner.Dispose(); + return ValueTask.CompletedTask; + } + } + + private sealed class AsyncToSyncAdapter(IAsyncHttpClient inner) : IHttpClient + { + public Response Execute(Request request) => + inner.ExecuteAsync(request).GetAwaiter().GetResult(); + + public void Dispose() => + inner.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } +} diff --git a/src/Dexpace.Sdk.Core/Client/IAsyncHttpClient.cs b/src/Dexpace.Sdk.Core/Client/IAsyncHttpClient.cs new file mode 100644 index 0000000..b5aa718 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Client/IAsyncHttpClient.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.Http.Request; +using Dexpace.Sdk.Core.Http.Response; + +namespace Dexpace.Sdk.Core.Client; + +/// +/// The asynchronous transport SPI — the async-first counterpart of . +/// +/// +/// +/// is the SDK's canonical async contract: it is the lowest common +/// denominator every other .NET async pattern (channels, Rx, Dataflow) adapts to. Transport +/// packages (such as Dexpace.Sdk.Http.SystemNet) adapt one HTTP library to this interface; +/// core ships no transport of its own. +/// +/// +/// Thread-safety. Implementations must be safe for concurrent calls from multiple threads; +/// per-call state must be confined to the returned task. +/// +/// +/// Cancellation. A signalled cancellation token is a best-effort request to abort the +/// in-flight exchange. If the response has already been delivered, cancelling does NOT dispose +/// the body; callers still own Dispose. +/// +/// +/// Lifecycle. Implementations are . Dispose is idempotent and +/// ownership-aware: only SDK-owned resources are released; a caller-supplied +/// System.Net.Http.HttpClient is left untouched. +/// +/// +public interface IAsyncHttpClient : IAsyncDisposable +{ + /// + /// Sends over the underlying transport. The returned task completes + /// with the matching (caller owns disposal) or faults with the transport + /// failure. Implementations MUST NOT complete with on success. + /// + /// The request to send. + /// A token to abort the exchange. + /// A task that completes with the response. + Task ExecuteAsync(Request request, CancellationToken cancellationToken = default); +} diff --git a/src/Dexpace.Sdk.Core/Client/IHttpClient.cs b/src/Dexpace.Sdk.Core/Client/IHttpClient.cs new file mode 100644 index 0000000..fcc94eb --- /dev/null +++ b/src/Dexpace.Sdk.Core/Client/IHttpClient.cs @@ -0,0 +1,27 @@ +// 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.Request; +using Dexpace.Sdk.Core.Http.Response; + +namespace Dexpace.Sdk.Core.Client; + +/// +/// The synchronous transport SPI. +/// +/// +/// The response body is not pre-buffered — callers are responsible for disposing the returned +/// . Most consumers should prefer ; this +/// blocking variant exists for callers and call sites that cannot go async. See +/// for sync/async bridges. +/// +public interface IHttpClient : IDisposable +{ + /// + /// Sends over the underlying transport and returns the matching + /// . Implementations MUST NOT return . + /// + /// The request to send. + /// The response (caller owns disposal). + Response Execute(Request request); +} diff --git a/src/Dexpace.Sdk.Core/Dexpace.Sdk.Core.csproj b/src/Dexpace.Sdk.Core/Dexpace.Sdk.Core.csproj new file mode 100644 index 0000000..c0fcc82 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Dexpace.Sdk.Core.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + Dexpace.Sdk.Core + Dexpace.Sdk.Core + Dexpace.Sdk.Core + + HTTP-client toolkit (not an HTTP client): immutable HTTP models, transport SPI, + pipeline, auth, and instrumentation abstractions. Transports plug in via IHttpClient. + + http;sdk;rest;toolkit;dexpace + true + + + + + + + + + + + + diff --git a/src/Dexpace.Sdk.Core/Errors/LifecycleExceptions.cs b/src/Dexpace.Sdk.Core/Errors/LifecycleExceptions.cs new file mode 100644 index 0000000..f90b082 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Errors/LifecycleExceptions.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. + +namespace Dexpace.Sdk.Core.Errors; + +/// The base type for request/response body and stream lifecycle violations. +public class StreamingException : SdkException +{ + /// Initializes a new instance. + public StreamingException() + { + } + + /// Initializes a new instance with a message. + /// The error message. + public StreamingException(string message) + : base(message) + { + } + + /// Initializes a new instance with a message and inner exception. + /// The error message. + /// The cause. + public StreamingException(string message, Exception? innerException) + : base(message, innerException) + { + } +} + +/// A single-use body or stream was consumed more than once. +public sealed class StreamConsumedException : StreamingException +{ + /// Initializes a new instance. + public StreamConsumedException() + { + } + + /// Initializes a new instance with a message. + /// The error message. + public StreamConsumedException(string message) + : base(message) + { + } + + /// Initializes a new instance with a message and inner exception. + /// The error message. + /// The cause. + public StreamConsumedException(string message, Exception? innerException) + : base(message, innerException) + { + } +} + +/// A read or write was attempted on a body whose stream has already been closed. +public sealed class StreamClosedException : StreamingException +{ + /// Initializes a new instance. + public StreamClosedException() + { + } + + /// Initializes a new instance with a message. + /// The error message. + public StreamClosedException(string message) + : base(message) + { + } + + /// Initializes a new instance with a message and inner exception. + /// The error message. + /// The cause. + public StreamClosedException(string message, Exception? innerException) + : base(message, innerException) + { + } +} + +/// A buffered accessor was used before the response body had been read into memory. +public sealed class ResponseNotReadException : StreamingException +{ + /// Initializes a new instance. + public ResponseNotReadException() + { + } + + /// Initializes a new instance with a message. + /// The error message. + public ResponseNotReadException(string message) + : base(message) + { + } + + /// Initializes a new instance with a message and inner exception. + /// The error message. + /// The cause. + public ResponseNotReadException(string message, Exception? innerException) + : base(message, innerException) + { + } +} + +/// A pipeline policy aborted the exchange before a response was produced. +public sealed class PipelineAbortedException : SdkException +{ + /// Initializes a new instance. + public PipelineAbortedException() + { + } + + /// Initializes a new instance with a message. + /// The error message. + public PipelineAbortedException(string message) + : base(message) + { + } + + /// Initializes a new instance with a message and inner exception. + /// The error message. + /// The cause. + public PipelineAbortedException(string message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/src/Dexpace.Sdk.Core/Errors/SdkException.cs b/src/Dexpace.Sdk.Core/Errors/SdkException.cs new file mode 100644 index 0000000..ba45889 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Errors/SdkException.cs @@ -0,0 +1,37 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Errors; + +/// +/// The base type for every exception the SDK raises. +/// +/// +/// The hierarchy distinguishes three transport failure shapes — +/// (request never reached the server, safe to retry on +/// idempotent methods), (request sent but the response +/// could not be read), and (a 4xx/5xx received intact) — +/// alongside body/stream lifecycle, serialization, and pipeline failures. +/// +public class SdkException : Exception +{ + /// Initializes a new instance. + public SdkException() + { + } + + /// Initializes a new instance with a message. + /// The error message. + public SdkException(string message) + : base(message) + { + } + + /// Initializes a new instance with a message and inner exception. + /// The error message. + /// The cause. + public SdkException(string message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/src/Dexpace.Sdk.Core/Errors/SerializationExceptions.cs b/src/Dexpace.Sdk.Core/Errors/SerializationExceptions.cs new file mode 100644 index 0000000..78de221 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Errors/SerializationExceptions.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. + +namespace Dexpace.Sdk.Core.Errors; + +/// A value could not be serialized into a request payload. +public sealed class SerializationException : SdkException +{ + /// Initializes a new instance. + public SerializationException() + { + } + + /// Initializes a new instance with a message. + /// The error message. + public SerializationException(string message) + : base(message) + { + } + + /// Initializes a new instance with a message and inner exception. + /// The error message. + /// The cause. + public SerializationException(string message, Exception? innerException) + : base(message, innerException) + { + } +} + +/// A response payload could not be deserialized into the requested type. +public sealed class DeserializationException : SdkException +{ + /// Initializes a new instance. + public DeserializationException() + { + } + + /// Initializes a new instance with a message. + /// The error message. + public DeserializationException(string message) + : base(message) + { + } + + /// Initializes a new instance with a message and inner exception. + /// The error message. + /// The cause. + public DeserializationException(string message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/src/Dexpace.Sdk.Core/Errors/TransportExceptions.cs b/src/Dexpace.Sdk.Core/Errors/TransportExceptions.cs new file mode 100644 index 0000000..4fb18c9 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Errors/TransportExceptions.cs @@ -0,0 +1,134 @@ +// 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.Response; +using Dexpace.Sdk.Core.Serialization; + +namespace Dexpace.Sdk.Core.Errors; + +/// +/// The request never reached the server (DNS failure, connection refused, TLS handshake failure). +/// +/// Safe to retry on idempotent methods. +public class ServiceRequestException : SdkException +{ + /// Initializes a new instance. + public ServiceRequestException() + { + } + + /// Initializes a new instance with a message. + /// The error message. + public ServiceRequestException(string message) + : base(message) + { + } + + /// Initializes a new instance with a message and inner exception. + /// The error message. + /// The cause. + public ServiceRequestException(string message, Exception? innerException) + : base(message, innerException) + { + } +} + +/// +/// A timeout occurred before the request could be dispatched to the server. +/// +public sealed class ServiceRequestTimeoutException : ServiceRequestException +{ + /// Initializes a new instance. + public ServiceRequestTimeoutException() + { + } + + /// Initializes a new instance with a message. + /// The error message. + public ServiceRequestTimeoutException(string message) + : base(message) + { + } + + /// Initializes a new instance with a message and inner exception. + /// The error message. + /// The cause. + public ServiceRequestTimeoutException(string message, Exception? innerException) + : base(message, innerException) + { + } +} + +/// +/// The request was sent but the response could not be read (connection dropped mid-response, +/// decode failure on a chunked stream). +/// +public class ServiceResponseException : SdkException +{ + /// Initializes a new instance. + public ServiceResponseException() + { + } + + /// Initializes a new instance with a message. + /// The error message. + public ServiceResponseException(string message) + : base(message) + { + } + + /// Initializes a new instance with a message and inner exception. + /// The error message. + /// The cause. + public ServiceResponseException(string message, Exception? innerException) + : base(message, innerException) + { + } +} + +/// +/// An intact 4xx or 5xx response was received. Carries the so callers can +/// inspect status, headers, and body. +/// +public class HttpResponseException : SdkException +{ + /// Initializes a new instance carrying the offending response. + /// The received error response. + /// An optional message; defaults to the status line. + public HttpResponseException(Response response, string? message = null) + : base(message ?? $"The server returned an error response: {response.Status}.") + { + Response = response; + Status = response.Status; + } + + /// The received error response. + public Response Response { get; } + + /// The status code of the error response. + public Status Status { get; } + + /// + /// Deserializes the error response body as using . + /// + /// The error model type. + /// The serializer. + /// A token to cancel the read. + /// The deserialized error model (possibly ). + /// The error body has already been consumed. + /// Deserialization failed. + public async ValueTask GetErrorAsync(ISerde serde, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(serde); + try + { + await using var stream = await Response.Body.OpenReadAsync(cancellationToken).ConfigureAwait(false); + return await serde.DeserializeAsync(stream, cancellationToken).ConfigureAwait(false); + } + catch (StreamConsumedException ex) + { + throw new ResponseNotReadException( + "The error response body has already been consumed and cannot be deserialized.", ex); + } + } +} diff --git a/src/Dexpace.Sdk.Core/Http/Common/CommonMediaTypes.cs b/src/Dexpace.Sdk.Core/Http/Common/CommonMediaTypes.cs new file mode 100644 index 0000000..c207a5a --- /dev/null +++ b/src/Dexpace.Sdk.Core/Http/Common/CommonMediaTypes.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. + +namespace Dexpace.Sdk.Core.Http.Common; + +/// +/// Cached instances for the formats the SDK handles most often. +/// +public static class CommonMediaTypes +{ + /// application/json. + public static MediaType ApplicationJson { get; } = MediaType.Of("application", "json"); + + /// application/json; charset=utf-8. + public static MediaType ApplicationJsonUtf8 { get; } = + MediaType.Of("application", "json", new Dictionary { ["charset"] = "utf-8" }); + + /// application/octet-stream. + public static MediaType ApplicationOctetStream { get; } = MediaType.Of("application", "octet-stream"); + + /// application/x-www-form-urlencoded. + public static MediaType ApplicationFormUrlEncoded { get; } = + MediaType.Of("application", "x-www-form-urlencoded"); + + /// text/plain. + public static MediaType TextPlain { get; } = MediaType.Of("text", "plain"); + + /// text/event-stream (Server-Sent Events). + public static MediaType TextEventStream { get; } = MediaType.Of("text", "event-stream"); + + /// application/jsonl (JSON Lines). + public static MediaType ApplicationJsonLines { get; } = MediaType.Of("application", "jsonl"); + + /// multipart/form-data. + public static MediaType MultipartFormData { get; } = MediaType.Of("multipart", "form-data"); +} diff --git a/src/Dexpace.Sdk.Core/Http/Common/Headers.cs b/src/Dexpace.Sdk.Core/Http/Common/Headers.cs new file mode 100644 index 0000000..89121e5 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Http/Common/Headers.cs @@ -0,0 +1,161 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Collections; +using System.Collections.Immutable; + +namespace Dexpace.Sdk.Core.Http.Common; + +/// +/// An immutable, case-insensitive collection of HTTP headers that preserves multiple values +/// per name (in insertion order). +/// +/// +/// Names are stored in lower-case canonical form (RFC 7230 §3.2 — field names are +/// case-insensitive). Lookups compare names case-insensitively; iteration yields the canonical +/// lower-cased name (the order of distinct names is unspecified). Values for a given name retain +/// insertion order. Mutation is non-destructive: , +/// , and each return a new +/// . Use for batched edits. +/// +public sealed class Headers : IEnumerable>> +{ + /// An empty header set. + public static Headers Empty { get; } = new(ImmutableDictionary>.Empty); + + private readonly ImmutableDictionary> _values; + + private Headers(ImmutableDictionary> values) => _values = values; + + /// The number of distinct header names present. + public int Count => _values.Count; + + /// The distinct header names present, in canonical lower-case form. + public IEnumerable Names => _values.Keys; + + /// True when a header with is present. + /// The header name (compared case-insensitively). + /// if at least one value is present. + public bool Contains(string name) => _values.ContainsKey(Canonical(name)); + + /// + /// Returns the first value for , or if absent. + /// + /// The header name (compared case-insensitively). + /// The first value, or . + public string? Get(string name) => + _values.TryGetValue(Canonical(name), out var list) && list.Length > 0 ? list[0] : null; + + /// Returns all values for , or an empty list if absent. + /// The header name (compared case-insensitively). + /// The values in insertion order. + public IReadOnlyList GetAll(string name) => + _values.TryGetValue(Canonical(name), out var list) ? list : ImmutableArray.Empty; + + /// + /// Returns a copy with appended under , keeping + /// any existing values for that name. + /// + /// The header name. + /// The value to append. + /// A new . + public Headers With(string name, string value) + { + var key = Canonical(name); + var existing = _values.TryGetValue(key, out var list) ? list : ImmutableArray.Empty; + return new Headers(_values.SetItem(key, existing.Add(value))); + } + + /// + /// Returns a copy with set to exactly , replacing + /// any existing values for that name. + /// + /// The header name. + /// The single value. + /// A new . + public Headers Set(string name, string value) => + new(_values.SetItem(Canonical(name), ImmutableArray.Create(value))); + + /// Returns a copy with every value for removed. + /// The header name. + /// A new (or this instance if the name was absent). + public Headers Without(string name) + { + var key = Canonical(name); + return _values.ContainsKey(key) ? new Headers(_values.Remove(key)) : this; + } + + /// Returns a mutable builder seeded with this set's contents. + /// A . + public Builder ToBuilder() => new(_values); + + /// + public IEnumerator>> GetEnumerator() + { + foreach (var (key, list) in _values) + { + yield return new KeyValuePair>(key, list); + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + private static string Canonical(string name) + { + ArgumentNullException.ThrowIfNull(name); + return name.ToLowerInvariant(); + } + + /// + /// A mutable accumulator for building a without allocating an intermediate + /// instance per edit. Not thread-safe. + /// + public sealed class Builder + { + private readonly ImmutableDictionary>.Builder _values; + + internal Builder(ImmutableDictionary> seed) => + _values = seed.ToBuilder(); + + /// Creates an empty builder. + public Builder() + : this(ImmutableDictionary>.Empty) + { + } + + /// Appends under . + /// The header name. + /// The value to append. + /// This builder, for chaining. + public Builder Add(string name, string value) + { + var key = Canonical(name); + var existing = _values.TryGetValue(key, out var list) ? list : ImmutableArray.Empty; + _values[key] = existing.Add(value); + return this; + } + + /// Sets to exactly . + /// The header name. + /// The single value. + /// This builder, for chaining. + public Builder Set(string name, string value) + { + _values[Canonical(name)] = ImmutableArray.Create(value); + return this; + } + + /// Removes every value for . + /// The header name. + /// This builder, for chaining. + public Builder Remove(string name) + { + _values.Remove(Canonical(name)); + return this; + } + + /// Builds the immutable . + /// A new . + public Headers Build() => new(_values.ToImmutable()); + } +} diff --git a/src/Dexpace.Sdk.Core/Http/Common/HttpHeaderName.cs b/src/Dexpace.Sdk.Core/Http/Common/HttpHeaderName.cs new file mode 100644 index 0000000..e837aec --- /dev/null +++ b/src/Dexpace.Sdk.Core/Http/Common/HttpHeaderName.cs @@ -0,0 +1,98 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Http.Common; + +/// +/// A typed, case-insensitive HTTP header name. +/// +/// +/// Header field names are case-insensitive (RFC 7230 §3.2). This type stores the name in +/// lower-case canonical form so it can be used directly as a key without +/// re-lowering on the hot path, while preserves the spelling the caller +/// supplied for display. Equality and hashing are over the canonical form. +/// +public readonly record struct HttpHeaderName +{ + private HttpHeaderName(string canonical, string original) + { + CanonicalName = canonical; + Original = original; + } + + /// The lower-cased canonical name used for lookups and equality. + public string CanonicalName { get; } + + /// The original spelling supplied by the caller (for display only). + public string Original { get; } + + /// Creates a header name, validating it as an RFC 7230 token. + /// The header field name. + /// The typed header name. + /// is empty or not a valid token. + public static HttpHeaderName Of(string name) + { + ArgumentNullException.ThrowIfNull(name); + if (name.Length == 0) + { + throw new ArgumentException("Header name must not be empty.", nameof(name)); + } + + foreach (var c in name) + { + if (!IsTokenChar(c)) + { + throw new ArgumentException($"Invalid header-name character '{c}'.", nameof(name)); + } + } + + return new HttpHeaderName(name.ToLowerInvariant(), name); + } + + /// Returns the canonical (lower-cased) name. + public override string ToString() => CanonicalName; + + /// Equality over the canonical (case-insensitive) name. + public bool Equals(HttpHeaderName other) => CanonicalName == other.CanonicalName; + + /// + public override int GetHashCode() => CanonicalName.GetHashCode(StringComparison.Ordinal); + + private static bool IsTokenChar(char c) => + c is >= 'a' and <= 'z' + or >= 'A' and <= 'Z' + or >= '0' and <= '9' + or '!' or '#' or '$' or '%' or '&' or '\'' or '*' + or '+' or '-' or '.' or '^' or '_' or '`' or '|' or '~'; + + /// Common request/response header names as typed constants. + public static class WellKnown + { + /// The Accept header. + public static HttpHeaderName Accept { get; } = Of("Accept"); + + /// The Authorization header. + public static HttpHeaderName Authorization { get; } = Of("Authorization"); + + /// The Content-Length header. + public static HttpHeaderName ContentLength { get; } = Of("Content-Length"); + + /// The Content-Type header. + public static HttpHeaderName ContentType { get; } = Of("Content-Type"); + + /// The Date header. + public static HttpHeaderName Date { get; } = Of("Date"); + + /// The ETag header. + public static HttpHeaderName ETag { get; } = Of("ETag"); + + /// The Location header. + public static HttpHeaderName Location { get; } = Of("Location"); + + /// The Retry-After header. + public static HttpHeaderName RetryAfter { get; } = Of("Retry-After"); + + /// The User-Agent header. + public static HttpHeaderName UserAgent { get; } = Of("User-Agent"); + } +} diff --git a/src/Dexpace.Sdk.Core/Http/Common/MediaType.cs b/src/Dexpace.Sdk.Core/Http/Common/MediaType.cs new file mode 100644 index 0000000..7c48cfc --- /dev/null +++ b/src/Dexpace.Sdk.Core/Http/Common/MediaType.cs @@ -0,0 +1,297 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Collections.Immutable; +using System.Text; + +namespace Dexpace.Sdk.Core.Http.Common; + +/// +/// A media type, as defined by RFC 7231 §3.1.1.1. +/// +/// +/// Instances are immutable and constructed exclusively through and +/// ; both factories normalise the type, subtype, and parameter keys to +/// lower case so equality is case-insensitive in practice. Parameter values are case-preserved — +/// boundaries, base64 tokens, and other values must not be folded. A wildcard primary type is +/// only valid when the subtype is also a wildcard. +/// +public sealed record MediaType +{ + private MediaType(string type, string subtype, ImmutableSortedDictionary parameters) + { + Type = type; + Subtype = subtype; + Parameters = parameters; + } + + /// The primary type (e.g. application, text), lower-cased. + public string Type { get; } + + /// The subtype (e.g. json, plain), lower-cased. + public string Subtype { get; } + + /// The media-type parameters keyed by lower-cased name (values case-preserved). + public ImmutableSortedDictionary Parameters { get; } + + /// The bare type/subtype form, without any parameters. + public string FullType => $"{Type}/{Subtype}"; + + /// + /// The charset parameter resolved through , or + /// if absent or unknown. Unknown-charset failures are swallowed so callers + /// can fall back to a default rather than wrapping every access in a try/catch. + /// + public Encoding? Charset + { + get + { + if (!Parameters.TryGetValue("charset", out var value)) + { + return null; + } + + try + { + return Encoding.GetEncoding(value); + } + catch (ArgumentException) + { + return null; + } + } + } + + /// + /// Constructs a media type from its components. Type and subtype are validated as RFC 7230 + /// tokens and lower-cased; parameter keys are lower-cased, values preserved. + /// + /// The primary type. + /// The subtype. + /// Optional parameters. + /// The constructed . + /// A component is empty, not a valid token, or a half-wildcard. + public static MediaType Of( + string type, + string subtype, + IReadOnlyDictionary? parameters = null) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(subtype); + + var t = RequireToken(type, nameof(type)).ToLowerInvariant(); + var s = RequireToken(subtype, nameof(subtype)).ToLowerInvariant(); + if (t == "*" && s != "*") + { + throw new ArgumentException("A wildcard type requires a wildcard subtype.", nameof(type)); + } + + var builder = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); + if (parameters is not null) + { + foreach (var (key, value) in parameters) + { + builder[RequireToken(key, nameof(parameters)).ToLowerInvariant()] = value; + } + } + + return new MediaType(t, s, builder.ToImmutable()); + } + + /// + /// Parses a media type in type/subtype;key=value form. Quoted-string parameter values + /// are unescaped. This is the inverse of for every constructible value. + /// + /// The header value to parse. + /// The parsed . + /// is not a well-formed media type. + public static MediaType Parse(string value) + { + ArgumentNullException.ThrowIfNull(value); + var segments = SplitRespectingQuotes(value); + var typeParts = segments[0].Trim().Split('/'); + if (typeParts.Length != 2) + { + throw new ArgumentException($"Malformed media type: '{value}'.", nameof(value)); + } + + var parameters = new Dictionary(StringComparer.Ordinal); + for (var i = 1; i < segments.Count; i++) + { + var segment = segments[i].Trim(); + if (segment.Length == 0) + { + continue; + } + + var eq = segment.IndexOf('='); + if (eq < 0) + { + throw new ArgumentException($"Malformed media-type parameter: '{segment}'.", nameof(value)); + } + + var key = segment[..eq].Trim(); + var raw = segment[(eq + 1)..].Trim(); + parameters[key] = Unquote(raw); + } + + return Of(typeParts[0], typeParts[1], parameters); + } + + /// + /// True when this media type includes , treating wildcards in either + /// position as matching anything. Parameters are not considered. + /// + /// The concrete media type to test. + /// if this pattern matches . + public bool Includes(MediaType other) + { + ArgumentNullException.ThrowIfNull(other); + var typeMatches = Type == "*" || string.Equals(Type, other.Type, StringComparison.OrdinalIgnoreCase); + var subtypeMatches = Subtype == "*" || string.Equals(Subtype, other.Subtype, StringComparison.OrdinalIgnoreCase); + return typeMatches && subtypeMatches; + } + + /// + /// Returns the wire form: type/subtype followed by ;key=value for each parameter. + /// Values that are not RFC 7230 tokens are emitted as backslash-escaped quoted-strings, so + /// Parse(x.ToString()) round-trips for every constructible value. + /// + /// The serialised media type. + public override string ToString() + { + if (Parameters.Count == 0) + { + return FullType; + } + + var sb = new StringBuilder(FullType); + foreach (var (key, value) in Parameters) + { + sb.Append(';').Append(key).Append('=').Append(FormatParameterValue(value)); + } + + return sb.ToString(); + } + + /// Value equality consistent with the case-folding factories. + public bool Equals(MediaType? other) => + other is not null + && Type == other.Type + && Subtype == other.Subtype + && Parameters.Count == other.Parameters.Count + && Parameters.All(kv => other.Parameters.TryGetValue(kv.Key, out var v) && v == kv.Value); + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Type); + hash.Add(Subtype); + foreach (var (key, value) in Parameters) + { + hash.Add(key); + hash.Add(value); + } + + return hash.ToHashCode(); + } + + private static string RequireToken(string candidate, string paramName) + { + if (candidate.Length == 0) + { + throw new ArgumentException("Media-type component must not be empty.", paramName); + } + + foreach (var c in candidate) + { + if (!IsTokenChar(c) && c != '*') + { + throw new ArgumentException($"Invalid media-type token character '{c}'.", paramName); + } + } + + return candidate; + } + + private static string FormatParameterValue(string value) + { + if (value.Length > 0 && value.All(IsTokenChar)) + { + return value; + } + + var sb = new StringBuilder(value.Length + 2); + sb.Append('"'); + foreach (var c in value) + { + if (c is '"' or '\\') + { + sb.Append('\\'); + } + + sb.Append(c); + } + + sb.Append('"'); + return sb.ToString(); + } + + private static List SplitRespectingQuotes(string value) + { + var segments = new List(); + var start = 0; + var inQuotes = false; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + switch (c) + { + case '"': + inQuotes = !inQuotes; + break; + case '\\' when inQuotes && i + 1 < value.Length: + i++; // skip the escaped character + break; + case ';' when !inQuotes: + segments.Add(value[start..i]); + start = i + 1; + break; + } + } + + segments.Add(value[start..]); + return segments; + } + + private static string Unquote(string raw) + { + if (raw.Length < 2 || raw[0] != '"' || raw[^1] != '"') + { + return raw; + } + + var sb = new StringBuilder(raw.Length - 2); + for (var i = 1; i < raw.Length - 1; i++) + { + var c = raw[i]; + if (c == '\\' && i + 1 < raw.Length - 1) + { + c = raw[++i]; + } + + sb.Append(c); + } + + return sb.ToString(); + } + + // RFC 7230 §3.2.6 token characters. + private static bool IsTokenChar(char c) => + c is >= 'a' and <= 'z' + or >= 'A' and <= 'Z' + or >= '0' and <= '9' + or '!' or '#' or '$' or '%' or '&' or '\'' or '*' + or '+' or '-' or '.' or '^' or '_' or '`' or '|' or '~'; +} diff --git a/src/Dexpace.Sdk.Core/Http/Common/Method.cs b/src/Dexpace.Sdk.Core/Http/Common/Method.cs new file mode 100644 index 0000000..619a20b --- /dev/null +++ b/src/Dexpace.Sdk.Core/Http/Common/Method.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. + +namespace Dexpace.Sdk.Core.Http.Common; + +/// +/// An HTTP request method. +/// +/// +/// Modelled as an immutable value type wrapping the wire token rather than a closed +/// so that callers may use registered extension methods (WebDAV, +/// PATCH variants, vendor verbs) the SDK does not enumerate. The well-known verbs are +/// exposed as static members; arbitrary tokens go through . +/// Two methods are equal when their (case-sensitive, upper-cased) +/// tokens match, mirroring the case sensitivity HTTP assigns to method names (RFC 7231 +/// §4.1). +/// +public readonly record struct Method +{ + private Method(string name) => Name = name; + + /// The method token exactly as it appears on the wire (e.g. GET). + public string Name { get; } + + /// The GET method — safe, idempotent, no request body. + public static Method Get { get; } = new("GET"); + + /// The HEAD method — like GET but no response body. + public static Method Head { get; } = new("HEAD"); + + /// The POST method — submits an entity; neither safe nor idempotent. + public static Method Post { get; } = new("POST"); + + /// The PUT method — replaces the target resource; idempotent. + public static Method Put { get; } = new("PUT"); + + /// The PATCH method — applies a partial modification. + public static Method Patch { get; } = new("PATCH"); + + /// The DELETE method — removes the target resource; idempotent. + public static Method Delete { get; } = new("DELETE"); + + /// The OPTIONS method — describes the communication options. + public static Method Options { get; } = new("OPTIONS"); + + /// The TRACE method — performs a message loop-back test. + public static Method Trace { get; } = new("TRACE"); + + /// The CONNECT method — establishes a tunnel to the server. + public static Method Connect { get; } = new("CONNECT"); + + /// + /// Returns the for . Known verbs resolve to + /// their cached static instances; unknown tokens are accepted verbatim (after trimming and + /// upper-casing the canonical HTTP verbs). + /// + /// The method token, e.g. "GET" or a vendor verb. + /// A wrapping the token. + /// is empty or whitespace. + public static Method Of(string token) + { + ArgumentNullException.ThrowIfNull(token); + var trimmed = token.Trim(); + if (trimmed.Length == 0) + { + throw new ArgumentException("HTTP method token must not be empty.", nameof(token)); + } + + return trimmed.ToUpperInvariant() switch + { + "GET" => Get, + "HEAD" => Head, + "POST" => Post, + "PUT" => Put, + "PATCH" => Patch, + "DELETE" => Delete, + "OPTIONS" => Options, + "TRACE" => Trace, + "CONNECT" => Connect, + _ => new Method(trimmed), + }; + } + + /// + /// True when this method is defined as safe (read-only) by RFC 7231: GET, HEAD, OPTIONS, TRACE. + /// + public bool IsSafe => + Name is "GET" or "HEAD" or "OPTIONS" or "TRACE"; + + /// + /// True when repeating the request has the same effect as issuing it once (RFC 7231 §4.2.2). + /// Safe methods plus PUT and DELETE are idempotent. + /// + public bool IsIdempotent => + IsSafe || Name is "PUT" or "DELETE"; + + /// Returns . + public override string ToString() => Name; +} diff --git a/src/Dexpace.Sdk.Core/Http/Common/Protocol.cs b/src/Dexpace.Sdk.Core/Http/Common/Protocol.cs new file mode 100644 index 0000000..0c34c60 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Http/Common/Protocol.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. + +namespace Dexpace.Sdk.Core.Http.Common; + +/// +/// HTTP protocol versions the SDK can describe on a . +/// +/// +/// The wire form (returned by and consumed by +/// ) is lower-case with a slash separator, matching the +/// ALPN identifiers (http/1.1, h2-like). is a +/// marker (no formal ALPN form) used when an HTTP/2 connection is opened without a prior +/// HTTP/1.1 upgrade. +/// +public enum Protocol +{ + /// HTTP/1.0 — legacy; unlikely to be seen in practice. + Http10, + + /// HTTP/1.1 — the default for text-protocol HTTP. + Http11, + + /// HTTP/2 — multiplexed binary protocol negotiated via ALPN. + Http2, + + /// HTTP/2 opened without HTTP/1.1 upgrade (prior-knowledge mode, RFC 7540 §3.4). + H2PriorKnowledge, + + /// QUIC transport (HTTP/3 over UDP). + Quic, +} + +/// +/// Wire-form conversions for . +/// +public static class ProtocolExtensions +{ + /// Returns the canonical lower-case wire form (e.g. "http/1.1"). + /// The protocol to render. + /// The ALPN-style identifier string. + /// is not a known value. + public static string ToWireString(this Protocol protocol) => protocol switch + { + Protocol.Http10 => "http/1.0", + Protocol.Http11 => "http/1.1", + Protocol.Http2 => "http/2", + Protocol.H2PriorKnowledge => "h2_prior_knowledge", + Protocol.Quic => "quic", + _ => throw new ArgumentOutOfRangeException(nameof(protocol), protocol, "Unknown protocol."), + }; + + /// + /// Parses a protocol identifier (case-insensitively). Accepts the canonical forms emitted by + /// plus the alternative spellings HTTP/2 and HTTP/2.0. + /// + /// The identifier to parse. + /// The matching . + /// does not match a known protocol. + public static Protocol Parse(string value) + { + ArgumentNullException.ThrowIfNull(value); + return value.ToUpperInvariant() switch + { + "HTTP/1.0" => Protocol.Http10, + "HTTP/1.1" => Protocol.Http11, + "HTTP/2" or "HTTP/2.0" => Protocol.Http2, + "H2_PRIOR_KNOWLEDGE" => Protocol.H2PriorKnowledge, + "QUIC" => Protocol.Quic, + _ => throw new ArgumentException($"Unexpected protocol: {value}", nameof(value)), + }; + } +} diff --git a/src/Dexpace.Sdk.Core/Http/Request/Request.cs b/src/Dexpace.Sdk.Core/Http/Request/Request.cs new file mode 100644 index 0000000..61e5b89 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Http/Request/Request.cs @@ -0,0 +1,101 @@ +// 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.Core.Http.Request; + +/// +/// An immutable HTTP request the SDK hands to a transport. +/// +/// +/// Instances are immutable and safe to share across threads; the , when +/// present, may carry single-use stream state (see ). Use the +/// With* helpers or C# with expressions for non-destructive mutation. The +/// is compared by value as a ; no DNS resolution is performed +/// during equality. +/// +public sealed record Request +{ + /// Creates a request. Prefer or the per-method factories. + /// The HTTP method. + /// The fully-resolved, absolute target URL. + /// The request headers (defaults to empty). + /// The request body, or . + /// + /// is not an absolute http or https URI. + /// + public Request(Method method, Uri url, Headers? headers = null, RequestBody? body = null) + { + ArgumentNullException.ThrowIfNull(url); + if (!url.IsAbsoluteUri) + { + throw new ArgumentException("Request URL must be an absolute URI.", nameof(url)); + } + + if (!url.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + && !url.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"Request URL scheme must be http or https, but was '{url.Scheme}'.", nameof(url)); + } + + Method = method; + Url = url; + Headers = headers ?? Headers.Empty; + Body = body; + } + + /// The HTTP method on the wire. + public Method Method { get; init; } + + /// The fully-resolved, absolute target URL. + public Uri Url { get; init; } + + /// The request headers; may be empty but never . + public Headers Headers { get; init; } + + /// The request body, or for methods without a payload. + public RequestBody? Body { get; init; } + + /// Creates a request from a method and a string URL. + /// The HTTP method. + /// The absolute target URL. + /// The request headers, or . + /// The request body, or . + /// A new . + /// is not an absolute URI. + public static Request Create(Method method, string url, Headers? headers = null, RequestBody? body = null) + { + ArgumentNullException.ThrowIfNull(url); + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + throw new ArgumentException("Request URL must be an absolute URI.", nameof(url)); + } + + return new Request(method, uri, headers, body); + } + + /// Creates a GET request for . + /// The absolute target URL. + /// A new . + public static Request Get(string url) => Create(Method.Get, url); + + /// Creates a POST request for with the given body. + /// The absolute target URL. + /// The request body. + /// A new . + public static Request Post(string url, RequestBody body) => + Create(Method.Post, url, body: body); + + /// Returns a copy with appended under . + /// The header name. + /// The header value. + /// A new . + public Request WithHeader(string name, string value) => this with { Headers = Headers.With(name, value) }; + + /// Returns a copy with the given body. + /// The replacement body. + /// A new . + public Request WithBody(RequestBody body) => this with { Body = body }; +} diff --git a/src/Dexpace.Sdk.Core/Http/Request/RequestBody.cs b/src/Dexpace.Sdk.Core/Http/Request/RequestBody.cs new file mode 100644 index 0000000..deaeda5 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Http/Request/RequestBody.cs @@ -0,0 +1,159 @@ +// 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.Text; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Serialization; + +namespace Dexpace.Sdk.Core.Http.Request; + +/// +/// A typed abstraction over an outgoing request payload. +/// +/// +/// is the primary streaming surface; transports call it to drain the +/// body to the wire. Implementations differ on whether they can be replayed (see +/// ): byte- and string-backed bodies are replayable, while +/// stream-backed bodies are single-use and raise on a +/// second write. Call before the first send if retries are +/// needed. Use the static factories rather than subclassing for the common cases. +/// +public abstract class RequestBody +{ + /// The media type describing the payload, or if unknown. + public abstract MediaType? ContentType { get; } + + /// + /// The payload length in bytes, or -1 when not known ahead of time (the transport then + /// uses chunked transfer-encoding). + /// + public virtual long ContentLength => -1; + + /// + /// True when the body can be written more than once (required for transparent retries). + /// + public virtual bool IsReplayable => false; + + /// Writes the entire payload to . + /// The stream to write the body to. + /// A token to cancel the write. + /// A task that completes when the body has been fully written. + /// A single-use body was written more than once. + public abstract Task WriteToAsync(Stream destination, CancellationToken cancellationToken = default); + + /// + /// Returns a replayable equivalent of this body. If is already + /// , returns this instance; otherwise drains the payload into memory and + /// returns a buffered copy. + /// + /// A token to cancel the buffering. + /// A replayable . + public virtual async Task ToReplayableAsync(CancellationToken cancellationToken = default) + { + if (IsReplayable) + { + return this; + } + + using var buffer = new MemoryStream(); + await WriteToAsync(buffer, cancellationToken).ConfigureAwait(false); + return new BytesRequestBody(buffer.ToArray(), ContentType); + } + + /// Creates a replayable body from an in-memory byte buffer. + /// The payload bytes (copied defensively). + /// The media type, or . + /// A replayable . + public static RequestBody FromBytes(ReadOnlyMemory bytes, MediaType? contentType = null) => + new BytesRequestBody(bytes.ToArray(), contentType); + + /// + /// Creates a replayable body from a string. Defaults to UTF-8 and + /// with a charset parameter when no type is given. + /// + /// The text payload. + /// The media type, or for text/plain. + /// The encoding, or for UTF-8. + /// A replayable . + public static RequestBody FromString(string text, MediaType? contentType = null, Encoding? encoding = null) + { + ArgumentNullException.ThrowIfNull(text); + var enc = encoding ?? Encoding.UTF8; + var type = contentType ?? MediaType.Of( + "text", + "plain", + new Dictionary { ["charset"] = enc.WebName }); + return new BytesRequestBody(enc.GetBytes(text), type); + } + + /// + /// Creates a replayable body by serializing with . + /// + /// The value type. + /// The value to serialize. + /// The serializer. + /// The media type, or for the serde's default. + /// A replayable . + public static RequestBody FromValue(T value, ISerde serde, MediaType? contentType = null) + { + ArgumentNullException.ThrowIfNull(serde); + var buffer = new ArrayBufferWriter(); + serde.Serialize(buffer, value); + return FromBytes(buffer.WrittenMemory, contentType ?? serde.DefaultMediaType); + } + + /// + /// Creates a single-use body that streams from . The source is read + /// exactly once; call first if retries are needed. + /// + /// The stream to read the payload from. + /// The media type, or . + /// The known length, or -1 if unknown. + /// A single-use . + public static RequestBody FromStream(Stream source, MediaType? contentType = null, long contentLength = -1) + { + ArgumentNullException.ThrowIfNull(source); + return new StreamRequestBody(source, contentType, contentLength); + } + + private sealed class BytesRequestBody(byte[] bytes, MediaType? contentType) : RequestBody + { + public override MediaType? ContentType { get; } = contentType; + + public override long ContentLength => bytes.LongLength; + + public override bool IsReplayable => true; + + public override Task WriteToAsync(Stream destination, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(destination); + return destination.WriteAsync(bytes, cancellationToken).AsTask(); + } + } + + private sealed class StreamRequestBody(Stream source, MediaType? contentType, long contentLength) : RequestBody + { + private int _consumed; + + public override MediaType? ContentType { get; } = contentType; + + public override long ContentLength => contentLength; + + public override bool IsReplayable => false; + + public override async Task WriteToAsync(Stream destination, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(destination); + if (Interlocked.Exchange(ref _consumed, 1) != 0) + { + throw new StreamConsumedException( + "This request body is single-use and has already been written. " + + "Call ToReplayableAsync() before the first send if retries are needed."); + } + + await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/Dexpace.Sdk.Core/Http/Response/Response.cs b/src/Dexpace.Sdk.Core/Http/Response/Response.cs new file mode 100644 index 0000000..2cd339a --- /dev/null +++ b/src/Dexpace.Sdk.Core/Http/Response/Response.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.Http.Common; + +namespace Dexpace.Sdk.Core.Http.Response; + +/// +/// An immutable HTTP response returned by a transport. +/// +/// +/// The is not pre-buffered — callers own its lifecycle and must dispose the +/// response (which disposes the body) to release the underlying connection. The metadata +/// (, , ) is immutable and safe to +/// share, but the body carries single-use read state. +/// +public sealed class Response : IAsyncDisposable, IDisposable +{ + /// Creates a response. + /// The status code. + /// The response headers (defaults to empty). + /// The response body (defaults to an empty buffered body). + /// The negotiated protocol version (defaults to HTTP/1.1). + public Response( + Status status, + Headers? headers = null, + ResponseBody? body = null, + Protocol protocol = Protocol.Http11) + { + Status = status; + Headers = headers ?? Headers.Empty; + Body = body ?? ResponseBody.FromBytes(ReadOnlyMemory.Empty); + Protocol = protocol; + } + + /// The response status code. + public Status Status { get; } + + /// The response headers; may be empty but never . + public Headers Headers { get; } + + /// The response body; never (empty bodies are buffered). + public ResponseBody Body { get; } + + /// The negotiated protocol version. + public Protocol Protocol { get; } + + /// Shorthand for Status.IsSuccess. + public bool IsSuccess => Status.IsSuccess; + + /// + public void Dispose() + { + Body.Dispose(); + GC.SuppressFinalize(this); + } + + /// + public async ValueTask DisposeAsync() + { + await Body.DisposeAsync().ConfigureAwait(false); + GC.SuppressFinalize(this); + } +} diff --git a/src/Dexpace.Sdk.Core/Http/Response/ResponseBody.cs b/src/Dexpace.Sdk.Core/Http/Response/ResponseBody.cs new file mode 100644 index 0000000..59a1008 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Http/Response/ResponseBody.cs @@ -0,0 +1,138 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; + +namespace Dexpace.Sdk.Core.Http.Response; + +/// +/// A typed abstraction over an incoming response payload. +/// +/// +/// The body is not pre-buffered. exposes the raw stream; +/// and fully drain and then close +/// it. Reads are single-use: a second read after the stream is consumed raises +/// . Always dispose the body (directly or via the owning +/// ) to release the underlying connection. +/// +public abstract class ResponseBody : IAsyncDisposable, IDisposable +{ + /// The media type declared by the response, or if absent. + public abstract MediaType? ContentType { get; } + + /// + /// The declared length in bytes from Content-Length, or -1 when not provided. + /// + public virtual long ContentLength => -1; + + /// Opens the underlying payload stream for reading. + /// A token to cancel opening the stream. + /// The payload stream; the caller must not dispose it independently of this body. + /// The body has already been read. + public abstract Task OpenReadAsync(CancellationToken cancellationToken = default); + + /// Fully reads the payload into a byte array, then closes the stream. + /// A token to cancel the read. + /// The payload bytes. + public virtual async Task ReadAsBytesAsync(CancellationToken cancellationToken = default) + { + await using var stream = await OpenReadAsync(cancellationToken).ConfigureAwait(false); + using var buffer = new MemoryStream(); + await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false); + return buffer.ToArray(); + } + + /// + /// Fully reads the payload and decodes it as text, then closes the stream. Uses the charset from + /// when present, otherwise UTF-8. + /// + /// A token to cancel the read. + /// The decoded text. + public virtual async Task ReadAsStringAsync(CancellationToken cancellationToken = default) + { + var bytes = await ReadAsBytesAsync(cancellationToken).ConfigureAwait(false); + var encoding = ContentType?.Charset ?? Encoding.UTF8; + return encoding.GetString(bytes); + } + + /// Creates a buffered, in-memory response body (useful for tests and replay). + /// The payload bytes. + /// The media type, or . + /// A backed by the supplied bytes. + public static ResponseBody FromBytes(ReadOnlyMemory bytes, MediaType? contentType = null) => + new BytesResponseBody(bytes.ToArray(), contentType); + + /// Creates a streaming response body wrapping . + /// The payload stream (owned by the returned body). + /// The media type, or . + /// The declared length, or -1. + /// A single-use . + public static ResponseBody FromStream(Stream source, MediaType? contentType = null, long contentLength = -1) + { + ArgumentNullException.ThrowIfNull(source); + return new StreamResponseBody(source, contentType, contentLength); + } + + /// + public virtual void Dispose() => GC.SuppressFinalize(this); + + /// + public virtual ValueTask DisposeAsync() + { + Dispose(); + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; + } + + private sealed class BytesResponseBody(byte[] bytes, MediaType? contentType) : ResponseBody + { + private int _consumed; + + public override MediaType? ContentType { get; } = contentType; + + public override long ContentLength => bytes.LongLength; + + public override Task OpenReadAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _consumed, 1) != 0) + { + throw new StreamConsumedException("This response body has already been read."); + } + + return Task.FromResult(new MemoryStream(bytes, writable: false)); + } + } + + private sealed class StreamResponseBody(Stream source, MediaType? contentType, long contentLength) : ResponseBody + { + private int _consumed; + + public override MediaType? ContentType { get; } = contentType; + + public override long ContentLength => contentLength; + + public override Task OpenReadAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _consumed, 1) != 0) + { + throw new StreamConsumedException("This response body has already been read."); + } + + return Task.FromResult(source); + } + + public override void Dispose() + { + source.Dispose(); + base.Dispose(); + } + + public override async ValueTask DisposeAsync() + { + await source.DisposeAsync().ConfigureAwait(false); + await base.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/src/Dexpace.Sdk.Core/Http/Response/Status.cs b/src/Dexpace.Sdk.Core/Http/Response/Status.cs new file mode 100644 index 0000000..f6cae27 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Http/Response/Status.cs @@ -0,0 +1,143 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Http.Response; + +/// +/// An HTTP response status code with an optional human-readable name. +/// +/// +/// Two values are equal when their s are equal, so +/// Status.FromCode(200) == Status.Ok. Canonical codes carry a ; +/// unrecognised codes carry . +/// +public readonly record struct Status +{ + private Status(int code, string? name) + { + Code = code; + Name = name; + } + + /// The numeric status code as it appears on the wire. + public int Code { get; } + + /// The canonical name (e.g. OK), or for unknown codes. + public string? Name { get; } + + /// True when the code is informational (100–199). + public bool IsInformational => Code is >= 100 and <= 199; + + /// True when the code is in the 2xx success range. + public bool IsSuccess => Code is >= 200 and <= 299; + + /// True when the code is a redirect (3xx). + public bool IsRedirect => Code is >= 300 and <= 399; + + /// True when the code is a client error (4xx). + public bool IsClientError => Code is >= 400 and <= 499; + + /// True when the code is a server error (5xx). + public bool IsServerError => Code is >= 500 and <= 599; + + /// + /// Returns the for . Canonical codes resolve to a + /// cached instance with a populated ; others get a name-less instance. + /// + /// The numeric status code. + /// A wrapping the code. + public static Status FromCode(int code) => + KnownByCode.TryGetValue(code, out var known) ? known : new Status(code, null); + + /// Equality over only. + public bool Equals(Status other) => Code == other.Code; + + /// + public override int GetHashCode() => Code; + + /// Returns Name(Code) for known codes, otherwise HTTP Code. + public override string ToString() => Name is not null ? $"{Name}({Code})" : $"HTTP {Code}"; + + // Informational (1xx) + /// 100 Continue. + public static Status Continue { get; } = new(100, "CONTINUE"); + + /// 101 Switching Protocols. + public static Status SwitchingProtocols { get; } = new(101, "SWITCHING_PROTOCOLS"); + + // Success (2xx) + /// 200 OK. + public static Status Ok { get; } = new(200, "OK"); + + /// 201 Created. + public static Status Created { get; } = new(201, "CREATED"); + + /// 202 Accepted. + public static Status Accepted { get; } = new(202, "ACCEPTED"); + + /// 204 No Content. + public static Status NoContent { get; } = new(204, "NO_CONTENT"); + + /// 206 Partial Content. + public static Status PartialContent { get; } = new(206, "PARTIAL_CONTENT"); + + // Redirection (3xx) + /// 301 Moved Permanently. + public static Status MovedPermanently { get; } = new(301, "MOVED_PERMANENTLY"); + + /// 302 Found. + public static Status Found { get; } = new(302, "FOUND"); + + /// 304 Not Modified. + public static Status NotModified { get; } = new(304, "NOT_MODIFIED"); + + /// 307 Temporary Redirect. + public static Status TemporaryRedirect { get; } = new(307, "TEMPORARY_REDIRECT"); + + /// 308 Permanent Redirect. + public static Status PermanentRedirect { get; } = new(308, "PERMANENT_REDIRECT"); + + // Client error (4xx) + /// 400 Bad Request. + public static Status BadRequest { get; } = new(400, "BAD_REQUEST"); + + /// 401 Unauthorized. + public static Status Unauthorized { get; } = new(401, "UNAUTHORIZED"); + + /// 403 Forbidden. + public static Status Forbidden { get; } = new(403, "FORBIDDEN"); + + /// 404 Not Found. + public static Status NotFound { get; } = new(404, "NOT_FOUND"); + + /// 409 Conflict. + public static Status Conflict { get; } = new(409, "CONFLICT"); + + /// 412 Precondition Failed. + public static Status PreconditionFailed { get; } = new(412, "PRECONDITION_FAILED"); + + /// 429 Too Many Requests. + public static Status TooManyRequests { get; } = new(429, "TOO_MANY_REQUESTS"); + + // Server error (5xx) + /// 500 Internal Server Error. + public static Status InternalServerError { get; } = new(500, "INTERNAL_SERVER_ERROR"); + + /// 502 Bad Gateway. + public static Status BadGateway { get; } = new(502, "BAD_GATEWAY"); + + /// 503 Service Unavailable. + public static Status ServiceUnavailable { get; } = new(503, "SERVICE_UNAVAILABLE"); + + /// 504 Gateway Timeout. + public static Status GatewayTimeout { get; } = new(504, "GATEWAY_TIMEOUT"); + + private static readonly Dictionary KnownByCode = new[] + { + Continue, SwitchingProtocols, + Ok, Created, Accepted, NoContent, PartialContent, + MovedPermanently, Found, NotModified, TemporaryRedirect, PermanentRedirect, + BadRequest, Unauthorized, Forbidden, NotFound, Conflict, PreconditionFailed, TooManyRequests, + InternalServerError, BadGateway, ServiceUnavailable, GatewayTimeout, + }.ToDictionary(s => s.Code); +} diff --git a/src/Dexpace.Sdk.Core/Serialization/ISerde.cs b/src/Dexpace.Sdk.Core/Serialization/ISerde.cs new file mode 100644 index 0000000..a4b6e92 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Serialization/ISerde.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 System.Buffers; +using Dexpace.Sdk.Core.Http.Common; + +namespace Dexpace.Sdk.Core.Serialization; + +/// +/// Serializes values into, and deserializes them out of, request/response payloads. The seam is +/// generic and serializer-agnostic; implementations are responsible for resolving type metadata +/// (a System.Text.Json implementation ships separately). +/// +public interface ISerde +{ + /// The media type stamped on bodies created from values (for example, application/json). + MediaType DefaultMediaType { get; } + + /// Serializes to . + /// The value type. + /// The stream to write to. + /// The value to serialize. + /// A token to cancel the operation. + /// A task that completes when serialization finishes. + /// Serialization failed. + ValueTask SerializeAsync(Stream destination, T value, CancellationToken cancellationToken = default); + + /// Deserializes a value of type from . + /// The target type. + /// The stream to read from. + /// A token to cancel the operation. + /// The deserialized value, or . + /// Deserialization failed. + ValueTask DeserializeAsync(Stream source, CancellationToken cancellationToken = default); + + /// Serializes synchronously to . + /// The value type. + /// The buffer writer to write to. + /// The value to serialize. + /// Serialization failed. + void Serialize(IBufferWriter destination, T value); + + /// Deserializes a value of type from a UTF-8 buffer. + /// The target type. + /// The UTF-8 encoded payload. + /// The deserialized value, or . + /// Deserialization failed. + T? Deserialize(ReadOnlySpan utf8); +} diff --git a/src/Dexpace.Sdk.Core/Serialization/ResponseBodySerdeExtensions.cs b/src/Dexpace.Sdk.Core/Serialization/ResponseBodySerdeExtensions.cs new file mode 100644 index 0000000..19b568d --- /dev/null +++ b/src/Dexpace.Sdk.Core/Serialization/ResponseBodySerdeExtensions.cs @@ -0,0 +1,28 @@ +// 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.Response; + +namespace Dexpace.Sdk.Core.Serialization; + +/// Serialization conveniences over . +public static class ResponseBodySerdeExtensions +{ + /// Reads and deserializes the body as using . + /// The target type. + /// The response body (read once). + /// The serializer. + /// A token to cancel the read. + /// The deserialized value, or . + /// The body has already been read. + /// Deserialization failed. + public static async ValueTask ReadValueAsync( + this ResponseBody body, ISerde serde, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(body); + ArgumentNullException.ThrowIfNull(serde); + + await using var stream = await body.OpenReadAsync(cancellationToken).ConfigureAwait(false); + return await serde.DeserializeAsync(stream, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Dexpace.Sdk.Http.SystemNet/Dexpace.Sdk.Http.SystemNet.csproj b/src/Dexpace.Sdk.Http.SystemNet/Dexpace.Sdk.Http.SystemNet.csproj new file mode 100644 index 0000000..13b6bbb --- /dev/null +++ b/src/Dexpace.Sdk.Http.SystemNet/Dexpace.Sdk.Http.SystemNet.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + Dexpace.Sdk.Http.SystemNet + Dexpace.Sdk.Http.SystemNet + Dexpace.Sdk.Http.SystemNet + + Reference transport for the dexpace SDK: adapts System.Net.Http.HttpClient to the + Dexpace.Sdk.Core IHttpClient / IAsyncHttpClient transport SPI. + + http;sdk;transport;httpclient;dexpace + true + + + + + + + + + + + + + + + diff --git a/src/Dexpace.Sdk.Http.SystemNet/HttpResponseMessageBody.cs b/src/Dexpace.Sdk.Http.SystemNet/HttpResponseMessageBody.cs new file mode 100644 index 0000000..5261912 --- /dev/null +++ b/src/Dexpace.Sdk.Http.SystemNet/HttpResponseMessageBody.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.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Response; + +namespace Dexpace.Sdk.Http.SystemNet; + +/// +/// A backed by an 's content stream. +/// Disposing the body disposes the underlying , releasing the +/// connection back to the pool. +/// +internal sealed class HttpResponseMessageBody : ResponseBody +{ + private readonly HttpResponseMessage _message; + private int _consumed; + + public HttpResponseMessageBody(HttpResponseMessage message) + { + _message = message; + var mediaType = message.Content.Headers.ContentType?.ToString(); + ContentType = mediaType is not null ? MediaType.Parse(mediaType) : null; + ContentLength = message.Content.Headers.ContentLength ?? -1; + } + + public override MediaType? ContentType { get; } + + public override long ContentLength { get; } + + public override async Task OpenReadAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _consumed, 1) != 0) + { + throw new StreamConsumedException("This response body has already been read."); + } + + return await _message.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + } + + public override void Dispose() + { + _message.Dispose(); + base.Dispose(); + } + + public override async ValueTask DisposeAsync() + { + _message.Dispose(); + await base.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/src/Dexpace.Sdk.Http.SystemNet/RequestBodyContent.cs b/src/Dexpace.Sdk.Http.SystemNet/RequestBodyContent.cs new file mode 100644 index 0000000..ac05ea6 --- /dev/null +++ b/src/Dexpace.Sdk.Http.SystemNet/RequestBodyContent.cs @@ -0,0 +1,47 @@ +// 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.Http.Request; + +namespace Dexpace.Sdk.Http.SystemNet; + +/// +/// Adapts a to so it can be streamed by +/// without first buffering into memory. +/// +internal sealed class RequestBodyContent : HttpContent +{ + private readonly RequestBody _body; + + public RequestBodyContent(RequestBody body) + { + _body = body; + if (body.ContentType is { } contentType) + { + Headers.TryAddWithoutValidation("Content-Type", contentType.ToString()); + } + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + _body.WriteToAsync(stream); + + protected override Task SerializeToStreamAsync( + Stream stream, + TransportContext? context, + CancellationToken cancellationToken) => + _body.WriteToAsync(stream, cancellationToken); + + protected override bool TryComputeLength(out long length) + { + var declared = _body.ContentLength; + if (declared >= 0) + { + length = declared; + return true; + } + + length = 0; + return false; + } +} diff --git a/src/Dexpace.Sdk.Http.SystemNet/SystemNetHttpClient.cs b/src/Dexpace.Sdk.Http.SystemNet/SystemNetHttpClient.cs new file mode 100644 index 0000000..55c444b --- /dev/null +++ b/src/Dexpace.Sdk.Http.SystemNet/SystemNetHttpClient.cs @@ -0,0 +1,169 @@ +// 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.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using SystemHttpClient = System.Net.Http.HttpClient; + +namespace Dexpace.Sdk.Http.SystemNet; + +/// +/// A transport that adapts System.Net.Http.HttpClient to the SDK's +/// and SPIs. +/// +/// +/// +/// The response body is delivered as a live stream () +/// rather than buffered, so callers must dispose the returned to release the +/// connection. +/// +/// +/// Ownership. When constructed with a caller-supplied HttpClient the underlying client +/// is not disposed by this adapter; when constructed with the parameterless constructor the +/// adapter owns and disposes an internally created client. +/// +/// +public sealed class SystemNetHttpClient : IAsyncHttpClient, IHttpClient +{ + private readonly SystemHttpClient _client; + private readonly bool _ownsClient; + + /// Creates a transport backed by an internally owned HttpClient. + public SystemNetHttpClient() + : this(new SystemHttpClient(), ownsClient: true) + { + } + + /// + /// Creates a transport backed by a caller-supplied HttpClient. The supplied client is not + /// disposed when this adapter is disposed. + /// + /// The HTTP client to wrap. + public SystemNetHttpClient(SystemHttpClient client) + : this(client, ownsClient: false) + { + } + + private SystemNetHttpClient(SystemHttpClient client, bool ownsClient) + { + ArgumentNullException.ThrowIfNull(client); + _client = client; + _ownsClient = ownsClient; + } + + /// + public async Task ExecuteAsync(Request request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + using var message = ToHttpRequestMessage(request); + + HttpResponseMessage response; + try + { + response = await _client + .SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException ex) + { + // Cancellation not requested by the caller ⇒ the client's internal timeout fired. + throw new ServiceRequestTimeoutException("The request timed out before a response was received.", ex); + } + catch (HttpRequestException ex) + { + throw new ServiceRequestException("The request could not be sent to the server.", ex); + } + + return ToResponse(response); + } + + /// + public Response Execute(Request request) => + ExecuteAsync(request).GetAwaiter().GetResult(); + + /// + public void Dispose() + { + if (_ownsClient) + { + _client.Dispose(); + } + } + + /// + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + + private static HttpRequestMessage ToHttpRequestMessage(Request request) + { + var message = new HttpRequestMessage(new HttpMethod(request.Method.Name), request.Url); + if (request.Body is { } body) + { + message.Content = new RequestBodyContent(body); + } + + foreach (var (name, values) in request.Headers) + { + foreach (var value in values) + { + // Content-Type is owned by the content (set in RequestBodyContent); skip it here. + if (string.Equals(name, "content-type", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!message.Headers.TryAddWithoutValidation(name, value)) + { + message.Content?.Headers.TryAddWithoutValidation(name, value); + } + } + } + + return message; + } + + private static Response ToResponse(HttpResponseMessage message) + { + var headersBuilder = new Headers.Builder(); + foreach (var (name, values) in message.Headers) + { + foreach (var value in values) + { + headersBuilder.Add(name, value); + } + } + + foreach (var (name, values) in message.Content.Headers) + { + foreach (var value in values) + { + headersBuilder.Add(name, value); + } + } + + return new Response( + Status.FromCode((int)message.StatusCode), + headersBuilder.Build(), + new HttpResponseMessageBody(message), + MapProtocol(message.Version)); + } + + private static Protocol MapProtocol(Version version) => version switch + { + { Major: 1, Minor: 0 } => Protocol.Http10, + { Major: 1, Minor: 1 } => Protocol.Http11, + { Major: 2 } => Protocol.Http2, + { Major: 3 } => Protocol.Quic, + _ => Protocol.Http11, + }; +} diff --git a/src/Dexpace.Sdk.Serialization.SystemTextJson/Dexpace.Sdk.Serialization.SystemTextJson.csproj b/src/Dexpace.Sdk.Serialization.SystemTextJson/Dexpace.Sdk.Serialization.SystemTextJson.csproj new file mode 100644 index 0000000..f8e8548 --- /dev/null +++ b/src/Dexpace.Sdk.Serialization.SystemTextJson/Dexpace.Sdk.Serialization.SystemTextJson.csproj @@ -0,0 +1,29 @@ + + + + net8.0;net10.0 + Dexpace.Sdk.Serialization.SystemTextJson + Dexpace.Sdk.Serialization.SystemTextJson + Dexpace.Sdk.Serialization.SystemTextJson + + System.Text.Json implementation of the Dexpace.Sdk.Core ISerde serialization seam, + built on source-generated JsonSerializerContext metadata for trim- and AOT-safety. + + http;sdk;json;serialization;dexpace + true + true + + + + + + + + + + + + + + + diff --git a/src/Dexpace.Sdk.Serialization.SystemTextJson/SystemTextJsonSerde.cs b/src/Dexpace.Sdk.Serialization.SystemTextJson/SystemTextJsonSerde.cs new file mode 100644 index 0000000..59441b5 --- /dev/null +++ b/src/Dexpace.Sdk.Serialization.SystemTextJson/SystemTextJsonSerde.cs @@ -0,0 +1,141 @@ +// 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.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Serialization; + +namespace Dexpace.Sdk.Serialization.SystemTextJson; + +/// +/// A implementation backed by System.Text.Json. Type metadata is resolved from +/// a source-generated , keeping serialization trim- and +/// NativeAOT-safe with no runtime reflection. +/// +public sealed class SystemTextJsonSerde : ISerde +{ + private readonly JsonSerializerOptions _options; + + /// Initializes a new instance from explicit options. + /// + /// Options whose is set (typically a + /// source-generated ). The supplied options are made + /// read-only by this constructor. The guard only verifies that a + /// is present; AOT-safety holds only when that resolver is a source-generated + /// — use the + /// constructor to make this explicit. + /// + /// The options have no type-info resolver. + public SystemTextJsonSerde(JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + if (options.TypeInfoResolver is null) + { + throw new ArgumentException( + "The JsonSerializerOptions must have a TypeInfoResolver (for example, a source-generated " + + "JsonSerializerContext) for AOT-safe serialization.", + nameof(options)); + } + + options.MakeReadOnly(); + _options = options; + } + + /// Initializes a new instance from a source-generated context. + /// The source-generated serializer context. + public SystemTextJsonSerde(JsonSerializerContext context) + : this((context ?? throw new ArgumentNullException(nameof(context))).Options) + { + } + + /// + public MediaType DefaultMediaType => CommonMediaTypes.ApplicationJsonUtf8; + + /// + public async ValueTask SerializeAsync(Stream destination, T value, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(destination); + var info = GetTypeInfo(forSerialize: true); + try + { + await JsonSerializer.SerializeAsync(destination, value, info, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is JsonException or NotSupportedException) + { + throw new SerializationException($"Failed to serialize '{typeof(T)}' to JSON.", ex); + } + } + + /// + public async ValueTask DeserializeAsync(Stream source, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); + var info = GetTypeInfo(forSerialize: false); + try + { + return await JsonSerializer.DeserializeAsync(source, info, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is JsonException or NotSupportedException) + { + throw new DeserializationException($"Failed to deserialize JSON to '{typeof(T)}'.", ex); + } + } + + /// + public void Serialize(IBufferWriter destination, T value) + { + ArgumentNullException.ThrowIfNull(destination); + var info = GetTypeInfo(forSerialize: true); + using var writer = new Utf8JsonWriter(destination); + try + { + JsonSerializer.Serialize(writer, value, info); + } + catch (Exception ex) when (ex is JsonException or NotSupportedException or InvalidOperationException) + { + throw new SerializationException($"Failed to serialize '{typeof(T)}' to JSON.", ex); + } + } + + /// + public T? Deserialize(ReadOnlySpan utf8) + { + var info = GetTypeInfo(forSerialize: false); + try + { + return JsonSerializer.Deserialize(utf8, info); + } + catch (Exception ex) when (ex is JsonException or NotSupportedException) + { + throw new DeserializationException($"Failed to deserialize JSON to '{typeof(T)}'.", ex); + } + } + + private JsonTypeInfo GetTypeInfo(bool forSerialize) + { + try + { + if (_options.GetTypeInfo(typeof(T)) is JsonTypeInfo info) + { + return info; + } + } + catch (NotSupportedException) + { + // Source-generated contexts throw NotSupportedException for unregistered types + // instead of returning null; map to the SDK exception type below. + } + + return forSerialize + ? throw new SerializationException(TypeInfoMessage()) + : throw new DeserializationException(TypeInfoMessage()); + } + + private static string TypeInfoMessage() => + $"No JsonTypeInfo is registered for '{typeof(T)}'. Add it to a source-generated " + + "JsonSerializerContext supplied to SystemTextJsonSerde."; +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj b/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj new file mode 100644 index 0000000..df2e140 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0;net10.0 + Dexpace.Sdk.Core.Tests + false + true + + false + $(NoWarn);CS1591 + + + + + + + + + + + + + + + diff --git a/tests/Dexpace.Sdk.Core.Tests/Http/BodiesAndRequestTests.cs b/tests/Dexpace.Sdk.Core.Tests/Http/BodiesAndRequestTests.cs new file mode 100644 index 0000000..abf36da --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Http/BodiesAndRequestTests.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 System.Text; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Http; + +public class BodiesAndRequestTests +{ + [Fact] + public async Task RequestBody_FromString_WritesUtf8AndIsReplayable() + { + var body = RequestBody.FromString("héllo"); + Assert.True(body.IsReplayable); + + using var first = new MemoryStream(); + await body.WriteToAsync(first); + using var second = new MemoryStream(); + await body.WriteToAsync(second); + + Assert.Equal(Encoding.UTF8.GetBytes("héllo"), first.ToArray()); + Assert.Equal(first.ToArray(), second.ToArray()); + } + + [Fact] + public async Task RequestBody_FromStream_IsSingleUse() + { + var body = RequestBody.FromStream(new MemoryStream(Encoding.UTF8.GetBytes("data"))); + using var sink = new MemoryStream(); + await body.WriteToAsync(sink); + + await Assert.ThrowsAsync( + () => body.WriteToAsync(new MemoryStream())); + } + + [Fact] + public async Task RequestBody_ToReplayable_BuffersSingleUseStream() + { + var body = RequestBody.FromStream(new MemoryStream(Encoding.UTF8.GetBytes("data"))); + var replayable = await body.ToReplayableAsync(); + Assert.True(replayable.IsReplayable); + + using var a = new MemoryStream(); + await replayable.WriteToAsync(a); + using var b = new MemoryStream(); + await replayable.WriteToAsync(b); + Assert.Equal(a.ToArray(), b.ToArray()); + } + + [Fact] + public async Task ResponseBody_ReadAsString_UsesContentTypeCharset() + { + var body = ResponseBody.FromBytes( + Encoding.UTF8.GetBytes("{\"ok\":true}"), + CommonMediaTypes.ApplicationJsonUtf8); + Assert.Equal("{\"ok\":true}", await body.ReadAsStringAsync()); + } + + [Fact] + public async Task ResponseBody_SecondReadThrows() + { + var body = ResponseBody.FromBytes(Encoding.UTF8.GetBytes("x")); + _ = await body.ReadAsBytesAsync(); + await Assert.ThrowsAsync(() => body.OpenReadAsync()); + } + + [Fact] + public void Request_RejectsRelativeUrl() + { + Assert.Throws(() => Request.Create(Method.Get, "/relative")); + } + + [Fact] + public void Request_WithHelpers_AreNonDestructive() + { + var original = Request.Get("https://example.test/items"); + var modified = original.WithHeader("Accept", "application/json"); + Assert.False(original.Headers.Contains("Accept")); + Assert.Equal("application/json", modified.Headers.Get("accept")); + Assert.Equal(Method.Get, modified.Method); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Http/Common/HeadersTests.cs b/tests/Dexpace.Sdk.Core.Tests/Http/Common/HeadersTests.cs new file mode 100644 index 0000000..89ddf59 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Http/Common/HeadersTests.cs @@ -0,0 +1,66 @@ +// 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 Xunit; + +namespace Dexpace.Sdk.Core.Tests.Http.Common; + +public class HeadersTests +{ + [Fact] + public void Get_IsCaseInsensitive() + { + var headers = Headers.Empty.Set("Content-Type", "application/json"); + Assert.Equal("application/json", headers.Get("content-type")); + Assert.Equal("application/json", headers.Get("CONTENT-TYPE")); + Assert.True(headers.Contains("Content-Type")); + } + + [Fact] + public void With_AppendsMultipleValues() + { + var headers = Headers.Empty + .With("Accept", "text/plain") + .With("Accept", "application/json"); + Assert.Equal((string[])["text/plain", "application/json"], headers.GetAll("accept")); + } + + [Fact] + public void Set_ReplacesExistingValues() + { + var headers = Headers.Empty + .With("X-Test", "one") + .With("X-Test", "two") + .Set("X-Test", "final"); + Assert.Equal((string[])["final"], headers.GetAll("x-test")); + } + + [Fact] + public void Mutation_IsNonDestructive() + { + var original = Headers.Empty.Set("A", "1"); + var modified = original.With("A", "2"); + Assert.Single(original.GetAll("a")); + Assert.Equal(2, modified.GetAll("a").Count); + } + + [Fact] + public void Without_RemovesName() + { + var headers = Headers.Empty.Set("A", "1").Without("a"); + Assert.False(headers.Contains("A")); + } + + [Fact] + public void Builder_BatchesEdits() + { + var headers = new Headers.Builder() + .Add("A", "1") + .Add("A", "2") + .Set("B", "x") + .Build(); + Assert.Equal((string[])["1", "2"], headers.GetAll("a")); + Assert.Equal("x", headers.Get("b")); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Http/Common/MediaTypeTests.cs b/tests/Dexpace.Sdk.Core.Tests/Http/Common/MediaTypeTests.cs new file mode 100644 index 0000000..696ae9b --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Http/Common/MediaTypeTests.cs @@ -0,0 +1,63 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text; +using Dexpace.Sdk.Core.Http.Common; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Http.Common; + +public class MediaTypeTests +{ + [Fact] + public void Of_LowerCasesTypeAndSubtype() + { + var mt = MediaType.Of("Application", "JSON"); + Assert.Equal("application", mt.Type); + Assert.Equal("json", mt.Subtype); + Assert.Equal("application/json", mt.FullType); + } + + [Fact] + public void Parse_ReadsParametersAndCharset() + { + var mt = MediaType.Parse("text/plain; charset=UTF-8"); + Assert.Equal("text", mt.Type); + Assert.Equal("plain", mt.Subtype); + Assert.Equal("UTF-8", mt.Parameters["charset"]); + Assert.Equal(Encoding.UTF8, mt.Charset); + } + + [Fact] + public void ToString_RoundTripsQuotedBoundaryValue() + { + var mt = MediaType.Of( + "multipart", + "form-data", + new Dictionary { ["boundary"] = "a;b" }); + var roundTripped = MediaType.Parse(mt.ToString()); + Assert.Equal(mt, roundTripped); + Assert.Equal("a;b", roundTripped.Parameters["boundary"]); + } + + [Fact] + public void Includes_HonoursWildcards() + { + var wildcard = MediaType.Of("application", "*"); + Assert.True(wildcard.Includes(CommonMediaTypes.ApplicationJson)); + Assert.False(wildcard.Includes(CommonMediaTypes.TextPlain)); + } + + [Fact] + public void Equality_IsCaseInsensitiveOnTypeAndSubtype() + { + Assert.Equal(MediaType.Parse("Application/Json"), CommonMediaTypes.ApplicationJson); + } + + [Fact] + public void Charset_ReturnsNullForUnknownEncoding() + { + var mt = MediaType.Parse("text/plain; charset=not-a-real-charset"); + Assert.Null(mt.Charset); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Http/Common/MethodAndStatusTests.cs b/tests/Dexpace.Sdk.Core.Tests/Http/Common/MethodAndStatusTests.cs new file mode 100644 index 0000000..1b414df --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Http/Common/MethodAndStatusTests.cs @@ -0,0 +1,65 @@ +// 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.Http.Response; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Http.Common; + +public class MethodAndStatusTests +{ + [Theory] + [InlineData("get", "GET")] + [InlineData(" Post ", "POST")] + public void Method_Of_NormalisesKnownVerbs(string input, string expected) + { + Assert.Equal(expected, Method.Of(input).Name); + } + + [Fact] + public void Method_Of_PreservesUnknownVerb() + { + Assert.Equal("PROPFIND", Method.Of("PROPFIND").Name); + } + + [Fact] + public void Method_SafetyAndIdempotency() + { + Assert.True(Method.Get.IsSafe); + Assert.True(Method.Get.IsIdempotent); + Assert.False(Method.Post.IsSafe); + Assert.False(Method.Post.IsIdempotent); + Assert.True(Method.Put.IsIdempotent); + } + + [Fact] + public void Status_FromCode_ResolvesKnownAndUnknown() + { + Assert.Equal(Status.Ok, Status.FromCode(200)); + Assert.Equal("OK", Status.FromCode(200).Name); + Assert.Null(Status.FromCode(799).Name); + } + + [Fact] + public void Status_RangeHelpers() + { + Assert.True(Status.FromCode(204).IsSuccess); + Assert.True(Status.FromCode(301).IsRedirect); + Assert.True(Status.FromCode(404).IsClientError); + Assert.True(Status.FromCode(503).IsServerError); + } + + [Fact] + public void Status_EqualityIsByCode() + { + Assert.Equal(Status.NotFound, Status.FromCode(404)); + } + + [Fact] + public void Protocol_ParseAndWireRoundTrip() + { + Assert.Equal(Protocol.Http2, ProtocolExtensions.Parse("HTTP/2.0")); + Assert.Equal("http/1.1", Protocol.Http11.ToWireString()); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Transport/SystemNetHttpClientTests.cs b/tests/Dexpace.Sdk.Core.Tests/Transport/SystemNetHttpClientTests.cs new file mode 100644 index 0000000..ef28866 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Transport/SystemNetHttpClientTests.cs @@ -0,0 +1,79 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Net; +using System.Text; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Http.SystemNet; +using Xunit; +using SystemHttpClient = System.Net.Http.HttpClient; + +namespace Dexpace.Sdk.Core.Tests.Transport; + +public class SystemNetHttpClientTests +{ + [Fact] + public async Task ExecuteAsync_MapsStatusHeadersAndBody() + { + var handler = new StubHandler(request => + { + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal("https://example.test/ping", request.RequestUri!.ToString()); + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("pong", Encoding.UTF8, "text/plain"), + }; + response.Headers.TryAddWithoutValidation("X-Trace", "abc123"); + return response; + }); + + await using var transport = new SystemNetHttpClient(new SystemHttpClient(handler)); + await using var response = await transport.ExecuteAsync(Request.Get("https://example.test/ping")); + + Assert.Equal(Status.Ok, response.Status); + Assert.Equal("abc123", response.Headers.Get("x-trace")); + Assert.Equal("text", response.Body.ContentType!.Type); + Assert.Equal("pong", await response.Body.ReadAsStringAsync()); + } + + [Fact] + public async Task ExecuteAsync_SendsRequestBody() + { + string? observed = null; + var handler = new StubHandler(request => + { + observed = request.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + return new HttpResponseMessage(HttpStatusCode.Created); + }); + + await using var transport = new SystemNetHttpClient(new SystemHttpClient(handler)); + var request = Request.Post( + "https://example.test/items", + RequestBody.FromString("{\"name\":\"widget\"}", CommonMediaTypes.ApplicationJson)); + await using var response = await transport.ExecuteAsync(request); + + Assert.Equal(Status.Created, response.Status); + Assert.Equal("{\"name\":\"widget\"}", observed); + } + + [Fact] + public async Task ExecuteAsync_WrapsTransportFailure() + { + var handler = new StubHandler(_ => throw new HttpRequestException("boom")); + await using var transport = new SystemNetHttpClient(new SystemHttpClient(handler)); + + await Assert.ThrowsAsync( + () => transport.ExecuteAsync(Request.Get("https://example.test/x"))); + } + + private sealed class StubHandler(Func responder) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) => + Task.FromResult(responder(request)); + } +} diff --git a/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/BodyConvenienceTests.cs b/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/BodyConvenienceTests.cs new file mode 100644 index 0000000..7476ab5 --- /dev/null +++ b/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/BodyConvenienceTests.cs @@ -0,0 +1,80 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Serialization; +using Xunit; + +namespace Dexpace.Sdk.Serialization.SystemTextJson.Tests; + +public sealed class BodyConvenienceTests +{ + private static SystemTextJsonSerde Serde() => new(TestJsonContext.Default); + + [Fact] + public async Task FromValue_produces_replayable_json_body() + { + var body = RequestBody.FromValue(new Widget("gear", 9), Serde()); + + Assert.True(body.IsReplayable); + Assert.Equal(CommonMediaTypes.ApplicationJsonUtf8, body.ContentType); + + using var first = new MemoryStream(); + await body.WriteToAsync(first); + using var second = new MemoryStream(); + await body.WriteToAsync(second); + Assert.Equal(first.ToArray(), second.ToArray()); + } + + [Fact] + public async Task ReadValueAsync_deserializes_the_body() + { + var json = Encoding.UTF8.GetBytes("""{"Name":"bolt","Size":3}"""); + var body = ResponseBody.FromBytes(json, CommonMediaTypes.ApplicationJson); + + var widget = await body.ReadValueAsync(Serde()); + + Assert.Equal(new Widget("bolt", 3), widget); + } + + [Fact] + public async Task ReadValueAsync_is_single_use() + { + var json = Encoding.UTF8.GetBytes("""{"Name":"bolt","Size":3}"""); + var body = ResponseBody.FromBytes(json, CommonMediaTypes.ApplicationJson); + + await body.ReadValueAsync(Serde()); + await Assert.ThrowsAsync( + async () => await body.ReadValueAsync(Serde())); + } + + [Fact] + public async Task GetErrorAsync_deserializes_the_buffered_error_body() + { + var json = Encoding.UTF8.GetBytes("""{"Code":"rate_limited","Message":"slow down"}"""); + var response = new Response(Status.TooManyRequests, Headers.Empty, + ResponseBody.FromBytes(json, CommonMediaTypes.ApplicationJson)); + var ex = new HttpResponseException(response); + + var error = await ex.GetErrorAsync(Serde()); + + Assert.Equal(new ApiError("rate_limited", "slow down"), error); + } + + [Fact] + public async Task GetErrorAsync_throws_when_body_already_consumed() + { + var json = Encoding.UTF8.GetBytes("""{"Code":"x","Message":"y"}"""); + var response = new Response(Status.BadRequest, Headers.Empty, + ResponseBody.FromBytes(json, CommonMediaTypes.ApplicationJson)); + var ex = new HttpResponseException(response); + + await ex.GetErrorAsync(Serde()); + await Assert.ThrowsAsync( + async () => await ex.GetErrorAsync(Serde())); + } +} diff --git a/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests.csproj b/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests.csproj new file mode 100644 index 0000000..c6971dc --- /dev/null +++ b/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests.csproj @@ -0,0 +1,24 @@ + + + + net8.0;net10.0 + Dexpace.Sdk.Serialization.SystemTextJson.Tests + false + true + false + $(NoWarn);CS1591 + + + + + + + + + + + + + + + diff --git a/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/SystemTextJsonSerdeTests.cs b/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/SystemTextJsonSerdeTests.cs new file mode 100644 index 0000000..30161a2 --- /dev/null +++ b/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/SystemTextJsonSerdeTests.cs @@ -0,0 +1,102 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Buffers; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Xunit; + +namespace Dexpace.Sdk.Serialization.SystemTextJson.Tests; + +public sealed class SystemTextJsonSerdeTests +{ + private static SystemTextJsonSerde Serde() => new(TestJsonContext.Default); + + [Fact] + public async Task SerializeAsync_then_DeserializeAsync_round_trips() + { + var serde = Serde(); + var widget = new Widget("gizmo", 42); + + using var stream = new MemoryStream(); + await serde.SerializeAsync(stream, widget); + stream.Position = 0; + var result = await serde.DeserializeAsync(stream); + + Assert.Equal(widget, result); + } + + [Fact] + public void DefaultMediaType_is_application_json_utf8() + { + Assert.Equal(CommonMediaTypes.ApplicationJsonUtf8, Serde().DefaultMediaType); + } + + [Fact] + public void Serialize_then_Deserialize_sync_round_trips() + { + var serde = Serde(); + var widget = new Widget("sprocket", 7); + + var buffer = new ArrayBufferWriter(); + serde.Serialize(buffer, widget); + var result = serde.Deserialize(buffer.WrittenSpan); + + Assert.Equal(widget, result); + } + + private sealed record Unregistered(string Value); + + [Fact] + public void Deserialize_unknown_type_throws_DeserializationException() + { + var serde = Serde(); + var ex = Assert.Throws( + () => serde.Deserialize("{}"u8)); + Assert.Contains("Unregistered", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void Deserialize_malformed_json_throws_DeserializationException() + { + var serde = Serde(); + Assert.Throws(() => serde.Deserialize("{ not json"u8)); + } + + // --- Widened catch tests --- + + [Fact] + public void Serialize_reference_cycle_throws_SerializationException() + { + // JsonSerializer throws JsonException for reference cycles; the widened catch maps it. + var serde = Serde(); + var n = new Node(); + n.Next = n; + + var buffer = new ArrayBufferWriter(); + Assert.Throws(() => serde.Serialize(buffer, n)); + } + + [Fact] + public async Task SerializeAsync_reference_cycle_throws_SerializationException() + { + var serde = Serde(); + var n = new Node(); + n.Next = n; + + using var stream = new MemoryStream(); + await Assert.ThrowsAsync(() => serde.SerializeAsync(stream, n).AsTask()); + } + + [Fact] + public async Task DeserializeAsync_cancelled_token_propagates_OperationCanceledException() + { + // Cancellation must NOT be swallowed by the widened catch and re-thrown as DeserializationException. + var serde = Serde(); + using var stream = new MemoryStream("{\"Name\":\"x\",\"Size\":1}"u8.ToArray()); + var cancelled = new CancellationToken(canceled: true); + + await Assert.ThrowsAnyAsync( + () => serde.DeserializeAsync(stream, cancelled).AsTask()); + } +} diff --git a/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/TestModels.cs b/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/TestModels.cs new file mode 100644 index 0000000..45b7e4b --- /dev/null +++ b/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/TestModels.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 System.Text.Json.Serialization; + +namespace Dexpace.Sdk.Serialization.SystemTextJson.Tests; + +public sealed record Widget(string Name, int Size); + +public sealed record ApiError(string Code, string Message); + +/// A linked-list node used to test reference-cycle serialization failures. +public sealed class Node +{ + public Node? Next { get; set; } +} + +[JsonSerializable(typeof(Widget))] +[JsonSerializable(typeof(ApiError))] +[JsonSerializable(typeof(Node))] +internal sealed partial class TestJsonContext : JsonSerializerContext;