diff --git a/README.md b/README.md index 7bc5919..d102794 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,99 @@ Apps targeting modern TFMs (such as .NET 5 and later) already include `System.Te FluentHttpClient is available on [NuGet.org](https://www.nuget.org/packages/FluentHttpClient/) and can be installed using a NuGet package manager or the .NET CLI. +## When to Use FluentHttpClient + +While `HttpClient` is a powerful and flexible tool, building HTTP requests with it often involves repetitive boilerplate, manual serialization, and scattered configuration logic. FluentHttpClient addresses these pain points by providing a fluent, chainable API that reduces cognitive load and improves code readability. + +### Common HttpClient Challenges + +**Repetitive Configuration** +Every request requires manually setting headers, query parameters, and content, often scattered across multiple lines. This makes it easy to miss required headers or forget encoding rules. + +**Manual Serialization** +Converting objects to JSON, setting the correct `Content-Type`, and deserializing responses requires multiple steps and imports. Error-prone encoding and parsing logic often needs to be duplicated across your codebase. + +**Inconsistent Error Handling** +Without a unified approach to handling success and failure responses, status code checks and logging logic tend to be duplicated or omitted entirely. + +**Lifetime and Reuse Concerns** +Properly managing `HttpClient` lifetime, avoiding socket exhaustion, and reusing instances while still configuring per-request state requires careful planning and often leads to awkward patterns. + +### How FluentHttpClient Helps + +FluentHttpClient wraps `HttpClient` (you still manage the lifetime) and provides extension methods that let you configure requests in a single, readable chain: + +- **Fluent Configuration**: Add headers, query parameters, cookies, and authentication in a natural, discoverable flow +- **Automatic Serialization**: Built-in JSON and XML serialization/deserialization with support for `System.Text.Json`, Native AOT, and custom options +- **Response Handlers**: Attach success and failure callbacks directly in the request chain without breaking fluency +- **Reduced Boilerplate**: Express the entire request lifecycle—configuration, sending, and deserialization—in a single expression + +### Side-by-Side Comparison + +Here's the same request implemented with raw `HttpClient` and FluentHttpClient: + +#### Raw HttpClient + +```csharp +using System.Net.Http.Json; + +var client = new HttpClient +{ + BaseAddress = new Uri("https://jsonplaceholder.typicode.com") +}; + +var request = new HttpRequestMessage(HttpMethod.Get, "/posts/1"); +request.Headers.Add("X-Correlation-Id", correlationId); + +var response = await client.SendAsync(request); + +Post? post = null; +if (response.IsSuccessStatusCode) +{ + post = await response.Content.ReadFromJsonAsync(); + Console.WriteLine($"Success: {response.StatusCode}"); +} +else +{ + Console.WriteLine($"Failed: {response.StatusCode}"); +} + +public class Post +{ + public int Id { get; set; } + public string? Title { get; set; } + public string? Body { get; set; } +} +``` + +#### FluentHttpClient + +```csharp +using FluentHttpClient; + +var client = new HttpClient +{ + BaseAddress = new Uri("https://jsonplaceholder.typicode.com") +}; + +var post = await client + .UsingRoute("/posts/1") + .WithHeader("X-Correlation-Id", correlationId) + .GetAsync() + .OnSuccess(r => Console.WriteLine($"Success: {r.StatusCode}")) + .OnFailure(r => Console.WriteLine($"Failed: {r.StatusCode}")) + .ReadJsonAsync(); + +public class Post +{ + public int Id { get; set; } + public string? Title { get; set; } + public string? Body { get; set; } +} +``` + +The FluentHttpClient version expresses the same logic in fewer lines, with better readability and no loss of functionality. All configuration, sending, error handling, and deserialization happen in a single fluent chain. + ## Usage and Support - Check out the project documentation https://scottoffen.github.io/fluenthttpclient. diff --git a/docs/docs/configure-cookies.md b/docs/docs/configure-cookies.md index 52b3e86..399a8ac 100644 --- a/docs/docs/configure-cookies.md +++ b/docs/docs/configure-cookies.md @@ -17,8 +17,42 @@ var builder = client * Throws if `name` is `null`, empty, or whitespace. * `null` values are converted to an empty string. +* Cookie values are **automatically URL-encoded** by default using RFC 6265 encoding. * Existing cookies with the same name are overwritten. +### Controlling Encoding + +By default, cookie values are URL-encoded to ensure special characters (such as `;`, `=`, `,`, and whitespace) do not break the Cookie header format. This is recommended for most use cases. + +```csharp +// Default behavior - value is URL-encoded +var builder = client + .UsingBase() + .WithCookie("session", "value with spaces"); + +// Explicit encoding control +var builder = client + .UsingBase() + .WithCookie("session", "value with spaces", encode: true); + +// Disable encoding (use with caution) +var preEncodedValue = Uri.EscapeDataString("my value"); +var builder = client + .UsingBase() + .WithCookie("session", preEncodedValue, encode: false); +``` + +:::caution When to disable encoding + +Set `encode` to `false` only if: +* The value is already properly encoded +* You need to preserve exact byte sequences for legacy systems +* You are certain the value contains no special characters + +Disabling encoding with raw special characters can produce malformed Cookie headers. + +::: + ## Add Multiple Cookies Use `WithCookies` to attach multiple cookies in one call. @@ -39,12 +73,28 @@ var builder = client * Throws if any cookie name is `null`, empty, or whitespace. * Adds or overwrites existing entries. * `null` values are stored as empty strings. +* Cookie values are **automatically URL-encoded** by default. -:::note +### Controlling Encoding for Multiple Cookies -Unlike query parameters or headers, cookies do not support multiple values per name. +The `encode` parameter applies to all cookies in the collection: -::: +```csharp +// Default behavior - all values are URL-encoded +var builder = client + .UsingBase() + .WithCookies(cookies); + +// Explicit encoding control +var builder = client + .UsingBase() + .WithCookies(cookies, encode: true); + +// Disable encoding for all cookies +var builder = client + .UsingBase() + .WithCookies(preEncodedCookies, encode: false); +``` ## Behavior Notes @@ -52,13 +102,27 @@ Unlike query parameters or headers, cookies do not support multiple values per n * Assigning the same cookie name overwrites the previous value. * Cookies are applied to the final request using a standard `Cookie` header. * Whitespace-only names are treated as invalid. -* Values are stored exactly as provided (aside from null becoming an empty string). +* **Values are URL-encoded by default** using `Uri.EscapeDataString` ([RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265) compliance). +* When `encode` is `false`, values are stored exactly as provided (aside from `null` becoming an empty string). +* The encoding option defaults to `true` for safety and RFC compliance. +* Multiple cookies are assembled into a single `Cookie` header using semicolon separators. + +:::note + +Unlike query parameters or headers, cookies do not support multiple values per name. + +::: --- ## Quick Reference -| Method | Purpose | -| ------------------------------------------------------- | ----------------------------------- | -| `WithCookie(string, string)` | Adds or overwrites a single cookie. | -| `WithCookies(IEnumerable>)` | Add multiple cookies at once. | +| Method | Purpose | +| ------------------------------------------------------------- | ----------------------------------- | +| `WithCookie(string name, string value, bool encode = true)` | Adds or overwrites a single cookie. | +| `WithCookies(IEnumerable>, bool encode = true)` | Add multiple cookies at once. | + +**Parameters:** +* `name` - The cookie name (required, cannot be null/empty/whitespace) +* `value` - The cookie value (null becomes empty string) +* `encode` - Whether to URL-encode the value (default: `true`) diff --git a/docs/docs/configure-headers.md b/docs/docs/configure-headers.md index d08b41d..8743768 100644 --- a/docs/docs/configure-headers.md +++ b/docs/docs/configure-headers.md @@ -3,7 +3,10 @@ sidebar_position: 2 title: Configure Headers --- -FluentHttpClient lets you configure request headers directly on `HttpRequestBuilder`. All header methods add *deferred configurators* to the builder, so headers are applied when the `HttpRequestMessage` is finally built, not when you call the fluent methods. +FluentHttpClient lets you configure request headers directly on `HttpRequestBuilder`. Header configuration uses two approaches: + +* **String headers** (`WithHeader`, `WithHeaders`) are validated and stored immediately when called, providing fast fail-fast validation for common header scenarios. +* **Typed headers** (`WithAuthentication`, `WithBasicAuthentication`, `WithOAuthBearerToken`, `ConfigureHeaders`) use deferred configurators that are applied when the `HttpRequestMessage` is built, allowing strongly-typed header configuration with complex types like `CacheControl` and `Authorization`. ## Adding Single Headers @@ -83,11 +86,110 @@ Use the bulk overloads when you already have headers in a collection (e.g. from ::: +## Typed Headers + +For headers that require strongly-typed values (such as `Authorization`, `CacheControl`, `Accept`, `IfModifiedSince`, `IfNoneMatch`, and others), use `ConfigureHeaders` to configure them directly through the `HttpRequestHeaders` API. + +### Using ConfigureHeaders + +```csharp +var builder = client + .UsingBase() + .ConfigureHeaders(headers => + { + headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + }); +``` + +* Provides direct access to the `HttpRequestHeaders` collection. +* Supports all strongly-typed header properties available on `HttpRequestHeaders`. +* Throws if `configure` action is `null`. +* Configuration is applied when the request is built (deferred execution). + +### Complex Headers + +`ConfigureHeaders` is ideal for headers with multiple properties, quality values, or complex structures: + +#### Cache Control + +```csharp +builder.ConfigureHeaders(headers => +{ + headers.CacheControl = new CacheControlHeaderValue + { + NoCache = true, + NoStore = true, + MaxAge = TimeSpan.FromSeconds(30) + }; +}); +``` + +#### Accept with Quality Values + +```csharp +builder.ConfigureHeaders(headers => +{ + headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml", 0.9)); + headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain", 0.8)); +}); +``` + +#### Conditional Request Headers + +```csharp +builder.ConfigureHeaders(headers => +{ + headers.IfModifiedSince = lastModified; + headers.IfNoneMatch.Add(new EntityTagHeaderValue("\"12345\"")); +}); +``` + +### Accumulation Behavior + +Multiple calls to `ConfigureHeaders` accumulate - each configurator is stored and executed in order when the request is built: + +```csharp +builder + .ConfigureHeaders(headers => + headers.Authorization = new AuthenticationHeaderValue("Bearer", token)) + .ConfigureHeaders(headers => + headers.CacheControl = new CacheControlHeaderValue { NoCache = true }) + .ConfigureHeaders(headers => + headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"))); + +// All three configurators will be applied +``` + +If multiple configurators set the same header property, the last one wins (for single-value headers like `Authorization`). For collection-based headers (like `Accept`), all values accumulate unless a configurator explicitly clears the collection. + +### When to Use ConfigureHeaders + +Use `ConfigureHeaders` when you need: + +* **Strongly-typed headers** like `Authorization`, `CacheControl`, `Accept`, `IfModifiedSince` +* **Headers with quality values** (e.g., `Accept: application/json;q=0.9`) +* **Headers with multiple properties** (e.g., `CacheControl` with NoCache, NoStore, MaxAge) +* **Collection-based headers** that support multiple values with typed entries +* **Direct access** to the `HttpRequestHeaders` API + +For simple string-based headers (like `X-Correlation-Id` or `X-Tenant`), prefer `WithHeader` instead - it validates immediately, performs better, and keeps your code simpler. + +### Performance Notes + +`ConfigureHeaders` uses deferred execution, which means: + +* Configurators are stored in a list and executed when the request is built. +* Multiple configurators have a small overhead compared to a single call. +* For high-throughput scenarios with simple headers, prefer `WithHeader`. + +However, for most applications, the performance difference is negligible, and the strongly-typed API provides better compile-time safety and IntelliSense support. + ## Reserved Headers FluentHttpClient intentionally restricts a small set of HTTP headers that are controlled by the underlying `HttpClient` and its transport layers. These headers define wire-level framing and routing behavior, and overriding them can produce ambiguous requests, protocol violations, or security issues. -Because of this, the fluent header extensions do **not** allow setting the following headers: +Because of this, the fluent string-header extensions (`WithHeader` and `WithHeaders`) do **not** allow setting the following headers: * `Host` * `Content-Length` @@ -97,9 +199,9 @@ These values are determined automatically based on the request URI, the configur ### Advanced Usage -This restriction only applies to the high-level fluent extensions. If advanced scenarios require manual control of these headers, you can still modify the underlying `HttpRequestMessage` using a configuration delegate (for example, via [`When`](./conditional-configuration.md) with an always-true bool or predicate). This opt-in approach allows experienced users to take full control without exposing casual users to common footguns. +This restriction only applies to the `WithHeader` and `Withheaders` fluent extensions. If advanced scenarios require manual control of these headers, you can still accomplish this using `ConfigureHeaders`. This opt-in approach allows experienced users to take full control without exposing casual users to common footguns. -In short, the fluent API keeps the safe path safe, while still leaving the door open for expert customization or tom-foolery when needed. +In short, the fluent API keeps the simple path safe, while still leaving the door open for expert customization or tom-foolery when needed. :::tip Indirect Control @@ -166,15 +268,33 @@ var builder = client ## Behavior Notes -All of these methods work by adding actions to `HttpRequestBuilder.HeaderConfigurators`: +### String Header Methods + +`WithHeader` and `WithHeaders` methods: + +* Store headers in an internal dictionary with **immediate validation**. +* Validate header keys and values when the method is called (fail-fast). +* Reject reserved headers (`Host`, `Content-Length`, `Transfer-Encoding`) immediately. +* Headers are case-insensitive (per HTTP specification). +* Multiple values for the same header key are supported and accumulated. +* Headers are applied to the `HttpRequestMessage` when the request is built. + +### Typed Header Methods + +`WithAuthentication`, `WithBasicAuthentication`, `WithOAuthBearerToken`, and `ConfigureHeaders`: + +* Add deferred configurators to `HttpRequestBuilder.HeaderConfigurators`. +* Validation occurs when the request is built (when you call `SendAsync`). +* Multiple configurators are cumulative and applied in order. +* The last configurator that sets a particular header wins (e.g., `Authorization`). +* Configurators have direct access to strongly-typed header properties. + +### General Behavior -* Headers are applied when the request is built (for example, when you call `SendAsync`). -* Multiple calls to header methods are cumulative: - * Multiple non-auth headers are combined as expected. - * The most recent method that sets `Authorization` wins. -* Headers are added via `TryAddWithoutValidation`, which: - * Skips strict header format checks. - * Still respects HTTP semantics at send time. +* All header configuration is cumulative - multiple calls add or update headers. +* String headers and typed headers can be mixed in the same request. +* Headers set via `WithHeader` take effect before typed header configurators run. +* Reserved headers (`Host`, `Content-Length`, `Transfer-Encoding`) cannot be set via string methods but may be set via advanced techniques (see Reserved Headers section). --- @@ -186,6 +306,7 @@ All of these methods work by adding actions to `HttpRequestBuilder.HeaderConfigu | `WithHeader(string key, IEnumerable values)` | Add a header with multiple values. | | `WithHeaders(IEnumerable> headers)` | Add multiple headers, one value each. | | `WithHeaders(IEnumerable>> headers)` | Add multiple headers with multiple values each. | +| `ConfigureHeaders(Action configure)` | Configure strongly-typed headers directly. | | `WithAuthentication(string scheme, string token)` | Set `Authorization` with a custom scheme. | | `WithBasicAuthentication(string token)` | Set `Authorization: Basic {token}`. | | `WithBasicAuthentication(string username, string password)` | Build and set a Basic auth token. | diff --git a/docs/docs/deserializing-json.md b/docs/docs/deserializing-json.md index e597b7d..f12b6cc 100644 --- a/docs/docs/deserializing-json.md +++ b/docs/docs/deserializing-json.md @@ -40,7 +40,7 @@ var model = await response.ReadJsonAsync(); ```csharp // In the fluent chain -var doc = await client +using var doc = await client .UsingRoute("/api/data") .GetAsync() .ReadJsonDocumentAsync(); @@ -56,6 +56,32 @@ using var doc = await response.ReadJsonDocumentAsync(); - `ReadJsonDocumentAsync(CancellationToken)` - `ReadJsonDocumentAsync(JsonDocumentOptions, CancellationToken)` +:::danger Memory Management + +`JsonDocument` implements `IDisposable` and **must be disposed** to avoid memory leaks. The parsed JSON data is backed by pooled memory that must be returned to avoid accumulation. + +**Always use `using` statements:** + +```csharp +// Correct - using statement +using var doc = await response.ReadJsonDocumentAsync(); +var value = doc.RootElement.GetProperty("id").GetInt32(); + +// Correct - using declaration +using (var doc = await response.ReadJsonDocumentAsync()) +{ + ProcessDocument(doc); +} + +// WRONG - Memory leak! +var doc = await response.ReadJsonDocumentAsync(); // No using! +var value = doc.RootElement.GetProperty("id").GetInt32(); +``` + +If you need to keep the data beyond the disposal scope, extract the values you need before disposing, or copy the `JsonElement` data into your own objects. + +::: + ## JsonObject Parsing `JsonObject` is mutable and ideal for lightweight manipulation of dynamic or semi-structured JSON. Use these methods when you want to deserialize JSON into a `JsonObject` object from `Task` or `HttpResponseMessage`. @@ -93,6 +119,8 @@ var obj = await response.ReadJsonObjectAsync(); * All deserialization is performed using `System.Text.Json`. * Stream reading honors `CancellationToken` on TFMs that support it. * `JsonObject` APIs are only available when targeting `.NET 6` or later. +* **`JsonDocument` must be disposed** - it implements `IDisposable` and uses pooled memory that must be returned to avoid memory leaks. Always use `using` statements. +* `JsonObject` does not require disposal - it allocates managed objects on the heap. --- diff --git a/docs/docs/deserializing-xml.md b/docs/docs/deserializing-xml.md index 58dba6f..159bfde 100644 --- a/docs/docs/deserializing-xml.md +++ b/docs/docs/deserializing-xml.md @@ -35,6 +35,20 @@ var model = await response.ReadXmlAsync(); Empty or whitespace content returns `null`. Malformed XML throws the underlying serializer or XML parsing exception. +:::warning XmlSerializer Memory Implications + +FluentHttpClient uses `System.Xml.Serialization.XmlSerializer` for typed XML deserialization. `XmlSerializer` generates and caches code for each unique type at runtime. On .NET Framework and some older .NET Core versions, these generated assemblies **cannot be unloaded**, which may lead to memory accumulation in long-running applications that deserialize many different XML types. + +**Recommendations for long-running applications:** +* Limit the number of distinct types you deserialize +* Consider using `XElement` parsing (see below) for dynamic or varied XML schemas +* For high-throughput scenarios with many types, consider JSON serialization instead +* On .NET Framework, you can pre-generate serializers using [sgen.exe](https://learn.microsoft.com/en-us/dotnet/standard/serialization/xml-serializer-generator-tool-sgen-exe) + +For most applications with a small, fixed set of XML types, this is not a concern. + +::: + ## XElement Parsing Use these methods when you want to parse the response body into an `XElement` for LINQ-to-XML processing. @@ -65,6 +79,8 @@ Empty or whitespace input returns `null`. Malformed XML results in the underlyin * `CancellationToken` is honored in all methods that accept one. * [`LoadOptions`](https://learn.microsoft.com/en-us/dotnet/api/system.xml.linq.loadoptions) allow controlling whitespace, line information, and base URI preservation when parsing into `XElement`. * See the documentation for [`XElement.Parse`](https://learn.microsoft.com/en-us/dotnet/api/system.xml.linq.xelement.parse) for information on using `LoadOptions`. +* `XmlSerializer` instances are cached per type to avoid repeated code generation overhead. +* For Native AOT compatibility, use `XElement` parsing instead of typed deserialization. --- ## Quick Reference diff --git a/docs/docs/httprequestbuilder.md b/docs/docs/httprequestbuilder.md index 81f159c..282a475 100644 --- a/docs/docs/httprequestbuilder.md +++ b/docs/docs/httprequestbuilder.md @@ -20,7 +20,7 @@ FluentHttpClient is built around a simple pattern: ```csharp var response = await client - .UsingRoute("/api/widgets") // produces a HttpRequestBuilder + .UsingRoute("/api/widgets") // returns HttpRequestBuilder .WithHeader("X-Tenant", tenantId) .WithQueryParameter("state", "active") .WithJsonContent(payload) @@ -45,7 +45,7 @@ Starting in FluentHttpClient 5.0, `HttpRequestBuilder` is no longer able to be c :::warning Query Strings and Fragments -`BaseAddress` and `Route` must remain clean—free of query strings and fragments—so that FluentHttpClient has a single, predictable source of truth for all query-related behavior. Allowing query components in multiple places leads to ambiguous URI construction, duplicated encoding, and inconsistent request signatures. By enforcing that all query values flow through `QueryParameters`, the builder can reliably compose the final URI, ensure consistent encoding rules, and prevent subtle bugs caused by mixing inline query strings with fluent configuration. +`BaseAddress` and `Route` must remain clean - free of query strings and fragments - so that FluentHttpClient has a single, predictable source of truth for all query-related behavior. Allowing query components in multiple places leads to ambiguous URI construction, duplicated encoding, and inconsistent request signatures. By enforcing that all query values flow through `QueryParameters`, the builder can reliably compose the final URI, ensure consistent encoding rules, and prevent subtle bugs caused by mixing inline query strings with fluent configuration. ::: @@ -94,18 +94,18 @@ As with the string-based overload, the provided `Uri` must not contain query or The table below lists the key properties on `HttpRequestBuilder` and how they are used. -| Property | Description | -| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `HttpContent? Content` | The request body to send. Set by content-related extensions such as JSON, XML, or form encoded data. | -| `IDictionary Cookies` | Per-request cookies serialized into a single `Cookie` header. Values are not encoded automatically; long-lived cookie storage should come from the `HttpClient` handler. | -| `List> DeferredConfigurators` | Actions executed immediately before building the request, enabling late-bound or conditional configuration (used by `.When(...)` extensions). | -| `List> HeaderConfigurators` | Actions that mutate `HttpRequestMessage.Headers` during request construction. Populated by header-related extensions. | -| `List> OptionConfigurators`* | Actions that set values in `HttpRequestMessage.Options`. Useful for per-request flags, tracing, or contextual data. | -| `bool BufferRequestContent` | Forces the request content to be fully buffered in memory before sending. Intended only for compatibility edge cases where buffering is required. | -| `HttpQueryParameterCollection QueryParameters` | Represents all query string values for the request. The route and base address must not contain query components; this collection is the single source of truth. | -| `string? Route` | The relative or absolute request route originally provided to the builder; validated so it contains no query or fragment. | -| `Version Version` | The HTTP protocol version applied to the outgoing request. Defaults to HTTP/1.1. | -| `HttpVersionPolicy VersionPolicy`* | Controls how the requested HTTP version is interpreted and negotiated (e.g., upgrade, downgrade, or strict). Defaults to `RequestVersionOrLower`. | +| Property | Description | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `HttpContent? Content` | The request body to send. Set by content-related extensions such as JSON, XML, or form encoded data. See [Configure Content](./configure-content.md). | +| `IDictionary Cookies` | Per-request cookies serialized into a single `Cookie` header. Values are URL-encoded by default using RFC 6265 encoding; encoding can be disabled per-cookie if needed. See [Configure Cookies](./configure-cookies.md). | +| `List> DeferredConfigurators` | Actions executed immediately before building the request, enabling late-bound or conditional configuration. See [Conditional Configuration](./conditional-configuration.md). | +| `List> HeaderConfigurators` | Actions that configure strongly-typed headers on `HttpRequestMessage.Headers` during request construction. Simple string headers are stored separately for better performance. See [Configure Headers](./configure-headers.md). | +| `List> OptionConfigurators`* | Actions that set values in `HttpRequestMessage.Options`. Useful for per-request flags, tracing, or contextual data. See [Configure Options](./configure-options.md). | +| `bool BufferRequestContent` | Forces the request content to be fully buffered in memory before sending. Intended only for compatibility edge cases where buffering is required. See [Configure Content](./configure-content.md). | +| `HttpQueryParameterCollection QueryParameters` | Represents all query string values for the request. The route and base address must not contain query components; this collection is the single source of truth. See [Configure Query Parameters](./configure-query-parameters.md). | +| `string? Route` | The relative or absolute request route originally provided to the builder; validated so it contains no query or fragment. This value can be read, but cannot be changed. | +| `Version Version` | The HTTP protocol version applied to the outgoing request. Defaults to HTTP/1.1. See [Configure Version](./configure-version.md). | +| `HttpVersionPolicy VersionPolicy`* | Controls how the requested HTTP version is interpreted and negotiated (e.g., upgrade, downgrade, or strict). Defaults to `RequestVersionOrLower`. See [Configure Version](./configure-version.md). | \* *Available only on target frameworks that support `HttpRequestOptions` / `HttpVersionPolicy` (e.g., .NET 5+).* @@ -119,7 +119,7 @@ The table below lists the key properties on `HttpRequestBuilder` and how they ar :::important Use Recommended Overloads -While `SendAsync` is the core sending primitive, most consumers should prefer the convenience extensions such as `GetAsync`, `PostAsync`, `PutAsync`, `DeleteAsync`, `HeadAsync`, `OptionsAsync`, and `PatchAsync` (where available). These extensions select the correct `HttpMethod`, keep your call sites clean, and make intent immediately obvious. Use `SendAsync` directly only when you are using a non-standard `HttpMethod`. +While `SendAsync` is the core sending primitive, most consumers should prefer the convenience extensions found in the [**Sending Requests**](./sending-requests.md) documentation. These extensions select the correct `HttpMethod`, keep your call sites clean, and make intent immediately obvious. Use `SendAsync` directly only when you are using a non-standard `HttpMethod`. ::: @@ -138,9 +138,9 @@ All overloads delegate to the most complete overload. That method: - `Version` and (when available) `VersionPolicy` 5. Applies deferred configurations such as: - Disable `ExpectContinue` for multipart content. - - Apply each `HeaderConfigurator`. + - Apply each header, then each `HeaderConfigurator`. - Serialize Cookies into the Cookie header. - - Apply each `OptionConfigurator` (when available). + - Apply each `OptionConfigurator` (where available). 6. Sends the request via `_client.SendAsync`. :::danger Experimental Method diff --git a/docs/docs/response-handlers.md b/docs/docs/response-handlers.md index c6a96d6..84601b2 100644 --- a/docs/docs/response-handlers.md +++ b/docs/docs/response-handlers.md @@ -75,7 +75,7 @@ var response = await builder.PostAsync() ### Asynchronous handler ```csharp -aawait builder.PostAsync() +await builder.PostAsync() .OnFailure(async r => await metrics.RecordFailureAsync(r)); ``` diff --git a/docs/versioned_docs/version-4.x/create-builder.md b/docs/versioned_docs/version-4.x/create-builder.md index 01f31ae..a7b22ac 100644 --- a/docs/versioned_docs/version-4.x/create-builder.md +++ b/docs/versioned_docs/version-4.x/create-builder.md @@ -27,7 +27,7 @@ Alternatively, you can create a builder directly: var request = new HttpRequestBuilder(client, "/users/12345"); ``` -Both approaches are equivalent — the fluent API simply provides a more concise and expressive syntax. +Both approaches are equivalent - the fluent API simply provides a more concise and expressive syntax. ### Create Request Without Route diff --git a/docs/versioned_docs/version-4.x/response-deserialization-json.md b/docs/versioned_docs/version-4.x/response-deserialization-json.md index 52d7b37..6a0c410 100644 --- a/docs/versioned_docs/version-4.x/response-deserialization-json.md +++ b/docs/versioned_docs/version-4.x/response-deserialization-json.md @@ -52,8 +52,8 @@ In some cases, you may want to define a fallback behavior when deserialization f The delegate provided for handling exceptions has two possible signatures. -- `Func` — Handle errors using the response and exception. -- `Func` — Handle errors with only the exception. +- `Func` - Handle errors using the response and exception. +- `Func` - Handle errors with only the exception. - Async variants are also available (`Func<..., Task>`). In each case, the delegate should return a valid instance of type `T`. This ensures your application can recover gracefully even if the response content isn't as expected. diff --git a/docs/versioned_docs/version-4.x/response-deserialization-xml.md b/docs/versioned_docs/version-4.x/response-deserialization-xml.md index 53990fc..9a2df2a 100644 --- a/docs/versioned_docs/version-4.x/response-deserialization-xml.md +++ b/docs/versioned_docs/version-4.x/response-deserialization-xml.md @@ -98,7 +98,7 @@ While XML deserialization offers the same core functionality, not all features a * XML deserialization does **not** include fallback delegates (e.g., `Func`) for error handling. * JSON deserialization allows custom `JsonSerializerOptions`; XML deserialization uses `XmlSerializer` and standard `XmlReader`/`XElement` configuration instead. -* Error handling is less granular—exceptions from `XmlSerializer` or malformed XML are thrown directly. +* Error handling is less granular - exceptions from `XmlSerializer` or malformed XML are thrown directly. * Future releases will bring feature parity for fallback handling and enhanced serializer configuration. These differences are intentional for now to prioritize performance and simplicity. Full parity is planned for a future major version. diff --git a/samples/AuthenticationSample/AuthenticationSample.csproj b/samples/AuthenticationSample/AuthenticationSample.csproj new file mode 100644 index 0000000..cc19cbc --- /dev/null +++ b/samples/AuthenticationSample/AuthenticationSample.csproj @@ -0,0 +1,14 @@ + + + + + + + + Exe + net8.0 + enable + enable + + + diff --git a/samples/AuthenticationSample/Program.cs b/samples/AuthenticationSample/Program.cs new file mode 100644 index 0000000..650a93d --- /dev/null +++ b/samples/AuthenticationSample/Program.cs @@ -0,0 +1,16 @@ +using FluentHttpClient; + +var client = new HttpClient +{ + BaseAddress = new Uri("https://jsonplaceholder.typicode.com") +}; + +var token = "demo-token-12345"; + +var json = await client + .UsingRoute("/posts/1") + .WithOAuthBearerToken(token) + .GetAsync() + .ReadContentAsStringAsync(); + +Console.WriteLine(json); diff --git a/samples/BasicGetSample/BasicGetSample.csproj b/samples/BasicGetSample/BasicGetSample.csproj new file mode 100644 index 0000000..cc19cbc --- /dev/null +++ b/samples/BasicGetSample/BasicGetSample.csproj @@ -0,0 +1,14 @@ + + + + + + + + Exe + net8.0 + enable + enable + + + diff --git a/samples/BasicGetSample/Program.cs b/samples/BasicGetSample/Program.cs new file mode 100644 index 0000000..f40954f --- /dev/null +++ b/samples/BasicGetSample/Program.cs @@ -0,0 +1,13 @@ +using FluentHttpClient; + +var client = new HttpClient +{ + BaseAddress = new Uri("https://jsonplaceholder.typicode.com") +}; + +var json = await client + .UsingRoute("/posts/1") + .GetAsync() + .ReadContentAsStringAsync(); + +Console.WriteLine(json); diff --git a/samples/ErrorHandlingSample/ErrorHandlingSample.csproj b/samples/ErrorHandlingSample/ErrorHandlingSample.csproj new file mode 100644 index 0000000..cc19cbc --- /dev/null +++ b/samples/ErrorHandlingSample/ErrorHandlingSample.csproj @@ -0,0 +1,14 @@ + + + + + + + + Exe + net8.0 + enable + enable + + + diff --git a/samples/ErrorHandlingSample/Program.cs b/samples/ErrorHandlingSample/Program.cs new file mode 100644 index 0000000..360f737 --- /dev/null +++ b/samples/ErrorHandlingSample/Program.cs @@ -0,0 +1,15 @@ +using FluentHttpClient; + +var client = new HttpClient +{ + BaseAddress = new Uri("https://jsonplaceholder.typicode.com") +}; + +var json = await client + .UsingRoute("/posts/999") + .GetAsync() + .OnSuccess(r => Console.WriteLine($"Success! Status: {r.StatusCode}")) + .OnFailure(r => Console.WriteLine($"Failed! Status: {r.StatusCode}")) + .ReadContentAsStringAsync(); + +Console.WriteLine(json); diff --git a/samples/JsonDeserializeSample/JsonDeserializeSample.csproj b/samples/JsonDeserializeSample/JsonDeserializeSample.csproj new file mode 100644 index 0000000..cc19cbc --- /dev/null +++ b/samples/JsonDeserializeSample/JsonDeserializeSample.csproj @@ -0,0 +1,14 @@ + + + + + + + + Exe + net8.0 + enable + enable + + + diff --git a/samples/JsonDeserializeSample/Program.cs b/samples/JsonDeserializeSample/Program.cs new file mode 100644 index 0000000..84430dd --- /dev/null +++ b/samples/JsonDeserializeSample/Program.cs @@ -0,0 +1,27 @@ +using FluentHttpClient; + +var client = new HttpClient +{ + BaseAddress = new Uri("https://jsonplaceholder.typicode.com") +}; + +var user = await client + .UsingRoute("/users/1") + .GetAsync() + .ReadJsonAsync(); + +Console.WriteLine($"ID: {user?.Id}"); +Console.WriteLine($"Name: {user?.Name}"); +Console.WriteLine($"Email: {user?.Email}"); +Console.WriteLine($"Phone: {user?.Phone}"); +Console.WriteLine($"Website: {user?.Website}"); + +public class User +{ + public int Id { get; set; } + public string? Name { get; set; } + public string? Username { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + public string? Website { get; set; } +} diff --git a/samples/JsonPostSample/JsonPostSample.csproj b/samples/JsonPostSample/JsonPostSample.csproj new file mode 100644 index 0000000..cc19cbc --- /dev/null +++ b/samples/JsonPostSample/JsonPostSample.csproj @@ -0,0 +1,14 @@ + + + + + + + + Exe + net8.0 + enable + enable + + + diff --git a/samples/JsonPostSample/Program.cs b/samples/JsonPostSample/Program.cs new file mode 100644 index 0000000..06e6b8c --- /dev/null +++ b/samples/JsonPostSample/Program.cs @@ -0,0 +1,22 @@ +using FluentHttpClient; +using System.Text.Json; + +var client = new HttpClient +{ + BaseAddress = new Uri("https://jsonplaceholder.typicode.com") +}; + +var newPost = new +{ + title = "FluentHttpClient Demo", + body = "Testing POST with JSON content", + userId = 1 +}; + +var response = await client + .UsingRoute("/posts") + .WithJsonContent(newPost) + .PostAsync() + .ReadContentAsStringAsync(); + +Console.WriteLine(response); diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 0000000..64fa14b --- /dev/null +++ b/samples/README.md @@ -0,0 +1,149 @@ +# FluentHttpClient Samples + +This folder contains minimal, runnable console applications demonstrating common FluentHttpClient usage patterns. Each sample is self-contained and can be run with `dotnet run`. + +## Running a Sample + +```bash +cd sample/BasicGetSample +dotnet run +``` + +## Available Samples + +### BasicGetSample +Demonstrates the simplest possible GET request using FluentHttpClient. + +**What it shows:** +- Creating an `HttpClient` with a base address +- Using `UsingRoute()` to specify an endpoint +- Performing a GET request with `GetAsync()` +- Reading the response as a string + +**Run:** +```bash +cd sample/BasicGetSample && dotnet run +``` + +--- + +### JsonPostSample +Shows how to POST JSON content to an API. + +**What it shows:** +- Creating JSON content from an anonymous object +- Using `WithJsonContent()` to attach JSON to the request +- Sending a POST request with `PostAsync()` +- Reading the response body + +**Run:** +```bash +cd sample/JsonPostSample && dotnet run +``` + +--- + +### WithQueryParametersSample +Demonstrates adding query string parameters to a request. + +**What it shows:** +- Using `WithQueryParameter()` to add query parameters +- Automatic URL encoding and query string construction +- Retrieving filtered results from an API + +**Run:** +```bash +cd sample/WithQueryParametersSample && dotnet run +``` + +--- + +### JsonDeserializeSample +Shows strongly-typed JSON deserialization using FluentHttpClient's built-in extensions. + +**What it shows:** +- Defining a model class for deserialization +- Using `ReadJsonAsync()` to deserialize JSON responses +- Accessing typed properties from the response + +**Run:** +```bash +cd sample/JsonDeserializeSample && dotnet run +``` + +--- + +### XmlDeserializeSample +Demonstrates XML deserialization from an HTTP response. + +**What it shows:** +- Using `ReadXmlAsync()` to deserialize XML responses +- Working with XML serialization attributes +- Handling complex XML structures with nested elements + +**Run:** +```bash +cd sample/XmlDeserializeSample && dotnet run +``` + +--- + +### WithHeadersSample +Shows how to add custom headers to requests. + +**What it shows:** +- Using `WithHeader()` to add custom headers +- Adding multiple headers to a single request +- Common patterns like correlation IDs + +**Run:** +```bash +cd sample/WithHeadersSample && dotnet run +``` + +--- + +### AuthenticationSample +Demonstrates adding authentication headers, specifically Bearer tokens. + +**What it shows:** +- Using `WithOAuthBearerToken()` for Bearer authentication +- Adding Authorization headers to requests +- Common OAuth/API authentication patterns + +**Run:** +```bash +cd sample/AuthenticationSample && dotnet run +``` + +--- + +### ErrorHandlingSample +Shows how to handle success and failure responses using FluentHttpClient's response handlers. + +**What it shows:** +- Using `OnSuccess()` to handle successful responses +- Using `OnFailure()` to handle failed responses +- Fluent error handling without breaking the chain + +**Run:** +```bash +cd sample/ErrorHandlingSample && dotnet run +``` + +--- + +## Test API + +All samples (except XML) use [JSONPlaceholder](https://jsonplaceholder.typicode.com/), a free fake REST API for testing and prototyping. No authentication or setup is required. + +## Requirements + +- .NET 8.0 SDK or later +- All samples reference the local `FluentHttpClient` project + +## Additional Resources + +- [FluentHttpClient Documentation](../docs) +- [JSONPlaceholder API Guide](https://jsonplaceholder.typicode.com/guide/) + diff --git a/samples/WithHeadersSample/Program.cs b/samples/WithHeadersSample/Program.cs new file mode 100644 index 0000000..3f57f25 --- /dev/null +++ b/samples/WithHeadersSample/Program.cs @@ -0,0 +1,15 @@ +using FluentHttpClient; + +var client = new HttpClient +{ + BaseAddress = new Uri("https://jsonplaceholder.typicode.com") +}; + +var json = await client + .UsingRoute("/posts/1") + .WithHeader("X-Custom-Header", "demo-value") + .WithHeader("X-Correlation-Id", Guid.NewGuid().ToString()) + .GetAsync() + .ReadContentAsStringAsync(); + +Console.WriteLine(json); diff --git a/samples/WithHeadersSample/WithHeadersSample.csproj b/samples/WithHeadersSample/WithHeadersSample.csproj new file mode 100644 index 0000000..cc19cbc --- /dev/null +++ b/samples/WithHeadersSample/WithHeadersSample.csproj @@ -0,0 +1,14 @@ + + + + + + + + Exe + net8.0 + enable + enable + + + diff --git a/samples/WithQueryParametersSample/Program.cs b/samples/WithQueryParametersSample/Program.cs new file mode 100644 index 0000000..5ddab03 --- /dev/null +++ b/samples/WithQueryParametersSample/Program.cs @@ -0,0 +1,14 @@ +using FluentHttpClient; + +var client = new HttpClient +{ + BaseAddress = new Uri("https://jsonplaceholder.typicode.com") +}; + +var json = await client + .UsingRoute("/comments") + .WithQueryParameter("postId", "1") + .GetAsync() + .ReadContentAsStringAsync(); + +Console.WriteLine(json); diff --git a/samples/WithQueryParametersSample/WithQueryParametersSample.csproj b/samples/WithQueryParametersSample/WithQueryParametersSample.csproj new file mode 100644 index 0000000..cc19cbc --- /dev/null +++ b/samples/WithQueryParametersSample/WithQueryParametersSample.csproj @@ -0,0 +1,14 @@ + + + + + + + + Exe + net8.0 + enable + enable + + + diff --git a/samples/XmlDeserializeSample/Program.cs b/samples/XmlDeserializeSample/Program.cs new file mode 100644 index 0000000..a88b3cb --- /dev/null +++ b/samples/XmlDeserializeSample/Program.cs @@ -0,0 +1,40 @@ +using FluentHttpClient; + +var client = new HttpClient(); + +var menu = await client + .UsingRoute("https://www.w3schools.com/xml/simple.xml") + .GetAsync() + .ReadXmlAsync(); + +if (menu?.Food != null) +{ + Console.WriteLine("Menu Items:"); + foreach (var item in menu.Food) + { + Console.WriteLine($"- {item.Name}: {item.Price} ({item.Description})"); + Console.WriteLine($" Calories: {item.Calories}"); + } +} + +[System.Xml.Serialization.XmlRoot("breakfast_menu")] +public class BreakfastMenu +{ + [System.Xml.Serialization.XmlElement("food")] + public List? Food { get; set; } +} + +public class FoodItem +{ + [System.Xml.Serialization.XmlElement("name")] + public string? Name { get; set; } + + [System.Xml.Serialization.XmlElement("price")] + public string? Price { get; set; } + + [System.Xml.Serialization.XmlElement("description")] + public string? Description { get; set; } + + [System.Xml.Serialization.XmlElement("calories")] + public int Calories { get; set; } +} diff --git a/samples/XmlDeserializeSample/XmlDeserializeSample.csproj b/samples/XmlDeserializeSample/XmlDeserializeSample.csproj new file mode 100644 index 0000000..cc19cbc --- /dev/null +++ b/samples/XmlDeserializeSample/XmlDeserializeSample.csproj @@ -0,0 +1,14 @@ + + + + + + + + Exe + net8.0 + enable + enable + + + diff --git a/src/FluentHttpClient.Tests/FluentCookieExtensionsTests.cs b/src/FluentHttpClient.Tests/FluentCookieExtensionsTests.cs index 89aedf5..6ea7852 100644 --- a/src/FluentHttpClient.Tests/FluentCookieExtensionsTests.cs +++ b/src/FluentHttpClient.Tests/FluentCookieExtensionsTests.cs @@ -94,7 +94,7 @@ public void ThrowsArgumentException_WhenCookieNameIsNullOrWhitespace(string? nam var exception = Should.Throw(() => builder.WithCookies(cookies)); - exception.ParamName.ShouldBe("cookies"); + exception.ParamName.ShouldBe("name"); } [Fact] diff --git a/src/FluentHttpClient.Tests/FluentHeaderExtensionsTests.cs b/src/FluentHttpClient.Tests/FluentHeaderExtensionsTests.cs index 472ba8e..b9ba2ea 100644 --- a/src/FluentHttpClient.Tests/FluentHeaderExtensionsTests.cs +++ b/src/FluentHttpClient.Tests/FluentHeaderExtensionsTests.cs @@ -12,7 +12,7 @@ public void WithHeader_AddsConfigurator_WhenCalled() var result = builder.WithHeader("X-Test", "value"); result.ShouldBeSameAs(builder); - builder.HeaderConfigurators.Count.ShouldBe(1); + builder.InternalHeaders.Count.ShouldBe(1); } [Fact] @@ -21,13 +21,9 @@ public void WithHeader_AddsHeaderToRequest_WhenConfiguratorInvoked() var builder = CreateBuilder(); builder.WithHeader("X-Test", "value"); - var configurator = builder.HeaderConfigurators.Single(); - - var message = new HttpRequestMessage(HttpMethod.Get, "https://example.com/resource"); - configurator(message.Headers); - message.Headers.Contains("X-Test").ShouldBeTrue(); - message.Headers.GetValues("X-Test").Single().ShouldBe("value"); + builder.InternalHeaders.ShouldContainKey("X-Test"); + builder.InternalHeaders["X-Test"].ShouldBe(new[] { "value" }); } [Fact] @@ -78,7 +74,7 @@ public void WithHeader_AddsConfigurator_WhenCalled() var result = builder.WithHeader("X-Multi", values); result.ShouldBeSameAs(builder); - builder.HeaderConfigurators.Count.ShouldBe(1); + builder.InternalHeaders.Count.ShouldBe(1); } [Fact] @@ -88,13 +84,9 @@ public void WithHeader_AddsMultipleValuesToRequest_WhenConfiguratorInvoked() var values = new[] { "one", "two" }; builder.WithHeader("X-Multi", values); - var configurator = builder.HeaderConfigurators.Single(); - - var message = new HttpRequestMessage(HttpMethod.Get, "https://example.com/resource"); - configurator(message.Headers); - message.Headers.Contains("X-Multi").ShouldBeTrue(); - message.Headers.GetValues("X-Multi").ShouldBe(values); + builder.InternalHeaders.ShouldContainKey("X-Multi"); + builder.InternalHeaders["X-Multi"].ShouldBe(values); } [Fact] @@ -151,7 +143,7 @@ public void WithHeaders_AddsConfigurator_WhenCalled() var result = builder.WithHeaders(headers); result.ShouldBeSameAs(builder); - builder.HeaderConfigurators.Count.ShouldBe(1); + builder.InternalHeaders.Count.ShouldBe(2); } [Fact] @@ -165,16 +157,12 @@ public void WithHeaders_AddsAllHeadersToRequest_WhenConfiguratorInvoked() }; builder.WithHeaders(headers); - var configurator = builder.HeaderConfigurators.Single(); - - var message = new HttpRequestMessage(HttpMethod.Get, "https://example.com/resource"); - configurator(message.Headers); - message.Headers.Contains("X-One").ShouldBeTrue(); - message.Headers.GetValues("X-One").Single().ShouldBe("1"); + builder.InternalHeaders.ShouldContainKey("X-One"); + builder.InternalHeaders["X-One"].ShouldBe(new[] { "1" }); - message.Headers.Contains("X-Two").ShouldBeTrue(); - message.Headers.GetValues("X-Two").Single().ShouldBe("2"); + builder.InternalHeaders.ShouldContainKey("X-Two"); + builder.InternalHeaders["X-Two"].ShouldBe(new[] { "2" }); } [Fact] @@ -197,12 +185,8 @@ public void WithHeaders_ThrowsArgumentException_WhenHeaderKeyIsNull() new KeyValuePair(null!, "1") }; - builder.WithHeaders(headers); - var configurator = builder.HeaderConfigurators.Single(); - - var message = new HttpRequestMessage(HttpMethod.Get, "https://example.com/resource"); var ex = Should.Throw(() => - configurator(message.Headers)); + builder.WithHeaders(headers)); ex.ParamName.ShouldBe("key"); } @@ -211,7 +195,7 @@ public void WithHeaders_ThrowsArgumentException_WhenHeaderKeyIsNull() [InlineData("Host")] [InlineData("Content-Length")] [InlineData("Transfer-Encoding")] - public async Task WithHeader_ThrowsArgumentException_WhenKeyIsReserved(string key) + public void WithHeader_ThrowsArgumentException_WhenKeyIsReserved(string key) { var builder = CreateBuilder(); var headers = new[] @@ -219,10 +203,8 @@ public async Task WithHeader_ThrowsArgumentException_WhenKeyIsReserved(string ke new KeyValuePair(key, "1") }; - builder.WithHeaders(headers); - - var ex = await Should.ThrowAsync(async () => - await builder.BuildRequest(HttpMethod.Get, CancellationToken.None)); + var ex = Should.Throw(() => + builder.WithHeaders(headers)); ex.ParamName.ShouldBe("key"); } @@ -236,12 +218,8 @@ public void WithHeaders_ThrowsArgumentException_WhenHeaderValueIsNull() new KeyValuePair("X-One", null!) }; - builder.WithHeaders(headers); - var configurator = builder.HeaderConfigurators.Single(); - - var message = new HttpRequestMessage(HttpMethod.Get, "https://example.com/resource"); var ex = Should.Throw(() => - configurator(message.Headers)); + builder.WithHeaders(headers)); ex.ParamName.ShouldBe("value"); } @@ -262,7 +240,7 @@ public void WithHeaders_AddsConfigurator_WhenCalled() var result = builder.WithHeaders(headers); result.ShouldBeSameAs(builder); - builder.HeaderConfigurators.Count.ShouldBe(1); + builder.InternalHeaders.Count.ShouldBe(2); } [Fact] @@ -276,16 +254,12 @@ public void WithHeaders_AddsAllHeadersAndValuesToRequest_WhenConfiguratorInvoked }; builder.WithHeaders(headers); - var configurator = builder.HeaderConfigurators.Single(); - - var message = new HttpRequestMessage(HttpMethod.Get, "https://example.com/resource"); - configurator(message.Headers); - message.Headers.Contains("X-One").ShouldBeTrue(); - message.Headers.GetValues("X-One").ShouldBe(new[] { "1", "1b" }); + builder.InternalHeaders.ShouldContainKey("X-One"); + builder.InternalHeaders["X-One"].ShouldBe(new[] { "1", "1b" }); - message.Headers.Contains("X-Two").ShouldBeTrue(); - message.Headers.GetValues("X-Two").Single().ShouldBe("2"); + builder.InternalHeaders.ShouldContainKey("X-Two"); + builder.InternalHeaders["X-Two"].ShouldBe(new[] { "2" }); } [Fact] @@ -303,7 +277,7 @@ public void WithHeaders_ThrowsArgumentNullException_WhenHeadersIsNull() [InlineData("Host")] [InlineData("Content-Length")] [InlineData("Transfer-Encoding")] - public async Task WithHeader_ThrowsArgumentException_WhenKeyIsReserved(string key) + public void WithHeader_ThrowsArgumentException_WhenKeyIsReserved(string key) { var builder = CreateBuilder(); var headers = new[] @@ -311,10 +285,8 @@ public async Task WithHeader_ThrowsArgumentException_WhenKeyIsReserved(string ke new KeyValuePair>(key, new[] { "1" }) }; - builder.WithHeaders(headers); - - var ex = await Should.ThrowAsync(async () => - await builder.BuildRequest(HttpMethod.Get, CancellationToken.None)); + var ex = Should.Throw(() => + builder.WithHeaders(headers)); ex.ParamName.ShouldBe("key"); } @@ -328,12 +300,8 @@ public void WithHeaders_ThrowsArgumentException_WhenHeaderKeyIsNull() new KeyValuePair>(null!, new[] { "1" }) }; - builder.WithHeaders(headers); - var configurator = builder.HeaderConfigurators.Single(); - - var message = new HttpRequestMessage(HttpMethod.Get, "https://example.com/resource"); var ex = Should.Throw(() => - configurator(message.Headers)); + builder.WithHeaders(headers)); ex.ParamName.ShouldBe("key"); } @@ -347,17 +315,165 @@ public void WithHeaders_ThrowsArgumentException_WhenHeaderValuesIsNull() new KeyValuePair>("X-One", null!) }; - builder.WithHeaders(headers); - var configurator = builder.HeaderConfigurators.Single(); - - var message = new HttpRequestMessage(HttpMethod.Get, "https://example.com/resource"); var ex = Should.Throw(() => - configurator(message.Headers)); + builder.WithHeaders(headers)); ex.ParamName.ShouldBe("values"); } } + public class ConfigureHeadersTests + { + [Fact] + public void ConfigureHeaders_AddsConfiguratorToList_WhenCalled() + { + var builder = CreateBuilder(); + + var result = builder.ConfigureHeaders(headers => + headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "token")); + + result.ShouldBeSameAs(builder); + builder.HeaderConfigurators.Count.ShouldBe(1); + } + + [Fact] + public async Task ConfigureHeaders_AppliesTypedHeaderCorrectly_WhenRequestIsBuilt() + { + var builder = CreateBuilder(); + + builder.ConfigureHeaders(headers => + headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "test-token")); + + var request = await builder.BuildRequest(HttpMethod.Get, CancellationToken.None); + + request.Headers.Authorization.ShouldNotBeNull(); + request.Headers.Authorization!.Scheme.ShouldBe("Bearer"); + request.Headers.Authorization.Parameter.ShouldBe("test-token"); + } + + [Fact] + public async Task ConfigureHeaders_AppliesCacheControlHeader_WhenRequestIsBuilt() + { + var builder = CreateBuilder(); + + builder.ConfigureHeaders(headers => + { + headers.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue + { + NoCache = true, + NoStore = true, + MaxAge = TimeSpan.FromSeconds(30) + }; + }); + + var request = await builder.BuildRequest(HttpMethod.Get, CancellationToken.None); + + request.Headers.CacheControl.ShouldNotBeNull(); + request.Headers.CacheControl!.NoCache.ShouldBeTrue(); + request.Headers.CacheControl.NoStore.ShouldBeTrue(); + request.Headers.CacheControl.MaxAge.ShouldBe(TimeSpan.FromSeconds(30)); + } + + [Fact] + public async Task ConfigureHeaders_AppliesMultipleAcceptHeaders_WhenRequestIsBuilt() + { + var builder = CreateBuilder(); + + builder.ConfigureHeaders(headers => + { + headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); + headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/xml", 0.9)); + }); + + var request = await builder.BuildRequest(HttpMethod.Get, CancellationToken.None); + + request.Headers.Accept.Count.ShouldBe(2); + request.Headers.Accept.First().MediaType.ShouldBe("application/json"); + request.Headers.Accept.First().Quality.ShouldBeNull(); + request.Headers.Accept.Last().MediaType.ShouldBe("application/xml"); + request.Headers.Accept.Last().Quality.ShouldBe(0.9); + } + + [Fact] + public async Task ConfigureHeaders_AccumulatesMultipleConfigurators_WhenCalledMultipleTimes() + { + var builder = CreateBuilder(); + + builder.ConfigureHeaders(headers => + headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "token")) + .ConfigureHeaders(headers => + headers.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true }); + + var request = await builder.BuildRequest(HttpMethod.Get, CancellationToken.None); + + builder.HeaderConfigurators.Count.ShouldBe(2); + request.Headers.Authorization.ShouldNotBeNull(); + request.Headers.Authorization!.Scheme.ShouldBe("Bearer"); + request.Headers.CacheControl.ShouldNotBeNull(); + request.Headers.CacheControl!.NoCache.ShouldBeTrue(); + } + + [Fact] + public async Task ConfigureHeaders_AppliesIfModifiedSinceHeader_WhenRequestIsBuilt() + { + var builder = CreateBuilder(); + var testDate = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); + + builder.ConfigureHeaders(headers => + headers.IfModifiedSince = testDate); + + var request = await builder.BuildRequest(HttpMethod.Get, CancellationToken.None); + + request.Headers.IfModifiedSince.ShouldBe(testDate); + } + + [Fact] + public async Task ConfigureHeaders_AppliesIfNoneMatchHeader_WhenRequestIsBuilt() + { + var builder = CreateBuilder(); + + builder.ConfigureHeaders(headers => + headers.IfNoneMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue("\"12345\""))); + + var request = await builder.BuildRequest(HttpMethod.Get, CancellationToken.None); + + request.Headers.IfNoneMatch.Count.ShouldBe(1); + request.Headers.IfNoneMatch.First().Tag.ShouldBe("\"12345\""); + } + + [Fact] + public void ConfigureHeaders_ThrowsArgumentNullException_WhenConfigureIsNull() + { + var builder = CreateBuilder(); + + var ex = Should.Throw(() => + builder.ConfigureHeaders(null!)); + + ex.ParamName.ShouldBe("configure"); + } + + [Fact] + public async Task ConfigureHeaders_WorksWithOtherFluentMethods_WhenChained() + { + var builder = CreateBuilder(); + + builder.WithHeader("X-Custom", "value") + .ConfigureHeaders(headers => + headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "token")) + .WithHeader("X-Another", "value2"); + + var request = await builder.BuildRequest(HttpMethod.Get, CancellationToken.None); + + // Verify InternalHeaders were applied + builder.InternalHeaders.ShouldContainKey("X-Custom"); + builder.InternalHeaders.ShouldContainKey("X-Another"); + + // Verify typed header was applied + request.Headers.Authorization.ShouldNotBeNull(); + request.Headers.Authorization!.Scheme.ShouldBe("Bearer"); + } + } + private static HttpRequestBuilder CreateBuilder() => new HttpRequestBuilder(new HttpClient(), "https://example.com"); } diff --git a/src/FluentHttpClient/FluentAuthenticationExtensions.cs b/src/FluentHttpClient/FluentAuthenticationExtensions.cs index ebaf91f..cefa7d4 100644 --- a/src/FluentHttpClient/FluentAuthenticationExtensions.cs +++ b/src/FluentHttpClient/FluentAuthenticationExtensions.cs @@ -11,10 +11,10 @@ public static class FluentAuthenticationExtensions /// /// Sets the for the request using the specified scheme and token. /// - /// - /// - /// - /// + /// The instance. + /// The authentication scheme (e.g., "Bearer", "Basic"). + /// The authentication token or credentials. + /// The for method chaining. public static HttpRequestBuilder WithAuthentication(this HttpRequestBuilder builder, string scheme, string token) { Guard.AgainstNull(scheme, nameof(scheme)); @@ -31,9 +31,9 @@ public static HttpRequestBuilder WithAuthentication(this HttpRequestBuilder buil /// /// Sets the authentication header to Basic using the specified token value. /// - /// - /// - /// + /// The instance. + /// The Base64-encoded Basic authentication token. + /// The for method chaining. public static HttpRequestBuilder WithBasicAuthentication(this HttpRequestBuilder builder, string token) { Guard.AgainstNull(token, nameof(token)); @@ -47,10 +47,10 @@ public static HttpRequestBuilder WithBasicAuthentication(this HttpRequestBuilder /// /// The username and password will be properly concatenated and Base64 encoded. /// - /// - /// - /// - /// + /// The instance. + /// The username for Basic authentication. + /// The password for Basic authentication. + /// The for method chaining. public static HttpRequestBuilder WithBasicAuthentication(this HttpRequestBuilder builder, string username, string password) { Guard.AgainstNull(username, nameof(username)); @@ -63,9 +63,9 @@ public static HttpRequestBuilder WithBasicAuthentication(this HttpRequestBuilder /// /// Sets the authentication header to Bearer using the specified OAuth token. /// - /// - /// - /// + /// The instance. + /// The OAuth Bearer token. + /// The for method chaining. public static HttpRequestBuilder WithOAuthBearerToken(this HttpRequestBuilder builder, string token) { Guard.AgainstNull(token, nameof(token)); diff --git a/src/FluentHttpClient/FluentConditionalExtensions.cs b/src/FluentHttpClient/FluentConditionalExtensions.cs index 2b069ce..bf3294d 100644 --- a/src/FluentHttpClient/FluentConditionalExtensions.cs +++ b/src/FluentHttpClient/FluentConditionalExtensions.cs @@ -21,9 +21,10 @@ public static class FluentConditionalExtensions /// around the configuration logic, but keeps the control flow within the fluent /// pipeline. /// - /// - /// - /// + /// The instance. + /// The boolean condition that determines whether to apply the configuration. + /// The action to invoke when the condition is true. + /// The for method chaining. public static HttpRequestBuilder When( this HttpRequestBuilder builder, bool condition, @@ -50,9 +51,10 @@ public static HttpRequestBuilder When( /// for conditions that depend on late-bound state such as ambient context values, /// feature flags, or other runtime information only available at request creation time. /// - /// - /// - /// + /// The instance. + /// A function that evaluates to determine whether to apply the configuration. + /// The action to invoke when the predicate returns true. + /// The for method chaining. public static HttpRequestBuilder When( this HttpRequestBuilder builder, Func predicate, diff --git a/src/FluentHttpClient/FluentContentExtensions.cs b/src/FluentHttpClient/FluentContentExtensions.cs index 1f439a3..e21ece2 100644 --- a/src/FluentHttpClient/FluentContentExtensions.cs +++ b/src/FluentHttpClient/FluentContentExtensions.cs @@ -17,10 +17,10 @@ public static class FluentContentExtensions /// the underlying handler requires the content /// length to be known in advance, or when streaming content may cause /// protocol or middleware issues. - /// /// Buffering can have a significant memory impact for large payloads. /// - /// + /// The instance. + /// The for method chaining. public static HttpRequestBuilder WithBufferedContent(this HttpRequestBuilder builder) { builder.BufferRequestContent = true; @@ -33,6 +33,9 @@ public static HttpRequestBuilder WithBufferedContent(this HttpRequestBuilder bui /// /// Use this for adding any pre-built content that inherits from (e.g. ). /// + /// The instance. + /// The HTTP content to send with the request. + /// The for method chaining. public static HttpRequestBuilder WithContent( this HttpRequestBuilder builder, HttpContent content) @@ -44,6 +47,9 @@ public static HttpRequestBuilder WithContent( /// /// Sets the request content using form URL encoded data represented by a dictionary. /// + /// The instance. + /// The dictionary containing form data as key-value pairs. + /// The for method chaining. public static HttpRequestBuilder WithFormContent( this HttpRequestBuilder builder, Dictionary data) @@ -63,6 +69,9 @@ public static HttpRequestBuilder WithFormContent( /// Sets the request content using form URL encoded data represented by a sequence /// of key/value pairs. Allows multiple values for the same key. /// + /// The instance. + /// The sequence of key-value pairs containing form data. + /// The for method chaining. public static HttpRequestBuilder WithFormContent( this HttpRequestBuilder builder, IEnumerable> data) @@ -83,77 +92,131 @@ public static HttpRequestBuilder WithFormContent( /// /// Sets the request content using a with default encoding. /// + /// The instance. + /// The string content to send with the request. + /// The for method chaining. public static HttpRequestBuilder WithContent( this HttpRequestBuilder builder, string content) { - builder.Content = new StringContent(content); - return builder; + Guard.AgainstNull(content, nameof(content)); + return builder.WithContent(content, null, null, null); } /// /// Sets the request content using a created with the specified encoding. /// + /// The instance. + /// The string content to send with the request. + /// The encoding to use for the content. + /// The for method chaining. public static HttpRequestBuilder WithContent( this HttpRequestBuilder builder, string content, Encoding encoding) { - builder.Content = new StringContent(content, encoding); - return builder; + Guard.AgainstNull(content, nameof(content)); + Guard.AgainstNull(encoding, nameof(encoding)); + return builder.WithContent(content, encoding, null, null); } /// /// Sets the request content using a with UTF-8 encoding and the specified media type. /// + /// The instance. + /// The string content to send with the request. + /// The media type string (e.g., "application/json"). + /// The for method chaining. public static HttpRequestBuilder WithContent( this HttpRequestBuilder builder, string content, string mediaType) { - builder.Content = new StringContent(content, Encoding.UTF8, mediaType); - return builder; + Guard.AgainstNull(content, nameof(content)); + Guard.AgainstNull(mediaType, nameof(mediaType)); + return builder.WithContent(content, null, mediaType, null); } /// /// Sets the request content using a with the specified encoding and media type. /// + /// The instance. + /// The string content to send with the request. + /// The encoding to use for the content. + /// The media type string (e.g., "application/json"). + /// The for method chaining. public static HttpRequestBuilder WithContent( this HttpRequestBuilder builder, string content, Encoding encoding, string mediaType) { - builder.Content = new StringContent(content, encoding, mediaType); - return builder; + Guard.AgainstNull(content, nameof(content)); + Guard.AgainstNull(encoding, nameof(encoding)); + Guard.AgainstNull(mediaType, nameof(mediaType)); + return builder.WithContent(content, encoding, mediaType, null); } /// /// Sets the request content using a and applies the specified . /// + /// The instance. + /// The string content to send with the request. + /// The media type header value to apply to the content. + /// The for method chaining. public static HttpRequestBuilder WithContent( this HttpRequestBuilder builder, string content, MediaTypeHeaderValue mediaTypeHeaderValue) { - var sc = new StringContent(content); - sc.Headers.ContentType = mediaTypeHeaderValue; - builder.Content = sc; - return builder; + Guard.AgainstNull(content, nameof(content)); + Guard.AgainstNull(mediaTypeHeaderValue, nameof(mediaTypeHeaderValue)); + return builder.WithContent(content, null, null, mediaTypeHeaderValue); } /// /// Sets the request content using a with the specified encoding and applies the given . /// + /// The instance. + /// The string content to send with the request. + /// The encoding to use for the content. + /// The media type header value to apply to the content. + /// The for method chaining. public static HttpRequestBuilder WithContent( this HttpRequestBuilder builder, string content, Encoding encoding, MediaTypeHeaderValue mediaTypeHeaderValue) { - var sc = new StringContent(content, encoding); - sc.Headers.ContentType = mediaTypeHeaderValue; - builder.Content = sc; + Guard.AgainstNull(content, nameof(content)); + Guard.AgainstNull(encoding, nameof(encoding)); + Guard.AgainstNull(mediaTypeHeaderValue, nameof(mediaTypeHeaderValue)); + return builder.WithContent(content, encoding, null, mediaTypeHeaderValue); + } + + private static HttpRequestBuilder WithContent( + this HttpRequestBuilder builder, + string content, + Encoding? encoding, + string? mediaType, + MediaTypeHeaderValue? mediaTypeHeaderValue + ) + { + if (mediaType is not null) + { + builder.Content = new StringContent(content, encoding, mediaType); + } + else if (mediaTypeHeaderValue is not null) + { + var sc = new StringContent(content, encoding); + sc.Headers.ContentType = mediaTypeHeaderValue; + builder.Content = sc; + } + else + { + builder.Content = new StringContent(content, encoding); + } + return builder; } @@ -164,78 +227,99 @@ public static HttpRequestBuilder WithContent( /// /// Sets the request content to the provided XML string using UTF-8 encoding and the default XML media type. /// + /// The instance. + /// The XML string content to send with the request. + /// The for method chaining. public static HttpRequestBuilder WithXmlContent( this HttpRequestBuilder builder, string xml) { - builder.Content = new StringContent(xml, Encoding.UTF8, FluentXmlSerializer.DefaultContentType); - return builder; + Guard.AgainstNull(xml, nameof(xml)); + return builder.WithContent(xml, Encoding.UTF8, FluentXmlSerializer.DefaultContentType); } /// /// Sets the request content to the provided XML string using the specified encoding and the default XML media type. /// + /// The instance. + /// The XML string content to send with the request. + /// The encoding to use for the content. + /// The for method chaining. public static HttpRequestBuilder WithXmlContent( this HttpRequestBuilder builder, string xml, Encoding encoding) { - builder.Content = new StringContent(xml, encoding, FluentXmlSerializer.DefaultContentType); - return builder; + Guard.AgainstNull(xml, nameof(xml)); + return builder.WithContent(xml, encoding, FluentXmlSerializer.DefaultContentType); } /// /// Sets the request content to the provided XML string using UTF-8 encoding and the specified media type. /// + /// The instance. + /// The XML string content to send with the request. + /// The media type string for the content. + /// The for method chaining. public static HttpRequestBuilder WithXmlContent( this HttpRequestBuilder builder, string xml, string contentType) { - builder.Content = new StringContent(xml, Encoding.UTF8, contentType); - return builder; + Guard.AgainstNull(xml, nameof(xml)); + return builder.WithContent(xml, Encoding.UTF8, contentType); } /// /// Sets the request content to the provided XML string using UTF-8 encoding and applies the specified . /// + /// The instance. + /// The XML string content to send with the request. + /// The media type header value to apply to the content. + /// The for method chaining. public static HttpRequestBuilder WithXmlContent( this HttpRequestBuilder builder, string xml, MediaTypeHeaderValue contentTypeHeaderValue) { - var sc = new StringContent(xml, Encoding.UTF8); - sc.Headers.ContentType = contentTypeHeaderValue; - builder.Content = sc; - return builder; + Guard.AgainstNull(xml, nameof(xml)); + return builder.WithContent(xml, Encoding.UTF8, contentTypeHeaderValue); } /// /// Sets the request content to the provided XML string using the specified encoding and media type string. /// + /// The instance. + /// The XML string content to send with the request. + /// The encoding to use for the content. + /// The media type string for the content. + /// The for method chaining. public static HttpRequestBuilder WithXmlContent( this HttpRequestBuilder builder, string xml, Encoding encoding, string contentType) { - builder.Content = new StringContent(xml, encoding, contentType); - return builder; + Guard.AgainstNull(xml, nameof(xml)); + return builder.WithContent(xml, encoding, contentType); } /// /// Sets the request content to the provided XML string using the specified encoding and applies the given . /// + /// The instance. + /// The XML string content to send with the request. + /// The encoding to use for the content. + /// The media type header value to apply to the content. + /// The for method chaining. public static HttpRequestBuilder WithXmlContent( this HttpRequestBuilder builder, string xml, Encoding encoding, MediaTypeHeaderValue contentTypeHeaderValue) { - var sc = new StringContent(xml, encoding); - sc.Headers.ContentType = contentTypeHeaderValue; - builder.Content = sc; - return builder; + Guard.AgainstNull(xml, nameof(xml)); + return builder.WithContent(xml, encoding, contentTypeHeaderValue); } #endregion @@ -245,78 +329,99 @@ public static HttpRequestBuilder WithXmlContent( /// /// Sets the request content to the provided JSON string using UTF-8 encoding and the default JSON media type. /// + /// The instance. + /// The JSON string content to send with the request. + /// The for method chaining. public static HttpRequestBuilder WithJsonContent( this HttpRequestBuilder builder, string json) { - builder.Content = new StringContent(json, Encoding.UTF8, FluentJsonSerializer.DefaultContentType); - return builder; + Guard.AgainstNull(json, nameof(json)); + return builder.WithContent(json, Encoding.UTF8, FluentJsonSerializer.DefaultContentType); } /// /// Sets the request content to the provided JSON string using the specified encoding and the default JSON media type. /// + /// The instance. + /// The JSON string content to send with the request. + /// The encoding to use for the content. + /// The for method chaining. public static HttpRequestBuilder WithJsonContent( this HttpRequestBuilder builder, string json, Encoding encoding) { - builder.Content = new StringContent(json, encoding, FluentJsonSerializer.DefaultContentType); - return builder; + Guard.AgainstNull(json, nameof(json)); + return builder.WithContent(json, encoding, FluentJsonSerializer.DefaultContentType); } /// /// Sets the request content to the provided JSON string using UTF-8 encoding and the specified media type. /// + /// The instance. + /// The JSON string content to send with the request. + /// The media type string for the content. + /// The for method chaining. public static HttpRequestBuilder WithJsonContent( this HttpRequestBuilder builder, string json, string contentType) { - builder.Content = new StringContent(json, Encoding.UTF8, contentType); - return builder; + Guard.AgainstNull(json, nameof(json)); + return builder.WithContent(json, Encoding.UTF8, contentType); } /// /// Sets the request content to the provided JSON string using the specified encoding and media type. /// + /// The instance. + /// The JSON string content to send with the request. + /// The encoding to use for the content. + /// The media type string for the content. + /// The for method chaining. public static HttpRequestBuilder WithJsonContent( this HttpRequestBuilder builder, string json, Encoding encoding, string contentType) { - builder.Content = new StringContent(json, encoding, contentType); - return builder; + Guard.AgainstNull(json, nameof(json)); + return builder.WithContent(json, encoding, contentType); } /// /// Sets the request content to the provided JSON string using UTF-8 encoding and applies the specified content type header value. /// + /// The instance. + /// The JSON string content to send with the request. + /// The media type header value to apply to the content. + /// The for method chaining. public static HttpRequestBuilder WithJsonContent( this HttpRequestBuilder builder, string json, MediaTypeHeaderValue contentTypeHeaderValue) { - var sc = new StringContent(json, Encoding.UTF8); - sc.Headers.ContentType = contentTypeHeaderValue; - builder.Content = sc; - return builder; + Guard.AgainstNull(json, nameof(json)); + return builder.WithContent(json, Encoding.UTF8, contentTypeHeaderValue); } /// /// Sets the request content to the provided JSON string using the specified encoding and applies the given content type header value. /// + /// The instance. + /// The JSON string content to send with the request. + /// The encoding to use for the content. + /// The media type header value to apply to the content. + /// The for method chaining. public static HttpRequestBuilder WithJsonContent( this HttpRequestBuilder builder, string json, Encoding encoding, MediaTypeHeaderValue contentTypeHeaderValue) { - var sc = new StringContent(json, encoding); - sc.Headers.ContentType = contentTypeHeaderValue; - builder.Content = sc; - return builder; + Guard.AgainstNull(json, nameof(json)); + return builder.WithContent(json, encoding, contentTypeHeaderValue); } #endregion diff --git a/src/FluentHttpClient/FluentCookieExtensions.cs b/src/FluentHttpClient/FluentCookieExtensions.cs index 68363e9..1def38e 100644 --- a/src/FluentHttpClient/FluentCookieExtensions.cs +++ b/src/FluentHttpClient/FluentCookieExtensions.cs @@ -6,20 +6,41 @@ namespace FluentHttpClient; public static class FluentCookieExtensions { /// - /// Adds a single cookie to the request. + /// Adds a single cookie to the request with optional URL encoding of the value. /// - /// - /// - /// - /// - public static HttpRequestBuilder WithCookie(this HttpRequestBuilder builder, string name, string value) + /// + /// + /// By default, cookie values are URL-encoded using to ensure + /// special characters (such as ;, =, ,, and whitespace) do not break the + /// Cookie header format. This is recommended for most use cases. + /// + /// + /// Set to false only if the value is already properly encoded + /// or if you need to preserve exact byte sequences for legacy systems. + /// + /// + /// Existing cookies with the same name will be overwritten. + /// + /// + /// The instance. + /// The name of the cookie. + /// The value of the cookie. + /// If true (default), the value will be URL-encoded per RFC 6265. + /// Set to false to use the value as-is. + /// The for method chaining. + public static HttpRequestBuilder WithCookie( + this HttpRequestBuilder builder, + string name, + string value, + bool encode = true) { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Cookie name cannot be null or empty.", nameof(name)); - } + Guard.AgainstNullOrEmpty(name, nameof(name)); + + var finalValue = encode + ? Uri.EscapeDataString(value ?? string.Empty) + : value ?? string.Empty; - builder.Cookies[name] = value ?? string.Empty; + builder.Cookies[name] = finalValue; return builder; } @@ -27,31 +48,37 @@ public static HttpRequestBuilder WithCookie(this HttpRequestBuilder builder, str /// Adds multiple cookies to the request. /// /// + /// + /// By default, cookie values are URL-encoded using to ensure + /// special characters (such as ;, =, ,, and whitespace) do not break the + /// Cookie header format. This is recommended for most use cases. + /// + /// + /// Set to false only if the value is already properly encoded + /// or if you need to preserve exact byte sequences for legacy systems. + /// + /// /// Existing cookies with the same name will be overwritten. + /// /// - /// - /// - /// + /// The instance. + /// The collection of cookies as key-value pairs to add to the request. + /// If true (default), the value will be URL-encoded per RFC 6265. + /// Set to false to use the value as-is. + /// The for method chaining. public static HttpRequestBuilder WithCookies( this HttpRequestBuilder builder, - IEnumerable> cookies) + IEnumerable> cookies, + bool encode = true) { - if (cookies is null) - { - throw new ArgumentNullException(nameof(cookies)); - } + Guard.AgainstNull(cookies, nameof(cookies)); foreach (var kvp in cookies) { var key = kvp.Key; var value = kvp.Value; - if (string.IsNullOrWhiteSpace(key)) - { - throw new ArgumentException("Cookie name cannot be null or empty.", nameof(cookies)); - } - - builder.Cookies[key] = value ?? string.Empty; + builder.WithCookie(key, value, encode); } return builder; diff --git a/src/FluentHttpClient/FluentHeaderExtensions.cs b/src/FluentHttpClient/FluentHeaderExtensions.cs index 2a0a587..8210677 100644 --- a/src/FluentHttpClient/FluentHeaderExtensions.cs +++ b/src/FluentHttpClient/FluentHeaderExtensions.cs @@ -1,3 +1,8 @@ +#if NET8_0_OR_GREATER +using System.Collections.Frozen; +#endif +using System.Net.Http.Headers; + namespace FluentHttpClient; /// @@ -5,20 +10,105 @@ namespace FluentHttpClient; /// public static class FluentHeaderExtensions { +#if NET8_0_OR_GREATER + private static readonly FrozenSet _reservedHeaders = + FrozenSet.ToFrozenSet( + ["Host", "Content-Length", "Transfer-Encoding"], + StringComparer.OrdinalIgnoreCase); +#else private static readonly HashSet _reservedHeaders = - new(StringComparer.OrdinalIgnoreCase) + new(StringComparer.OrdinalIgnoreCase) + { + "Host", + "Content-Length", + "Transfer-Encoding" + }; +#endif + + /// + /// Configures HTTP request headers using the strongly-typed API. + /// + /// + /// + /// Use this method when you need to set headers that require strongly-typed values, such as + /// Authorization, CacheControl, Accept, IfModifiedSince, and other + /// headers with complex types or specialized formatting. + /// + /// + /// For simple string-based headers, use + /// instead, which provides better performance through direct dictionary storage. + /// + /// + /// Multiple calls to this method will accumulate. Each configuration action is executed when the + /// request is built, allowing you to compose complex header configurations. + /// + /// + /// The instance. + /// An action that configures the strongly-typed request headers. + /// The for method chaining. + /// + /// Setting cache control headers: + /// + /// builder.ConfigureHeaders(headers => + /// { + /// headers.CacheControl = new CacheControlHeaderValue + /// { + /// NoCache = true, + /// NoStore = true, + /// MaxAge = TimeSpan.Zero + /// }; + /// }); + /// + /// + /// Setting multiple accept types with quality values: + /// + /// builder.ConfigureHeaders(headers => + /// { + /// headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + /// headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml", 0.9)); + /// }); + /// + /// + /// Setting conditional request headers: + /// + /// builder.ConfigureHeaders(headers => + /// { + /// headers.IfModifiedSince = lastModifiedDate; + /// headers.IfNoneMatch.Add(new EntityTagHeaderValue("\"12345\"")); + /// }); + /// + /// + /// Thrown when is null. + public static HttpRequestBuilder ConfigureHeaders(this HttpRequestBuilder builder, Action configure) { - "Host", - "Content-Length", - "Transfer-Encoding" - }; + Guard.AgainstNull(configure, nameof(configure)); + + builder.HeaderConfigurators.Add(configure); + return builder; + } /// /// Adds the specified header and its value to the request. /// - /// - /// - /// + /// + /// + /// If the same header name is added multiple times, all values are accumulated and sent with the request. + /// This is useful for headers that accept multiple values, such as Accept or Cache-Control. + /// + /// + /// Header names are case-insensitive per HTTP specifications. + /// + /// + /// Reserved headers (Host, Content-Length, Transfer-Encoding) managed by + /// cannot be set and will throw an immediately. + /// + /// + /// The instance. + /// The name of the header to add. + /// The value of the header. + /// The for method chaining. + /// Thrown when or is null. + /// Thrown when is a reserved header. public static HttpRequestBuilder WithHeader(this HttpRequestBuilder builder, string key, string value) { Guard.AgainstNull(key, nameof(key)); @@ -32,97 +122,130 @@ public static HttpRequestBuilder WithHeader(this HttpRequestBuilder builder, str nameof(key)); } - builder.HeaderConfigurators.Add(target => - target.TryAddWithoutValidation(key, value)); + if (builder.InternalHeaders.TryGetValue(key, out var existingValues)) + { + var list = existingValues as List ?? existingValues.ToList(); + list.Add(value); + builder.InternalHeaders[key] = list; + } + else + { + builder.InternalHeaders[key] = new List { value }; + } return builder; } /// - /// Adds the specified header and its values to the request. + /// Adds the specified header with multiple values to the request. /// - /// - /// - /// + /// + /// + /// Each value in the collection is added to the header. If the header already exists, + /// the new values are appended to any existing values. This is useful for headers that + /// accept multiple values, such as Accept or Cache-Control. + /// + /// + /// This is equivalent to calling + /// multiple times with the same key but different values. + /// + /// + /// Header names are case-insensitive per HTTP specifications. + /// + /// + /// Reserved headers (Host, Content-Length, Transfer-Encoding) managed by + /// cannot be set and will throw an immediately. + /// + /// + /// The instance. + /// The name of the header to add. + /// The collection of values for the header. + /// The for method chaining. + /// Thrown when or is null. + /// Thrown when is a reserved header. public static HttpRequestBuilder WithHeader(this HttpRequestBuilder builder, string key, IEnumerable values) { - Guard.AgainstNull(key, nameof(key)); Guard.AgainstNull(values, nameof(values)); - if (_reservedHeaders.Contains(key)) + foreach (var value in values) { - throw new ArgumentException( - $"Header '{key}' is managed by HttpClient/HttpContent and cannot be set using FluentHttpClient. " + - "Configure the request URI or content instead.", - nameof(key)); + builder.WithHeader(key, value); } - builder.HeaderConfigurators.Add(target => - target.TryAddWithoutValidation(key, values)); - return builder; } /// - /// Adds the specified headers and their values to the request. + /// Adds multiple headers to the request from a collection of key-value pairs. /// - /// - /// + /// + /// + /// Each header in the collection is validated and added individually. If the same header name + /// is provided multiple times, all values are accumulated and sent with the request. + /// This is useful for headers that accept multiple values, such as Accept or Cache-Control. + /// + /// + /// Header names are case-insensitive per HTTP specifications. + /// + /// + /// Reserved headers (Host, Content-Length, Transfer-Encoding) managed by + /// cannot be set and will throw an immediately. + /// If any header is invalid or reserved, an exception is thrown immediately before processing + /// subsequent headers. + /// + /// + /// The instance. + /// The collection of headers as key-value pairs to add to the request. + /// The for method chaining. + /// Thrown when is null, or when any header key or value is null. + /// Thrown when any header key is a reserved header. public static HttpRequestBuilder WithHeaders(this HttpRequestBuilder builder, IEnumerable> headers) { Guard.AgainstNull(headers, nameof(headers)); - builder.HeaderConfigurators.Add(target => + foreach (var header in headers) { - foreach (var header in headers) - { - Guard.AgainstNull(header.Key, "key"); - Guard.AgainstNull(header.Value, "value"); - - if (_reservedHeaders.Contains(header.Key)) - { - throw new ArgumentException( - $"Header '{header.Key}' is managed by HttpClient/HttpContent and cannot be set using FluentHttpClient. " + - "Configure the request URI or content instead.", - "key"); - } - - target.TryAddWithoutValidation(header.Key, header.Value); - } - }); + builder.WithHeader(header.Key, header.Value); + } return builder; } /// - /// Adds the specified headers and their multiple values to the request. + /// Adds multiple headers with potentially multiple values per header to the request. /// - /// - /// + /// + /// + /// Each header in the collection can have multiple values. All values for a given header + /// are added to the request. If the same header name is provided multiple times in the collection, + /// all values are accumulated and sent with the request. This is useful for headers that + /// accept multiple values, such as Accept or Cache-Control. + /// + /// + /// Header names are case-insensitive per HTTP specifications. + /// + /// + /// Reserved headers (Host, Content-Length, Transfer-Encoding) managed by + /// cannot be set and will throw an immediately. + /// If any header is invalid or reserved, an exception is thrown immediately before processing + /// subsequent headers. + /// + /// + /// The instance. + /// The collection of headers where each header can have multiple values. + /// The for method chaining. + /// Thrown when is null, or when any header key or value collection is null. + /// Thrown when any header key is a reserved header. public static HttpRequestBuilder WithHeaders( this HttpRequestBuilder builder, IEnumerable>> headers) { Guard.AgainstNull(headers, nameof(headers)); - builder.HeaderConfigurators.Add(target => + foreach (var header in headers) { - foreach (var header in headers) - { - Guard.AgainstNull(header.Key, "key"); - Guard.AgainstNull(header.Value, "values"); - - if (_reservedHeaders.Contains(header.Key)) - { - throw new ArgumentException( - $"Header '{header.Key}' is managed by HttpClient/HttpContent and cannot be set using FluentHttpClient. " + - "Configure the request URI or content instead.", - "key"); - } - - target.TryAddWithoutValidation(header.Key, header.Value); - } - }); + builder.WithHeader(header.Key, header.Value); + } return builder; } diff --git a/src/FluentHttpClient/FluentJsonContentExtensions.cs b/src/FluentHttpClient/FluentJsonContentExtensions.cs index 6f599f1..6fc9b57 100644 --- a/src/FluentHttpClient/FluentJsonContentExtensions.cs +++ b/src/FluentHttpClient/FluentJsonContentExtensions.cs @@ -20,6 +20,10 @@ public static partial class FluentJsonContentExtensions /// Serializes the specified value as JSON using the default serializer options and sets it as the request content /// with UTF-8 encoding and the default JSON media type. /// + /// The type of the value to serialize. + /// The instance. + /// The value to serialize as JSON. + /// The for method chaining. public static HttpRequestBuilder WithJsonContent( this HttpRequestBuilder builder, T value) @@ -34,6 +38,11 @@ public static HttpRequestBuilder WithJsonContent( /// Serializes the specified value as JSON using the provided serializer options and sets it as the request content /// with UTF-8 encoding and the default JSON media type. /// + /// The type of the value to serialize. + /// The instance. + /// The value to serialize as JSON. + /// The JSON serializer options to use. + /// The for method chaining. public static HttpRequestBuilder WithJsonContent( this HttpRequestBuilder builder, T value, @@ -49,6 +58,11 @@ public static HttpRequestBuilder WithJsonContent( /// Serializes the specified value as JSON using the default serializer options and sets it as the request content /// with UTF-8 encoding and the specified media type. /// + /// The type of the value to serialize. + /// The instance. + /// The value to serialize as JSON. + /// The media type string for the content. + /// The for method chaining. public static HttpRequestBuilder WithJsonContent( this HttpRequestBuilder builder, T value, @@ -64,6 +78,12 @@ public static HttpRequestBuilder WithJsonContent( /// Serializes the specified value as JSON using the provided serializer options and sets it as the request content /// with UTF-8 encoding and the specified media type. /// + /// The type of the value to serialize. + /// The instance. + /// The value to serialize as JSON. + /// The JSON serializer options to use. + /// The media type string for the content. + /// The for method chaining. public static HttpRequestBuilder WithJsonContent( this HttpRequestBuilder builder, T value, @@ -80,6 +100,11 @@ public static HttpRequestBuilder WithJsonContent( /// Serializes the specified value as JSON using the default serializer options and sets it as the request content /// with UTF-8 encoding and applies the specified content type header value. /// + /// The type of the value to serialize. + /// The instance. + /// The value to serialize as JSON. + /// The media type header value to apply to the content. + /// The for method chaining. public static HttpRequestBuilder WithJsonContent( this HttpRequestBuilder builder, T value, @@ -97,6 +122,12 @@ public static HttpRequestBuilder WithJsonContent( /// Serializes the specified value as JSON using the provided serializer options and sets it as the request content /// with UTF-8 encoding and applies the given content type header value. /// + /// The type of the value to serialize. + /// The instance. + /// The value to serialize as JSON. + /// The JSON serializer options to use. + /// The media type header value to apply to the content. + /// The for method chaining. public static HttpRequestBuilder WithJsonContent( this HttpRequestBuilder builder, T value, diff --git a/src/FluentHttpClient/FluentJsonContentExtensionsAot.cs b/src/FluentHttpClient/FluentJsonContentExtensionsAot.cs index a4a004e..60cea71 100644 --- a/src/FluentHttpClient/FluentJsonContentExtensionsAot.cs +++ b/src/FluentHttpClient/FluentJsonContentExtensionsAot.cs @@ -13,6 +13,11 @@ public static partial class FluentJsonContentExtensions /// Serializes using the supplied and /// sets the JSON payload as the request content using UTF-8 and the default media type. /// + /// The type of the value to serialize. + /// The instance. + /// The value to serialize as JSON. + /// The JSON type metadata for AOT-safe serialization. + /// The for method chaining. public static HttpRequestBuilder WithJsonContent( this HttpRequestBuilder builder, T value, @@ -31,6 +36,11 @@ public static HttpRequestBuilder WithJsonContent( /// Serializes using metadata from the provided /// and sets the JSON payload as the request content. /// + /// The type of the value to serialize. + /// The instance. + /// The value to serialize as JSON. + /// The JSON serializer context containing type metadata for AOT-safe serialization. + /// The for method chaining. public static HttpRequestBuilder WithJsonContent( this HttpRequestBuilder builder, T value, @@ -54,6 +64,12 @@ public static HttpRequestBuilder WithJsonContent( /// Serializes using the supplied and /// sets the JSON payload as the request content using UTF-8 and the specified media type. /// + /// The type of the value to serialize. + /// The instance. + /// The value to serialize as JSON. + /// The JSON type metadata for AOT-safe serialization. + /// The media type string for the content. + /// The for method chaining. public static HttpRequestBuilder WithJsonContent( this HttpRequestBuilder builder, T value, @@ -75,6 +91,12 @@ public static HttpRequestBuilder WithJsonContent( /// and sets the JSON payload as the request content /// using UTF-8 and the specified media type. /// + /// The type of the value to serialize. + /// The instance. + /// The value to serialize as JSON. + /// The JSON serializer context containing type metadata for AOT-safe serialization. + /// The media type string for the content. + /// The for method chaining. public static HttpRequestBuilder WithJsonContent( this HttpRequestBuilder builder, T value, @@ -100,6 +122,12 @@ public static HttpRequestBuilder WithJsonContent( /// Serializes using the supplied and /// sets the JSON payload as the request content using UTF-8 and the specified content type header. /// + /// The type of the value to serialize. + /// The instance. + /// The value to serialize as JSON. + /// The JSON type metadata for AOT-safe serialization. + /// The media type header value to apply to the content. + /// The for method chaining. public static HttpRequestBuilder WithJsonContent( this HttpRequestBuilder builder, T value, @@ -124,6 +152,12 @@ public static HttpRequestBuilder WithJsonContent( /// and sets the JSON payload as the request content /// using UTF-8 and the specified content type header. /// + /// The type of the value to serialize. + /// The instance. + /// The value to serialize as JSON. + /// The JSON serializer context containing type metadata for AOT-safe serialization. + /// The media type header value to apply to the content. + /// The for method chaining. public static HttpRequestBuilder WithJsonContent( this HttpRequestBuilder builder, T value, diff --git a/src/FluentHttpClient/FluentJsonDeserialization.cs b/src/FluentHttpClient/FluentJsonDeserialization.cs index 9bea82d..868166d 100644 --- a/src/FluentHttpClient/FluentJsonDeserialization.cs +++ b/src/FluentHttpClient/FluentJsonDeserialization.cs @@ -19,8 +19,12 @@ public static class FluentJsonDeserialization /// /// Reads the JSON content of the response and parses it into a . /// - /// - /// + /// The HTTP response whose content will be read. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonDocument, or null if the content is empty. + /// + /// implements and must be disposed to avoid memory leaks. + /// Use using statements or ensure proper disposal in asynchronous scenarios. + /// public static Task ReadJsonDocumentAsync(this HttpResponseMessage response) { return response.ReadJsonDocumentAsync(_jsonDocumentOptions, CancellationToken.None); @@ -30,9 +34,13 @@ public static class FluentJsonDeserialization /// Reads the JSON content of the response and parses it into a , /// using the specified . /// - /// - /// - /// + /// The HTTP response whose content will be read. + /// Options that control the parsing behavior. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonDocument, or null if the content is empty. + /// + /// implements and must be disposed to avoid memory leaks. + /// Use using statements or ensure proper disposal in asynchronous scenarios. + /// public static Task ReadJsonDocumentAsync( this HttpResponseMessage response, JsonDocumentOptions documentOptions) @@ -44,9 +52,13 @@ public static class FluentJsonDeserialization /// Reads the JSON content of the response and parses it into a , /// observing the provided cancellation token. /// - /// - /// - /// + /// The HTTP response whose content will be read. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonDocument, or null if the content is empty. + /// + /// implements and must be disposed to avoid memory leaks. + /// Use using statements or ensure proper disposal in asynchronous scenarios. + /// public static Task ReadJsonDocumentAsync( this HttpResponseMessage response, CancellationToken cancellationToken) @@ -58,10 +70,14 @@ public static class FluentJsonDeserialization /// Reads the JSON content of the response and parses it into a , /// using the specified and cancellation token. /// - /// - /// - /// - /// + /// The HTTP response whose content will be read. + /// Options that control the parsing behavior. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonDocument, or null if the content is empty. + /// + /// implements and must be disposed to avoid memory leaks. + /// Use using statements or ensure proper disposal in asynchronous scenarios. + /// public static async Task ReadJsonDocumentAsync( this HttpResponseMessage response, JsonDocumentOptions documentOptions, @@ -88,8 +104,12 @@ public static class FluentJsonDeserialization /// /// Awaits the HTTP response task, then reads the JSON content and parses it into a . /// - /// - /// + /// A task that produces the HTTP response whose content will be read. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonDocument, or null if the content is empty. + /// + /// implements and must be disposed to avoid memory leaks. + /// Use using statements or ensure proper disposal in asynchronous scenarios. + /// public static Task ReadJsonDocumentAsync(this Task responseTask) { return responseTask.ReadJsonDocumentAsync(_jsonDocumentOptions, CancellationToken.None); @@ -99,9 +119,13 @@ public static class FluentJsonDeserialization /// Awaits the HTTP response task, then reads the JSON content and parses it into a , /// using the specified . /// - /// - /// - /// + /// A task that produces the HTTP response whose content will be read. + /// Options that control the parsing behavior. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonDocument, or null if the content is empty. + /// + /// implements and must be disposed to avoid memory leaks. + /// Use using statements or ensure proper disposal in asynchronous scenarios. + /// public static Task ReadJsonDocumentAsync( this Task responseTask, JsonDocumentOptions documentOptions) @@ -113,9 +137,13 @@ public static class FluentJsonDeserialization /// Awaits the HTTP response task, then reads the JSON content and parses it into a , /// observing the provided cancellation token. /// - /// - /// - /// + /// A task that produces the HTTP response whose content will be read. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonDocument, or null if the content is empty. + /// + /// implements and must be disposed to avoid memory leaks. + /// Use using statements or ensure proper disposal in asynchronous scenarios. + /// public static Task ReadJsonDocumentAsync( this Task responseTask, CancellationToken cancellationToken) @@ -127,10 +155,14 @@ public static class FluentJsonDeserialization /// Awaits the HTTP response task, then reads the JSON content and parses it into a , /// using the specified and cancellation token. /// - /// - /// - /// - /// + /// A task that produces the HTTP response whose content will be read. + /// Options that control the parsing behavior. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonDocument, or null if the content is empty. + /// + /// implements and must be disposed to avoid memory leaks. + /// Use using statements or ensure proper disposal in asynchronous scenarios. + /// public static async Task ReadJsonDocumentAsync( this Task responseTask, JsonDocumentOptions documentOptions, @@ -147,7 +179,8 @@ public static class FluentJsonDeserialization /// /// Reads the JSON content of the response and parses it into a . /// - /// + /// The HTTP response whose content will be read. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonObject, or null if the content is empty. public static Task ReadJsonObjectAsync(this HttpResponseMessage response) { return response.ReadJsonObjectAsync( @@ -160,8 +193,9 @@ public static class FluentJsonDeserialization /// Reads the JSON content of the response and parses it into a , /// observing the provided cancellation token. /// - /// - /// + /// The HTTP response whose content will be read. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonObject, or null if the content is empty. public static Task ReadJsonObjectAsync( this HttpResponseMessage response, CancellationToken cancellationToken) @@ -176,8 +210,9 @@ public static class FluentJsonDeserialization /// Reads the JSON content of the response and parses it into a , /// using the specified . /// - /// - /// + /// The HTTP response whose content will be read. + /// Options that control the behavior of the JsonNode. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonObject, or null if the content is empty. public static Task ReadJsonObjectAsync( this HttpResponseMessage response, JsonNodeOptions nodeOptions) @@ -192,9 +227,10 @@ public static class FluentJsonDeserialization /// Reads the JSON content of the response and parses it into a , /// using the specified and observing the provided cancellation token. /// - /// - /// - /// + /// The HTTP response whose content will be read. + /// Options that control the behavior of the JsonNode. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonObject, or null if the content is empty. public static Task ReadJsonObjectAsync( this HttpResponseMessage response, JsonNodeOptions nodeOptions, @@ -210,8 +246,9 @@ public static class FluentJsonDeserialization /// Reads the JSON content of the response and parses it into a , /// using the specified . /// - /// - /// + /// The HTTP response whose content will be read. + /// Options that control the parsing behavior. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonObject, or null if the content is empty. public static Task ReadJsonObjectAsync( this HttpResponseMessage response, JsonDocumentOptions documentOptions) @@ -226,9 +263,10 @@ public static class FluentJsonDeserialization /// Reads the JSON content of the response and parses it into a , /// using the specified and observing the provided cancellation token. /// - /// - /// - /// + /// The HTTP response whose content will be read. + /// Options that control the parsing behavior. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonObject, or null if the content is empty. public static Task ReadJsonObjectAsync( this HttpResponseMessage response, JsonDocumentOptions documentOptions, @@ -244,9 +282,10 @@ public static class FluentJsonDeserialization /// Reads the JSON content of the response and parses it into a , /// using the specified and . /// - /// - /// - /// + /// The HTTP response whose content will be read. + /// Options that control the behavior of the JsonNode. + /// Options that control the parsing behavior. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonObject, or null if the content is empty. public static Task ReadJsonObjectAsync( this HttpResponseMessage response, JsonNodeOptions nodeOptions, @@ -263,10 +302,11 @@ public static class FluentJsonDeserialization /// using the specified , , /// and observing the provided cancellation token. /// - /// - /// - /// - /// + /// The HTTP response whose content will be read. + /// Options that control the behavior of the JsonNode. + /// Options that control the parsing behavior. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonObject, or null if the content is empty. public static async Task ReadJsonObjectAsync( this HttpResponseMessage response, JsonNodeOptions nodeOptions, @@ -295,7 +335,8 @@ public static class FluentJsonDeserialization /// /// Awaits the HTTP response task, then reads the JSON content and parses it into a . /// - /// + /// A task that produces the HTTP response whose content will be read. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonObject, or null if the content is empty. public static Task ReadJsonObjectAsync(this Task responseTask) { return responseTask.ReadJsonObjectAsync( @@ -308,8 +349,9 @@ public static class FluentJsonDeserialization /// Awaits the HTTP response task, then reads the JSON content and parses it into a , /// observing the provided cancellation token. /// - /// - /// + /// A task that produces the HTTP response whose content will be read. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonObject, or null if the content is empty. public static Task ReadJsonObjectAsync( this Task responseTask, CancellationToken cancellationToken) @@ -324,8 +366,9 @@ public static class FluentJsonDeserialization /// Awaits the HTTP response task, then reads the JSON content and parses it into a , /// using the specified . /// - /// - /// + /// A task that produces the HTTP response whose content will be read. + /// Options that control the behavior of the JsonNode. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonObject, or null if the content is empty. public static Task ReadJsonObjectAsync( this Task responseTask, JsonNodeOptions nodeOptions) @@ -340,9 +383,10 @@ public static class FluentJsonDeserialization /// Awaits the HTTP response task, then reads the JSON content and parses it into a , /// using the specified and observing the provided cancellation token. /// - /// - /// - /// + /// A task that produces the HTTP response whose content will be read. + /// Options that control the behavior of the JsonNode. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonObject, or null if the content is empty. public static Task ReadJsonObjectAsync( this Task responseTask, JsonNodeOptions nodeOptions, @@ -358,8 +402,9 @@ public static class FluentJsonDeserialization /// Awaits the HTTP response task, then reads the JSON content and parses it into a , /// using the specified . /// - /// - /// + /// A task that produces the HTTP response whose content will be read. + /// Options that control the parsing behavior. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonObject, or null if the content is empty. public static Task ReadJsonObjectAsync( this Task responseTask, JsonDocumentOptions documentOptions) @@ -374,9 +419,10 @@ public static class FluentJsonDeserialization /// Awaits the HTTP response task, then reads the JSON content and parses it into a , /// using the specified and observing the provided cancellation token. /// - /// - /// - /// + /// A task that produces the HTTP response whose content will be read. + /// Options that control the parsing behavior. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonObject, or null if the content is empty. public static Task ReadJsonObjectAsync( this Task responseTask, JsonDocumentOptions documentOptions, @@ -392,9 +438,10 @@ public static class FluentJsonDeserialization /// Awaits the HTTP response task, then reads the JSON content and parses it into a , /// using the specified and . /// - /// - /// - /// + /// A task that produces the HTTP response whose content will be read. + /// Options that control the behavior of the JsonNode. + /// Options that control the parsing behavior. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonObject, or null if the content is empty. public static Task ReadJsonObjectAsync( this Task responseTask, JsonNodeOptions nodeOptions, @@ -411,10 +458,11 @@ public static class FluentJsonDeserialization /// using the specified , , /// and observing the provided cancellation token. /// - /// - /// - /// - /// + /// A task that produces the HTTP response whose content will be read. + /// Options that control the behavior of the JsonNode. + /// Options that control the parsing behavior. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the parsed JsonObject, or null if the content is empty. public static async Task ReadJsonObjectAsync( this Task responseTask, JsonNodeOptions nodeOptions, diff --git a/src/FluentHttpClient/FluentJsonSerializer.cs b/src/FluentHttpClient/FluentJsonSerializer.cs index 2d542a0..0fdc7cb 100644 --- a/src/FluentHttpClient/FluentJsonSerializer.cs +++ b/src/FluentHttpClient/FluentJsonSerializer.cs @@ -4,6 +4,9 @@ namespace FluentHttpClient; +/// +/// Provides default JSON serialization settings and constants for the FluentHttpClient library. +/// [ExcludeFromCodeCoverage] internal static class FluentJsonSerializer { diff --git a/src/FluentHttpClient/FluentJsonTypedDeserialization.cs b/src/FluentHttpClient/FluentJsonTypedDeserialization.cs index 34efebe..f7e48e1 100644 --- a/src/FluentHttpClient/FluentJsonTypedDeserialization.cs +++ b/src/FluentHttpClient/FluentJsonTypedDeserialization.cs @@ -17,9 +17,17 @@ public static partial class FluentJsonTypedDeserialization /// /// Reads the JSON content of the response and deserializes it to . /// - /// - /// - /// + /// + /// + /// Returns default(T?) when the response content is null or empty. For reference types, this is null. + /// For nullable value types (e.g., int?), this is also null. This behavior differs from + /// which explicitly + /// returns null for empty content. + /// + /// + /// The type to deserialize the JSON content into. + /// The HTTP response whose content will be read. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or default(T?) if the content is empty. public static Task ReadJsonAsync(this HttpResponseMessage response) { return response.ReadJsonAsync( @@ -31,10 +39,10 @@ public static partial class FluentJsonTypedDeserialization /// Reads the JSON content of the response and deserializes it to , /// using the specified serializer options. /// - /// - /// - /// - /// + /// The type to deserialize the JSON content into. + /// The HTTP response whose content will be read. + /// Options to control the deserialization behavior. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or default(T?) if the content is empty. public static Task ReadJsonAsync( this HttpResponseMessage response, JsonSerializerOptions? options) @@ -46,10 +54,10 @@ public static partial class FluentJsonTypedDeserialization /// Reads the JSON content of the response and deserializes it to , /// observing the provided cancellation token. /// - /// - /// - /// - /// + /// The type to deserialize the JSON content into. + /// The HTTP response whose content will be read. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or default(T?) if the content is empty. public static Task ReadJsonAsync( this HttpResponseMessage response, CancellationToken cancellationToken) @@ -63,11 +71,11 @@ public static partial class FluentJsonTypedDeserialization /// Reads the JSON content of the response and deserializes it to , /// using the specified serializer options and cancellation token. /// - /// - /// - /// - /// - /// + /// The type to deserialize the JSON content into. + /// The HTTP response whose content will be read. + /// Options to control the deserialization behavior. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or default(T?) if the content is empty. public static async Task ReadJsonAsync( this HttpResponseMessage response, JsonSerializerOptions? options, @@ -100,9 +108,9 @@ public static partial class FluentJsonTypedDeserialization /// /// Awaits the HTTP response task, then reads and deserializes the JSON content to . /// - /// - /// - /// + /// The type to deserialize the JSON content into. + /// A task that produces the HTTP response whose content will be read. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or default(T?) if the content is empty. public static Task ReadJsonAsync(this Task responseTask) { return responseTask.ReadJsonAsync( @@ -114,10 +122,10 @@ public static partial class FluentJsonTypedDeserialization /// Awaits the HTTP response task, then reads and deserializes the JSON content to , /// using the specified serializer options. /// - /// - /// - /// - /// + /// The type to deserialize the JSON content into. + /// A task that produces the HTTP response whose content will be read. + /// Options to control the deserialization behavior. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or default(T?) if the content is empty. public static Task ReadJsonAsync( this Task responseTask, JsonSerializerOptions? options) @@ -129,10 +137,10 @@ public static partial class FluentJsonTypedDeserialization /// Awaits the HTTP response task, then reads and deserializes the JSON content to , /// observing the provided cancellation token. /// - /// - /// - /// - /// + /// The type to deserialize the JSON content into. + /// A task that produces the HTTP response whose content will be read. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or default(T?) if the content is empty. public static Task ReadJsonAsync( this Task responseTask, CancellationToken cancellationToken) @@ -146,11 +154,11 @@ public static partial class FluentJsonTypedDeserialization /// Awaits the HTTP response task, then reads and deserializes the JSON content to , /// using the specified serializer options and cancellation token. /// - /// - /// - /// - /// - /// + /// The type to deserialize the JSON content into. + /// A task that produces the HTTP response whose content will be read. + /// Options to control the deserialization behavior. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or default(T?) if the content is empty. public static async Task ReadJsonAsync( this Task responseTask, JsonSerializerOptions? options, diff --git a/src/FluentHttpClient/FluentJsonTypedDeserializationAot.cs b/src/FluentHttpClient/FluentJsonTypedDeserializationAot.cs index 0d39786..d7728d9 100644 --- a/src/FluentHttpClient/FluentJsonTypedDeserializationAot.cs +++ b/src/FluentHttpClient/FluentJsonTypedDeserializationAot.cs @@ -11,6 +11,11 @@ public static partial class FluentJsonTypedDeserialization /// Reads the JSON content of the response and deserializes it to /// using the provided . /// + /// The type to deserialize the JSON content into. + /// The HTTP response whose content will be read. + /// The JSON type metadata for AOT-safe deserialization. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or null if the content is empty. public static async Task ReadJsonAsync( this HttpResponseMessage response, JsonTypeInfo jsonTypeInfo, @@ -37,6 +42,11 @@ public static partial class FluentJsonTypedDeserialization /// Reads the JSON content of the response and deserializes it to /// using the provided . /// + /// The type to deserialize the JSON content into. + /// The HTTP response whose content will be read. + /// The JSON serializer context containing type metadata for AOT-safe deserialization. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or null if the content is empty. public static Task ReadJsonAsync( this HttpResponseMessage response, JsonSerializerContext context, @@ -58,6 +68,11 @@ public static partial class FluentJsonTypedDeserialization /// Awaits the HTTP response task, then reads and deserializes the JSON content to /// using the provided . /// + /// The type to deserialize the JSON content into. + /// A task that produces the HTTP response whose content will be read. + /// The JSON type metadata for AOT-safe deserialization. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or null if the content is empty. public static async Task ReadJsonAsync( this Task responseTask, JsonTypeInfo jsonTypeInfo, @@ -77,6 +92,11 @@ public static partial class FluentJsonTypedDeserialization /// Awaits the HTTP response task, then reads and deserializes the JSON content to /// using the provided . /// + /// The type to deserialize the JSON content into. + /// A task that produces the HTTP response whose content will be read. + /// The JSON serializer context containing type metadata for AOT-safe deserialization. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or null if the content is empty. public static async Task ReadJsonAsync( this Task responseTask, JsonSerializerContext context, diff --git a/src/FluentHttpClient/FluentOptionsExtensions.cs b/src/FluentHttpClient/FluentOptionsExtensions.cs index 0255290..9642af1 100644 --- a/src/FluentHttpClient/FluentOptionsExtensions.cs +++ b/src/FluentHttpClient/FluentOptionsExtensions.cs @@ -9,8 +9,9 @@ public static class FluentOptionsExtensions /// /// Adds a configurator for modifying the collection. /// - /// - /// + /// The instance. + /// The action to configure the request options. + /// The for method chaining. public static HttpRequestBuilder ConfigureOptions(this HttpRequestBuilder builder, Action action) { Guard.AgainstNull(action, nameof(action)); @@ -22,9 +23,11 @@ public static HttpRequestBuilder ConfigureOptions(this HttpRequestBuilder builde /// /// Sets a typed option value on the collection. /// - /// - /// - /// + /// The type of the option value. + /// The instance. + /// The key identifying the option to set. + /// The value to set for the option. + /// The for method chaining. public static HttpRequestBuilder WithOption(this HttpRequestBuilder builder, HttpRequestOptionsKey key, T value) { builder.OptionConfigurators.Add(options => diff --git a/src/FluentHttpClient/FluentQueryParametersExtensions.cs b/src/FluentHttpClient/FluentQueryParametersExtensions.cs index 1a7e6be..24804cb 100644 --- a/src/FluentHttpClient/FluentQueryParametersExtensions.cs +++ b/src/FluentHttpClient/FluentQueryParametersExtensions.cs @@ -12,10 +12,10 @@ public static class FluentQueryParameterExtensions /// /// Adds a query string parameter with the specified key and value. /// - /// - /// - /// - /// + /// The instance. + /// The query parameter key. + /// The query parameter value. + /// The for method chaining. public static HttpRequestBuilder WithQueryParameter( this HttpRequestBuilder builder, string key, @@ -30,10 +30,10 @@ public static HttpRequestBuilder WithQueryParameter( /// /// Adds a query string parameter with the specified key and a value converted using . /// - /// - /// - /// - /// + /// The instance. + /// The query parameter key. + /// The query parameter value that will be converted to a string. + /// The for method chaining. public static HttpRequestBuilder WithQueryParameter( this HttpRequestBuilder builder, string key, @@ -48,10 +48,10 @@ public static HttpRequestBuilder WithQueryParameter( /// /// Adds one or more query string parameter values for the specified key. /// - /// - /// - /// - /// + /// The instance. + /// The query parameter key. + /// The collection of values for the parameter. + /// The for method chaining. public static HttpRequestBuilder WithQueryParameter( this HttpRequestBuilder builder, string key, @@ -67,10 +67,10 @@ public static HttpRequestBuilder WithQueryParameter( /// /// Adds one or more query string parameter values for the specified key, converting each value using . /// - /// - /// - /// - /// + /// The instance. + /// The query parameter key. + /// The collection of values that will be converted to strings. + /// The for method chaining. public static HttpRequestBuilder WithQueryParameter( this HttpRequestBuilder builder, string key, @@ -93,9 +93,9 @@ public static HttpRequestBuilder WithQueryParameter( /// /// Adds multiple query string parameters from the specified sequence of key/value pairs. /// - /// - /// - /// + /// The instance. + /// The collection of query parameters as key-value pairs. + /// The for method chaining. public static HttpRequestBuilder WithQueryParameters( this HttpRequestBuilder builder, IEnumerable> parameters) @@ -113,9 +113,9 @@ public static HttpRequestBuilder WithQueryParameters( /// /// Adds multiple query string parameters from the specified sequence of key/value pairs, converting values using . /// - /// - /// - /// + /// The instance. + /// The collection of query parameters whose values will be converted to strings. + /// The for method chaining. public static HttpRequestBuilder WithQueryParameters( this HttpRequestBuilder builder, IEnumerable> parameters) @@ -133,9 +133,9 @@ public static HttpRequestBuilder WithQueryParameters( /// /// Adds multiple query string parameters from the specified sequence of keys and value sequences. /// - /// - /// - /// + /// The instance. + /// The collection of query parameters where each can have multiple values. + /// The for method chaining. public static HttpRequestBuilder WithQueryParameters( this HttpRequestBuilder builder, IEnumerable>> parameters) @@ -153,9 +153,9 @@ public static HttpRequestBuilder WithQueryParameters( /// /// Adds multiple query string parameters from the specified sequence of keys and value sequences, converting values using . /// - /// - /// - /// + /// The instance. + /// The collection of query parameters where each can have multiple values that will be converted to strings. + /// The for method chaining. public static HttpRequestBuilder WithQueryParameters( this HttpRequestBuilder builder, IEnumerable>> parameters) @@ -182,10 +182,10 @@ public static HttpRequestBuilder WithQueryParameters( /// /// Adds a query string parameter with the specified key and value when the value is not null. /// - /// - /// - /// - /// + /// The instance. + /// The query parameter key. + /// The query parameter value, which is only added if not null. + /// The for method chaining. public static HttpRequestBuilder WithQueryParameterIfNotNull( this HttpRequestBuilder builder, string key, @@ -198,10 +198,10 @@ public static HttpRequestBuilder WithQueryParameterIfNotNull( /// /// Adds a query string parameter with the specified key and a value converted using when the value is not null. /// - /// - /// - /// - /// + /// The instance. + /// The query parameter key. + /// The query parameter value that will be converted to a string, only added if not null. + /// The for method chaining. public static HttpRequestBuilder WithQueryParameterIfNotNull( this HttpRequestBuilder builder, string key, diff --git a/src/FluentHttpClient/FluentSendExtensions.cs b/src/FluentHttpClient/FluentSendExtensions.cs index cd3fdd6..0fb252d 100644 --- a/src/FluentHttpClient/FluentSendExtensions.cs +++ b/src/FluentHttpClient/FluentSendExtensions.cs @@ -11,7 +11,8 @@ public static class FluentSendExtensions /// /// Sends an HTTP DELETE request using the configured . /// - /// + /// The instance. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task DeleteAsync(this HttpRequestBuilder builder) { return builder.SendAsync(HttpMethod.Delete); @@ -20,8 +21,9 @@ public static Task DeleteAsync(this HttpRequestBuilder buil /// /// Sends an HTTP DELETE request using the specified . /// - /// - /// + /// The instance. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task DeleteAsync( this HttpRequestBuilder builder, CancellationToken cancellationToken) @@ -32,8 +34,9 @@ public static Task DeleteAsync( /// /// Sends an HTTP DELETE request using the specified . /// - /// - /// + /// The instance. + /// Indicates when the operation should complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task DeleteAsync( this HttpRequestBuilder builder, HttpCompletionOption completionOption) @@ -44,9 +47,10 @@ public static Task DeleteAsync( /// /// Sends an HTTP DELETE request using the specified and . /// - /// - /// - /// + /// The instance. + /// Indicates when the operation should complete. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task DeleteAsync( this HttpRequestBuilder builder, HttpCompletionOption completionOption, @@ -60,7 +64,8 @@ public static Task DeleteAsync( /// /// Sends an HTTP GET request using the configured . /// - /// + /// The instance. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task GetAsync(this HttpRequestBuilder builder) { return builder.SendAsync(HttpMethod.Get); @@ -69,8 +74,9 @@ public static Task GetAsync(this HttpRequestBuilder builder /// /// Sends an HTTP GET request using the specified . /// - /// - /// + /// The instance. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task GetAsync( this HttpRequestBuilder builder, CancellationToken cancellationToken) @@ -81,8 +87,9 @@ public static Task GetAsync( /// /// Sends an HTTP GET request using the specified . /// - /// - /// + /// The instance. + /// Indicates when the operation should complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task GetAsync( this HttpRequestBuilder builder, HttpCompletionOption completionOption) @@ -93,9 +100,10 @@ public static Task GetAsync( /// /// Sends an HTTP GET request using the specified and . /// - /// - /// - /// + /// The instance. + /// Indicates when the operation should complete. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task GetAsync( this HttpRequestBuilder builder, HttpCompletionOption completionOption, @@ -109,7 +117,8 @@ public static Task GetAsync( /// /// Sends an HTTP HEAD request using the configured . /// - /// + /// The instance. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task HeadAsync(this HttpRequestBuilder builder) { return builder.SendAsync(HttpMethod.Head); @@ -118,8 +127,9 @@ public static Task HeadAsync(this HttpRequestBuilder builde /// /// Sends an HTTP HEAD request using the specified . /// - /// - /// + /// The instance. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task HeadAsync( this HttpRequestBuilder builder, CancellationToken cancellationToken) @@ -130,8 +140,9 @@ public static Task HeadAsync( /// /// Sends an HTTP HEAD request using the specified . /// - /// - /// + /// The instance. + /// Indicates when the operation should complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task HeadAsync( this HttpRequestBuilder builder, HttpCompletionOption completionOption) @@ -142,9 +153,10 @@ public static Task HeadAsync( /// /// Sends an HTTP HEAD request using the specified and . /// - /// - /// - /// + /// The instance. + /// Indicates when the operation should complete. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task HeadAsync( this HttpRequestBuilder builder, HttpCompletionOption completionOption, @@ -158,7 +170,8 @@ public static Task HeadAsync( /// /// Sends an HTTP OPTIONS request using the configured . /// - /// + /// The instance. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task OptionsAsync(this HttpRequestBuilder builder) { return builder.SendAsync(HttpMethod.Options); @@ -167,8 +180,9 @@ public static Task OptionsAsync(this HttpRequestBuilder bui /// /// Sends an HTTP OPTIONS request using the specified . /// - /// - /// + /// The instance. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task OptionsAsync( this HttpRequestBuilder builder, CancellationToken cancellationToken) @@ -179,8 +193,9 @@ public static Task OptionsAsync( /// /// Sends an HTTP OPTIONS request using the specified . /// - /// - /// + /// The instance. + /// Indicates when the operation should complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task OptionsAsync( this HttpRequestBuilder builder, HttpCompletionOption completionOption) @@ -191,9 +206,10 @@ public static Task OptionsAsync( /// /// Sends an HTTP OPTIONS request using the specified and . /// - /// - /// - /// + /// The instance. + /// Indicates when the operation should complete. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task OptionsAsync( this HttpRequestBuilder builder, HttpCompletionOption completionOption, @@ -207,7 +223,8 @@ public static Task OptionsAsync( /// /// Sends an HTTP PATCH request using the configured . /// - /// + /// The instance. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task PatchAsync(this HttpRequestBuilder builder) { #if NETSTANDARD2_0 @@ -220,8 +237,9 @@ public static Task PatchAsync(this HttpRequestBuilder build /// /// Sends an HTTP PATCH request using the specified . /// - /// - /// + /// The instance. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task PatchAsync( this HttpRequestBuilder builder, CancellationToken cancellationToken) @@ -236,8 +254,9 @@ public static Task PatchAsync( /// /// Sends an HTTP PATCH request using the specified . /// - /// - /// + /// The instance. + /// Indicates when the operation should complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task PatchAsync( this HttpRequestBuilder builder, HttpCompletionOption completionOption) @@ -252,9 +271,10 @@ public static Task PatchAsync( /// /// Sends an HTTP PATCH request using the specified and . /// - /// - /// - /// + /// The instance. + /// Indicates when the operation should complete. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task PatchAsync( this HttpRequestBuilder builder, HttpCompletionOption completionOption, @@ -272,7 +292,8 @@ public static Task PatchAsync( /// /// Sends an HTTP POST request using the configured . /// - /// + /// The instance. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task PostAsync(this HttpRequestBuilder builder) { return builder.SendAsync(HttpMethod.Post); @@ -281,8 +302,9 @@ public static Task PostAsync(this HttpRequestBuilder builde /// /// Sends an HTTP POST request using the specified . /// - /// - /// + /// The instance. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task PostAsync( this HttpRequestBuilder builder, CancellationToken cancellationToken) @@ -293,8 +315,9 @@ public static Task PostAsync( /// /// Sends an HTTP POST request using the specified . /// - /// - /// + /// The instance. + /// Indicates when the operation should complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task PostAsync( this HttpRequestBuilder builder, HttpCompletionOption completionOption) @@ -305,9 +328,10 @@ public static Task PostAsync( /// /// Sends an HTTP POST request using the specified and . /// - /// - /// - /// + /// The instance. + /// Indicates when the operation should complete. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task PostAsync( this HttpRequestBuilder builder, HttpCompletionOption completionOption, @@ -321,7 +345,8 @@ public static Task PostAsync( /// /// Sends an HTTP PUT request using the configured . /// - /// + /// The instance. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task PutAsync(this HttpRequestBuilder builder) { return builder.SendAsync(HttpMethod.Put); @@ -330,8 +355,9 @@ public static Task PutAsync(this HttpRequestBuilder builder /// /// Sends an HTTP PUT request using the specified . /// - /// - /// + /// The instance. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task PutAsync( this HttpRequestBuilder builder, CancellationToken cancellationToken) @@ -342,8 +368,9 @@ public static Task PutAsync( /// /// Sends an HTTP PUT request using the specified . /// - /// - /// + /// The instance. + /// Indicates when the operation should complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task PutAsync( this HttpRequestBuilder builder, HttpCompletionOption completionOption) @@ -354,9 +381,10 @@ public static Task PutAsync( /// /// Sends an HTTP PUT request using the specified and . /// - /// - /// - /// + /// The instance. + /// Indicates when the operation should complete. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the HTTP response message. public static Task PutAsync( this HttpRequestBuilder builder, HttpCompletionOption completionOption, diff --git a/src/FluentHttpClient/FluentVersionExtensions.cs b/src/FluentHttpClient/FluentVersionExtensions.cs index c3f74a3..a5ec839 100644 --- a/src/FluentHttpClient/FluentVersionExtensions.cs +++ b/src/FluentHttpClient/FluentVersionExtensions.cs @@ -9,15 +9,12 @@ public static class FluentVersionExtensions /// /// Sets the HTTP message version using a version string such as "1.1" or "2.0". /// - /// - /// - /// + /// The instance. + /// The HTTP version as a string (e.g., "1.1", "2.0"). + /// The for method chaining. public static HttpRequestBuilder UsingVersion(this HttpRequestBuilder builder, string version) { - if (string.IsNullOrWhiteSpace(version)) - { - throw new ArgumentException("Version cannot be null or empty.", nameof(version)); - } + Guard.AgainstNullOrEmpty(version, nameof(version)); if (!Version.TryParse(version, out var parsed)) { @@ -33,10 +30,10 @@ public static HttpRequestBuilder UsingVersion(this HttpRequestBuilder builder, s /// /// Sets the HTTP message version using the specified major and minor components. /// - /// - /// - /// - /// + /// The instance. + /// The major version number. + /// The minor version number. + /// The for method chaining. public static HttpRequestBuilder UsingVersion(this HttpRequestBuilder builder, int major, int minor) { builder.Version = new Version(major, minor); @@ -46,12 +43,12 @@ public static HttpRequestBuilder UsingVersion(this HttpRequestBuilder builder, i /// /// Sets the HTTP message version. /// - /// - /// - /// + /// The instance. + /// The HTTP version to use. + /// The for method chaining. public static HttpRequestBuilder UsingVersion(this HttpRequestBuilder builder, Version version) { - if (version is null) throw new ArgumentNullException(nameof(version)); + Guard.AgainstNull(version, nameof(version)); builder.Version = version; return builder; @@ -63,10 +60,10 @@ public static HttpRequestBuilder UsingVersion(this HttpRequestBuilder builder, V /// is interpreted and how the final HTTP version /// is negotiated with the server. /// - /// - /// - /// - /// + /// The instance. + /// The HTTP version as a string (e.g., "1.1", "2.0"). + /// The version policy to use for negotiation. + /// The for method chaining. public static HttpRequestBuilder UsingVersion(this HttpRequestBuilder builder, string version, HttpVersionPolicy policy) { builder.UsingVersion(version); @@ -79,13 +76,13 @@ public static HttpRequestBuilder UsingVersion(this HttpRequestBuilder builder, s /// is interpreted and how the final HTTP version /// is negotiated with the server. /// - /// - /// - /// - /// + /// The instance. + /// The HTTP version to use. + /// The version policy to use for negotiation. + /// The for method chaining. public static HttpRequestBuilder UsingVersion(this HttpRequestBuilder builder, Version version, HttpVersionPolicy policy) { - if (version is null) throw new ArgumentNullException(nameof(version)); + Guard.AgainstNull(version, nameof(version)); builder.Version = version; builder.VersionPolicy = policy; @@ -97,9 +94,9 @@ public static HttpRequestBuilder UsingVersion(this HttpRequestBuilder builder, V /// is interpreted and how the final HTTP version /// is negotiated with the server. /// - /// - /// - /// + /// The instance. + /// The version policy to use for negotiation. + /// The for method chaining. public static HttpRequestBuilder UsingVersionPolicy(this HttpRequestBuilder builder, HttpVersionPolicy policy) { builder.VersionPolicy = policy; diff --git a/src/FluentHttpClient/FluentXmlContentExtensions.cs b/src/FluentHttpClient/FluentXmlContentExtensions.cs index 33c7956..1c77d97 100644 --- a/src/FluentHttpClient/FluentXmlContentExtensions.cs +++ b/src/FluentHttpClient/FluentXmlContentExtensions.cs @@ -5,18 +5,25 @@ namespace FluentHttpClient; +/// +/// Fluent extension methods for adding XML content to the . +/// #if NET7_0_OR_GREATER [RequiresDynamicCode("XmlSerializer uses dynamic code generation which is not supported with Native AOT.")] #endif #if NET6_0_OR_GREATER [RequiresUnreferencedCode("XML serialization using XmlSerializer may be incompatible with trimming. Ensure all required members are preserved or use these APIs only in non-trimmed scenarios.")] #endif -internal static class FluentXmlContentExtensions +public static class FluentXmlContentExtensions { /// /// Serializes the specified value as XML using the default settings and sets it as the request content /// with UTF-8 encoding and the default XML media type. /// + /// The type of the value to serialize. + /// The instance. + /// The value to serialize as XML. + /// The for method chaining. public static HttpRequestBuilder WithXmlContent( this HttpRequestBuilder builder, T obj) @@ -31,6 +38,11 @@ public static HttpRequestBuilder WithXmlContent( /// Serializes the specified value as XML using the provided settings and sets it as the request content /// with the encoding derived from the provided and the default XML media type. /// + /// The type of the value to serialize. + /// The instance. + /// The value to serialize as XML. + /// The XML writer settings to use during serialization. + /// The for method chaining. public static HttpRequestBuilder WithXmlContent( this HttpRequestBuilder builder, T obj, @@ -47,6 +59,11 @@ public static HttpRequestBuilder WithXmlContent( /// Serializes the specified value as XML using the default settings and sets it as the request content /// with UTF-8 encoding and the specified media type. /// + /// The type of the value to serialize. + /// The instance. + /// The value to serialize as XML. + /// The media type string for the content. + /// The for method chaining. public static HttpRequestBuilder WithXmlContent( this HttpRequestBuilder builder, T obj, @@ -62,6 +79,12 @@ public static HttpRequestBuilder WithXmlContent( /// Serializes the specified value as XML using the provided settings and sets it as the request content /// with the encoding derived from the provided and the specified media type. /// + /// The type of the value to serialize. + /// The instance. + /// The value to serialize as XML. + /// The XML writer settings to use during serialization. + /// The media type string for the content. + /// The for method chaining. public static HttpRequestBuilder WithXmlContent( this HttpRequestBuilder builder, T obj, @@ -79,6 +102,11 @@ public static HttpRequestBuilder WithXmlContent( /// Serializes the specified value as XML using the default settings and sets it as the request content /// with UTF-8 encoding and applies the specified . /// + /// The type of the value to serialize. + /// The instance. + /// The value to serialize as XML. + /// The media type header value to apply to the content. + /// The for method chaining. public static HttpRequestBuilder WithXmlContent( this HttpRequestBuilder builder, T obj, @@ -97,6 +125,12 @@ public static HttpRequestBuilder WithXmlContent( /// with the encoding derived from the provided and applies the given /// . /// + /// The type of the value to serialize. + /// The instance. + /// The value to serialize as XML. + /// The XML writer settings to use during serialization. + /// The media type header value to apply to the content. + /// The for method chaining. public static HttpRequestBuilder WithXmlContent( this HttpRequestBuilder builder, T obj, diff --git a/src/FluentHttpClient/FluentXmlDeserialization.cs b/src/FluentHttpClient/FluentXmlDeserialization.cs index 029defd..c8e2c81 100644 --- a/src/FluentHttpClient/FluentXmlDeserialization.cs +++ b/src/FluentHttpClient/FluentXmlDeserialization.cs @@ -10,7 +10,8 @@ public static class FluentXmlDeserialization /// /// Reads the response content as XML and parses it into an . /// - /// + /// The HTTP response whose content will be read. + /// A task that represents the asynchronous operation. The task result contains the parsed XElement, or null if the content is empty. public static Task ReadXmlElementAsync( this HttpResponseMessage response) { @@ -21,8 +22,9 @@ public static class FluentXmlDeserialization /// Reads the response content as XML and parses it into an /// using the provided . /// - /// - /// + /// The HTTP response whose content will be read. + /// Options that control how the XML is loaded. + /// A task that represents the asynchronous operation. The task result contains the parsed XElement, or null if the content is empty. public static Task ReadXmlElementAsync( this HttpResponseMessage response, LoadOptions options) @@ -34,8 +36,9 @@ public static class FluentXmlDeserialization /// Reads the response content as XML and parses it into an , /// honoring the provided . /// - /// - /// + /// The HTTP response whose content will be read. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the parsed XElement, or null if the content is empty. public static Task ReadXmlElementAsync( this HttpResponseMessage response, CancellationToken token) @@ -47,9 +50,10 @@ public static class FluentXmlDeserialization /// Reads the response content as XML and parses it into an /// using the provided and . /// - /// - /// - /// + /// The HTTP response whose content will be read. + /// Options that control how the XML is loaded. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the parsed XElement, or null if the content is empty. public static Task ReadXmlElementAsync( this HttpResponseMessage response, LoadOptions options, @@ -61,7 +65,8 @@ public static class FluentXmlDeserialization /// /// Awaits the response task, then reads and parses the XML content into an . /// - /// + /// A task that produces the HTTP response whose content will be read. + /// A task that represents the asynchronous operation. The task result contains the parsed XElement, or null if the content is empty. public static Task ReadXmlElementAsync( this Task responseMessage) { @@ -73,8 +78,9 @@ public static class FluentXmlDeserialization /// Awaits the response task, then reads and parses the XML content into an /// using the provided . /// - /// - /// + /// A task that produces the HTTP response whose content will be read. + /// Options that control how the XML is loaded. + /// A task that represents the asynchronous operation. The task result contains the parsed XElement, or null if the content is empty. public static Task ReadXmlElementAsync( this Task responseMessage, LoadOptions options) @@ -87,8 +93,9 @@ public static class FluentXmlDeserialization /// Awaits the response task, then reads and parses the XML content into an , /// honoring the provided . /// - /// - /// + /// A task that produces the HTTP response whose content will be read. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the parsed XElement, or null if the content is empty. public static Task ReadXmlElementAsync( this Task responseMessage, CancellationToken token) @@ -101,9 +108,10 @@ public static class FluentXmlDeserialization /// Awaits the response task, then reads and parses the XML content into an /// using the provided and . /// - /// - /// - /// + /// A task that produces the HTTP response whose content will be read. + /// Options that control how the XML is loaded. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the parsed XElement, or null if the content is empty. public static Task ReadXmlElementAsync( this Task responseMessage, LoadOptions options, diff --git a/src/FluentHttpClient/FluentXmlSerializer.cs b/src/FluentHttpClient/FluentXmlSerializer.cs index 5e761c8..9484eb6 100644 --- a/src/FluentHttpClient/FluentXmlSerializer.cs +++ b/src/FluentHttpClient/FluentXmlSerializer.cs @@ -7,6 +7,22 @@ namespace FluentHttpClient; +/// +/// Provides internal XML serialization and deserialization functionality using XmlSerializer. +/// +/// +/// +/// XmlSerializer instances are cached per type to avoid repeated code generation overhead. +/// On .NET Framework and .NET Core versions prior to .NET 5, XmlSerializer generates dynamic assemblies +/// that cannot be unloaded, which may lead to memory accumulation in long-running applications +/// that serialize many different types. +/// +/// +/// For applications concerned about memory usage in scenarios with many types, or for Native AOT compatibility, +/// consider using JSON serialization instead. Alternatively, pre-generate XML serializers using +/// the XML Serializer Generator tool (sgen.exe) or source generators (.NET 6+). +/// +/// internal static class FluentXmlSerializer { private static readonly ConcurrentDictionary SerializerCache = new(); @@ -106,15 +122,26 @@ public static string Serialize(T obj, XmlWriterSettings settings) } } +/// +/// A specialized that supports custom encoding for XML serialization. +/// internal sealed class XmlStringWriter : StringWriter { private readonly Encoding _encoding; + /// + /// Initializes a new instance of the class with the specified format provider and encoding. + /// + /// An object that controls formatting. + /// The character encoding to use. public XmlStringWriter(IFormatProvider formatProvider, Encoding encoding) : base(formatProvider) { _encoding = encoding; } + /// + /// Gets the encoding for this string writer. + /// public override Encoding Encoding => _encoding; } diff --git a/src/FluentHttpClient/FluentXmlTypedDeserialization.cs b/src/FluentHttpClient/FluentXmlTypedDeserialization.cs index 2df9072..26770a6 100644 --- a/src/FluentHttpClient/FluentXmlTypedDeserialization.cs +++ b/src/FluentHttpClient/FluentXmlTypedDeserialization.cs @@ -17,8 +17,9 @@ public static class FluentXmlTypedDeserialization /// /// Reads the response content as XML and deserializes it into the specified type. /// - /// - /// + /// The type to deserialize the XML content into. + /// The HTTP response whose content will be read. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or null if the content is empty. public static Task ReadXmlAsync( this HttpResponseMessage response) where T : class @@ -30,9 +31,10 @@ public static class FluentXmlTypedDeserialization /// Reads the response content as XML and deserializes it into the specified type /// using the provided . /// - /// - /// - /// + /// The type to deserialize the XML content into. + /// The HTTP response whose content will be read. + /// The XML reader settings to use during deserialization. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or null if the content is empty. public static Task ReadXmlAsync( this HttpResponseMessage response, XmlReaderSettings settings) @@ -46,9 +48,10 @@ public static class FluentXmlTypedDeserialization /// Reads the response content as XML and deserializes it into the specified type, /// honoring the provided . /// - /// - /// - /// + /// The type to deserialize the XML content into. + /// The HTTP response whose content will be read. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or null if the content is empty. public static Task ReadXmlAsync( this HttpResponseMessage response, CancellationToken token) @@ -61,10 +64,11 @@ public static class FluentXmlTypedDeserialization /// Reads the response content as XML and deserializes it into the specified type /// using the provided and . /// - /// - /// - /// - /// + /// The type to deserialize the XML content into. + /// The HTTP response whose content will be read. + /// The XML reader settings to use during deserialization. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or null if the content is empty. public static Task ReadXmlAsync( this HttpResponseMessage response, XmlReaderSettings settings, @@ -78,8 +82,9 @@ public static class FluentXmlTypedDeserialization /// /// Awaits the response task, then reads and deserializes the XML content into the specified type. /// - /// - /// + /// The type to deserialize the XML content into. + /// A task that produces the HTTP response whose content will be read. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or null if the content is empty. public static Task ReadXmlAsync( this Task responseMessage) where T : class @@ -92,9 +97,10 @@ public static class FluentXmlTypedDeserialization /// Awaits the response task, then reads and deserializes the XML content into the specified type /// using the provided . /// - /// - /// - /// + /// The type to deserialize the XML content into. + /// A task that produces the HTTP response whose content will be read. + /// The XML reader settings to use during deserialization. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or null if the content is empty. public static Task ReadXmlAsync( this Task responseMessage, XmlReaderSettings settings) @@ -109,9 +115,10 @@ public static class FluentXmlTypedDeserialization /// Awaits the response task, then reads and deserializes the XML content into the specified type, /// honoring the provided . /// - /// - /// - /// + /// The type to deserialize the XML content into. + /// A task that produces the HTTP response whose content will be read. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or null if the content is empty. public static Task ReadXmlAsync( this Task responseMessage, CancellationToken token) @@ -125,10 +132,11 @@ public static class FluentXmlTypedDeserialization /// Awaits the response task, then reads and deserializes the XML content into the specified type /// using the provided and . /// - /// - /// - /// - /// + /// The type to deserialize the XML content into. + /// A task that produces the HTTP response whose content will be read. + /// The XML reader settings to use during deserialization. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. The task result contains the deserialized object, or null if the content is empty. public static Task ReadXmlAsync( this Task responseMessage, XmlReaderSettings settings, @@ -150,7 +158,7 @@ public static class FluentXmlTypedDeserialization var content = await response.ReadContentAsStringAsync(token).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(content)) { - return default; + return null; } return settings is not null diff --git a/src/FluentHttpClient/Guard.cs b/src/FluentHttpClient/Guard.cs index 1fb6478..68684a1 100644 --- a/src/FluentHttpClient/Guard.cs +++ b/src/FluentHttpClient/Guard.cs @@ -1,5 +1,8 @@ namespace FluentHttpClient; +/// +/// Provides internal guard clauses for parameter validation. +/// internal static class Guard { public static void AgainstNull(object? value, string? paramName = null) @@ -12,9 +15,14 @@ public static void AgainstNull(object? value, string? paramName = null) public static void AgainstNullOrEmpty(string? value, string? paramName = null) { - if (string.IsNullOrEmpty(value)) + if (value is null) { throw new ArgumentNullException(paramName); } + + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Value cannot be empty or consist only of whitespace.", paramName); + } } } diff --git a/src/FluentHttpClient/HttpClientExtensions.cs b/src/FluentHttpClient/HttpClientExtensions.cs index 8790f71..75c32cd 100644 --- a/src/FluentHttpClient/HttpClientExtensions.cs +++ b/src/FluentHttpClient/HttpClientExtensions.cs @@ -12,7 +12,8 @@ public static class HttpClientExtensions /// Creates a new using the 's /// configured as the starting point. /// - /// + /// The instance to use for sending requests. + /// A new instance initialized with the client's base address. public static HttpRequestBuilder UsingBase(this HttpClient client) { return new HttpRequestBuilder(client); @@ -22,8 +23,9 @@ public static HttpRequestBuilder UsingBase(this HttpClient client) /// Creates a new using the specified route /// as the initial request URI. The value can be absolute or relative. /// - /// - /// + /// The instance to use for sending requests. + /// The route string for the request URI, which can be absolute or relative. + /// A new instance initialized with the specified route. public static HttpRequestBuilder UsingRoute(this HttpClient client, string route) { return new HttpRequestBuilder(client, route); @@ -33,8 +35,9 @@ public static HttpRequestBuilder UsingRoute(this HttpClient client, string route /// Creates a new using the specified /// as the initial request URI. /// - /// - /// + /// The instance to use for sending requests. + /// The URI for the request. + /// A new instance initialized with the specified URI. public static HttpRequestBuilder UsingRoute(this HttpClient client, Uri uri) { return new HttpRequestBuilder(client, uri); diff --git a/src/FluentHttpClient/HttpQueryParameterCollection.cs b/src/FluentHttpClient/HttpQueryParameterCollection.cs index e0a2548..790c8b1 100644 --- a/src/FluentHttpClient/HttpQueryParameterCollection.cs +++ b/src/FluentHttpClient/HttpQueryParameterCollection.cs @@ -29,7 +29,7 @@ public sealed class HttpQueryParameterCollection : /// /// Gets the values associated with the specified key. /// - /// + /// The parameter name to retrieve values for. /// /// A read-only list of values for the key. /// @@ -214,7 +214,7 @@ public string ToQueryString() return string.Empty; } - var builder = new StringBuilder(); + var builder = new StringBuilder(capacity: _parameters.Count * 37); var first = true; void AppendPrefix() diff --git a/src/FluentHttpClient/HttpRequestBuilder.cs b/src/FluentHttpClient/HttpRequestBuilder.cs index 30762e9..48c4b21 100644 --- a/src/FluentHttpClient/HttpRequestBuilder.cs +++ b/src/FluentHttpClient/HttpRequestBuilder.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Headers; +using System.Text; namespace FluentHttpClient; @@ -22,8 +23,8 @@ public class HttpRequestBuilder /// Initializes a new instance of the class /// with the specified . /// - /// - /// + /// The HTTP client to use for sending requests. + /// Thrown when is null. /// /// Thrown when has a that /// contains a query string or fragment. @@ -46,8 +47,8 @@ internal HttpRequestBuilder(HttpClient client) /// Initializes a new instance of the class /// with the specified and route. /// - /// - /// + /// The HTTP client to use for sending requests. + /// The route string for the request. internal HttpRequestBuilder(HttpClient client, string route) : this(client, CreateRouteUri(route)) { @@ -57,9 +58,9 @@ internal HttpRequestBuilder(HttpClient client, string route) /// Initializes a new instance of the class /// with the specified and route. /// - /// - /// - /// + /// The HTTP client to use for sending requests. + /// The route URI for the request. + /// Thrown when is null. /// /// Thrown when contains a query string or fragment. /// @@ -97,7 +98,7 @@ internal HttpRequestBuilder(HttpClient client, Uri route) : this(client) /// /// These cookies are serialized into the Cookie header for this request only. /// Long-lived cookie management should be configured on the underlying handler used by . - /// Values are not automatically encoded when sending; encoding should occur prior to adding the cookie. + /// Values are not automatically encoded when sending; encoding should occur prior to or when adding the cookie. /// public IDictionary Cookies { get; } = new Dictionary(StringComparer.Ordinal); @@ -117,8 +118,51 @@ internal HttpRequestBuilder(HttpClient client, Uri route) : this(client) public List> DeferredConfigurators { get; } = []; /// - /// Returns the collection of actions used to configure HTTP request headers. + /// Gets the internal dictionary used to store headers that will be applied to the HTTP request. /// + /// + /// + /// This dictionary stores headers added via WithHeader and WithHeaders extension methods. + /// Headers are stored with their keys as case-insensitive strings and values as collections to support + /// HTTP headers with multiple values (e.g., Accept, Cache-Control). + /// + /// + /// The dictionary uses to ensure header name comparisons + /// are case-insensitive per HTTP specifications (RFC 7230). + /// + /// + /// Values are applied to the during request construction in + /// . + /// + /// + internal Dictionary> InternalHeaders { get; } = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + + /// + /// Returns the collection of actions used to configure strongly-typed HTTP request headers. + /// + /// + /// + /// This collection is intended for headers that require strongly-typed values from the + /// API, such as Authorization, CacheControl, + /// Accept, IfModifiedSince, and other headers with complex types or specialized formatting. + /// + /// + /// For simple string-based headers, use WithHeader extension methods instead, which populate + /// the dictionary for better performance. + /// + /// + /// Each action in this collection is executed during request construction in , + /// allowing multiple configurators to accumulate and apply their header settings. + /// + /// + /// + /// Adding a strongly-typed header: + /// + /// builder.HeaderConfigurators.Add(headers => + /// headers.Authorization = new AuthenticationHeaderValue("Bearer", token)); + /// + /// public List> HeaderConfigurators { get; } = []; #if NET5_0_OR_GREATER @@ -174,7 +218,7 @@ internal HttpRequestBuilder(HttpClient client, Uri route) : this(client) /// /// Uses and a default cancellation token. /// - /// + /// The HTTP method as a string (e.g., "GET", "POST"). /// The task object representing the asynchronous operation. /// /// Thrown when is null. @@ -192,7 +236,7 @@ public Task SendAsync(string method) /// /// Uses and a default cancellation token. /// - /// + /// The HTTP method to use for the request. /// The task object representing the asynchronous operation. /// /// Thrown when is null. @@ -210,8 +254,8 @@ public Task SendAsync(HttpMethod method) /// /// Uses . /// - /// - /// + /// The HTTP method as a string (e.g., "GET", "POST"). + /// A cancellation token to observe while waiting for the task to complete. /// The task object representing the asynchronous operation. /// /// Thrown when is null. @@ -229,8 +273,8 @@ public Task SendAsync(string method, CancellationToken canc /// /// Uses . /// - /// - /// + /// The HTTP method to use for the request. + /// A cancellation token to observe while waiting for the task to complete. /// The task object representing the asynchronous operation. /// /// Thrown when is null. @@ -248,8 +292,8 @@ public Task SendAsync(HttpMethod method, CancellationToken /// /// Uses the specified and a default cancellation token. /// - /// - /// + /// The HTTP method as a string (e.g., "GET", "POST"). + /// Indicates when the operation should complete. /// The task object representing the asynchronous operation. /// /// Thrown when is null. @@ -267,8 +311,8 @@ public Task SendAsync(string method, HttpCompletionOption c /// /// Uses the specified and a default cancellation token. /// - /// - /// + /// The HTTP method to use for the request. + /// Indicates when the operation should complete. /// The task object representing the asynchronous operation. /// /// Thrown when is null. @@ -283,9 +327,9 @@ public Task SendAsync(HttpMethod method, HttpCompletionOpti /// /// Sends an HTTP request as an asynchronous operation using the configured builder state. /// - /// - /// - /// + /// The HTTP method as a string (e.g., "GET", "POST"). + /// Indicates when the operation should complete. + /// A cancellation token to observe while waiting for the task to complete. /// The task object representing the asynchronous operation. /// /// Thrown when is null. @@ -306,9 +350,9 @@ public Task SendAsync( /// /// Sends an HTTP request as an asynchronous operation using the configured builder state. /// - /// - /// - /// + /// The HTTP method to use for the request. + /// Indicates when the operation should complete. + /// A cancellation token to observe while waiting for the task to complete. /// The task object representing the asynchronous operation. /// /// Thrown when is null. @@ -333,8 +377,8 @@ public async Task SendAsync( /// Builds and configures the for this builder instance. /// Intended for internal and testing use. Use and its overloads to send requests. /// - /// - /// + /// The HTTP method to use for the request. + /// A cancellation token to observe while building the request. /// The configured . /// /// Thrown when is null. @@ -347,7 +391,7 @@ internal async Task BuildRequest(HttpMethod method, Cancella { Guard.AgainstNull(method, nameof(method)); - foreach (var configure in DeferredConfigurators) + foreach (var configure in DeferredConfigurators.ToArray()) { configure(this); } @@ -412,7 +456,7 @@ internal Uri BuildRequestUri() /// /// Applies headers, cookies, and options from the builder to the request. /// - /// + /// The HTTP request message to configure. private void ApplyConfiguration(HttpRequestMessage request) { if (Content is MultipartContent) @@ -420,6 +464,14 @@ private void ApplyConfiguration(HttpRequestMessage request) request.Headers.ExpectContinue = false; } + foreach (var header in InternalHeaders) + { + foreach (var value in header.Value) + { + request.Headers.TryAddWithoutValidation(header.Key, value); + } + } + for (var i = 0; i < HeaderConfigurators.Count; i++) { HeaderConfigurators[i](request.Headers); @@ -427,11 +479,24 @@ private void ApplyConfiguration(HttpRequestMessage request) if (Cookies.Count > 0) { - var cookieHeader = string.Join( - "; ", - Cookies.Select(kvp => $"{kvp.Key}={kvp.Value}")); + var sb = new StringBuilder(capacity: Cookies.Count * 32); // Estimate ~32 chars per cookie + var first = true; + + foreach (var kvp in Cookies) + { + if (!first) + { + sb.Append("; "); + } + + sb.Append(kvp.Key); + sb.Append('='); + sb.Append(kvp.Value); + first = false; + } - if (!string.IsNullOrEmpty(cookieHeader)) + var cookieHeader = sb.ToString(); + if (cookieHeader.Length > 0) { request.Headers.Add(CookieHeaderName, cookieHeader); } diff --git a/src/FluentHttpClient/HttpRequestBuilderExensions.cs b/src/FluentHttpClient/HttpRequestBuilderExensions.cs index f38b5e1..9a9656c 100644 --- a/src/FluentHttpClient/HttpRequestBuilderExensions.cs +++ b/src/FluentHttpClient/HttpRequestBuilderExensions.cs @@ -13,6 +13,10 @@ public static class HttpRequestBuilderExtensions /// Builds an using the current state of the builder. /// This API is experimental and may change in future versions. /// + /// The instance. + /// The HTTP method to use for the request. + /// A cancellation token to observe while building the request. + /// A task that represents the asynchronous operation. The task result contains the built . [Experimental("FHCBUILDREQUEST")] public static Task BuildRequestAsync( this HttpRequestBuilder builder, diff --git a/src/FluentHttpClient/README.md b/src/FluentHttpClient/README.md index 04763c6..bc2d572 100644 --- a/src/FluentHttpClient/README.md +++ b/src/FluentHttpClient/README.md @@ -27,4 +27,33 @@ If you are building against **netstandard2.0** or **netstandard2.1**, or any TFM ``` -Apps targeting modern TFMs (such as .NET 5 and later) already include `System.Text.Json` and do not require this step. \ No newline at end of file +Apps targeting modern TFMs (such as .NET 5 and later) already include `System.Text.Json` and do not require this step. + +## When to Use FluentHttpClient + +While `HttpClient` is a powerful and flexible tool, building HTTP requests with it often involves repetitive boilerplate, manual serialization, and scattered configuration logic. FluentHttpClient addresses these pain points by providing a fluent, chainable API that reduces cognitive load and improves code readability. + +### Common HttpClient Challenges + +**Repetitive Configuration** +Every request requires manually setting headers, query parameters, and content, often scattered across multiple lines. This makes it easy to miss required headers or forget encoding rules. + +**Manual Serialization** +Converting objects to JSON, setting the correct `Content-Type`, and deserializing responses requires multiple steps and imports. Error-prone encoding and parsing logic often needs to be duplicated across your codebase. + +**Inconsistent Error Handling** +Without a unified approach to handling success and failure responses, status code checks and logging logic tend to be duplicated or omitted entirely. + +**Lifetime and Reuse Concerns** +Properly managing `HttpClient` lifetime, avoiding socket exhaustion, and reusing instances while still configuring per-request state requires careful planning and often leads to awkward patterns. + +### How FluentHttpClient Helps + +FluentHttpClient wraps `HttpClient` (you still manage the lifetime) and provides extension methods that let you configure requests in a single, readable chain: + +- **Fluent Configuration**: Add headers, query parameters, cookies, and authentication in a natural, discoverable flow +- **Automatic Serialization**: Built-in JSON and XML serialization/deserialization with support for `System.Text.Json`, Native AOT, and custom options +- **Response Handlers**: Attach success and failure callbacks directly in the request chain without breaking fluency +- **Reduced Boilerplate**: Express the entire request lifecycle—configuration, sending, and deserialization—in a single expression + +FluentHttpClient can expresses the same logic in fewer lines, with better readability and no loss of functionality. All configuration, sending, error handling, and deserialization happen in a single fluent chain. \ No newline at end of file diff --git a/src/version.json b/src/version.json index 175e865..ba47acf 100644 --- a/src/version.json +++ b/src/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "5.0.0-rc2", + "version": "5.0.0-rc3", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/v\\d+(?:\\.\\d+)?$"