Pooshit.Http 0.14.0-preview

This is a prerelease version of Pooshit.Http.
dotnet add package Pooshit.Http --version 0.14.0-preview
                    
NuGet\Install-Package Pooshit.Http -Version 0.14.0-preview
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Pooshit.Http" Version="0.14.0-preview" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Pooshit.Http" Version="0.14.0-preview" />
                    
Directory.Packages.props
<PackageReference Include="Pooshit.Http" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Pooshit.Http --version 0.14.0-preview
                    
#r "nuget: Pooshit.Http, 0.14.0-preview"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Pooshit.Http@0.14.0-preview
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Pooshit.Http&version=0.14.0-preview&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Pooshit.Http&version=0.14.0-preview&prerelease
                    
Install as a Cake Tool

Library simplifying http request handling

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.14.0-preview 53 9/22/2026
0.12.1-preview 879 8/28/2026
0.10.0-preview 166 8/27/2026
0.8.0-preview 320 8/18/2026
0.7.18-preview 473 6/25/2026
0.7.17-preview 1,921 4/12/2026
0.7.16-preview 80 4/11/2026
0.7.15-preview 82 4/11/2026
0.7.9-preview 867 2/5/2026
0.7.8-preview 2,940 3/2/2025
0.7.7-preview 1,517 10/15/2024
0.7.6-preview 369 10/12/2024

Rest.Path and Rest.PathQuery now percent-encode every path segment, and their first parameter is now a declared base url rather than the first entry of a params array. This is the change most likely to alter what a working caller observes, one of its three break shapes is a compile error and the other two are silent, so read this entry before upgrading. The signatures are now Path(string baseUrl, params object[] segments), PathQuery(string querystring, string baseUrl, params object[] segments) and PathQuery(QueryParameters querystring, string baseUrl, params object[] segments). The base url is reproduced verbatim, character for character, because it carries the scheme and authority without which the request cannot be sent at all - escaping it would turn http://host/api into http://host/api and break every caller without exception. Every argument after it is a segment: it is rendered, checked and passed through Uri.EscapeDataString, so a / ? # & = \ space or non-ascii character inside a value can no longer add a segment, start a query, truncate the url into a fragment or become a separator. That is the defect this fixes. These helpers exist so that callers stop hand-building urls, so a caller has every reason to assume they escape, and nothing in the old signature said they did not.

Two guards ship with the encoding. A null base url, a null segment array, or a null segment raises ArgumentNullException naming the position. A null segment previously rendered as empty and produced a doubled separator, and what that addresses is then decided by the server's normalisation rather than by the calling code - many servers collapse it, and a delete shaped that way reaches the collection instead of the one member that was meant. An empty string segment is still accepted and still yields the trailing slash that some apis require: an empty string is a value the caller wrote, while a null is the shape an unset value takes by default. A segment whose rendered form is exactly . or .. raises ArgumentException naming the position, and that guard is the half that makes the fix true rather than an extra on top of it: escaping provably does not neutralise a dot segment, because Uri removes . and .. before the request line is built and decodes .. back to .. before doing so, so encoding alone would have shipped a helper that advertises safety and still traverses. Only whole segments are rejected - a..b, ..., a. and ..a are measured safe and still pass - and a caller who genuinely wants to walk up a level puts .. in the base url, where it is verbatim text. A caller-supplied .. now survives as %2E%2E and is inert.

Three populations are affected and only the first is loud. The compile break has two independent causes, and the second is easy to overlook because it is the parameter rename rather than the added parameter. From the added parameter: a call in which the argument in the base url position is not a string, a call that passes an object array variable instead of separate arguments, and a call that passes nothing at all, all stop compiling - and note that the base url is the second argument of both PathQuery overloads, so a call written PathQuery(query, parts) fails on argument two rather than on argument one. From the rename of elements into baseUrl and segments: a call that names the old parameter, as in Path(elements: parts) or PathQuery(querystring: query, elements: parts), no longer binds, and a method group conversion such as Func<object[], string> f = Rest.Path no longer matches. Name the base url explicitly, or update the argument name, and they build again. The two silent ones fail at runtime, usually as a 404. A segment carrying a value that was already escaped by the caller is now escaped a second time, so a/b becomes a%2Fb - pass the raw value, escaping is the helper's job now. And a segment carrying more than one segment's worth of text, users/7/orders handed over as a single argument, is now the single encoded segment users/7/orders - split it into separate arguments, or fold the fixed prefix into the base url. A fourth population mostly needs nothing done to it: a segment carrying a + : @ or , inside an identifier now travels as + : @ or ,, which is the correct wire form and which any conforming server decodes; a server that does not was relying on the defect. The escape hatch for the two silent shapes is already in the signature and needs no new member, because the base url parameter is verbatim text: a caller genuinely holding a pre-built, pre-escaped or multi-segment tail composes it there, as in Rest.Path($"{root}/{tail}", id), and keeps the old behaviour for that portion while the values that follow are still escaped.

Finding your own call sites. Narrow first on the static type of each argument after the base url, since the base itself cannot break. A segment declared int, long, short, byte, Guid or bool is provably unaffected and needs no review at all - its rendering is digits, hex, hyphens or the words True and False, none of which Uri.EscapeDataString alters. A segment declared DateTime, DateTimeOffset, decimal, double or float does change, but it was already broken before this: an invariant DateTime renders as a date carrying two slashes, which already splits it into extra segments today, so review those against the separate invariant-rendering change rather than against this one. A segment declared as an enum is worth a glance: a single value renders as an identifier and is safe, while a Flags value with more than one bit set renders two names separated by a comma and a space, both of which are now encoded. Everything else - a segment declared string, declared object, or supplied through a generic type parameter - is the population to review, because a separator can only enter through one of those, and every other row is noise.

Inside that population, two mechanical searches find most of what breaks. For the multi-segment shape, look for a Rest.Path or Rest.PathQuery call carrying a / inside a string literal, or inside the literal portion of an interpolated string, in any argument after the base url; each of those is now one encoded segment, and it is the largest group either search reaches. For the double-encoding shape, look for any Rest.Path or Rest.PathQuery call site that also mentions Uri.EscapeDataString, Uri.EscapeUriString, HttpUtility.UrlEncode or HttpUtility.UrlPathEncode; each of those now encodes twice. Neither search reaches a string segment holding a pre-composed sub-path in a variable - typically named something like path, subPath, route, resource or endpoint - and that remains a manual pass. Reviewing by that name set is a heuristic and not a proof, and this note does not claim a coverage it does not have. And in the other direction, so that the triage does not read as pure loss: most of the string population is fixed by this rather than broken by it, because a segment carrying a name, a slug, a code, an email address or any free-text value is exactly what could restructure the url before and cannot now.

Unaffected: the base url, whose every character including its own separators, its colon and any dot segments it contains is reproduced exactly as given; the join rule itself, which still emits exactly one separator per segment and still leaves a base url ending in a separator producing a doubled one; the query half of PathQuery, whose parameter names and values were already form-encoded and are unchanged here; a null QueryParameters, which still throws exactly as it did; and every call whose segments are numbers, Guids or booleans, which is most of them. Value rendering is also unaffected and is deliberately not part of this change: a segment is still rendered through its own ToString and therefore still with the ambient culture, so a decimal or a DateTime in a path still changes shape with the host's culture. That is a separate change and it is not in this release. One structural improvement falls out of the fix: PathQuery no longer carries its own copy of the join rule but renders its path portion through Path, so the two can no longer drift apart.

HttpService.SensitiveHeaders now also holds the spellings apiKey and X-ApiKey, beside the Api-Key and X-Api-Key it already held. This is the second of the four changes in this release and it changes a path that succeeds today, so read this entry before upgrading. The set is matched by exact header name under OrdinalIgnoreCase, which normalises case but not hyphenation, so a credential sent as apiKey or X-ApiKey matched none of the names already in the set. That set is read in two places and both of them move for these two spellings. The first is the one the change was made for: in the message of an HttpServiceException under the default Redacted mode, the value of such a header was written out in full where every other credential name in the set was already replaced by a placeholder, so it reached whatever sink the caller logs errors to, and it is now replaced too. Full mode still writes every header verbatim and Omitted still writes none, both unchanged. The second is a consequence rather than an intention: the same set decides which headers are withheld from a redirect hop that leaves the origin, so a caller who forwards apiKey or X-ApiKey across a cross-origin 301, 302, 303, 307 or 308 now sends that hop without the credential, where it previously carried one. That failure is quiet - the hop goes out, the far host answers 401 or 403, and nothing in the library reports that a header was withheld - so an authentication failure appearing at a redirect target after upgrading is the shape to look for. A caller who needs the credential to reach that target should not take the name back out of SensitiveHeaders, because one set governs both behaviours and removing it restores the verbatim dump along with the carry. Rewrite the target through HttpOptions.UrlProcessor to a host you name, which still runs before the origin is resolved, or leave FollowRedirects unset and issue the second request yourself with the header on it. Unaffected: a credential sent under the already-listed Api-Key or X-Api-Key spelling, which behaved this way before and is unchanged; a same-origin hop, which carries every name in the set; the matching rule itself, which is still exact name comparison ignoring case, so a further spelling such as api_key is still not matched and is still the caller's to add through the set; and every call which neither produces an error message nor follows a cross-origin redirect.

A 307 or 308 redirect is now followed with the original method and the original request body, where a 308 previously returned as though the resource had been empty and a 307 raised NotSupportedException. This is the third of the four changes in this release, carried forward from 0.13.1-preview, and it changes two paths a caller can be on today, so read it before upgrading. Redirects are only followed when HttpOptions.FollowRedirects is set and nothing here changes for a caller who leaves it unset. For a caller who did opt in, a 308 matched no redirect branch at all, passed the 200 to 399 success check and returned the default of the requested type, so a moved resource was indistinguishable from an empty one and surfaced downstream as a NullReferenceException several frames away from its cause. 307 and 308 are now recognised together as the redirects which preserve the request, and the hop re-sends the original method carrying the original content instance to the resolved location. Nothing is buffered, so a streaming upload still streams on the first send and the followed path costs no extra memory. Three consequences are worth naming. First, 307 no longer raises NotSupportedException, so a caller catching that type around a redirect-following call now catches nothing. The throw was a placeholder and its message said so, but it is a thrown type that disappears. Second, a body which cannot be sent a second time, which means a StreamContent over a stream that cannot seek or a multipart body holding such a part, now raises HttpServiceException naming the status, the resolved target and the original method, with the transport exception on InnerException and the superseded redirect response left undisposed on HttpServiceException.Response for the caller to read and dispose. That call previously returned null for a 308 and raised NotSupportedException for a 307, so it is now loud and diagnosable rather than followed, which is a capability gap rather than a fix. Any other failure while following a redirect is not translated, which covers a network error, a caller-supplied content that signals exhaustion with some other exception type, and a UrlProcessor that throws or returns a location which will not resolve: the original exception reaches the caller unchanged, and the superseded redirect response is disposed rather than leaked. That holds on the 301, 302 and 303 arm too, where a failure of any kind between the redirect and the hop previously leaked the response it superseded. Third, a 307 or 308 which carries no Location, or one resolving to the url the request already went to, raises the same HttpServiceException instead of repeating a side-effecting request against the same address. The 301, 302 and 303 arm keeps its existing behaviour when a Location is missing. A cross-origin 307 or 308 carries the request body to the host the remote server named, which is what RFC 7538, browsers and curl all do for these two statuses, while every name in HttpService.SensitiveHeaders is still withheld from a hop leaving the origin by the same rule as before, applied to a set which gained two spellings in this release. A secret living inside a request body therefore now travels where the body previously did not, and a caller who needs to prevent that can rewrite the target through HttpOptions.UrlProcessor, which still runs before the origin is resolved, or take the raw reply with FollowRedirects unset. This capability is measured on .NET Core and .NET 5 or later only. The netstandard2.0 target running on .NET Framework is unmeasured: request content is widely reported to be disposed there once a send completes, and where it is, a body-carrying 307 or 308 lands on the loud-failure path described above rather than being followed. Unaffected: every call which does not set FollowRedirects; every 301, 302 and 303 hop, which still goes out as a body-less GET under the same header rules, the same body-descriptor exclusion and the same credential strip, though that strip reads the set which gained two spellings in this release, so a cross-origin hop carrying apiKey or X-ApiKey is not among the unaffected; the request built for the first send, which is unchanged; the status check, which still treats 200 to 399 as success, so a 3xx nobody followed is still returned rather than raised; and a chain of two permanent redirects, where the second 308 is still not followed and the call still returns as though nothing had been there.

A POST sent with neither a request body nor a requested response type now raises HttpServiceException when the server answers with an error status, instead of returning as though the call had succeeded. This is the fourth of the four changes in this release, carried forward from 0.13.0-preview, and it changes a path that succeeds today, so read this entry before upgrading. HttpService.Post(url, options) - the overload taking no body and no type parameter - awaited the send and returned without looking at the answer, so a 401, a 404 and a 500 were all indistinguishable from a 200 and a caller had no way to detect the failure short of switching to a different overload. That is the shape used for triggers, webhooks and state-change pokes, which is where a silent failure is least likely to be noticed and most likely to matter. The overload now runs the same status check as the seven result-less members that always ran it - Post with a body, Put, Patch, Get, Delete, Request and Send - so a status outside 200 to 399 raises HttpServiceException carrying the request url with its query redacted, the status and the header block under the configured dump mode, with the raw response body on Body. This was an inconsistency rather than a design: there was no reason for one member of eight to behave differently from the other seven, and nothing in its signature said it did. A status inside 200 to 399 still returns normally and that includes the 3xx range, because no result-less member follows redirects and none of them ever did - HttpOptions.FollowRedirects has no effect on any of them, unchanged here. Disposal moves on the failure path only, and it moves to match the same seven members: the response is disposed after the check passes, so a succeeding call still releases it as promptly as before, while a failing call now leaves it undisposed on HttpServiceException.Response for the caller to read and dispose, where previously it was disposed before any caller could see it. A caller who genuinely wants to send and ignore the outcome has two ways to keep doing that. Ask for the raw reply with Post<HttpResponseMessage>(url), which is exempt from status validation by design, hands back the response whatever the status, and makes the caller responsible for disposing it. Or catch HttpServiceException around the existing call and discard it, which is the smaller edit and keeps the response disposed by the library. Unaffected: every call to this overload against a server answering 200 to 399, which is every call that was already doing what its author intended; every other member of the service, none of which changes in this entry, though the members which follow redirects change in the entry on 307 and 308 above; the request that goes on the wire, which is built exactly as before; and HttpServiceException.Body, HttpServiceException.Response and the message format, which are the ones the seven siblings already produced. One consequence worth naming for anyone who logs by reflex: this overload can now produce an error message where it never produced one, so HttpService.HeaderDumpMode and HttpOptions.HeaderDumpMode reach a call site whose output was previously always silent.