diff --git a/.agents/codecs.md b/.agents/codecs.md new file mode 100644 index 0000000..4258550 --- /dev/null +++ b/.agents/codecs.md @@ -0,0 +1,7 @@ +# Content codecs implementation + +- Immutable first-match-wins registry. +- JSON, XML, text and binary/raw defaults. +- Opt-in RFC 9457 Problem Details and optional CsvHelper-based `Restling.Csv`. +- Existing request payloads stay JSON unless `UseContentCodec` is enabled. +- Restore/build/tests were initially deferred; on 2026-09-02 the authorized build and all 6 codec tests passed. See `test-verification.md`. diff --git a/.agents/context.md b/.agents/context.md index 8e013b5..99b7783 100644 --- a/.agents/context.md +++ b/.agents/context.md @@ -1,57 +1,77 @@ -# Documentation context - +# Restling development context ## Objective and status -- Objective: update the main Restling README with the current public APIs and quick starts, create a complete GitHub Wiki guide, and enforce the repository's multiline invocation formatting in all documentation examples. -- Status: completed. +- Objective: add extensible codecs, explicit resource ownership, complete MIME multipart support, and centralized HTTP execution with historical-behavior regression tests. +- Status: explicit context and per-request proxy configuration, including all direct-method overloads, is implemented and verified alongside the earlier work. Recovery from request-specific `ResponseEnded` failures is now implemented with a pending regression test; build and test execution for this follow-up were deferred at the user's request. ## Decisions made -- Kept the public documentation in English to match the existing repository language and public API terminology. -- Corrected the installation package ID from `AMDevIT.Restling.Core` to the published `Restling` package. -- Documented the target frameworks currently declared by the library: .NET 8, .NET 9, and .NET 10. -- Kept the README focused on installation, core capabilities, and copyable quick starts. -- Split detailed guidance into topic-specific GitHub Wiki pages and added `_Sidebar.md` for navigation. -- Derived examples and behavioral notes from the current source and tests rather than from the previous README. -- Expanded `AGENTS.md` with an explicit rule for method and constructor calls: the first argument remains beside the opening parenthesis and subsequent arguments align vertically with it. The rule explicitly applies to C# examples in Markdown. -- Reformatted every multiline C# invocation added to the README and wiki to follow that rule. -- Created only this context file, without a separate progress file, as explicitly requested by the user. +- `ContentCodecRegistry` is immutable and first-match-wins; custom codecs are prepended. +- Every `HttpClientContext` receives the four backward-compatible codecs plus the multipart reader. +- Non-JSON request serialization is explicit through `RestRequest.UseContentCodec` to preserve legacy media-type relabeling. +- Problem Details validates RFC 9457 member types and exposes extension members without dereferencing URI references. +- CSV is isolated in `Restling.Csv` so CsvHelper is not a dependency of the core package. +- Clients own contexts they create and borrow externally supplied contexts by default. +- `DisposeContext` remains a compatibility alias for the explicit `ContextOwnership` enum. +- Context ownership uses flags so `HttpClient` and `HttpMessageHandler` disposal can be selected independently. +- Builder-created contexts own their `HttpClient`; supplied handlers are borrowed by default, while internally created handlers are owned. +- Legacy context and handler overloads remain available. +- Multipart writing accepts any MIME subtype and creates each part per execution. +- Buffered multipart responses preserve MIME structure and decode parts through the configured codecs. +- `multipart/x-mixed-replace` uses a separate incremental API rather than the buffered response parser. +- Internal HTTP pipeline centralizes sending, timing, decoding, error results, logging, and response lifetime without taking ownership of shared transport resources. +- Historical serializer precedence, null-payload handling, direct HttpClient version defaults, and result-versus-exception differences remain explicit at the pipeline boundary. +- After separate user approval, the untyped POST/PUT header overloads now send their payload and return an untyped result through the centralized pipeline. Other serializer/overload contracts remain unchanged. +- After explicit approval, cookie binding is centralized for directly supplied/configured native handlers. Explicit containers take precedence; otherwise the handler's existing jar and cookie policy are retained. Redirect and ownership settings remain unchanged. +- AddProxy(string proxyUri, bool allowAutoRedirect) configures directly supported native handlers, enables the context's explicit proxy, and selects HTTP redirect behavior while preserving cookies/ownership. It supports handler registration in either order; later ConfigureHandler changes remain authoritative for that handler. Custom/delegating default handlers are rejected by AddProxy; request-level alternatives use an explicit factory when needed. +- RestRequest.ProxyOptions now selects Default, Direct, or Custom routing per request. Alternative transports are cached by immutable proxy/redirect selection, share the context CookieContainer, copy HttpClient defaults, and are owned by the context. Default builders supply a factory; externally supplied/configured handlers require AddRequestHandlerFactory rather than unsafe cloning. +- All 16 direct GET/POST/PUT/DELETE variants expose RequestProxyOptions before the final CancellationToken. Serializer parameters precede it to avoid ambiguity with existing positional null calls; IRestlingClient default bodies preserve compatibility for external implementations. +- A request-specific transport that fails with `HttpRequestError.ResponseEnded` is evicted and disposed only if it is still the cached instance. The default client remains untouched, and the caller's next retry creates a fresh native handler, connection pool, and SOCKS tunnel. ## Affected files -Main repository: - -- `AGENTS.md` -- `README.md` -- `.agents/context.md` - -Wiki repository: - -- `Home.md` -- `_Sidebar.md` -- `Installation.md` -- `Quick-Start.md` -- `Requests.md` -- `Client-Configuration.md` -- `Headers-and-Authentication.md` -- `Serialization.md` -- `Cookies.md` -- `Responses-and-Errors.md` -- `Security.md` +- Added core codec contracts, registry, implementations, problem model, and integrations. +- Added `AMDevIT.Restling.Csv`, its package README, solution entry, and test reference. +- Added codec models, helper codec, and regression tests. +- Updated the repository and NuGet package READMEs and added `.agents/codecs.md`. +- Added ownership enums, constructor overloads, builder integration, ownership regression tests, documentation, and `.agents/ownership.md`. +- Added multipart request composition, response parsing, models, limits, streaming, regression tests, documentation, and `.agents/multipart.md`. +- Added internal pipeline/streaming lease, integrated all client send paths, added 49 deterministic pipeline regression cases and test helpers, and documented the step in `.agents/http-pipeline.md`. +- Corrected the existing multipart tests' HttpMethod namespace alias. +- Recorded authorized restore/build/test results and remaining verification scope in `.agents/test-verification.md`. +- Updated POST/PUT regression tests, added loopback cookie tests/helper, and recorded the follow-up in `.agents/post-put-cookies.md`. +- Corrected HttpClientContextBuilder cookie binding, added 18 CookieBuilderTests cases, and recorded completion in `.agents/cookie-builder.md`. +- Added AddProxy to the builder/interface, 43 ProxyBuilderTests cases, proxy sections in both READMEs, and `.agents/proxy-builder.md`. +- Added request routing models/pool, integrated transport selection into the centralized buffered/streaming pipeline, added 16 RequestProxyOverrideTests cases, documented usage, and recorded `.agents/request-proxy.md`. +- Added direct proxy overloads to RestlingClient/IRestlingClient and an aggregate test that invokes all 16 signatures and verifies CancellationToken is last. +- Added targeted `ResponseEnded` recovery across the request transport pool and HTTP pipeline, plus a deterministic loopback regression. See `.agents/response-ended-recovery.md`. ## Checks performed -- Compared documented types, overloads, properties, serializer choices, handler behavior, cookie APIs, success codes, and security defaults against the current source files and tests. -- Ran `git diff --check` in both repositories: the edited Markdown files passed; only line-ending conversion warnings were reported. -- Checked all edited Markdown files for balanced fenced code blocks: passed. -- Checked relative links between GitHub Wiki pages and their target files: passed. -- Searched for the obsolete package ID and known example/version mistakes: no remaining matches. -- Checked C# fences in the README and wiki for invocations ending immediately after an opening parenthesis: no matches. -- Checked continuation columns for multiline method and constructor calls: every subsequent argument aligns with the first argument. -- Did not run `dotnet restore`, `dotnet build`, or tests because the user explicitly limited verification to Markdown files. +- Fetched the remote repository; the working branch required no pull or merge. +- Reviewed CsvHelper 33.1.0 public read/write APIs and RFC 9457 member rules. +- Re-fetched before resuming; the branch remained aligned with `origin/main`. +- Parsed all project XML, checked solution entries, Markdown fences, public method comments, codec registrations, and `git diff --check`. +- Performed static ownership checks for constructor defaults, compatibility aliases, disposal flags, and builder handler behavior. +- Reviewed RFC 2046, RFC 7578, RFC 8710, and the IANA multipart registry before defining multipart scope. +- Restore/build/test execution was initially deferred at the user's request, then explicitly authorized and completed on 2026-09-02. +- Fetched again for pipeline completion: HEAD is 3 commits ahead of origin/main, 0 behind; no pull/merge needed. +- Statically compared pipeline behavior against the pre-refactor implementation, verified centralized send call sites, and checked the diff for whitespace errors. These checks do not establish that the new tests pass. +- Authorized verification: restore passed; solution build passed for Core/CSV net8.0, net9.0, net10.0 and tests net10.0 with 0 warnings/errors. +- Runtime verification: 49 pipeline, 6 codec, 7 ownership, 6 multipart, and 6 XML security cases passed (74 total; 0 failed/skipped) on net10.0. TRX reports are under `TestResults/http-pipeline/`. +- Follow-up verification: reproduced the POST/PUT bug with 4 failing tests before fixing it. Latest run has 57 pipeline + 25 existing codec/ownership/multipart/security cases passing; 10/18 cookie cases pass and 8 expose the builder issue. Reports are under `TestResults/post-put-cookies/`. +- Cookie-builder completion: the preceding eight cookie failures are resolved. Targeted cookie tests: 36/36 passed; full selected suite: 118/118 passed, 0 failed/skipped. Solution build passed with 0 warnings/errors. Reports are under `TestResults/cookie-builder/`. +- Proxy completion: fetched Task-NewCodecs (aligned with upstream), restored and built successfully with 0 warnings/errors. All 161 selected local tests passed (118 existing + 43 proxy), including actual loopback proxy redirects/cookie persistence with both native handlers. Reports are under `TestResults/proxy/`; git diff --check passed. +- Per-request proxy completion: after the user pulled two upstream commits, fetch confirmed alignment. Restore succeeded; the final build passed across Core/CSV net8/net9/net10 and tests net10 with 0 warnings/errors. An intermediate test-triggered build emitted one generated MSTest CS8892 warning, absent from the final build. Targeted proxy tests passed 59/59 and the selected regression passed 177/177. Reports are under `TestResults/request-proxy/`; git diff --check passed. +- Direct-overload follow-up: the 60 proxy tests and all 178 selected regression tests passed. Every new signature was exercised through IRestlingClient; reports are in TestResults/request-proxy/. +- Follow-up multi-target build passed with 0 errors; the only warning was the previously observed CS8892 in generated MSTest entry-point code. +- `ResponseEnded` recovery follow-up: fetched the remote and confirmed the clean branch was aligned with its upstream before editing. The resulting targeted diff was inspected. No restore, build, or tests were run at the user's request. ## Open issues and recommended next step -- No documentation blocker remains. -- The wiki repository also contains a staged `.gitignore` outside this task. It was left untouched because the user limited changes to Markdown files; `git diff --cached --check` reports existing trailing whitespace on its line 9. -- Recommended next step: review the rendered README and GitHub Wiki after publishing, then commit and push the two repositories independently. +- No known failures remain in the selected local suites. Opaque custom/delegating-handler cookie processing remains the caller's responsibility; only directly supported native handlers are bound automatically. +- Runtime verification on other target frameworks/platforms and coverage/baseline comparison remain outside this run. +- Integration tests against httpbin remain separate and were not run. +- The new `ResponseEndedInvalidatesAlternativeTransport` regression and related proxy suites remain pending execution. +- POST/PUT payload omission is fixed; general serializer-precedence normalization remains separate. +- Per-request direct/custom proxy selection and convenience overloads are implemented. HTTPS CONNECT/TLS proxy, SOCKS handshakes, real proxy authentication exchanges, other runtime/platform executions, and bounded cache eviction remain untested/out of scope. diff --git a/.agents/cookie-builder.md b/.agents/cookie-builder.md new file mode 100644 index 0000000..6e00a34 --- /dev/null +++ b/.agents/cookie-builder.md @@ -0,0 +1,41 @@ +# Cookie builder correction + +## Objective and status + +The user explicitly approved correcting the cookie builder after the loopback tests exposed eight failures. The correction is implemented and verified: all 118 selected local regression cases pass, including all 36 cookie cases. + +## Decisions + +- `ResolveCookieContainer` is the single binding point for directly supplied SocketsHttpHandler and HttpClientHandler instances and handlers created through ConfigureHandler. +- An explicit AddCookieContainer selection takes precedence, regardless of whether it precedes or follows AddHandler. It is also available to a newly created ConfigureHandler callback. +- Without an explicit container, the native handler's existing jar is used by the context and by AddCookie/AddCookies. Existing cookie state is not replaced. +- Replacing a handler without an explicit container adopts the replacement handler's jar rather than copying unrelated cookie state. +- Selecting an explicit container enables native cookie handling, matching the previous AddCookieContainer behavior. Build does not subsequently override an intentional UseCookies=false setting made in ConfigureHandler. Without an explicit container, native UseCookies is preserved. +- Container assignments and UseCookies setters are skipped when no change is necessary, allowing repeated Build calls with a previously started borrowed handler. +- A separate fallback container preserves existing custom-handler behavior without turning an implicit fallback into an explicit override for later native handlers. +- Cookie clearing selects a new jar without destroying the old externally owned jar. +- Redirect and resource ownership policies are unchanged. Native handlers continue to enforce response-cookie scope, Secure, and deletion rules; no manual Cookie header forwarding is introduced. +- Opaque custom handlers and delegating-handler chains are not introspected; they remain responsible for their own cookie processing. This change targets the confirmed direct native-handler/container binding defect, not all cookie metadata/API semantics. + +## Affected files + +- `Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/HttpClientContextBuilder.cs` +- `Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/CookieBuilderTests.cs` (18 new cases) +- Existing `CookiePersistenceTests` (18 cases, previously 8 failing) run unchanged. +- Progressive context and prior follow-up status notes. + +## Verification + +- Fetch succeeded: HEAD is 4 commits ahead of origin/main, 0 behind; no pull/merge required. +- Restore succeeded with authorized access to NuGet configuration/cache. +- Targeted cookie run: 36 passed, 0 failed/skipped. +- Full solution build: Core/CSV net8.0, net9.0, net10.0 and tests net10.0; 0 warnings and 0 errors. +- Full selected regression run: 118 passed, 0 failed, 0 skipped on net10.0. This includes 57 pipeline, 6 codec, 7 ownership, 6 multipart, 6 XML security, 18 cookie persistence, and 18 cookie builder cases. +- The original eight cookie reproductions are now green, including supplied/configured handlers, AddCookieContainer/AddHandler ordering, consecutive calls, and automatic redirects. +- Loopback tests also cover manual redirects, path/domain/Secure filtering of response cookies, deletion, client recreation, and rebuilding with an active handler. +- `git diff --check` passed. +- Reports: `TestResults/cookie-builder/cookie-builder-targeted.trx` and `TestResults/cookie-builder/cookie-builder-regression.trx`. + +## Remaining scope + +No known failures remain in the selected local suites. httpbin integration tests, runtime execution on other frameworks/mobile platforms, coverage measurement, and general serializer-precedence normalization remain outside this change. diff --git a/.agents/http-pipeline.md b/.agents/http-pipeline.md new file mode 100644 index 0000000..9b8c35b --- /dev/null +++ b/.agents/http-pipeline.md @@ -0,0 +1,48 @@ +# Centralized HTTP pipeline + +## Objective and status + +Centralize transport execution while preserving the existing public API and observable request/result contracts. Implementation, static review, and authorized local verification are complete. All 49 new pipeline cases passed on net10.0; see `test-verification.md` for the full 74-test run. + +## Architecture + +- Internal `HttpExecutionPipeline` is the only component calling `HttpClient.SendAsync`. +- Buffered requests share timing, send-error conversion, parser/codec configuration, logging, and response disposal. +- Elapsed time covers sending and buffering, excluding request serialization and response decoding. +- Direct convenience methods use a request factory so preparation failures still become results with zero elapsed time. Explicit request APIs retain thrown preparation failures. +- `HttpResponseLease` owns streaming responses. Mixed-replace uses `ResponseHeadersRead`, keeps exception propagation, and releases its response when enumeration completes or stops early. +- Buffered responses are now released in `finally`, including when a decoder throws. The pipeline does not own or dispose the shared HttpClient/context. +- Log messages are standardized without adding request URI parameters to pipeline logs. + +## Compatibility decisions + +- Direct typed GET/DELETE/POST/PUT response decoding uses the explicit serializer before the client default. +- Direct POST/PUT request serialization uses the explicit serializer or automatic detection, not the client default. +- Header/explicit-request paths keep their historical default-serializer override during execution, after payload construction. +- Raw/form requests keep their own serializer selection and do not acquire the client default or mutate the request's serializer setting. +- Null direct POST/PUT payloads retain empty UTF-8 text content; explicit payload-request behavior is unchanged. +- Direct convenience methods retain HttpClient default request version/version policy; explicitly built request paths are unchanged. +- Buffered send errors and cancellation remain unsuccessful results; streaming failures continue to throw. +- Existing JSON default-on-decode-error and status-dependent XML errors are retained. +- Historical anomaly initially preserved, then fixed with separate user approval: untyped POST/PUT overloads with headers now build the payload and return an actual untyped result. See `post-put-cookies.md` for updated tests and cookie findings. + +## Files and tests + +- `AMDevIT.Restling.Core/RestlingClient.cs` +- `AMDevIT.Restling.Core/Network/Pipeline/HttpExecutionPipeline.cs` +- `AMDevIT.Restling.Core/Network/Pipeline/HttpResponseLease.cs` +- `AMDevIT.Restling.Tests/HttpPipelineCompatibilityTests.cs` +- `AMDevIT.Restling.Tests/HttpPipelineEdgeCaseTests.cs` +- `AMDevIT.Restling.Tests/Models/SerializerSelectionModel.cs` +- `AMDevIT.Restling.Tests/Pipeline/TrackingResponseContent.cs` +- Corrected the existing multipart test HttpMethod alias to `AMDevIT.Restling.Core.HttpMethod`. + +The initial pipeline suites contained 17 test methods / 49 cases. The approved POST/PUT correction replaces the two legacy-bug cases with four corrected-body cases and adds six null/error/cancellation cases: 57 pipeline cases now pass. They use in-memory message handlers, not httpbin. Coverage includes methods/URI/headers, payload and response shape, serializer precedence, null payloads, serialization failures, raw/form dispatch, pre-cancelled and in-flight cancellation, HTTP errors, decode errors, response disposal, HttpClient version defaults, and streaming early exit/failure. + +## Verification and next step + +- Fetched the remote; HEAD is three commits ahead of origin/main and zero behind. No pull/merge required. +- Compared the refactor with the pre-refactor RestlingClient implementation and corrected identified compatibility differences. +- Static checks: `git diff --check`, request-send call-site search, and new-code formatting review. Textual comparison found zero public/protected declaration differences; new source files contain no trailing-whitespace violations. +- After user authorization on 2026-09-02, restore and multi-target solution build passed with no warnings/errors. All 49 pipeline cases passed, along with codec, ownership, multipart, and XML security tests (74 total; 0 failed/skipped). +- Coverage measurement and a full baseline-versus-refactor runtime comparison have not been performed. Keep httpbin integration tests separate. Both the approved untyped-header fix and cookie-builder correction are complete; see `cookie-builder.md` for the latest 118 passing cases. diff --git a/.agents/multipart.md b/.agents/multipart.md new file mode 100644 index 0000000..87dbc34 --- /dev/null +++ b/.agents/multipart.md @@ -0,0 +1,18 @@ +# Multipart implementation + +## Contracts + +- `MultipartRequest` supports `form-data` by default and accepts any MIME multipart subtype. +- Every execution creates and owns fresh `HttpContent`; stream and content factories make request reuse explicit. +- Object parts use the client context's codec snapshot. +- `MultipartContentCodec` is a default reader for buffered `multipart/*` responses. +- `MultipartDocument` preserves preamble, epilogue, order, duplicates, and nested entities. +- `MultipartPart` exposes raw bytes and MIME metadata and can deserialize itself with the originating codec registry. +- `multipart/related` root selection and `multipart/byteranges` ranges have typed accessors. +- Signed and encrypted entities are parsed structurally but are not verified or decrypted. +- `multipart/x-mixed-replace` uses a dedicated `IAsyncEnumerable` API with response-header streaming. +- Parser limits cover part count, header bytes, nesting depth, and bytes per part. + +## Verification state + +Deterministic tests cover sending, codec-backed parts, nesting, related roots, duplicates, and mixed-replace streaming. After user authorization on 2026-09-02, restore/build succeeded and all 6 multipart tests passed on net10.0. Additional streaming lifecycle cases passed in the pipeline suites. See `test-verification.md`. diff --git a/.agents/ownership.md b/.agents/ownership.md new file mode 100644 index 0000000..2cb3b1e --- /dev/null +++ b/.agents/ownership.md @@ -0,0 +1,21 @@ +# Explicit ownership + +## Purpose + +Restling now distinguishes borrowed resources from resources that it owns. This makes disposal predictable when callers reuse an `HttpClientContext`, `HttpClient`, or `HttpMessageHandler` across multiple client instances. + +## Contracts + +- `RestlingClientContextOwnership` controls whether `RestlingClient.Dispose()` disposes its context. +- Constructors that create a context internally own it. +- Constructors receiving an existing `HttpClientContext` borrow it unless ownership is explicitly set to `Owned`. +- `DisposeContext` remains a Boolean compatibility alias for `ContextOwnership`. +- `HttpClientContextOwnership` independently controls disposal of the `HttpClient` and message handler. +- The legacy three-argument `HttpClientContext` constructor retains its historical ownership of both resources. +- A context built by `HttpClientContextBuilder` always owns its generated `HttpClient`. +- Handlers created internally by the builder are owned; supplied handlers are borrowed by default and can be made owned explicitly. +- The legacy Boolean `AddHandler` overload maps to the new handler ownership enum. + +## Verification state + +After user authorization on 2026-09-02, restore/build succeeded and all 7 ownership regression tests passed on net10.0. See `test-verification.md`. diff --git a/.agents/post-put-cookies.md b/.agents/post-put-cookies.md new file mode 100644 index 0000000..00d6de8 --- /dev/null +++ b/.agents/post-put-cookies.md @@ -0,0 +1,50 @@ +# POST/PUT header payload fix and cookie verification + +## Objective and status + +The user approved fixing the untyped POST/PUT overloads with headers, building, and running local tests. The user also requested checking cookies across redirects, responses, and consecutive calls. + +- POST/PUT fix: implemented and verified. +- Cookie investigation initially confirmed a separate builder defect with eight reproducible failing tests. The user subsequently approved the correction; it is now complete, and all tests pass. See `cookie-builder.md` for implementation details and the latest 118-case verification. + +## Implementation decisions + +- Both affected overloads now use a shared private `ExecuteHeaderPayloadRequestAsync` helper. +- The helper builds payload content and request headers with the existing builder, then runs the untyped centralized HTTP pipeline. +- Public signatures are unchanged. Returned objects are now `RestRequestResult`, not an incidental typed result attempting to decode the response as the request model. +- Per-request authentication, headers, explicit request serializer, client-default request metadata behavior, cancellation, and send-error handling are covered by tests. +- Null payloads retain the bodyless behavior of explicitly constructed header requests. Other overloads and serializer precedence were not normalized in this fix. + +## Cookie findings + +Tests use an ephemeral IPv4 loopback TCP server and real SocketsHttpHandler/HttpClientHandler instances. They do not use httpbin or bypass native cookie processing with a fake handler. + +- Default builder: seeded cookies and Set-Cookie updates persist across consecutive calls and recreated RestlingClient instances sharing one context. +- Manual redirects: response cookies are stored before following Location for 301, 302, 303, 307, and 308. Default automatic redirects remain disabled. +- Automatic redirects with an explicitly configured handler work when AddCookieContainer is called after AddHandler. Cookies from intermediate/final responses are reused on subsequent calls. +- Confirmed defect: when AddCookieContainer precedes AddHandler, the supplied container is not attached to the handler. Seeded cookies are missing on the wire. +- Confirmed defect: AddHandler and ConfigureHandler paths can leave the context cookie container separate from the handler container. Cookies added through the builder are then not sent. Six consecutive-call cases fail across SocketsHttpHandler, HttpClientHandler, and ConfigureHandler; two automatic-redirect cases fail due to builder call order. +- Native response-cookie path/domain/Secure/deletion rules pass in the default path. No manual Cookie forwarding was added. +- Proposed follow-up: synchronize the effective cookie container during handler setup/build, retain pre-existing handler cookies when no explicit container is supplied, and preserve handler redirect/ownership choices. Custom delegating handler chains and broader cookie configuration options have not been exhaustively assessed. + +## Affected files + +- `Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestlingClient.cs` +- `Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/HttpPipelineCompatibilityTests.cs` +- `Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/CookiePersistenceTests.cs` +- `Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Cookies/LoopbackCookieServer.cs` +- Progressive context and pipeline notes. + +## Verification + +- Fetch succeeded; HEAD is 4 commits ahead of origin/main, 0 behind. No pull required. +- Before the production fix, all 4 new body/serializer cases failed with a null outgoing body; after the fix, all pass. +- Added 2 null-payload and 4 error/cancellation cases. Corrected an initial test assumption: HttpClient can invoke a custom handler with a cancelled token; the test handler now observes that token, as real transports do. +- Restore succeeded. Full solution build passed for Core/CSV net8.0, net9.0, net10.0 and tests net10.0, with 0 warnings/errors. +- Latest combined run: 100 cases, 92 passed, 8 failed, 0 skipped. All 82 non-cookie cases passed, including 57 pipeline cases. Cookie suite: 18 cases, 10 passed, 8 failed as described above. +- Reports: `TestResults/post-put-cookies/post-put-cookie-initial.trx` and `TestResults/post-put-cookies/post-put-cookie-regression.trx`. +- httpbin integration tests were excluded. Runtime tests targeted net10.0 only; cookie checks used loopback networking on Windows. + +## Next step + +Completed after explicit confirmation: fixed cookie binding and reran the unchanged eight reproductions plus additional preservation tests. All 118 selected cases now pass; the earlier failing results above are retained as the diagnostic history. diff --git a/.agents/proxy-builder.md b/.agents/proxy-builder.md new file mode 100644 index 0000000..4253e31 --- /dev/null +++ b/.agents/proxy-builder.md @@ -0,0 +1,40 @@ +# Explicit proxy configuration + +## Objective and status + +Implemented the approved `HttpClientContextBuilder.AddProxy(string proxyUri, bool allowAutoRedirect)` API, interface member, documentation, and local regression tests. Per-request proxy overrides were explicitly deferred. All 161 selected local tests pass, including 43 new proxy cases. + +## Decisions + +- Configure directly supplied SocketsHttpHandler/HttpClientHandler instances without replacing their cookie container or changing ownership. Builder-created handlers remain owned. +- Require an absolute HTTP, HTTPS, SOCKS4, SOCKS4a, or SOCKS5 proxy URI. Reject embedded credentials, non-root paths, queries, and fragments; credentials can be supplied through ConfigureHandler on the native Proxy object. +- Enable UseProxy and apply the required allowAutoRedirect argument to HTTP response redirects. The argument does not control proxy bypass. +- Retain pending settings until native handler creation; apply them to a replacement AddHandler as well. Do not allocate an unused handler just to register the proxy. +- Later ConfigureHandler callbacks may override settings. Build does not reapply settings to an existing/active handler; borrowed transports can be reused across contexts without resetting proxy or cookies. +- Reject unsupported custom/delegating handlers explicitly. Do not inspect opaque handler chains or silently bypass an explicit proxy request. +- Proxy changes after transport startup retain the native InvalidOperationException behavior. Failed URI validation or unsupported-handler selection leaves the builder selection unchanged. +- Preserve custom builder compatibility with a default interface implementation that throws NotSupportedException. +- Without AddProxy, historical native proxy and redirect defaults are unchanged. Separate contexts/handlers remain the supported way to select a different proxy or direct connections; no per-request override was implemented. + +## Affected files + +- Core Network/Builders/HttpClientContextBuilder.cs and IHttpClientContextBuilder.cs. +- Tests/ProxyBuilderTests.cs; reuses the existing loopback HTTP script server without changing it. +- Root README.md and Assets/Documentation/README.md. +- This note and .agents/context.md. + +## Verification (2026-09-02) + +- Fetch succeeded; Task-NewCodecs and its upstream were aligned, with no pull or merge required. Worktree was initially clean. +- Authorized restore passed. Initial test compilation used an assertion method unavailable to the resolved test framework; corrected to the existing ThrowsException API. +- Final solution build: Core/CSV net8.0, net9.0, net10.0 and tests net10.0; 0 warnings/errors. +- Initial targeted proxy run: 38/38 passed. Five additional credential/default-policy cases were then added. +- Final selected regression suite: 161/161 passed, 0 failed/skipped (118 existing + 43 proxy cases). +- Loopback HTTP proxy checks exercise both native handlers, enabled/disabled redirects, absolute request targets, response cookies, consecutive requests, active-handler mutation rejection, and context recreation with a borrowed handler. No external destination or proxy is contacted. +- Scheme validation, URI rejection, call ordering, handler replacement, explicit cookie containers, ownership flags, credential configuration, and historical defaults are covered by configuration tests. +- git diff --check passed. Checked new Markdown C# calls for repository formatting. +- Reports: TestResults/proxy/proxy-targeted.trx (initial 38 cases) and TestResults/proxy/proxy-regression.trx (final 161 cases). + +## Remaining scope + +Per-request proxy routing was implemented in the subsequent `request-proxy.md` step. HTTPS CONNECT/TLS proxy and SOCKS handshakes, proxy authentication exchanges, httpbin integration tests, other runtime versions, and mobile/platform-specific handlers were not exercised. Configuration tests do not establish those transport integrations work on every platform. diff --git a/.agents/request-proxy.md b/.agents/request-proxy.md new file mode 100644 index 0000000..131aa64 --- /dev/null +++ b/.agents/request-proxy.md @@ -0,0 +1,42 @@ +# Per-request proxy routing + +## Objective and status + +Implemented request-level routing overrides with `Default`, `Direct`, and `Custom` proxy modes. The change is documented and verified: 177 selected local regression tests pass, including 16 new request-proxy cases and the 43 existing context-proxy cases. + +## Decisions + +- `RestRequest.ProxyOptions` applies to every request subtype, including raw, form-urlencoded, multipart, and multipart/x-mixed-replace streaming requests. Its default preserves the context transport unchanged. +- `RequestProxyOptions` is an immutable value object. Equivalent mode/URI/redirect selections share one cached alternative HttpClient and connection pool. +- `Direct` sets UseProxy=false and therefore bypasses explicit and system proxies. `Custom` uses a validated HTTP, HTTPS, SOCKS4, SOCKS4a, or SOCKS5 URI. Both explicitly select AllowAutoRedirect. +- The centralized pipeline resolves the transport immediately before sending. Buffered transport-selection failures remain result-based; streaming failures retain their throwing contract. +- Alternative clients copy the context client's BaseAddress, request version/policy, response buffer limit, timeout, and default headers. Their native handlers share the exact context CookieContainer. +- The context owns alternative clients and handlers independently from the ownership of the default transport and disposes them once. +- A default builder supplies a native alternative-handler factory automatically. AddHandler or ConfigureHandler disables implicit recreation because arbitrary external TLS, certificate, pooling, platform, and delegating-handler settings cannot be cloned safely. +- `AddRequestHandlerFactory` explicitly enables overrides for externally supplied/configured handlers. It receives the shared cookie container, must return a fresh SocketsHttpHandler or HttpClientHandler, and transfers credentials from a factory-seeded Proxy to the selected custom proxy. +- Missing/invalid factories fail explicitly rather than silently using the context route. Existing default calls and existing builder/context constructors remain source and binary compatible. +- A follow-up adds all 16 direct GET/POST/PUT/DELETE overloads, including typed and header variants, to RestlingClient and IRestlingClient. CancellationToken is always last. Serializer parameters precede RequestProxyOptions and remain required in the new signatures, preserving unambiguous compatibility for existing positional null calls. + +## Affected files + +- Added Network/RequestProxyMode.cs, RequestProxyOptions.cs, ProxyUriParser.cs, and RequestTransportPool.cs. +- Updated RestRequest, HttpExecutionPipeline, RestlingClient, HttpClientContext, HttpClientContextBuilder, and IHttpClientContextBuilder. +- Added RequestProxyOverrideTests and Proxy/TrackingHttpClientHandler; extended the existing loopback response helper. +- Updated both repository/package READMEs, this note, proxy-builder.md, and context.md. + +## Verification (2026-09-03) + +- After the user pulled two remote commits, fetch confirmed Task-NewCodecs was aligned with its upstream and the starting worktree was clean. +- Authorized restore succeeded. +- Final solution build passed for Core/CSV net8.0, net9.0, net10.0 and tests net10.0 with 0 warnings/errors. An earlier test-triggered incremental build emitted CS8892 from generated MSTest entry-point files after the upstream dependency/project update; it was absent from the final build and did not originate in modified Restling source. +- Targeted final run: 59/59 request/context proxy cases passed. +- Selected regression run: 177/177 passed, 0 failed/skipped (161 prior cases + 16 request-proxy cases). +- Direct-overload follow-up: 60/60 targeted proxy cases and 178/178 selected regression cases passed. The aggregate test invokes all 16 signatures through IRestlingClient and verifies CancellationToken is their final parameter. +- The follow-up solution build passed Core/CSV net8.0, net9.0, net10.0 and tests net10.0 with 0 errors. It reported the previously observed generated MSTest CS8892 entry-point warning; modified library sources emitted no warnings. +- Loopback tests cover default/custom/direct route isolation, absolute versus origin request targets, shared response cookies, copied headers, proxy/redirect cache keys, native factory invocation, missing/invalid factories, alternative ownership/disposal, buffered specialized requests, and mixed-replace streaming. +- No external proxy or destination was contacted. Reports are in TestResults/request-proxy/. +- git diff --check passed. + +## Remaining scope + +Runtime tests still target net10.0 on Windows. HTTPS CONNECT/TLS proxy and SOCKS handshakes, real proxy authentication exchanges, mobile/platform handlers, bounded/expiring transport-cache policies, and integration tests against external services remain outside this step. diff --git a/.agents/response-ended-recovery.md b/.agents/response-ended-recovery.md new file mode 100644 index 0000000..cdf8356 --- /dev/null +++ b/.agents/response-ended-recovery.md @@ -0,0 +1,30 @@ +# Request transport recovery after truncated responses + +## Objective and status + +Recover request-specific transports after .NET reports `HttpRequestError.ResponseEnded`. The implementation and a deterministic loopback regression are complete. Build and test execution were not performed at the user's request. + +## Decisions + +- The HTTP pipeline inspects the complete exception chain so it recognizes the `HttpIOException` wrapped by `HttpRequestException`. +- Only `ResponseEnded` triggers eviction. Authentication failures, cancellations, DNS failures, and other transport errors retain the existing cache behavior. +- The default context client is never invalidated or disposed by this recovery path. +- Direct and custom request-specific transports are evicted only if the failed `HttpClient` is still the cached instance for the immutable proxy selection. This prevents an older concurrent failure from evicting a replacement transport. +- Eviction disposes the failed alternative client and its owned native handler. The caller's existing retry policy then resolves a fresh handler, connection pool, and SOCKS tunnel. +- Restling does not add an internal retry, preserving request replay and retry-policy ownership at the caller boundary. +- The recovery path does not modify authentication or other request headers. + +## Affected files + +- `Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/RequestTransportPool.cs` +- `Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/HttpClientContext.cs` +- `Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Pipeline/HttpExecutionPipeline.cs` +- `Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestlingClient.cs` +- `Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/RequestProxyOverrideTests.cs` + +## Verification and next step + +- The preliminary fetch completed; `Task-NewCodecs` was clean and aligned with its upstream before editing. +- The resulting diff was inspected after applying the targeted patch. +- No restore, build, or tests were run at the user's request. +- Recommended next step: after authorization, run the targeted `ResponseEndedInvalidatesAlternativeTransport` regression, the proxy suite, and the multi-target solution build. diff --git a/.agents/test-verification.md b/.agents/test-verification.md new file mode 100644 index 0000000..44ddd28 --- /dev/null +++ b/.agents/test-verification.md @@ -0,0 +1,40 @@ +# Local verification — 2026-09-02 + +## Objective and status + +This report records the initial verification after the HTTP pipeline refactor, before the separately approved POST/PUT fix. Restore, solution build, and the selected offline regression suites completed successfully. No source-code correction was needed during this initial verification. For newer results and confirmed cookie defects, see `post-put-cookies.md`. + +## Execution + +- `git fetch`: completed; HEAD remains 3 commits ahead of origin/main and 0 behind. No pull/merge required. +- `dotnet restore Sources/AMDevIT.Restling`: completed after granting access to the user NuGet configuration/cache outside the sandbox. +- `dotnet build Sources/AMDevIT.Restling --no-restore --verbosity minimal`: passed with 0 warnings and 0 errors. Core and CSV built for net8.0, net9.0, and net10.0; the test project built for net10.0, Debug. +- Tests ran with `dotnet test` on the test project, using `--no-build --no-restore` and explicit class-name filters. No httpbin integration tests were selected. + +## Results + +| Suite | Passed | Failed | Skipped | +| --- | ---: | ---: | ---: | +| HttpPipelineCompatibilityTests | 22 | 0 | 0 | +| HttpPipelineEdgeCaseTests | 27 | 0 | 0 | +| ContentCodecTests | 6 | 0 | 0 | +| OwnershipTests | 7 | 0 | 0 | +| MultipartTests | 6 | 0 | 0 | +| SecurityTests (XML) | 6 | 0 | 0 | +| Total | 74 | 0 | 0 | + +The first five suites ran together: 68 tests passed, reported duration 511 ms. XML security ran separately: 6 tests passed, reported duration 102 ms. All runtime tests targeted net10.0. + +## Reports + +- `TestResults/http-pipeline/http-pipeline-regression.trx` +- `TestResults/http-pipeline/xml-security-regression.trx` + +Reports and normal build/package outputs are ignored by Git. The source worktree changes from implementation were preserved. + +## Remaining scope + +- httpbin integration tests were intentionally excluded. +- Runtime tests on net8.0, net9.0, iOS, Android, or MAUI were not performed; successful multi-target compilation is not runtime verification on those platforms. +- A baseline-versus-refactor runtime comparison and coverage measurement were not performed. +- The documented historical untyped POST/PUT-with-headers payload omission remains unchanged and covered by characterization tests. diff --git a/.gitignore b/.gitignore index a4fe18b..24b1e75 100644 --- a/.gitignore +++ b/.gitignore @@ -398,3 +398,4 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml +.DS_Store diff --git a/Assets/Documentation/README.md b/Assets/Documentation/README.md index 6782145..7a4747d 100644 --- a/Assets/Documentation/README.md +++ b/Assets/Documentation/README.md @@ -11,6 +11,19 @@ The goal of Restling is to provide a flexible yet easy-to-use REST client API. W - Designed for dependency injection (transient service). - Full support for .NET's `SocketsHttpHandler`. - Highly customizable via `HttpClientContextBuilder`. +- Extensible content codecs with JSON, XML, text, and binary/raw defaults. + +## Content codecs + +Restling decodes responses by media type. Add custom codecs through `HttpClientContextBuilder.AddCodec`. Existing typed request payloads remain JSON unless `RestRequest.UseContentCodec` is enabled explicitly. RFC 9457 Problem Details is available through `ProblemDetailsJsonCodec`, while CSV is supplied by the optional `Restling.Csv` package. + +## Ownership and disposal + +Clients created with the default constructor or a builder own their generated context. Clients receiving an existing `HttpClientContext` borrow it by default. Use `RestlingClientContextOwnership` to select the behavior explicitly and `HttpClientContextOwnership` to control disposal of the underlying `HttpClient` and handler. `DisposeContext` remains a compatibility alias. + +## Multipart content + +`MultipartRequest` sends form-data or any MIME multipart subtype with text, buffered binary, stream, arbitrary `HttpContent`, and codec-backed object parts. Buffered multipart responses deserialize to `MultipartDocument`, retaining ordered parts, duplicate names, headers, raw bytes, nested multipart content, related roots, and byte ranges. `multipart/x-mixed-replace` is available through a dedicated asynchronous streaming API. ## Why the name "Restling"? @@ -32,7 +45,7 @@ You can use Visual Studio solution package management or use the following comma From a valid terminal: ``` -dotnet add package AMDevIT.Restling.Core +dotnet add package Restling ``` ### NuGet Package Manager @@ -40,7 +53,7 @@ dotnet add package AMDevIT.Restling.Core Using Visual Studio powershell package manager: ``` -Install-Package AMDevIT.Restling.Core +Install-Package Restling ``` ## Basic usage: @@ -71,6 +84,51 @@ In the following example, we will instantiate a HttpClientContext using the Http This code will allow the Restling client to send and receive cookies when a method is executed, adding the app-version header and setting a new user-agent. +### Explicit proxy + +```csharp +HttpClientContextBuilder builder = new(); +builder.AddProxy("http://proxy.example.com:8080", allowAutoRedirect: false); +using RestlingClient client = new(builder); +``` + +`AddProxy(string proxyUri, bool allowAutoRedirect)` enables the proxy on a directly supplied `SocketsHttpHandler` or `HttpClientHandler`, or on the builder-created native handler. Cookie settings and ownership are preserved. `allowAutoRedirect` controls HTTP response redirects, not proxy bypass. + +The address must be an absolute HTTP, HTTPS, SOCKS4, SOCKS4a, or SOCKS5 URI containing a host and optional port; embedded credentials, non-root paths, queries, and fragments are rejected. Native/platform transport support still applies. Configure proxy credentials through `ConfigureHandler` after `AddProxy` when needed. + +Configure before the first request. `AddProxy` works before or after `AddHandler` and `ConfigureHandler`; later callback changes are retained by `Build`. A replacement native handler receives the last `AddProxy` selection. Custom/delegating handlers require explicit transport configuration and are rejected by this method. Existing custom builder implementations remain compatible through a default interface implementation that throws `NotSupportedException`. + +`AddProxy` selects the context-wide default. Individual requests can override it without mutating the active context handler. Existing defaults remain unchanged when neither setting is used. + +#### Per-request proxy override + +All `RestRequest` types can override the context route: + +```csharp +RestRequest request = new("https://api.example.com/status", HttpMethod.Get) +{ + ProxyOptions = RequestProxyOptions.Custom("http://another-proxy.example.com:8080", + allowAutoRedirect: true) +}; + +RestRequestResult result = await client.ExecuteRequestAsync(request); +``` + +GET, POST, PUT, and DELETE convenience methods also expose proxy options, including typed and header variants. `CancellationToken` is always the final argument: + +```csharp +RestRequestResult result = await client.GetAsync(uri, + forcePayloadJsonSerializerLibrary: null, + proxyOptions: RequestProxyOptions.Direct(), + cancellationToken: cancellationToken); +``` + +The serializer parameter remains explicit in these new signatures, preventing ambiguity with existing positional `null` calls. + +Use `RequestProxyOptions.Default` to retain the context transport, `Direct()` to disable explicit and system proxies, or `Custom(proxyUri)` for a dedicated proxy. Proxy and redirect combinations reuse isolated connection pools. Alternative transports share the context cookie jar, copy the default client's settings, and are disposed with the context. This applies to ordinary, raw, form-urlencoded, multipart, and mixed-replace streaming requests. + +The default builder creates suitable alternative native handlers automatically. A supplied handler or one customized through `ConfigureHandler` cannot be cloned safely, so register `AddRequestHandlerFactory(cookieContainer => ...)` when overrides are required. Factory-created handlers are owned by the context; Restling applies routing, redirect, and shared-cookie settings. A factory may seed `Proxy.Credentials`, which Restling retains when selecting the request proxy. Default routing remains available without a factory, while an attempted override fails explicitly. + ### Example 2: Advanced customization ```csharp @@ -92,4 +150,4 @@ restlingClient = new(contextBuilder, logger); restResponse = await restlingClient.GetAsync(uri, cancellationToken); -``` \ No newline at end of file +``` diff --git a/README.md b/README.md index 96899bb..718d2b2 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,152 @@ builder.ConfigureHandler(handler => RestlingClient client = new(builder); ``` +### Explicit proxy + +```csharp +HttpClientContextBuilder builder = new(); +builder.AddProxy("http://proxy.example.com:8080", allowAutoRedirect: false); +using RestlingClient client = new(builder); +``` + +`AddProxy(string proxyUri, bool allowAutoRedirect)` enables an explicit proxy on a directly supplied `SocketsHttpHandler` or `HttpClientHandler`, or on the native handler created by the builder. It preserves handler ownership and cookie settings. The boolean controls automatic HTTP response redirects, not proxy bypass. + +Use an absolute `http`, `https`, `socks4`, `socks4a`, or `socks5` URI with a host and optional port; embedded credentials, non-root paths, queries, and fragments are rejected. These schemes are supported by the [.NET proxy transport](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.defaultproxy?view=net-10.0); availability on platform-specific handlers depends on the runtime. For authentication, configure the native handler's `Proxy.Credentials` through `ConfigureHandler` after `AddProxy`. + +Call `AddProxy` before sending requests. It works before or after `AddHandler` and `ConfigureHandler`; a later `ConfigureHandler` callback can override its settings, and `Build` does not reset them. Replacing the native handler applies the last `AddProxy` selection. Opaque custom/delegating handlers throw `NotSupportedException` instead of silently ignoring the proxy. Without `AddProxy`, existing transport defaults are unchanged. + +`AddProxy` selects the context-wide default. Individual `RestRequest` instances can override it without mutating the shared active handler, as described below. + +### Per-request proxy override + +Every `RestRequest`, including raw, form-urlencoded, multipart, and mixed-replace streaming requests, can select a route independently: + +```csharp +RestRequest directRequest = new("https://api.example.com/status", HttpMethod.Get) +{ + ProxyOptions = RequestProxyOptions.Direct(allowAutoRedirect: false) +}; + +RestRequest proxiedRequest = new("https://api.example.com/status", HttpMethod.Get) +{ + ProxyOptions = RequestProxyOptions.Custom("http://another-proxy.example.com:8080", + allowAutoRedirect: true) +}; + +RestRequestResult directResult = await client.ExecuteRequestAsync(directRequest); +RestRequestResult proxiedResult = await client.ExecuteRequestAsync(proxiedRequest); +``` + +The direct GET, POST, PUT, and DELETE methods provide the same selection, including typed and per-request-header variants. `CancellationToken` remains the final parameter in every overload: + +```csharp +RequestProxyOptions proxyOptions = RequestProxyOptions.Custom("socks5://127.0.0.1:1080"); + +RestRequestResult getResult = await client.GetAsync(uri, + forcePayloadJsonSerializerLibrary: null, + proxyOptions: proxyOptions, + cancellationToken: cancellationToken); + +RestRequestResult postResult = await client.PostAsync(uri, + payload, + forcePayloadJsonSerializerLibrary: null, + proxyOptions: proxyOptions, + cancellationToken: cancellationToken); +``` + +The serializer argument is explicit in these overloads so existing calls that pass `null` positionally remain unambiguous and source-compatible. + +`RequestProxyOptions.Default` (the initial value) uses the context transport unchanged. `Direct` disables both explicit and system proxies. `Custom` uses its dedicated proxy. Equivalent selections reuse one connection pool, while different proxy or redirect settings remain isolated. Alternative transports share the context cookie container and copy its `HttpClient` defaults; the context owns and disposes them. + +The default builder supplies alternative native handlers automatically. When an external handler or `ConfigureHandler` is used, provide an explicit factory so Restling does not guess how to clone TLS, certificate, pooling, or platform-specific settings: + +```csharp +builder.AddHandler(sharedHandler) + .AddRequestHandlerFactory(cookieContainer => new SocketsHttpHandler + { + CookieContainer = cookieContainer, + PooledConnectionLifetime = TimeSpan.FromMinutes(5) + }); +``` + +The factory creates fresh handlers owned by the context. Restling applies the selected proxy, redirect policy, and shared cookie container after creation. To provide proxy credentials, initialize `Proxy.Credentials` in the factory; the credentials are transferred to the request-selected proxy address. Without a factory, default requests still work, while a `Direct` or `Custom` override returns a `NotSupportedException` failure. Buffered requests retain result-based transport failures; streaming transport failures continue to throw. + +## Ownership and disposal + +A client created with its default constructor or with `HttpClientContextBuilder` owns the generated context and disposes it: + +```csharp +using RestlingClient client = new(); +``` + +A client constructed with an existing context borrows it by default. Disposing the client leaves the context, its `HttpClient`, and its handler available for reuse: + +```csharp +HttpClientContext sharedContext = builder.Build(); + +using (RestlingClient client = new(sharedContext)) +{ + await client.GetAsync("https://api.example.com/status"); +} + +// sharedContext is still owned by the caller. +``` + +Ownership can be selected explicitly: + +```csharp +RestlingClient client = new(sharedContext, RestlingClientContextOwnership.Owned); +``` + +`DisposeContext` remains available as a compatibility alias. At the context level, `HttpClientContextOwnership` independently controls disposal of `HttpClient` and `HttpMessageHandler`. A builder-created context always owns its `HttpClient`; handler ownership can be selected with `HttpMessageHandlerOwnership.Borrowed` or `Owned`. The old boolean `AddHandler` overload remains supported. + +## Multipart content + +`MultipartRequest` creates fresh content for every execution. Buffered parts are reusable; stream and arbitrary content factories return instances owned and disposed by that execution: + +```csharp +MultipartRequest request = new("https://api.example.com/documents", HttpMethod.Post); +request.AddText("description", "Quarterly report") + .AddObject("metadata", metadata, HttpMediaType.ApplicationJson) + .AddStream("document", + () => File.OpenRead(documentPath), + "report.pdf", + HttpMediaType.ApplicationPdf); + +RestRequestResult result = await client.ExecuteMultipartRequestAsync(request); +``` + +Buffered `multipart/*` responses can be decoded as `MultipartDocument`. Parts retain their order, duplicate names, headers and original bytes. Each part can be decoded through the same codec registry: + +```csharp +RestRequestResult result = await client.GetAsync(uri); + +foreach (MultipartPart part in result.Data?.Parts ?? []) +{ + byte[] original = part.RawContent; + Metadata? metadata = part.ContentType?.MediaType == HttpMediaType.ApplicationJson + ? part.Deserialize() + : null; +} +``` + +Nested multipart entities are exposed through `NestedContent`; `RootPart` resolves the `start` parameter of `multipart/related`, and `ContentRange` exposes `multipart/byteranges` metadata. File names are untrusted metadata and are never interpreted as local paths. + +`multipart/signed` and `multipart/encrypted` are parsed structurally, but Restling does not verify signatures or decrypt their parts. + +Parsing defaults can be replaced by registering `new MultipartContentCodec(new MultipartOptions { ... })` before the standard codecs. + +`multipart/x-mixed-replace` has a dedicated streaming API because the response can be unbounded: + +```csharp +RestRequest streamRequest = new(uri, HttpMethod.Get); + +await foreach (MultipartPart part in client.StreamMultipartMixedReplaceAsync(streamRequest, cancellationToken: cancellationToken)) +{ + ProcessFrame(part.RawContent, part.ContentType); +} +``` + ## More request types ### Raw content @@ -180,6 +326,32 @@ RestRequestResult result = await client.GetAsync("https://ap cancellationToken: cancellationToken); ``` +## Extensible content codecs + +Restling selects response decoders by media type. Every client context includes JSON (including `application/*+json`), XML (including `application/*+xml`), text, and binary/raw codecs by default, so existing requests require no configuration. Problem media types remain opt-in. Custom codecs have priority when added through `HttpClientContextBuilder`: + +```csharp +using AMDevIT.Restling.Core.Codecs; +using AMDevIT.Restling.Core.Network.Builders; + +HttpClientContextBuilder builder = new(); +builder.AddCodec(new MyContentCodec()); +``` + +Request models remain JSON by default for backward compatibility. To serialize a model with another registered codec, set its media type and `UseContentCodec`: + +```csharp +RestRequest request = new("https://api.example.com/import", + AMDevIT.Restling.Core.HttpMethod.Post, + model) +{ + ContentMediaType = HttpMediaType.ApplicationXml, + UseContentCodec = true +}; +``` + +CSV is supplied by the optional `Restling.Csv` project and is registered with `builder.AddCodec(new CsvContentCodec())`. RFC 9457 Problem Details is enabled with `builder.AddCodec(new ProblemDetailsJsonCodec())`; structured errors appear in `RestRequestResult.Problem`, while malformed problem documents appear in `ProblemException` without losing the HTTP response. + ## Cookies Inject individual cookies or a complete `CookieContainer` through the builder: diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/AMDevIT.Restling.Core.csproj b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/AMDevIT.Restling.Core.csproj index 4de9ca7..4af6652 100644 --- a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/AMDevIT.Restling.Core.csproj +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/AMDevIT.Restling.Core.csproj @@ -1,52 +1,37 @@  - - net10.0;net9.0;net8.0 - enable - enable - True - Restling - Alessandro Morvillo - AMDev.IT di Alessandro Morvillo - Restling REST Client - A lightweight, powerful, and easy-to-use REST client library for .NET - © 2025 Alessandro Morvillo - https://github.com/AMDevIT/Restling - README.md - https://github.com/AMDevIT/Restling.git - - rest;http;httpclient;api;apiclient;client;restclient; - dotnet;csharp;networking;web;request;response; - json;xml;serialization;deserialization;async;await;typed-client; - fluent;builder;wrapper - - 1.0.25.0 - $(AssemblyVersion) - Fix some security vulnerabilities in diagnostic logs. Updated dependencies to latest versions. Filtered some log messages. - - LICENSE - True - $(AssemblyVersion) - RestlingIcon.png - Restling - + + net10.0;net9.0;net8.0 + enable + Restling + Restling REST Client + A lightweight, powerful, and easy-to-use REST client library for .NET + + Added support for multipart and additional serialization providers. + + LICENSE + + README.md + True + Restling + - - - True - \ - - - True - \ - - - True - \ - - + + + True + \ + + + True + \ + + + True + \ + + - - - - + + + + diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/BinaryContentCodec.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/BinaryContentCodec.cs new file mode 100644 index 0000000..4a356ae --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/BinaryContentCodec.cs @@ -0,0 +1,44 @@ +using System.Net.Http.Headers; + +namespace AMDevIT.Restling.Core.Codecs +{ + /// Buffered binary fallback. Register after specific codecs. + public sealed class BinaryContentCodec : IContentCodec + { + #region Properties + + public bool IsBinary => true; + + #endregion + + #region Methods + + /// + public bool CanRead(string? mediaType) => true; + + /// + public bool CanWrite(string? mediaType) => true; + + /// + public T? Deserialize(byte[] content, MediaTypeHeaderValue? contentType, ContentCodecContext context) + { + if (typeof(T) == typeof(byte[])) + return (T)(object)content; + if (typeof(T) == typeof(string)) + return (T)(object)Convert.ToBase64String(content); + return default; + } + + /// + public HttpContent Serialize(T value, MediaTypeHeaderValue contentType, ContentCodecContext context) + { + if (value is not byte[] bytes) + throw new ArgumentException("The binary codec requires a byte array.", nameof(value)); + ByteArrayContent content = new(bytes); + content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType.ToString()); + return content; + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/ContentCodecContext.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/ContentCodecContext.cs new file mode 100644 index 0000000..b05f936 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/ContentCodecContext.cs @@ -0,0 +1,58 @@ +using AMDevIT.Restling.Core.Common; +using AMDevIT.Restling.Core.Serialization; +using AMDevIT.Restling.Core.Text; +using Microsoft.Extensions.Logging; +using System.Net.Http.Headers; +using System.Text; + +namespace AMDevIT.Restling.Core.Codecs +{ + /// Immutable per-operation settings shared with content codecs. + public sealed class ContentCodecContext + { + #region Properties + + public PayloadJsonSerializerLibrary? JsonSerializerLibrary { get; init; } + public ILogger? Logger { get; init; } + public bool AllowUnsafeXml { get; init; } + public ContentCodecRegistry? Codecs { get; init; } + + #endregion + + #region Methods + + /// Decodes text using Restling's existing charset rules. + public string DecodeText(byte[] content, MediaTypeHeaderValue? contentType) + { + ArgumentNullException.ThrowIfNull(content); + return this.GetEncoding(contentType).GetString(content); + } + + /// Resolves an encoding, retaining UTF-8 as the legacy fallback. + public Encoding GetEncoding(MediaTypeHeaderValue? contentType) + { + return CharsetParser.Parse(contentType?.CharSet) switch + { + Charset.UTF16 => Encoding.Unicode, + Charset.UTF32 => Encoding.UTF32, + Charset.ASCII => Encoding.ASCII, + Charset.ISO_8859_1 => Encoding.Latin1, + Charset.WINDOWS_1252 => Encoding.GetEncoding("windows-1252"), + _ => Encoding.UTF8 + }; + } + + /// Creates text content and preserves media-type parameters. + public HttpContent CreateTextContent(string text, MediaTypeHeaderValue contentType) + { + Encoding encoding = this.GetEncoding(contentType); + StringContent content = new(text, encoding); + MediaTypeHeaderValue headers = MediaTypeHeaderValue.Parse(contentType.ToString()); + headers.CharSet ??= encoding.WebName; + content.Headers.ContentType = headers; + return content; + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/ContentCodecRegistry.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/ContentCodecRegistry.cs new file mode 100644 index 0000000..a389c26 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/ContentCodecRegistry.cs @@ -0,0 +1,76 @@ +using System.Collections.ObjectModel; +using System.Net.Http.Headers; + +namespace AMDevIT.Restling.Core.Codecs +{ + /// An immutable, ordered codec registry. First matching codec wins. + public sealed class ContentCodecRegistry + { + #region Fields + + private readonly ReadOnlyCollection codecs; + + #endregion + + #region Properties + + public IReadOnlyList Codecs => this.codecs; + + #endregion + + #region .ctor + + /// Registers JSON, XML, text, multipart and the binary fallback. + public ContentCodecRegistry() + : this([new JsonContentCodec(), + new XmlContentCodec(), + new TextContentCodec(), + new MultipartContentCodec(), + new BinaryContentCodec()]) + { + } + + /// Creates a registry from an explicit ordered list, without adding defaults. + public ContentCodecRegistry(IEnumerable codecs) + { + ArgumentNullException.ThrowIfNull(codecs); + IContentCodec[] snapshot = codecs.ToArray(); + if (snapshot.Any(codec => codec == null)) + throw new ArgumentException("Codecs cannot contain null entries.", nameof(codecs)); + this.codecs = Array.AsReadOnly(snapshot); + } + + #endregion + + #region Methods + + /// Returns a new registry with the codec before all existing registrations. + public ContentCodecRegistry WithCodec(IContentCodec codec) + { + ArgumentNullException.ThrowIfNull(codec); + return new ContentCodecRegistry(new[] { codec }.Concat(this.codecs)); + } + + /// Finds the first reader for a content type, ignoring its parameters. + public IContentCodec? FindReader(string? contentType) + { + string? mediaType = NormalizeMediaType(contentType); + return this.codecs.FirstOrDefault(codec => codec.CanRead(mediaType)); + } + + /// Finds the first writer for a content type, ignoring its parameters. + public IContentCodec? FindWriter(string? contentType) + { + string? mediaType = NormalizeMediaType(contentType); + return this.codecs.FirstOrDefault(codec => codec.CanWrite(mediaType)); + } + + /// Normalizes the media type without changing the caller's headers. + private static string? NormalizeMediaType(string? contentType) + { + return string.IsNullOrWhiteSpace(contentType) ? null : MediaTypeHeaderValue.Parse(contentType).MediaType?.ToLowerInvariant(); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/IContentCodec.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/IContentCodec.cs new file mode 100644 index 0000000..423b227 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/IContentCodec.cs @@ -0,0 +1,31 @@ +using System.Net.Http.Headers; + +namespace AMDevIT.Restling.Core.Codecs +{ + /// Converts buffered HTTP content. Implementations must support concurrent calls. + public interface IContentCodec + { + #region Properties + + /// Whether the original response content is binary rather than text. + bool IsBinary { get; } + + #endregion + + #region Methods + + /// Determines whether this codec reads a media type without parameters. + bool CanRead(string? mediaType); + + /// Determines whether this codec writes a media type without parameters. + bool CanWrite(string? mediaType); + + /// Decodes a buffered response. The caller retains ownership of the bytes. + T? Deserialize(byte[] content, MediaTypeHeaderValue? contentType, ContentCodecContext context); + + /// Encodes a request. The caller owns and disposes the returned content. + HttpContent Serialize(T value, MediaTypeHeaderValue contentType, ContentCodecContext context); + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/IProblemDetailsCodec.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/IProblemDetailsCodec.cs new file mode 100644 index 0000000..150ed34 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/IProblemDetailsCodec.cs @@ -0,0 +1,15 @@ +using System.Net.Http.Headers; + +namespace AMDevIT.Restling.Core.Codecs +{ + /// Exposes structured HTTP problems independently of the success model. + public interface IProblemDetailsCodec : IContentCodec + { + #region Methods + + /// Decodes a problem without interpreting its status as the transport status. + RestProblemDetails? DeserializeProblem(byte[] content, MediaTypeHeaderValue? contentType, ContentCodecContext context); + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/JsonContentCodec.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/JsonContentCodec.cs new file mode 100644 index 0000000..bd8258e --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/JsonContentCodec.cs @@ -0,0 +1,48 @@ +using AMDevIT.Restling.Core.Serialization; +using System.Net.Http.Headers; + +namespace AMDevIT.Restling.Core.Codecs +{ + /// JSON codec retaining automatic Newtonsoft.Json/System.Text.Json selection. + public sealed class JsonContentCodec : IContentCodec + { + #region Properties + + public bool IsBinary => false; + + #endregion + + #region Methods + + /// + public bool CanRead(string? mediaType) + { + if (string.Equals(mediaType, "application/json", StringComparison.OrdinalIgnoreCase)) + return true; + + return mediaType?.StartsWith("application/", StringComparison.OrdinalIgnoreCase) == true && + mediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase) && + !string.Equals(mediaType, "application/problem+json", StringComparison.OrdinalIgnoreCase); + } + + /// + public bool CanWrite(string? mediaType) => this.CanRead(mediaType); + + /// + public T? Deserialize(byte[] content, MediaTypeHeaderValue? contentType, ContentCodecContext context) + { + JsonSerialization serializer = new(context.Logger); + return serializer.Deserialize(context.DecodeText(content, contentType), context.JsonSerializerLibrary); + } + + /// + public HttpContent Serialize(T value, MediaTypeHeaderValue contentType, ContentCodecContext context) + { + JsonSerialization serializer = new(context.Logger); + string json = value is null ? string.Empty : serializer.Serialize(value, context.JsonSerializerLibrary); + return context.CreateTextContent(json, contentType); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/MultipartContentCodec.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/MultipartContentCodec.cs new file mode 100644 index 0000000..f335ee2 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/MultipartContentCodec.cs @@ -0,0 +1,76 @@ +using AMDevIT.Restling.Core.Multipart; +using System.Net.Http.Headers; + +namespace AMDevIT.Restling.Core.Codecs +{ + /// Decodes buffered MIME multipart responses into structured documents. + public sealed class MultipartContentCodec : IContentCodec + { + #region Fields + + private readonly MultipartOptions options; + + #endregion + + #region Properties + + public bool IsBinary => true; + + #endregion + + #region .ctor + + /// Creates a codec with the default multipart safety limits. + public MultipartContentCodec() + : this(new MultipartOptions()) + { + } + + /// Creates a codec with explicit multipart safety limits. + /// Limits applied to multipart parsing. + public MultipartContentCodec(MultipartOptions options) + { + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + this.options = options; + } + + #endregion + + #region Methods + + /// + public bool CanRead(string? mediaType) + { + return mediaType?.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase) == true; + } + + /// + public bool CanWrite(string? mediaType) => false; + + /// + public T? Deserialize(byte[] content, MediaTypeHeaderValue? contentType, ContentCodecContext context) + { + ContentCodecRegistry codecs; + MultipartDocument document; + + ArgumentNullException.ThrowIfNull(contentType); + codecs = context.Codecs ?? new ContentCodecRegistry(); + document = MultipartParser.Parse(content, contentType, codecs, context, this.options); + + if (typeof(T) == typeof(MultipartDocument)) + return (T)(object)document; + if (typeof(T) == typeof(IReadOnlyList)) + return (T)(object)document.Parts; + throw new NotSupportedException($"Multipart content cannot be deserialized as {typeof(T).FullName}."); + } + + /// + public HttpContent Serialize(T value, MediaTypeHeaderValue contentType, ContentCodecContext context) + { + throw new NotSupportedException("Multipart requests are created with MultipartRequest."); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/ProblemDetailsJsonCodec.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/ProblemDetailsJsonCodec.cs new file mode 100644 index 0000000..fd67a93 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/ProblemDetailsJsonCodec.cs @@ -0,0 +1,84 @@ +using System.Net.Http.Headers; +using System.Text.Json; + +namespace AMDevIT.Restling.Core.Codecs +{ + /// Opt-in application/problem+json codec following RFC 9457 member type handling. + public sealed class ProblemDetailsJsonCodec : IProblemDetailsCodec + { + #region Properties + + public bool IsBinary => false; + + #endregion + + #region Methods + + /// + public bool CanRead(string? mediaType) + { + return string.Equals(mediaType, "application/problem+json", StringComparison.OrdinalIgnoreCase); + } + + /// + public bool CanWrite(string? mediaType) => this.CanRead(mediaType); + + /// + public T? Deserialize(byte[] content, MediaTypeHeaderValue? contentType, ContentCodecContext context) + { + if (typeof(T) != typeof(RestProblemDetails)) + throw new NotSupportedException("Problem documents decode to RestProblemDetails, not the success model."); + return (T?)(object?)this.DeserializeProblem(content, contentType, context); + } + + /// + public RestProblemDetails? DeserializeProblem(byte[] content, MediaTypeHeaderValue? contentType, ContentCodecContext context) + { + RestProblemDetails problem = new(); + using JsonDocument document = JsonDocument.Parse(context.DecodeText(content, contentType)); + if (document.RootElement.ValueKind != JsonValueKind.Object) + throw new JsonException("A problem document must be a JSON object."); + + foreach (JsonProperty property in document.RootElement.EnumerateObject()) + { + switch (property.Name) + { + case "type": + if (property.Value.ValueKind == JsonValueKind.String) + problem.Type = property.Value.GetString()!; + break; + case "title": + if (property.Value.ValueKind == JsonValueKind.String) + problem.Title = property.Value.GetString(); + break; + case "detail": + if (property.Value.ValueKind == JsonValueKind.String) + problem.Detail = property.Value.GetString(); + break; + case "instance": + if (property.Value.ValueKind == JsonValueKind.String) + problem.Instance = property.Value.GetString(); + break; + case "status": + if (property.Value.ValueKind == JsonValueKind.Number && property.Value.TryGetInt32(out int status)) + problem.Status = status; + break; + default: + problem.Extensions[property.Name] = property.Value.Clone(); + break; + } + } + return problem; + } + + /// + public HttpContent Serialize(T value, MediaTypeHeaderValue contentType, ContentCodecContext context) + { + if (value is not RestProblemDetails problem) + throw new ArgumentException("The problem codec requires RestProblemDetails.", nameof(value)); + return context.CreateTextContent(JsonSerializer.Serialize(problem), contentType); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/RestProblemDetails.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/RestProblemDetails.cs new file mode 100644 index 0000000..14ea3e1 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/RestProblemDetails.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AMDevIT.Restling.Core.Codecs +{ + /// RFC 9457 fields and extensions. URI references are never fetched automatically. + public sealed class RestProblemDetails + { + #region Properties + + [JsonPropertyName("type")] + public string Type { get; set; } = "about:blank"; + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("status")] + public int? Status { get; set; } + + [JsonPropertyName("detail")] + public string? Detail { get; set; } + + [JsonPropertyName("instance")] + public string? Instance { get; set; } + + [JsonExtensionData] + public Dictionary Extensions { get; set; } = new(StringComparer.Ordinal); + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/TextContentCodec.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/TextContentCodec.cs new file mode 100644 index 0000000..efaf52b --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/TextContentCodec.cs @@ -0,0 +1,42 @@ +using System.Net.Http.Headers; + +namespace AMDevIT.Restling.Core.Codecs +{ + /// Text and primitive conversion for the legacy textual media types. + public sealed class TextContentCodec : IContentCodec + { + #region Properties + + public bool IsBinary => false; + + #endregion + + #region Methods + + /// + public bool CanRead(string? mediaType) + { + return mediaType?.ToLowerInvariant() is "text/plain" or "text/html" or "text/css" or "text/javascript" or "image/svg+xml"; + } + + /// + public bool CanWrite(string? mediaType) => this.CanRead(mediaType); + + /// + public T? Deserialize(byte[] content, MediaTypeHeaderValue? contentType, ContentCodecContext context) + { + string text = context.DecodeText(content, contentType); + if (typeof(T) == typeof(string)) + return (T)(object)text; + return typeof(T).IsPrimitive ? (T)Convert.ChangeType(text, typeof(T)) : default; + } + + /// + public HttpContent Serialize(T value, MediaTypeHeaderValue contentType, ContentCodecContext context) + { + return context.CreateTextContent(value?.ToString() ?? string.Empty, contentType); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/XmlContentCodec.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/XmlContentCodec.cs new file mode 100644 index 0000000..fbb149f --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Codecs/XmlContentCodec.cs @@ -0,0 +1,60 @@ +using System.Net.Http.Headers; +using System.Xml; +using System.Xml.Serialization; + +namespace AMDevIT.Restling.Core.Codecs +{ + /// XML codec with DTD processing prohibited by default. + public sealed class XmlContentCodec : IContentCodec + { + #region Properties + + public bool IsBinary => false; + + #endregion + + #region Methods + + /// + public bool CanRead(string? mediaType) + { + if (mediaType?.ToLowerInvariant() is "application/xml" or "text/xml" or "application/atom+xml") + return true; + + return mediaType?.StartsWith("application/", StringComparison.OrdinalIgnoreCase) == true && + mediaType.EndsWith("+xml", StringComparison.OrdinalIgnoreCase) && + !string.Equals(mediaType, "application/problem+xml", StringComparison.OrdinalIgnoreCase); + } + + /// + public bool CanWrite(string? mediaType) => this.CanRead(mediaType); + + /// + public T? Deserialize(byte[] content, MediaTypeHeaderValue? contentType, ContentCodecContext context) + { + XmlSerializer serializer = new(typeof(T)); + using StringReader input = new(context.DecodeText(content, contentType)); + if (context.AllowUnsafeXml) + return (T?)serializer.Deserialize(input); + + XmlReaderSettings settings = new() { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }; + using XmlReader reader = XmlReader.Create(input, settings); + return (T?)serializer.Deserialize(reader); + } + + /// + public HttpContent Serialize(T value, MediaTypeHeaderValue contentType, ContentCodecContext context) + { + XmlSerializer serializer = new(typeof(T)); + XmlWriterSettings settings = new() { OmitXmlDeclaration = true }; + using StringWriter output = new(); + using (XmlWriter writer = XmlWriter.Create(output, settings)) + { + serializer.Serialize(writer, value); + } + return context.CreateTextContent(output.ToString(), contentType); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/HttpResponseParser.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/HttpResponseParser.cs index ee5b0f6..6462e75 100644 --- a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/HttpResponseParser.cs +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/HttpResponseParser.cs @@ -1,23 +1,16 @@ using AMDevIT.Restling.Core.Common; using AMDevIT.Restling.Core.Network; +using AMDevIT.Restling.Core.Codecs; using AMDevIT.Restling.Core.Serialization; using AMDevIT.Restling.Core.Text; using Microsoft.Extensions.Logging; using System.Net.Http.Headers; using System.Text; -using System.Xml; -using System.Xml.Serialization; namespace AMDevIT.Restling.Core { internal class HttpResponseParser(ILogger? logger) { - #region Consts - - - - #endregion - #region Fields private readonly ILogger? logger = logger; @@ -28,6 +21,8 @@ internal class HttpResponseParser(ILogger? logger) protected ILogger? Logger => this.logger; + public ContentCodecRegistry Codecs { get; init; } = new(); + public bool AllowUnsafeXml { get; set; } = false; public bool ThrowOnDecodeError { get; set; } = false; @@ -73,6 +68,7 @@ public async Task DecodeAsync(HttpResponseMessage resultHttpM this.Logger?.LogError(httpClientException, "Http response object is null"); } + this.AttachProblem(restRequestResult); return restRequestResult; } @@ -153,6 +149,7 @@ public async Task> DecodeAsync(HttpResponseMessage resul this.Logger?.LogError(httpClientException, "Http response object is null"); } + this.AttachProblem(restRequestResult); return restRequestResult; } @@ -177,65 +174,53 @@ public async Task> DecodeAsync(HttpResponseMessage resul } - private T? DecodeData(byte[] rawContent, - RetrievedContentResult content, + /// Decodes a model using the registry while retaining legacy JSON error handling. + private T? DecodeData(byte[] rawContent, + RetrievedContentResult content, MediaTypeHeaderValue? contentType, PayloadJsonSerializerLibrary? payloadJsonSerializerLibrary = null) { - T? data = default; - - // Try decoding data. - switch (contentType?.MediaType) + IContentCodec? codec = this.Codecs.FindReader(contentType?.MediaType); + ContentCodecContext context = new() { - case HttpMediaType.ApplicationJson: - try - { - if (content.Content is string json) - { - JsonSerialization jsonSerialization = new(this.Logger); - data = jsonSerialization.Deserialize(json, payloadJsonSerializerLibrary); - } - } - catch (Exception exc) - { - this.Logger?.LogError(exc, "Failed to deserialize JSON content."); - } - break; + Logger = this.Logger, + JsonSerializerLibrary = payloadJsonSerializerLibrary, + AllowUnsafeXml = this.AllowUnsafeXml, + Codecs = this.Codecs + }; + if (codec is IProblemDetailsCodec && typeof(T) != typeof(RestProblemDetails)) + return default; + if (codec == null) + return this.RetrievePrimitiveType(rawContent, content, contentType); - case HttpMediaType.ApplicationAtomXml: - case HttpMediaType.ApplicationXml: - case HttpMediaType.TextXml: - try - { - if (content.Content is string xml) - { - if (!this.AllowUnsafeXml) - { - data = this.DecodeXmlSecure(xml); - } - else - { - XmlSerializer serializer = new(typeof(T)); - using var stringReader = new StringReader(xml); - data = (T?)serializer.Deserialize(stringReader); - } - } - } - catch (Exception exc) - { - this.Logger?.LogError(exc, "Failed to deserialize XML content."); - throw; - } - break; + try + { + return codec.Deserialize(rawContent, contentType, context); + } + catch (Exception exception) when (codec is JsonContentCodec) + { + // Retain the legacy JSON default-on-error behavior. + this.Logger?.LogError(exception, "Failed to deserialize JSON content."); + return default; + } + } - default: - // If it's a string or other primitive, try to parse it. - data = this.RetrievePrimitiveType(rawContent, content, contentType); - break; + /// Attaches optional problem metadata without replacing the HTTP result. + private void AttachProblem(RestRequestResult result) + { + IContentCodec? codec = this.Codecs.FindReader(result.ContentType); + if (codec is not IProblemDetailsCodec problemCodec || result.RawContent == null) + return; + try + { + ContentCodecContext context = new() { Logger = this.Logger, Codecs = this.Codecs }; + result.Problem = problemCodec.DeserializeProblem(result.RawContent, result.RetrievedContent?.ContentType, context); + } + catch (Exception exception) + { + result.ProblemException = exception; } - - return data; } @@ -259,68 +244,16 @@ public async Task> DecodeAsync(HttpResponseMessage resul return default; } - private static RetrievedContentResult RetrieveContent(byte[] rawContent, - MediaTypeHeaderValue? contentType) + /// Classifies the original body through the selected codec, preserving the missing-header fallback. + private RetrievedContentResult RetrieveContent(byte[] rawContent, MediaTypeHeaderValue? contentType) { - object? content; - bool isBinaryData = false; - RetrievedContentResult contentResult; - if (contentType == null) - { - content = rawContent; - } - else - { - Charset charset = CharsetParser.Parse(contentType.CharSet); - switch (contentType.MediaType) - { - case HttpMediaType.ApplicationJson: - case HttpMediaType.ApplicationXml: - case HttpMediaType.TextXml: - case HttpMediaType.TextPlain: - case HttpMediaType.TextHtml: - case HttpMediaType.TextCss: - case HttpMediaType.TextJavascript: - case HttpMediaType.ImageSvgXml: - case HttpMediaType.ApplicationAtomXml: - { - string stringContent = DecodeContentString(rawContent, charset); - content = stringContent; - isBinaryData = false; - } - break; - - case HttpMediaType.ImagePng: - case HttpMediaType.ImageJpeg: - case HttpMediaType.ImageGif: - case HttpMediaType.ImageBmp: - case HttpMediaType.ImageWebp: - case HttpMediaType.ApplicationOctetStream: - case HttpMediaType.VideoMp4: - case HttpMediaType.VideoMpeg: - case HttpMediaType.VideoOgg: - case HttpMediaType.VideoWebm: - case HttpMediaType.VideoQuicktime: - { - content = rawContent; - isBinaryData = true; - } - break; + return new RetrievedContentResult(rawContent, false, null); - default: - { - // This is a fallback, if the content type is not recognized. - // The content is returned as a byte array. - content = rawContent; - isBinaryData = true; - } - break; - } - } - - contentResult = new(content, isBinaryData, contentType); - return contentResult; + IContentCodec? codec = this.Codecs.FindReader(contentType.MediaType); + bool isBinary = codec?.IsBinary ?? true; + object content = isBinary ? rawContent : DecodeContentString(rawContent, CharsetParser.Parse(contentType.CharSet)); + return new RetrievedContentResult(content, isBinary, contentType); } private static string DecodeContentString(byte[] rawContent, Charset charset) @@ -338,18 +271,6 @@ private static string DecodeContentString(byte[] rawContent, Charset charset) return result; } - private T? DecodeXmlSecure(string xml) - { - XmlSerializer serializer = new(typeof(T)); - XmlReaderSettings settings = new() - { - DtdProcessing = DtdProcessing.Prohibit, - XmlResolver = null - }; - - using var reader = XmlReader.Create(new StringReader(xml), settings); - return (T?)serializer.Deserialize(reader); - } #endregion } diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/IRestlingClient.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/IRestlingClient.cs index 5b8526b..98e1297 100644 --- a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/IRestlingClient.cs +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/IRestlingClient.cs @@ -1,6 +1,7 @@ using AMDevIT.Restling.Core.Network; using AMDevIT.Restling.Core.Network.Builders; using AMDevIT.Restling.Core.Serialization; +using AMDevIT.Restling.Core.Multipart; namespace AMDevIT.Restling.Core { @@ -22,8 +23,22 @@ PayloadJsonSerializerLibrary SelectedDefaultSerializationLibrary set; } + /// Gets or sets whether this client owns and disposes its context. + RestlingClientContextOwnership ContextOwnership + { + get => this.DisposeContext + ? RestlingClientContextOwnership.Owned + : RestlingClientContextOwnership.Borrowed; + set + { + if (!Enum.IsDefined(value)) + throw new ArgumentOutOfRangeException(nameof(value)); + this.DisposeContext = value == RestlingClientContextOwnership.Owned; + } + } + /// - /// Dispose the HttpClient instance and all the handlers when disposing the RestlingClient instance. + /// Compatibility alias for ContextOwnership. /// bool DisposeContext { @@ -70,6 +85,40 @@ Task> GetAsync(string uri, PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary = null, CancellationToken cancellationToken = default); + /// Executes an untyped GET request with a per-request proxy selection. + Task GetAsync(string uri, RequestProxyOptions proxyOptions, CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support per-request proxy overrides."); + } + + /// Executes an untyped GET request with headers and a per-request proxy selection. + Task GetAsync(string uri, + RequestHeaders requestHeaders, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support per-request proxy overrides."); + } + + /// Executes a typed GET request with a per-request proxy selection. + Task> GetAsync(string uri, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support per-request proxy overrides."); + } + + /// Executes a typed GET request with headers and a per-request proxy selection. + Task> GetAsync(string uri, + RequestHeaders requestHeaders, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support per-request proxy overrides."); + } + #endregion #region POST @@ -113,6 +162,48 @@ Task> PostAsync(string uri, PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary = null, CancellationToken cancellationToken = default); + /// Executes an untyped POST request with a per-request proxy selection. + Task PostAsync(string uri, + T requestData, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support per-request proxy overrides."); + } + + /// Executes a typed POST request with a per-request proxy selection. + Task> PostAsync(string uri, + T requestData, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support per-request proxy overrides."); + } + + /// Executes an untyped POST request with headers and a per-request proxy selection. + Task PostAsync(string uri, + T requestData, + RequestHeaders requestHeaders, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support per-request proxy overrides."); + } + + /// Executes a typed POST request with headers and a per-request proxy selection. + Task> PostAsync(string uri, + T requestData, + RequestHeaders requestHeaders, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support per-request proxy overrides."); + } + #endregion #region PUT @@ -148,6 +239,48 @@ Task> PutAsync(string uri, PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary = null, CancellationToken cancellationToken = default); + /// Executes an untyped PUT request with a per-request proxy selection. + Task PutAsync(string uri, + T requestData, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support per-request proxy overrides."); + } + + /// Executes a typed PUT request with a per-request proxy selection. + Task> PutAsync(string uri, + T requestData, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support per-request proxy overrides."); + } + + /// Executes an untyped PUT request with headers and a per-request proxy selection. + Task PutAsync(string uri, + T requestData, + RequestHeaders requestHeaders, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support per-request proxy overrides."); + } + + /// Executes a typed PUT request with headers and a per-request proxy selection. + Task> PutAsync(string uri, + T requestData, + RequestHeaders requestHeaders, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support per-request proxy overrides."); + } + #endregion #region DELETE @@ -181,6 +314,42 @@ Task> DeleteAsync(string uri, PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary = null, CancellationToken cancellationToken = default); + /// Executes an untyped DELETE request with a per-request proxy selection. + Task DeleteAsync(string uri, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support per-request proxy overrides."); + } + + /// Executes a typed DELETE request with a per-request proxy selection. + Task> DeleteAsync(string uri, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support per-request proxy overrides."); + } + + /// Executes an untyped DELETE request with headers and a per-request proxy selection. + Task DeleteAsync(string uri, + RequestHeaders requestHeaders, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support per-request proxy overrides."); + } + + /// Executes a typed DELETE request with headers and a per-request proxy selection. + Task> DeleteAsync(string uri, + RequestHeaders requestHeaders, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support per-request proxy overrides."); + } + #endregion @@ -196,6 +365,28 @@ Task> ExecuteRequestAsync(RestRequest restRequest, bool throwOnGenerics = false, CancellationToken cancellationToken = default); + /// Executes a multipart request. + Task ExecuteMultipartRequestAsync(MultipartRequest multipartRequest, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support multipart requests."); + } + + /// Executes a multipart request and deserializes its response. + Task> ExecuteMultipartRequestAsync(MultipartRequest multipartRequest, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support multipart requests."); + } + + /// Streams parts from a multipart/x-mixed-replace response. + IAsyncEnumerable StreamMultipartMixedReplaceAsync(RestRequest restRequest, + MultipartOptions? options = null, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("This client does not support multipart streaming."); + } + #endregion #endregion diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Multipart/MultipartDocument.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Multipart/MultipartDocument.cs new file mode 100644 index 0000000..49c1340 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Multipart/MultipartDocument.cs @@ -0,0 +1,53 @@ +using System.Collections.ObjectModel; +using System.Net.Http.Headers; + +namespace AMDevIT.Restling.Core.Multipart +{ + /// Represents a parsed MIME multipart entity. + public sealed class MultipartDocument + { + #region Properties + + public MediaTypeHeaderValue ContentType { get; } + public IReadOnlyList Parts { get; } + public byte[] Preamble { get; } + public byte[] Epilogue { get; } + + /// Gets the root part selected by multipart/related's start parameter, or the first part. + public MultipartPart? RootPart + { + get + { + NameValueHeaderValue? start; + string? contentId; + + if (!string.Equals(this.ContentType.MediaType, "multipart/related", StringComparison.OrdinalIgnoreCase)) + return this.Parts.FirstOrDefault(); + + start = this.ContentType.Parameters.FirstOrDefault(parameter => + string.Equals(parameter.Name, "start", StringComparison.OrdinalIgnoreCase)); + contentId = start?.Value?.Trim().Trim('"').Trim('<', '>'); + return string.IsNullOrWhiteSpace(contentId) + ? this.Parts.FirstOrDefault() + : this.Parts.FirstOrDefault(part => string.Equals(part.ContentId, contentId, StringComparison.Ordinal)); + } + } + + #endregion + + #region .ctor + + internal MultipartDocument(MediaTypeHeaderValue contentType, + IEnumerable parts, + byte[] preamble, + byte[] epilogue) + { + this.ContentType = contentType; + this.Parts = new ReadOnlyCollection(parts.ToArray()); + this.Preamble = preamble; + this.Epilogue = epilogue; + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Multipart/MultipartMixedReplaceReader.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Multipart/MultipartMixedReplaceReader.cs new file mode 100644 index 0000000..1674640 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Multipart/MultipartMixedReplaceReader.cs @@ -0,0 +1,69 @@ +using AMDevIT.Restling.Core.Codecs; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Text; + +namespace AMDevIT.Restling.Core.Multipart +{ + /// Reads finite parts from a potentially unbounded multipart/x-mixed-replace stream. + internal static class MultipartMixedReplaceReader + { + #region Methods + + public static async IAsyncEnumerable ReadAsync(Stream stream, + MediaTypeHeaderValue contentType, + ContentCodecRegistry codecs, + ContentCodecContext codecContext, + MultipartOptions options, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + List pending = []; + byte[] readBuffer = new byte[81_920]; + string boundary = MultipartParser.GetBoundary(contentType); + byte[] closingMarker = Encoding.ASCII.GetBytes($"--{boundary}--\r\n"); + + while (true) + { + List offsets = MultipartParser.FindBoundaryOffsets(pending, boundary); + while (offsets.Count >= 2) + { + if (IsClosing(pending, offsets[0], boundary)) + yield break; + + byte[] singlePart = new byte[offsets[1] - offsets[0] + closingMarker.Length]; + pending.CopyTo(offsets[0], singlePart, 0, offsets[1] - offsets[0]); + closingMarker.CopyTo(singlePart, offsets[1] - offsets[0]); + MultipartDocument document = MultipartParser.Parse(singlePart, + contentType, + codecs, + codecContext, + options); + pending.RemoveRange(0, offsets[1]); + foreach (MultipartPart part in document.Parts) + yield return part; + offsets = MultipartParser.FindBoundaryOffsets(pending, boundary); + } + + if (offsets.Count == 1 && IsClosing(pending, offsets[0], boundary)) + yield break; + if (pending.Count > options.MaxPartBytes + options.MaxHeaderBytes + 1024) + throw new InvalidDataException("The multipart stream part-size limit was exceeded."); + + int read = await stream.ReadAsync(readBuffer.AsMemory(), cancellationToken); + if (read == 0) + throw new EndOfStreamException("The multipart/x-mixed-replace stream ended without a closing boundary."); + pending.EnsureCapacity(pending.Count + read); + for (int index = 0; index < read; index++) + pending.Add(readBuffer[index]); + } + } + + private static bool IsClosing(IReadOnlyList content, int offset, string boundary) + { + int suffix = offset + 2 + Encoding.ASCII.GetByteCount(boundary); + return suffix + 1 < content.Count && content[suffix] == 45 && content[suffix + 1] == 45; + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Multipart/MultipartOptions.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Multipart/MultipartOptions.cs new file mode 100644 index 0000000..e3918ea --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Multipart/MultipartOptions.cs @@ -0,0 +1,39 @@ +namespace AMDevIT.Restling.Core.Multipart +{ + /// Defines safety limits used while parsing multipart content. + public sealed class MultipartOptions + { + #region Properties + + /// Maximum number of parts in each buffered multipart entity. + public int MaxParts { get; init; } = 1000; + + /// Maximum header bytes allowed for one part. + public int MaxHeaderBytes { get; init; } = 16 * 1024; + + /// Maximum number of nested multipart levels below the root. + public int MaxNestingDepth { get; init; } = 8; + + /// Maximum buffered bytes allowed for one part or streamed mixed-replace frame. + public long MaxPartBytes { get; init; } = 128L * 1024L * 1024L; + + #endregion + + #region Methods + + /// Validates all configured limits. + internal void Validate() + { + if (this.MaxParts <= 0) + throw new ArgumentOutOfRangeException(nameof(this.MaxParts)); + if (this.MaxHeaderBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(this.MaxHeaderBytes)); + if (this.MaxNestingDepth < 0) + throw new ArgumentOutOfRangeException(nameof(this.MaxNestingDepth)); + if (this.MaxPartBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(this.MaxPartBytes)); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Multipart/MultipartParser.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Multipart/MultipartParser.cs new file mode 100644 index 0000000..d8b3bf5 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Multipart/MultipartParser.cs @@ -0,0 +1,249 @@ +using AMDevIT.Restling.Core.Codecs; +using System.Net.Http.Headers; +using System.Text; + +namespace AMDevIT.Restling.Core.Multipart +{ + /// Parses buffered MIME multipart entities without converting bodies to text. + internal static class MultipartParser + { + #region Methods + + public static MultipartDocument Parse(byte[] content, + MediaTypeHeaderValue contentType, + ContentCodecRegistry codecs, + ContentCodecContext codecContext, + MultipartOptions options, + int depth = 0) + { + List boundaries; + List parts = []; + string boundary; + byte[] preamble; + byte[] epilogue = []; + int closingIndex = -1; + + ArgumentNullException.ThrowIfNull(content); + ArgumentNullException.ThrowIfNull(contentType); + ArgumentNullException.ThrowIfNull(codecs); + ArgumentNullException.ThrowIfNull(codecContext); + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + + if (contentType.MediaType?.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase) != true) + throw new InvalidDataException("The content type is not multipart."); + if (depth > options.MaxNestingDepth) + throw new InvalidDataException("The multipart nesting limit was exceeded."); + + boundary = GetBoundary(contentType); + boundaries = FindBoundaries(content, boundary); + if (boundaries.Count == 0) + throw new InvalidDataException("The multipart boundary was not found in the content."); + + preamble = SliceWithoutDelimiterCrLf(content, 0, boundaries[0].Start); + + for (int index = 0; index < boundaries.Count; index++) + { + BoundaryMatch current = boundaries[index]; + if (current.IsClosing) + { + closingIndex = index; + epilogue = content[current.AfterLine..]; + break; + } + + if (index + 1 >= boundaries.Count) + throw new InvalidDataException("The multipart closing boundary is missing."); + if (parts.Count >= options.MaxParts) + throw new InvalidDataException("The multipart part-count limit was exceeded."); + + BoundaryMatch next = boundaries[index + 1]; + parts.Add(ParsePart(content, + current.AfterLine, + next.Start, + contentType, + codecs, + codecContext, + options, + depth)); + } + + if (closingIndex < 0) + throw new InvalidDataException("The multipart closing boundary is missing."); + + return new MultipartDocument(MediaTypeHeaderValue.Parse(contentType.ToString()), parts, preamble, epilogue); + } + + internal static List FindBoundaryOffsets(IReadOnlyList content, string boundary) + { + return FindBoundaries(content, boundary).Select(match => match.Start).ToList(); + } + + internal static string GetBoundary(MediaTypeHeaderValue contentType) + { + NameValueHeaderValue? parameter = contentType.Parameters.FirstOrDefault(item => + string.Equals(item.Name, "boundary", StringComparison.OrdinalIgnoreCase)); + string? boundary = parameter?.Value?.Trim().Trim('"'); + + if (string.IsNullOrWhiteSpace(boundary)) + throw new InvalidDataException("A multipart boundary parameter is required."); + const string allowed = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'()+_,-./:=? "; + if (boundary.Length > 70 || boundary.EndsWith(' ') || boundary.Any(character => !allowed.Contains(character))) + throw new InvalidDataException("The multipart boundary is invalid."); + return boundary; + } + + private static MultipartPart ParsePart(byte[] source, + int start, + int end, + MediaTypeHeaderValue parentContentType, + ContentCodecRegistry codecs, + ContentCodecContext codecContext, + MultipartOptions options, + int depth) + { + Dictionary> headers; + MultipartDocument? nested = null; + MediaTypeHeaderValue partContentType; + byte[] body; + int bodyStart; + int effectiveEnd = TrimDelimiterCrLf(source, start, end); + int separator = FindSequence(source, start, effectiveEnd, [13, 10, 13, 10]); + + if (start + 1 < effectiveEnd && source[start] == 13 && source[start + 1] == 10) + { + headers = new(StringComparer.OrdinalIgnoreCase); + bodyStart = start + 2; + } + else + { + if (separator < 0) + throw new InvalidDataException("A multipart part does not contain a header terminator."); + if (separator - start > options.MaxHeaderBytes) + throw new InvalidDataException("The multipart header-size limit was exceeded."); + headers = ParseHeaders(source[start..separator]); + bodyStart = separator + 4; + } + + if (effectiveEnd - bodyStart > options.MaxPartBytes) + throw new InvalidDataException("The multipart part-size limit was exceeded."); + body = source[bodyStart..effectiveEnd]; + + if (!headers.TryGetValue("Content-Type", out IReadOnlyList? values) || values.Count == 0) + { + string defaultType = string.Equals(parentContentType.MediaType, "multipart/digest", StringComparison.OrdinalIgnoreCase) + ? "message/rfc822" + : "text/plain"; + headers["Content-Type"] = new[] { defaultType }; + partContentType = MediaTypeHeaderValue.Parse(defaultType); + } + else + { + partContentType = MediaTypeHeaderValue.Parse(values[0]); + } + + if (partContentType.MediaType?.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase) == true) + nested = Parse(body, partContentType, codecs, codecContext, options, depth + 1); + + return new MultipartPart(headers, body, nested, codecs, codecContext); + } + + private static Dictionary> ParseHeaders(byte[] content) + { + Dictionary> parsed = new(StringComparer.OrdinalIgnoreCase); + string[] lines = Encoding.ASCII.GetString(content).Split("\r\n", StringSplitOptions.None); + string? currentName = null; + + foreach (string line in lines) + { + if ((line.StartsWith(' ') || line.StartsWith('\t')) && currentName != null) + { + List values = parsed[currentName]; + values[^1] = $"{values[^1]} {line.Trim()}"; + continue; + } + + int colon = line.IndexOf(':'); + if (colon <= 0) + throw new InvalidDataException("A multipart part contains an invalid header."); + currentName = line[..colon].Trim(); + string value = line[(colon + 1)..].Trim(); + if (!parsed.TryGetValue(currentName, out List? valuesForName)) + { + valuesForName = []; + parsed[currentName] = valuesForName; + } + valuesForName.Add(value); + } + + return parsed.ToDictionary(item => item.Key, + item => (IReadOnlyList)item.Value.AsReadOnly(), + StringComparer.OrdinalIgnoreCase); + } + + private static List FindBoundaries(IReadOnlyList content, string boundary) + { + byte[] marker = Encoding.ASCII.GetBytes($"--{boundary}"); + List result = []; + + for (int index = 0; index <= content.Count - marker.Length; index++) + { + if (index != 0 && (index < 2 || content[index - 2] != 13 || content[index - 1] != 10)) + continue; + if (!Matches(content, index, marker)) + continue; + + int cursor = index + marker.Length; + bool closing = cursor + 1 < content.Count && content[cursor] == 45 && content[cursor + 1] == 45; + if (closing) + cursor += 2; + while (cursor < content.Count && (content[cursor] == 32 || content[cursor] == 9)) + cursor++; + if (cursor == content.Count) + { + result.Add(new BoundaryMatch(index, cursor, closing)); + continue; + } + if (cursor + 1 >= content.Count || content[cursor] != 13 || content[cursor + 1] != 10) + continue; + result.Add(new BoundaryMatch(index, cursor + 2, closing)); + } + + return result; + } + + private static bool Matches(IReadOnlyList content, int offset, IReadOnlyList marker) + { + for (int index = 0; index < marker.Count; index++) + { + if (content[offset + index] != marker[index]) + return false; + } + return true; + } + + private static int FindSequence(byte[] source, int start, int end, byte[] sequence) + { + for (int index = start; index <= end - sequence.Length; index++) + { + if (source.AsSpan(index, sequence.Length).SequenceEqual(sequence)) + return index; + } + return -1; + } + + private static int TrimDelimiterCrLf(byte[] source, int start, int end) + { + return end - start >= 2 && source[end - 2] == 13 && source[end - 1] == 10 ? end - 2 : end; + } + + private static byte[] SliceWithoutDelimiterCrLf(byte[] source, int start, int end) + { + return source[start..TrimDelimiterCrLf(source, start, end)]; + } + + private readonly record struct BoundaryMatch(int Start, int AfterLine, bool IsClosing); + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Multipart/MultipartPart.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Multipart/MultipartPart.cs new file mode 100644 index 0000000..b2bb2cf --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Multipart/MultipartPart.cs @@ -0,0 +1,81 @@ +using AMDevIT.Restling.Core.Codecs; +using System.Collections.ObjectModel; +using System.Net.Http.Headers; + +namespace AMDevIT.Restling.Core.Multipart +{ + /// Represents one MIME multipart body part without interpreting file names as paths. + public sealed class MultipartPart + { + #region Fields + + private readonly ContentCodecRegistry codecs; + private readonly ContentCodecContext codecContext; + + #endregion + + #region Properties + + public IReadOnlyDictionary> Headers { get; } + public byte[] RawContent { get; } + public MediaTypeHeaderValue? ContentType { get; } + public ContentDispositionHeaderValue? ContentDisposition { get; } + public string? Name => TrimQuotes(this.ContentDisposition?.Name); + public string? FileName => TrimQuotes(this.ContentDisposition?.FileNameStar ?? this.ContentDisposition?.FileName); + public string? ContentId => this.Headers.TryGetValue("Content-ID", out IReadOnlyList? values) && values.Count > 0 + ? values[0].Trim().Trim('<', '>') + : null; + public ContentRangeHeaderValue? ContentRange { get; } + public MultipartDocument? NestedContent { get; } + + #endregion + + #region .ctor + + internal MultipartPart(IDictionary> headers, + byte[] rawContent, + MultipartDocument? nestedContent, + ContentCodecRegistry codecs, + ContentCodecContext codecContext) + { + Dictionary> snapshot = new(headers, StringComparer.OrdinalIgnoreCase); + this.Headers = new ReadOnlyDictionary>(snapshot); + this.RawContent = rawContent; + this.NestedContent = nestedContent; + this.codecs = codecs; + this.codecContext = codecContext; + + if (headers.TryGetValue("Content-Type", out IReadOnlyList? contentTypes) && contentTypes.Count > 0) + this.ContentType = MediaTypeHeaderValue.Parse(contentTypes[0]); + if (headers.TryGetValue("Content-Disposition", out IReadOnlyList? dispositions) && dispositions.Count > 0) + this.ContentDisposition = ContentDispositionHeaderValue.Parse(dispositions[0]); + if (headers.TryGetValue("Content-Range", out IReadOnlyList? ranges) && ranges.Count > 0) + this.ContentRange = ContentRangeHeaderValue.Parse(ranges[0]); + } + + #endregion + + #region Methods + + /// Deserializes this part with the codec registry that parsed the response. + public T? Deserialize() + { + if (this.NestedContent != null && typeof(T) == typeof(MultipartDocument)) + return (T)(object)this.NestedContent; + if (typeof(T) == typeof(byte[])) + return (T)(object)this.RawContent; + + IContentCodec? codec = this.codecs.FindReader(this.ContentType?.MediaType); + if (codec == null) + return default; + return codec.Deserialize(this.RawContent, this.ContentType, this.codecContext); + } + + private static string? TrimQuotes(string? value) + { + return value?.Trim().Trim('"'); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Multipart/MultipartRequest.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Multipart/MultipartRequest.cs new file mode 100644 index 0000000..5f82969 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Multipart/MultipartRequest.cs @@ -0,0 +1,243 @@ +using AMDevIT.Restling.Core.Codecs; +using AMDevIT.Restling.Core.Network; +using System.Net.Http.Headers; + +namespace AMDevIT.Restling.Core.Multipart +{ + /// Represents a reusable multipart request whose contents are created for each execution. + public sealed class MultipartRequest : RestRequest + { + #region Fields + + private readonly List parts = []; + private readonly Dictionary parameters = new(StringComparer.OrdinalIgnoreCase); + + #endregion + + #region Properties + + public string Subtype { get; } + public string? Boundary { get; init; } + public int PartCount => this.parts.Count; + public IReadOnlyDictionary Parameters => this.parameters; + + #endregion + + #region .ctor + + /// Creates a multipart/form-data request. + public MultipartRequest(string uri, HttpMethod method) + : this(uri, method, "form-data", null) + { + } + + /// Creates a multipart request with an explicit subtype. + public MultipartRequest(string uri, HttpMethod method, string subtype, string? customMethod = null) + : base(uri, method, customMethod) + { + ArgumentException.ThrowIfNullOrWhiteSpace(subtype); + if (subtype.Contains('/') || subtype.Any(character => char.IsWhiteSpace(character))) + throw new ArgumentException("The multipart subtype is invalid.", nameof(subtype)); + this.Subtype = subtype; + } + + /// Creates a multipart request with explicit headers and subtype. + public MultipartRequest(string uri, + HttpMethod method, + RequestHeaders headers, + string subtype = "form-data", + string? customMethod = null) + : base(uri, method, headers, customMethod) + { + ArgumentException.ThrowIfNullOrWhiteSpace(subtype); + if (subtype.Contains('/') || subtype.Any(character => char.IsWhiteSpace(character))) + throw new ArgumentException("The multipart subtype is invalid.", nameof(subtype)); + this.Subtype = subtype; + } + + #endregion + + #region Methods + + /// Adds a UTF-8 text part. + public MultipartRequest AddText(string name, string value, string contentType = HttpMediaType.TextPlain) + { + MediaTypeHeaderValue mediaType; + + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(value); + mediaType = MediaTypeHeaderValue.Parse(contentType); + this.parts.Add(new PartFactory((_, context) => context.CreateTextContent(value, mediaType), name, null)); + return this; + } + + /// Adds a reusable, buffered binary part. + public MultipartRequest AddBytes(string name, + byte[] content, + string? fileName = null, + string contentType = HttpMediaType.ApplicationOctetStream) + { + byte[] snapshot; + + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(content); + snapshot = content.ToArray(); + return this.AddFactory(() => new ByteArrayContent(snapshot), + name, + fileName, + MediaTypeHeaderValue.Parse(contentType)); + } + + /// Adds a stream part created and owned separately for every request execution. + public MultipartRequest AddStream(string name, + Func streamFactory, + string? fileName = null, + string contentType = HttpMediaType.ApplicationOctetStream) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(streamFactory); + return this.AddFactory(() => new StreamContent(streamFactory() ?? + throw new InvalidOperationException("The stream factory returned null.")), + name, + fileName, + MediaTypeHeaderValue.Parse(contentType)); + } + + /// Adds an arbitrary HTTP content created and owned separately for every execution. + public MultipartRequest AddContent(Func contentFactory, + string? name = null, + string? fileName = null) + { + ArgumentNullException.ThrowIfNull(contentFactory); + this.parts.Add(new PartFactory((_, _) => contentFactory() ?? + throw new InvalidOperationException("The content factory returned null."), + name, + fileName)); + return this; + } + + /// Adds an object serialized by the registered writer for the supplied media type. + public MultipartRequest AddObject(string name, + T value, + string contentType, + string? fileName = null) + { + MediaTypeHeaderValue mediaType; + + ArgumentException.ThrowIfNullOrWhiteSpace(name); + mediaType = MediaTypeHeaderValue.Parse(contentType); + this.parts.Add(new PartFactory((codecs, context) => + { + IContentCodec codec = codecs.FindWriter(mediaType.MediaType) ?? + throw new NotSupportedException($"No writer is registered for {mediaType.MediaType}."); + return codec.Serialize(value, mediaType, context); + }, + name, + fileName)); + return this; + } + + /// Adds a quoted top-level multipart media-type parameter. + public MultipartRequest AddParameter(string name, string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(value); + if (string.Equals(name, "boundary", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Set Boundary instead of adding a boundary parameter.", nameof(name)); + this.parameters[name] = value; + return this; + } + + internal System.Net.Http.MultipartContent BuildContent(ContentCodecRegistry codecs, ContentCodecContext context) + { + System.Net.Http.MultipartContent result; + string boundary = this.Boundary ?? Guid.NewGuid().ToString("N"); + + result = string.Equals(this.Subtype, "form-data", StringComparison.OrdinalIgnoreCase) + ? new MultipartFormDataContent(boundary) + : new System.Net.Http.MultipartContent(this.Subtype, boundary); + + foreach (KeyValuePair parameter in this.parameters) + { + string escaped = parameter.Value.Replace("\\", "\\\\").Replace("\"", "\\\""); + result.Headers.ContentType!.Parameters.Add(new NameValueHeaderValue(parameter.Key, $"\"{escaped}\"")); + } + + try + { + foreach (PartFactory part in this.parts) + { + HttpContent? content = null; + bool added = false; + try + { + if (result is MultipartFormDataContent && string.IsNullOrWhiteSpace(part.Name)) + throw new InvalidOperationException("multipart/form-data parts require a name."); + + content = part.Factory(codecs, context); + if (part.ContentType != null) + content.Headers.ContentType = MediaTypeHeaderValue.Parse(part.ContentType.ToString()); + + if (result is MultipartFormDataContent formData) + { + if (part.FileName == null) + formData.Add(content, part.Name!); + else + formData.Add(content, part.Name!, part.FileName); + } + else + { + if (part.Name != null || part.FileName != null) + { + content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment") + { + Name = part.Name, + FileName = part.FileName + }; + } + result.Add(content); + } + added = true; + } + finally + { + if (!added) + content?.Dispose(); + } + } + return result; + } + catch + { + result.Dispose(); + throw; + } + } + + private MultipartRequest AddFactory(Func factory, + string name, + string? fileName, + MediaTypeHeaderValue contentType) + { + this.parts.Add(new PartFactory((_, _) => factory(), name, fileName, contentType)); + return this; + } + + private sealed class PartFactory(Func factory, + string? name, + string? fileName, + MediaTypeHeaderValue? contentType = null) + { + #region Properties + + public Func Factory { get; } = factory; + public string? Name { get; } = name; + public string? FileName { get; } = fileName; + public MediaTypeHeaderValue? ContentType { get; } = contentType; + + #endregion + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/HttpClientContext.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/HttpClientContext.cs index 34e5c2d..d8dece0 100644 --- a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/HttpClientContext.cs +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/HttpClientContext.cs @@ -2,16 +2,17 @@ namespace AMDevIT.Restling.Core.Network.Builders { - public class HttpClientContext(HttpClient httpClient, - HttpMessageHandler httpMessageHandler, - CookieContainer cookieContainer) - : IDisposable + using AMDevIT.Restling.Core.Codecs; + + public class HttpClientContext : IDisposable { #region Fields - private readonly HttpClient httpClient = httpClient; - private readonly HttpMessageHandler httpMessageHandler = httpMessageHandler; - private readonly CookieContainer cookieContainer = cookieContainer; + private readonly HttpClient httpClient; + private readonly HttpMessageHandler httpMessageHandler; + private readonly CookieContainer cookieContainer; + private readonly HttpClientContextOwnership ownership; + private readonly RequestTransportPool requestTransports; private bool disposedValue; #endregion @@ -20,52 +21,118 @@ public class HttpClientContext(HttpClient httpClient, public bool Disposed => this.disposedValue; - #endregion - - #region Properties + /// Gets the codec snapshot shared by clients using this context. + public ContentCodecRegistry Codecs { get; init; } = new(); public HttpClient HttpClient => this.httpClient; public HttpMessageHandler HttpMessageHandler => this.httpMessageHandler; public CookieContainer CookieContainer => this.cookieContainer; + public HttpClientContextOwnership Ownership => this.ownership; + + #endregion + + #region .ctor + + /// Creates a context that preserves the historical ownership of both resources. + public HttpClientContext(HttpClient httpClient, + HttpMessageHandler httpMessageHandler, + CookieContainer cookieContainer) + : this(httpClient, httpMessageHandler, cookieContainer, HttpClientContextOwnership.All) + { + } + + /// Creates a context with explicit resource ownership. + public HttpClientContext(HttpClient httpClient, + HttpMessageHandler httpMessageHandler, + CookieContainer cookieContainer, + HttpClientContextOwnership ownership) + : this(httpClient, httpMessageHandler, cookieContainer, ownership, null) + { + } + + /// Creates a context with explicit ownership and an optional factory for per-request proxy transports. + /// The default client used when a request has no transport override. + /// The handler associated with the default client. + /// The cookie jar shared by alternative transports. + /// The ownership of default transport resources. + /// An optional factory producing fresh, context-owned native handlers. + public HttpClientContext(HttpClient httpClient, + HttpMessageHandler httpMessageHandler, + CookieContainer cookieContainer, + HttpClientContextOwnership ownership, + Func? requestHandlerFactory) + { + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(httpMessageHandler); + ArgumentNullException.ThrowIfNull(cookieContainer); + if ((ownership & ~HttpClientContextOwnership.All) != 0) + throw new ArgumentOutOfRangeException(nameof(ownership)); + + this.httpClient = httpClient; + this.httpMessageHandler = httpMessageHandler; + this.cookieContainer = cookieContainer; + this.ownership = ownership; + this.requestTransports = new RequestTransportPool(httpClient, cookieContainer, requestHandlerFactory); + } #endregion #region Methods + /// Disposes only the resources declared by Ownership. + public void Dispose() + { + this.Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// Resolves the reusable transport selected for an individual request. + public HttpClient ResolveHttpClient(RequestProxyOptions? options) + { + return this.requestTransports.Resolve(options); + } + + /// Invalidates a failed request-specific transport without affecting the default client. + internal void InvalidateHttpClient(RequestProxyOptions? options, HttpClient failedClient) + { + this.requestTransports.Invalidate(options, failedClient); + } + + /// Releases resources owned by the context. protected virtual void Dispose(bool disposing) { - if (!disposedValue) + if (!this.disposedValue) { if (disposing) { - try - { - this.HttpMessageHandler.Dispose(); - } - catch(Exception) + this.requestTransports.Dispose(); + if (this.Ownership.HasFlag(HttpClientContextOwnership.HttpClient)) { - + try + { + this.HttpClient.Dispose(); + } + catch (Exception) + { + } } - try - { - this.HttpClient.Dispose(); - } - catch (Exception) + if (this.Ownership.HasFlag(HttpClientContextOwnership.HttpMessageHandler)) { + try + { + this.HttpMessageHandler.Dispose(); + } + catch (Exception) + { + } } } - disposedValue = true; + this.disposedValue = true; } } - public void Dispose() - { - Dispose(disposing: true); - GC.SuppressFinalize(this); - } - #endregion } } diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/HttpClientContextBuilder.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/HttpClientContextBuilder.cs index 491a5b9..ff36408 100644 --- a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/HttpClientContextBuilder.cs +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/HttpClientContextBuilder.cs @@ -1,5 +1,7 @@ using AMDevIT.Restling.Core.Cookies; using System.Collections.ObjectModel; +using AMDevIT.Restling.Core.Codecs; +using AMDevIT.Restling.Core.Network; using System.Net; using System.Net.Http.Headers; @@ -20,13 +22,19 @@ public class HttpClientContextBuilder : IHttpClientContextBuilder private HttpMessageHandler? httpMessageHandler; private CookieContainer? cookieContainer; - private bool disposeHandler = false; + private CookieContainer? fallbackCookieContainer; + private HttpMessageHandlerOwnership handlerOwnership = HttpMessageHandlerOwnership.Borrowed; private string userAgent = DefaultUserAgent; private readonly HashSet cookies = []; private readonly Dictionary defaultHeaders = []; private AuthenticationHeader? authenticationHeader = null; private TimeSpan? timeout = null; + private ContentCodecRegistry codecs = new(); + private WebProxy? proxy; + private bool allowAutoRedirect; + private Func? requestHandlerFactory; + private bool usesDefaultRequestHandlerFactory; #endregion @@ -38,27 +46,21 @@ public class HttpClientContextBuilder : IHttpClientContextBuilder #region Methods + /// Adds a codec with priority over previously registered codecs, retaining the defaults. + public HttpClientContextBuilder AddCodec(IContentCodec codec) + { + this.codecs = this.codecs.WithCodec(codec); + return this; + } + #region Cookies + /// Selects an explicit cookie container and enables cookies on a supported native handler. public HttpClientContextBuilder AddCookieContainer(CookieContainer cookieContainer) { + ArgumentNullException.ThrowIfNull(cookieContainer); this.cookieContainer = cookieContainer; - - if (this.httpMessageHandler != null) - { - switch (this.httpMessageHandler) - { - case SocketsHttpHandler socketsHttpHandler: - socketsHttpHandler.CookieContainer = this.cookieContainer; - socketsHttpHandler.UseCookies = true; - break; - - case HttpClientHandler httpClientHandler: - httpClientHandler.CookieContainer = this.cookieContainer; - httpClientHandler.UseCookies = true; - break; - } - } + this.ResolveCookieContainer(enableCookies: true); return this; } @@ -111,8 +113,29 @@ public HttpClientContextBuilder ClearCookies() public HttpClientContextBuilder AddHandler(HttpMessageHandler handler, bool diposeHandler = false) { + return this.AddHandler(handler, + diposeHandler + ? HttpMessageHandlerOwnership.Owned + : HttpMessageHandlerOwnership.Borrowed); + } + + /// Adds a handler with an explicit ownership contract. + /// The message handler used by the generated HTTP client. + /// Whether the generated context borrows or owns the handler. + /// The current builder instance. + public HttpClientContextBuilder AddHandler(HttpMessageHandler handler, HttpMessageHandlerOwnership ownership) + { + ArgumentNullException.ThrowIfNull(handler); + if (!Enum.IsDefined(ownership)) + throw new ArgumentOutOfRangeException(nameof(ownership)); + + if (this.proxy != null) + ApplyProxy(handler, this.proxy, this.allowAutoRedirect); + this.httpMessageHandler = handler; - this.disposeHandler = diposeHandler; + this.handlerOwnership = ownership; + this.usesDefaultRequestHandlerFactory = false; + this.ResolveCookieContainer(enableCookies: true); return this; } @@ -124,10 +147,47 @@ public HttpClientContextBuilder ConfigureHandler(Action conf if (this.httpMessageHandler == null) { this.httpMessageHandler = new SocketsHttpHandler(); - this.disposeHandler = true; + this.handlerOwnership = HttpMessageHandlerOwnership.Owned; + this.ResolveCookieContainer(enableCookies: true); + if (this.proxy != null) + ApplyProxy(this.httpMessageHandler, this.proxy, this.allowAutoRedirect); } configureHandler(this.httpMessageHandler); + this.usesDefaultRequestHandlerFactory = false; + return this; + } + + /// Selects an explicit proxy and HTTP redirect policy for a native handler. + /// An absolute HTTP, HTTPS, SOCKS4, SOCKS4a, or SOCKS5 proxy URI without credentials, query, fragment, or a non-root path. + /// Whether the handler automatically follows HTTP response redirects. + /// The current builder instance. + /// The proxy URI is invalid or unsupported. + /// The selected handler is not a directly supplied native handler. + /// The selected handler has already started processing requests. + /// Configure before sending requests. Credentials can be set through ConfigureHandler. Later ConfigureHandler changes are retained by Build. + public HttpClientContextBuilder AddProxy(string proxyUri, bool allowAutoRedirect) + { + Uri address; + WebProxy selectedProxy; + + address = ProxyUriParser.Parse(proxyUri); + selectedProxy = new WebProxy(address); + if (this.httpMessageHandler != null) + ApplyProxy(this.httpMessageHandler, selectedProxy, allowAutoRedirect); + this.proxy = selectedProxy; + this.allowAutoRedirect = allowAutoRedirect; + return this; + } + + /// Registers a factory for transports used by Direct and Custom per-request proxy overrides. + /// Creates a fresh handler and receives the context's shared cookie container. + /// The current builder instance. + /// The generated handlers are owned by the context. The factory must return a directly supported native handler. + public HttpClientContextBuilder AddRequestHandlerFactory(Func handlerFactory) + { + ArgumentNullException.ThrowIfNull(handlerFactory); + this.requestHandlerFactory = handlerFactory; return this; } @@ -210,31 +270,38 @@ public HttpClientContext Build() { HttpClient httpClient; HttpClientContext httpClientContext; - - this.cookieContainer ??= new CookieContainer(); + HttpClientContextOwnership ownership; + CookieContainer effectiveCookieContainer; if (this.httpMessageHandler == null) { SocketsHttpHandler socketsHttpHandler = new() { - CookieContainer = this.cookieContainer, UseCookies = true, AllowAutoRedirect = false }; this.httpMessageHandler = socketsHttpHandler; - this.disposeHandler = true; + this.handlerOwnership = HttpMessageHandlerOwnership.Owned; + this.usesDefaultRequestHandlerFactory = true; + if (this.proxy != null) + ApplyProxy(this.httpMessageHandler, this.proxy, this.allowAutoRedirect); } + effectiveCookieContainer = this.ResolveCookieContainer(); + if (this.cookies.Count > 0) { foreach (HttpCookieData cookieData in this.cookies) { Cookie cookie = new(cookieData.Name, cookieData.Value, cookieData.Path, cookieData.Domain); - this.cookieContainer.Add(cookie); + effectiveCookieContainer.Add(cookie); } } - httpClient = new(this.httpMessageHandler, this.disposeHandler); + httpClient = new(this.httpMessageHandler, disposeHandler: false); + ownership = HttpClientContextOwnership.HttpClient; + if (this.handlerOwnership == HttpMessageHandlerOwnership.Owned) + ownership |= HttpClientContextOwnership.HttpMessageHandler; httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(this.userAgent); @@ -253,11 +320,88 @@ public HttpClientContext Build() httpClient.DefaultRequestHeaders.Authorization = authenticationHeaderValue; } - httpClientContext = new(httpClient, this.httpMessageHandler, this.cookieContainer); + httpClientContext = new(httpClient, + this.httpMessageHandler, + effectiveCookieContainer, + ownership, + this.requestHandlerFactory ?? (this.usesDefaultRequestHandlerFactory ? CreateDefaultRequestHandler : null)) + { + Codecs = this.codecs + }; return httpClientContext; } + /// Creates the native baseline used by per-request proxy overrides for a default builder. + private static HttpMessageHandler CreateDefaultRequestHandler(CookieContainer cookieContainer) + { + return new SocketsHttpHandler + { + CookieContainer = cookieContainer, + UseCookies = true, + AllowAutoRedirect = false + }; + } + + /// Applies explicit proxy settings without replacing the handler or its cookie container. + private static void ApplyProxy(HttpMessageHandler handler, WebProxy proxy, bool allowAutoRedirect) + { + switch (handler) + { + case SocketsHttpHandler socketsHttpHandler: + if (!ReferenceEquals(socketsHttpHandler.Proxy, proxy)) + socketsHttpHandler.Proxy = proxy; + if (!socketsHttpHandler.UseProxy) + socketsHttpHandler.UseProxy = true; + if (socketsHttpHandler.AllowAutoRedirect != allowAutoRedirect) + socketsHttpHandler.AllowAutoRedirect = allowAutoRedirect; + break; + + case HttpClientHandler httpClientHandler: + if (!ReferenceEquals(httpClientHandler.Proxy, proxy)) + httpClientHandler.Proxy = proxy; + if (!httpClientHandler.UseProxy) + httpClientHandler.UseProxy = true; + if (httpClientHandler.AllowAutoRedirect != allowAutoRedirect) + httpClientHandler.AllowAutoRedirect = allowAutoRedirect; + break; + + default: + throw new NotSupportedException("AddProxy requires a directly supplied SocketsHttpHandler or HttpClientHandler. Configure custom or delegating handlers explicitly."); + } + } + + /// Shares the explicit or native cookie container without replacing existing handler state unnecessarily. + /// An explicit container takes precedence. Without one, native cookie settings and stored cookies are retained. + private CookieContainer ResolveCookieContainer(bool enableCookies = false) + { + switch (this.httpMessageHandler) + { + case SocketsHttpHandler socketsHttpHandler: + if (this.cookieContainer != null) + { + if (!ReferenceEquals(socketsHttpHandler.CookieContainer, this.cookieContainer)) + socketsHttpHandler.CookieContainer = this.cookieContainer; + if (enableCookies && !socketsHttpHandler.UseCookies) + socketsHttpHandler.UseCookies = true; + } + return socketsHttpHandler.CookieContainer; + + case HttpClientHandler httpClientHandler: + if (this.cookieContainer != null) + { + if (!ReferenceEquals(httpClientHandler.CookieContainer, this.cookieContainer)) + httpClientHandler.CookieContainer = this.cookieContainer; + if (enableCookies && !httpClientHandler.UseCookies) + httpClientHandler.UseCookies = true; + } + return httpClientHandler.CookieContainer; + + default: + return this.cookieContainer ?? (this.fallbackCookieContainer ??= new CookieContainer()); + } + } + #endregion } diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/HttpClientContextOwnership.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/HttpClientContextOwnership.cs new file mode 100644 index 0000000..1164057 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/HttpClientContextOwnership.cs @@ -0,0 +1,19 @@ +namespace AMDevIT.Restling.Core.Network.Builders +{ + /// Defines the resources disposed by an HttpClientContext. + [Flags] + public enum HttpClientContextOwnership + { + /// The context does not dispose either resource. + None = 0, + + /// The context disposes the HTTP client. + HttpClient = 1, + + /// The context disposes the message handler. + HttpMessageHandler = 2, + + /// The context disposes both the HTTP client and the message handler. + All = HttpClient | HttpMessageHandler + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/HttpMessageHandlerOwnership.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/HttpMessageHandlerOwnership.cs new file mode 100644 index 0000000..b134f04 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/HttpMessageHandlerOwnership.cs @@ -0,0 +1,12 @@ +namespace AMDevIT.Restling.Core.Network.Builders +{ + /// Defines whether a builder-created context borrows or owns a supplied handler. + public enum HttpMessageHandlerOwnership + { + /// The supplied handler remains owned by the caller. + Borrowed, + + /// The context created by the builder owns the supplied handler. + Owned + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/IHttpClientContextBuilder.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/IHttpClientContextBuilder.cs index 461f44f..411b67b 100644 --- a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/IHttpClientContextBuilder.cs +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Builders/IHttpClientContextBuilder.cs @@ -1,5 +1,6 @@ using AMDevIT.Restling.Core.Cookies; using System.Collections.ObjectModel; +using AMDevIT.Restling.Core.Codecs; using System.Net; using System.Net.Http.Headers; @@ -15,6 +16,12 @@ public interface IHttpClientContextBuilder #region Methods + /// Adds a codec when supported by the builder. Existing custom builders need not implement this member. + HttpClientContextBuilder AddCodec(IContentCodec codec) + { + throw new NotSupportedException("This builder does not support codec registration. Configure HttpClientContext.Codecs instead."); + } + #region Cookies HttpClientContextBuilder AddCookie(HttpCookieData cookie); @@ -43,8 +50,35 @@ public interface IHttpClientContextBuilder #region Handlers HttpClientContextBuilder AddHandler(HttpMessageHandler handler, bool diposeHandler = false); + + /// Adds a handler with an explicit ownership contract. + /// The message handler used by the generated HTTP client. + /// Whether the generated context borrows or owns the handler. + /// The current builder instance. + HttpClientContextBuilder AddHandler(HttpMessageHandler handler, HttpMessageHandlerOwnership ownership) + { + if (!Enum.IsDefined(ownership)) + throw new ArgumentOutOfRangeException(nameof(ownership)); + return this.AddHandler(handler, ownership == HttpMessageHandlerOwnership.Owned); + } HttpClientContextBuilder ConfigureHandler(Action configureHandler); + /// Selects an explicit proxy and HTTP redirect policy when supported by the builder. + /// An absolute proxy URI without embedded credentials. + /// Whether the handler automatically follows HTTP response redirects. + /// The current builder instance. + /// Existing custom builders need not implement this member. + HttpClientContextBuilder AddProxy(string proxyUri, bool allowAutoRedirect) + { + throw new NotSupportedException("This builder does not support proxy configuration. Configure its transport handler explicitly."); + } + + /// Registers a handler factory for per-request proxy overrides when supported by the builder. + HttpClientContextBuilder AddRequestHandlerFactory(Func handlerFactory) + { + throw new NotSupportedException("This builder does not support per-request transport factories."); + } + #endregion #region Http parameters @@ -57,4 +91,4 @@ public interface IHttpClientContextBuilder #endregion } -} \ No newline at end of file +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/HttpMediaType.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/HttpMediaType.cs index 1e8a442..db0f20f 100644 --- a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/HttpMediaType.cs +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/HttpMediaType.cs @@ -22,6 +22,7 @@ public static class HttpMediaType #region Text Media Types public const string TextPlain = "text/plain"; + public const string TextCsv = "text/csv"; public const string TextHtml = "text/html"; public const string TextCss = "text/css"; public const string TextXml = "text/xml"; @@ -64,6 +65,15 @@ public static class HttpMediaType public const string MultipartFormData = "multipart/form-data"; public const string MultipartMixed = "multipart/mixed"; public const string MultipartAlternative = "multipart/alternative"; + public const string MultipartRelated = "multipart/related"; + public const string MultipartByteRanges = "multipart/byteranges"; + public const string MultipartDigest = "multipart/digest"; + public const string MultipartParallel = "multipart/parallel"; + public const string MultipartSigned = "multipart/signed"; + public const string MultipartEncrypted = "multipart/encrypted"; + public const string MultipartReport = "multipart/report"; + public const string MultipartMultilingual = "multipart/multilingual"; + public const string MultipartMixedReplace = "multipart/x-mixed-replace"; #endregion diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Pipeline/HttpExecutionPipeline.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Pipeline/HttpExecutionPipeline.cs new file mode 100644 index 0000000..1a1f096 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Pipeline/HttpExecutionPipeline.cs @@ -0,0 +1,262 @@ +using AMDevIT.Restling.Core.Codecs; +using AMDevIT.Restling.Core.Serialization; +using Microsoft.Extensions.Logging; +using System.Diagnostics; + +namespace AMDevIT.Restling.Core.Network.Pipeline +{ + /// Centralizes finite and streaming HTTP execution behavior. + internal sealed class HttpExecutionPipeline + { + #region Fields + + private readonly Func httpClientResolver; + private readonly Action httpClientInvalidator; + private readonly ContentCodecRegistry codecs; + private readonly ILogger? logger; + + #endregion + + #region .ctor + + /// Creates a pipeline borrowing its transport and immutable codec registry. + public HttpExecutionPipeline(Func httpClientResolver, + Action httpClientInvalidator, + ContentCodecRegistry codecs, + ILogger? logger) + { + this.httpClientResolver = httpClientResolver; + this.httpClientInvalidator = httpClientInvalidator; + this.codecs = codecs; + this.logger = logger; + } + + #endregion + + #region Methods + + /// Sends a prepared request and buffers its untyped response. + public Task ExecuteAsync(RestRequest restRequest, + HttpRequestMessage httpRequest, + CancellationToken cancellationToken) + { + return this.ExecuteCoreAsync(restRequest, + httpRequest, + (parser, response, elapsed, token) => parser.DecodeAsync(response, + restRequest, + elapsed, + token), + (exception, elapsed) => new RestRequestResult(restRequest, exception, elapsed), + cancellationToken); + } + + /// Sends a prepared request and decodes its buffered typed response. + public Task> ExecuteAsync(RestRequest restRequest, + HttpRequestMessage httpRequest, + PayloadJsonSerializerLibrary? serializerLibrary, + CancellationToken cancellationToken) + { + return this.ExecuteCoreAsync(restRequest, + httpRequest, + (parser, response, elapsed, token) => parser.DecodeAsync(response, + restRequest, + elapsed, + serializerLibrary, + token), + (exception, elapsed) => new RestRequestResult(restRequest, exception, elapsed), + cancellationToken); + } + + /// Preserves result-based preparation failures for direct untyped convenience methods. + public Task ExecuteAsync(RestRequest restRequest, + Func requestFactory, + CancellationToken cancellationToken) + { + return this.PrepareAndExecuteAsync(restRequest, + requestFactory, + request => this.ExecuteAsync(restRequest, request, cancellationToken), + exception => new RestRequestResult(restRequest, exception, TimeSpan.Zero)); + } + + /// Preserves result-based preparation failures for direct typed convenience methods. + public Task> ExecuteAsync(RestRequest restRequest, + Func requestFactory, + PayloadJsonSerializerLibrary? serializerLibrary, + CancellationToken cancellationToken) + { + return this.PrepareAndExecuteAsync(restRequest, + requestFactory, + request => this.ExecuteAsync(restRequest, request, serializerLibrary, cancellationToken), + exception => new RestRequestResult(restRequest, exception, TimeSpan.Zero)); + } + + /// Sends without buffering and transfers response ownership to a streaming lease. + public async Task SendStreamingAsync(RestRequest restRequest, + HttpRequestMessage httpRequest, + CancellationToken cancellationToken) + { + HttpClient? httpClient = null; + HttpResponseMessage? response = null; + Stopwatch stopwatch = new(); + + try + { + this.LogStart(httpRequest); + stopwatch.Start(); + httpClient = this.httpClientResolver(restRequest.ProxyOptions); + response = await httpClient.SendAsync(httpRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + stopwatch.Stop(); + this.LogCompleted(httpRequest, stopwatch.Elapsed); + return new HttpResponseLease(response, stopwatch.Elapsed); + } + catch (Exception exception) + { + stopwatch.Stop(); + this.DisposeResponse(response); + this.InvalidatePrematureResponseTransport(restRequest.ProxyOptions, httpClient, exception); + this.LogFailure(httpRequest, exception); + throw; + } + } + + /// Owns requests created for convenience methods without catching decoder exceptions. + private async Task PrepareAndExecuteAsync(RestRequest restRequest, + Func requestFactory, + Func> execute, + Func failureFactory) + { + HttpRequestMessage httpRequest; + + try + { + httpRequest = requestFactory(); + } + catch (Exception exception) + { + this.logger?.LogError(exception, "Cannot prepare {method} REST request.", restRequest.Method); + return failureFactory(exception); + } + + try + { + return await execute(httpRequest); + } + finally + { + try + { + httpRequest.Dispose(); + } + catch (Exception exception) + { + this.logger?.LogTrace(exception, "Cannot dispose the HttpRequestMessage instance."); + } + } + } + + /// Measures buffered transport time, retains send failures and always releases responses. + private async Task ExecuteCoreAsync(RestRequest restRequest, + HttpRequestMessage httpRequest, + Func> decoder, + Func failureFactory, + CancellationToken cancellationToken) + { + HttpClient? httpClient = null; + HttpResponseMessage? response = null; + Stopwatch stopwatch = new(); + + try + { + this.LogStart(httpRequest); + stopwatch.Start(); + httpClient = this.httpClientResolver(restRequest.ProxyOptions); + response = await httpClient.SendAsync(httpRequest, cancellationToken); + stopwatch.Stop(); + this.LogCompleted(httpRequest, stopwatch.Elapsed); + } + catch (Exception exception) + { + stopwatch.Stop(); + this.DisposeResponse(response); + this.InvalidatePrematureResponseTransport(restRequest.ProxyOptions, httpClient, exception); + this.LogFailure(httpRequest, exception); + return failureFactory(exception, stopwatch.Elapsed); + } + + try + { + HttpResponseParser parser = new(this.logger) { Codecs = this.codecs }; + return await decoder(parser, response, stopwatch.Elapsed, cancellationToken); + } + finally + { + this.DisposeResponse(response); + } + } + + /// Evicts a request-specific transport whose response framing ended prematurely. + private void InvalidatePrematureResponseTransport(RequestProxyOptions? options, + HttpClient? httpClient, + Exception exception) + { + Exception? currentException = exception; + + if (httpClient == null) + return; + while (currentException != null) + { + if (currentException is HttpIOException httpException && + httpException.HttpRequestError == HttpRequestError.ResponseEnded) + { + try + { + this.httpClientInvalidator(options, httpClient); + } + catch (Exception invalidationException) + { + this.logger?.LogTrace(invalidationException, "Cannot invalidate the failed request transport."); + } + return; + } + currentException = currentException.InnerException; + } + } + + /// Releases a response without replacing the HTTP outcome with a disposal failure. + private void DisposeResponse(HttpResponseMessage? response) + { + try + { + response?.Dispose(); + } + catch (Exception exception) + { + this.logger?.LogTrace(exception, "Cannot dispose the HttpResponseMessage instance."); + } + } + + /// Logs the outgoing method without including potentially sensitive URI parameters. + private void LogStart(HttpRequestMessage request) + { + this.logger?.LogDebug("Executing {method} REST request.", request.Method.Method); + } + + /// Logs the transport duration consistently across request paths. + private void LogCompleted(HttpRequestMessage request, TimeSpan elapsed) + { + this.logger?.LogDebug("{method} REST request executed in {elapsed} ms.", + request.Method.Method, + elapsed.TotalMilliseconds); + } + + /// Logs a transport failure consistently across request paths. + private void LogFailure(HttpRequestMessage request, Exception exception) + { + this.logger?.LogError(exception, "Cannot execute {method} REST request.", request.Method.Method); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Pipeline/HttpResponseLease.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Pipeline/HttpResponseLease.cs new file mode 100644 index 0000000..c907cfb --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/Pipeline/HttpResponseLease.cs @@ -0,0 +1,34 @@ +namespace AMDevIT.Restling.Core.Network.Pipeline +{ + /// Owns a streaming HTTP response until its consumer finishes reading it. + internal sealed class HttpResponseLease : IDisposable + { + #region Properties + + public HttpResponseMessage Response { get; } + public TimeSpan Elapsed { get; } + + #endregion + + #region .ctor + + /// Accepts ownership of a response that has not been buffered. + public HttpResponseLease(HttpResponseMessage response, TimeSpan elapsed) + { + this.Response = response; + this.Elapsed = elapsed; + } + + #endregion + + #region Methods + + /// Releases the response and its content when streaming finishes. + public void Dispose() + { + this.Response.Dispose(); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/ProxyUriParser.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/ProxyUriParser.cs new file mode 100644 index 0000000..d38eb42 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/ProxyUriParser.cs @@ -0,0 +1,26 @@ +namespace AMDevIT.Restling.Core.Network +{ + /// Applies the common proxy URI contract used by context and request configuration. + internal static class ProxyUriParser + { + #region Methods + + /// Returns a normalized supported proxy URI. + public static Uri Parse(string proxyUri) + { + Uri? address; + + ArgumentException.ThrowIfNullOrWhiteSpace(proxyUri); + if (!Uri.TryCreate(proxyUri, UriKind.Absolute, out address) || + string.IsNullOrEmpty(address.Host) || + address.Scheme is not ("http" or "https" or "socks4" or "socks4a" or "socks5") || + address.UserInfo.Length != 0 || address.Query.Length != 0 || address.Fragment.Length != 0 || + (address.AbsolutePath.Length != 0 && address.AbsolutePath != "/")) + throw new ArgumentException("Specify an absolute HTTP, HTTPS, or SOCKS proxy URI containing only a host and optional port. Configure credentials on the transport factory.", nameof(proxyUri)); + + return address; + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/RequestProxyMode.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/RequestProxyMode.cs new file mode 100644 index 0000000..e3afbf3 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/RequestProxyMode.cs @@ -0,0 +1,15 @@ +namespace AMDevIT.Restling.Core.Network +{ + /// Identifies how an individual request reaches its destination. + public enum RequestProxyMode + { + /// Uses the HttpClientContext transport without creating an alternative transport. + Default, + + /// Connects directly and ignores both explicit and system proxy settings. + Direct, + + /// Uses the proxy selected for the individual request. + Custom + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/RequestProxyOptions.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/RequestProxyOptions.cs new file mode 100644 index 0000000..7ee8b1d --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/RequestProxyOptions.cs @@ -0,0 +1,54 @@ +namespace AMDevIT.Restling.Core.Network +{ + /// Defines an immutable transport override for an individual request. + public sealed record RequestProxyOptions + { + #region Properties + + /// Gets the context-wide transport selection. + public static RequestProxyOptions Default { get; } = new(RequestProxyMode.Default, null, false); + + /// Gets the selected routing mode. + public RequestProxyMode Mode { get; } + + /// Gets the custom proxy address, when Mode is Custom. + public Uri? ProxyUri { get; } + + /// Gets whether the alternative transport follows HTTP response redirects. + public bool AllowAutoRedirect { get; } + + #endregion + + #region .ctor + + /// Creates validated request proxy options. + private RequestProxyOptions(RequestProxyMode mode, Uri? proxyUri, bool allowAutoRedirect) + { + this.Mode = mode; + this.ProxyUri = proxyUri; + this.AllowAutoRedirect = allowAutoRedirect; + } + + #endregion + + #region Methods + + /// Creates an override that connects without using an explicit or system proxy. + /// Whether HTTP response redirects are followed automatically. + public static RequestProxyOptions Direct(bool allowAutoRedirect = false) + { + return new RequestProxyOptions(RequestProxyMode.Direct, null, allowAutoRedirect); + } + + /// Creates an override that uses a dedicated proxy. + /// An absolute supported proxy URI without embedded credentials. + /// Whether HTTP response redirects are followed automatically. + public static RequestProxyOptions Custom(string proxyUri, bool allowAutoRedirect = false) + { + Uri address = ProxyUriParser.Parse(proxyUri); + return new RequestProxyOptions(RequestProxyMode.Custom, address, allowAutoRedirect); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/RequestTransportPool.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/RequestTransportPool.cs new file mode 100644 index 0000000..98e482b --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/Network/RequestTransportPool.cs @@ -0,0 +1,174 @@ +using System.Net; + +namespace AMDevIT.Restling.Core.Network +{ + /// Owns and reuses one alternative native transport per immutable request proxy selection. + internal sealed class RequestTransportPool : IDisposable + { + #region Fields + + private readonly HttpClient defaultClient; + private readonly CookieContainer cookieContainer; + private readonly Func? handlerFactory; + private readonly Dictionary clients = []; + private bool disposed; + + #endregion + + #region .ctor + + /// Creates a pool that borrows the default client and owns only clients created by its factory. + public RequestTransportPool(HttpClient defaultClient, + CookieContainer cookieContainer, + Func? handlerFactory) + { + this.defaultClient = defaultClient; + this.cookieContainer = cookieContainer; + this.handlerFactory = handlerFactory; + } + + #endregion + + #region Methods + + /// Returns the context client or a cached client matching the request override. + public HttpClient Resolve(RequestProxyOptions? options) + { + RequestProxyOptions selection = options ?? RequestProxyOptions.Default; + + if (selection.Mode == RequestProxyMode.Default) + return this.defaultClient; + lock (this.clients) + { + ObjectDisposedException.ThrowIf(this.disposed, this); + if (!this.clients.TryGetValue(selection, out HttpClient? client)) + { + client = this.CreateClient(selection); + this.clients.Add(selection, client); + } + return client; + } + } + + /// Removes and disposes a failed alternative transport if it is still the cached instance. + public void Invalidate(RequestProxyOptions? options, HttpClient failedClient) + { + RequestProxyOptions selection = options ?? RequestProxyOptions.Default; + HttpClient? client = null; + + ArgumentNullException.ThrowIfNull(failedClient); + if (selection.Mode == RequestProxyMode.Default) + return; + lock (this.clients) + { + if (this.clients.TryGetValue(selection, out HttpClient? cachedClient) && + ReferenceEquals(cachedClient, failedClient)) + { + this.clients.Remove(selection); + client = cachedClient; + } + } + + try + { + client?.Dispose(); + } + catch (Exception) + { + } + } + + /// Disposes every alternative client and its owned handler. + public void Dispose() + { + lock (this.clients) + { + if (this.disposed) + return; + foreach (HttpClient client in this.clients.Values) + { + try + { + client.Dispose(); + } + catch (Exception) + { + } + } + this.clients.Clear(); + this.disposed = true; + } + } + + /// Creates and configures one owned alternative client. + private HttpClient CreateClient(RequestProxyOptions options) + { + HttpMessageHandler handler; + HttpClient client; + + if (this.handlerFactory == null) + throw new NotSupportedException("This HttpClientContext has no request handler factory. Register one with AddRequestHandlerFactory before using Direct or Custom request proxy options."); + handler = this.handlerFactory(this.cookieContainer) ?? + throw new InvalidOperationException("The request handler factory returned null."); + try + { + ConfigureHandler(handler, this.cookieContainer, options); + client = new HttpClient(handler, disposeHandler: true) + { + BaseAddress = this.defaultClient.BaseAddress, + DefaultRequestVersion = this.defaultClient.DefaultRequestVersion, + DefaultVersionPolicy = this.defaultClient.DefaultVersionPolicy, + MaxResponseContentBufferSize = this.defaultClient.MaxResponseContentBufferSize, + Timeout = this.defaultClient.Timeout + }; + foreach (KeyValuePair> header in this.defaultClient.DefaultRequestHeaders) + client.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value); + return client; + } + catch + { + handler.Dispose(); + throw; + } + } + + /// Applies routing while ensuring every alternative native handler shares the context cookie jar. + private static void ConfigureHandler(HttpMessageHandler handler, + CookieContainer cookieContainer, + RequestProxyOptions options) + { + IWebProxy? proxy = options.Mode == RequestProxyMode.Custom ? new WebProxy(options.ProxyUri!) : null; + ICredentials? credentials; + + switch (handler) + { + case SocketsHttpHandler socketsHttpHandler: + credentials = socketsHttpHandler.Proxy?.Credentials; + if (proxy != null) + proxy.Credentials = credentials; + socketsHttpHandler.CookieContainer = cookieContainer; + socketsHttpHandler.UseCookies = true; + socketsHttpHandler.Proxy = proxy; + socketsHttpHandler.UseProxy = options.Mode == RequestProxyMode.Custom; + socketsHttpHandler.AllowAutoRedirect = options.AllowAutoRedirect; + break; + + case HttpClientHandler httpClientHandler: + credentials = httpClientHandler.Proxy?.Credentials; + if (proxy != null) + proxy.Credentials = credentials; + httpClientHandler.CookieContainer = cookieContainer; + httpClientHandler.UseCookies = true; + httpClientHandler.Proxy = proxy; + httpClientHandler.UseProxy = options.Mode == RequestProxyMode.Custom; + httpClientHandler.AllowAutoRedirect = options.AllowAutoRedirect; + break; + + default: + throw new NotSupportedException("The request handler factory must return a SocketsHttpHandler or HttpClientHandler."); + } + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestRequest.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestRequest.cs index 1304d35..d26ddcb 100644 --- a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestRequest.cs +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestRequest.cs @@ -65,6 +65,9 @@ public PayloadJsonSerializerLibrary? ForcePayloadJsonSerializerLibrary public RequestHeaders Headers => this.headers; + /// Gets or sets the transport override used only for this request. + public RequestProxyOptions ProxyOptions { get; set; } = RequestProxyOptions.Default; + #endregion #region .ctor diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestRequestOfT.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestRequestOfT.cs index a7b4eea..8893772 100644 --- a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestRequestOfT.cs +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestRequestOfT.cs @@ -19,6 +19,10 @@ public class RestRequest(string uri, #region Properties + /// Uses the registered writer for ContentMediaType instead of legacy JSON serialization. + /// False by default so existing requests that relabel JSON preserve their behavior. + public bool UseContentCodec { get; set; } + public T? RequestData { get => this.requestData; diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestRequestResult.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestRequestResult.cs index 620548e..eb1bd9a 100644 --- a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestRequestResult.cs +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestRequestResult.cs @@ -1,5 +1,6 @@ using AMDevIT.Restling.Core.Network; using AMDevIT.Restling.Core.Text; +using AMDevIT.Restling.Core.Codecs; using System.Net; using System.Security.AccessControl; @@ -43,6 +44,12 @@ public class RestRequestResult(RestRequest request, public Charset CharSet => this.charSet; public Exception? Exception => this.exception; + /// Structured problem information when an optional problem codec is registered. + public RestProblemDetails? Problem { get; internal set; } + + /// A problem-document decoding failure, without replacing the HTTP status or original body. + public Exception? ProblemException { get; internal set; } + public ResponseHeaders ResponseHeaders => this.responseHeaders; #endregion diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestlingClient.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestlingClient.cs index 5a450a4..77f0d6b 100644 --- a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestlingClient.cs +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestlingClient.cs @@ -1,9 +1,13 @@ using AMDevIT.Restling.Core.Network; using AMDevIT.Restling.Core.Network.Builders; +using AMDevIT.Restling.Core.Network.Pipeline; +using AMDevIT.Restling.Core.Codecs; +using System.Net.Http.Headers; using AMDevIT.Restling.Core.Serialization; +using AMDevIT.Restling.Core.Multipart; using Microsoft.Extensions.Logging; -using System.Diagnostics; using System.Text; +using System.Runtime.CompilerServices; using NetHttpMethod = System.Net.Http.HttpMethod; namespace AMDevIT.Restling.Core @@ -11,16 +15,14 @@ namespace AMDevIT.Restling.Core /// /// Implements a REST client to execute HTTP requests to remote resources. /// - /// A valid that will be used to execute requests - /// A valid implementation of that will be used to log REST client messages - public class RestlingClient(HttpClientContext httpClientContext, - ILogger? logger) - : IRestlingClient, IDisposable + public class RestlingClient : IRestlingClient, IDisposable { #region Fields - private readonly HttpClientContext httpClientContext = httpClientContext; - private readonly ILogger? logger = logger; + private readonly HttpClientContext httpClientContext; + private readonly HttpExecutionPipeline httpExecutionPipeline; + private readonly ILogger? logger; + private RestlingClientContextOwnership contextOwnership; private bool disposedValue; #endregion @@ -40,14 +42,26 @@ public PayloadJsonSerializerLibrary SelectedDefaultSerializationLibrary set; } = PayloadJsonSerializerLibrary.Automatic; - /// - /// Dispose the HttpClient instance when disposing the RestlingClient instance. - /// + /// Gets or sets whether this client owns and disposes its context. + public RestlingClientContextOwnership ContextOwnership + { + get => this.contextOwnership; + set + { + if (!Enum.IsDefined(value)) + throw new ArgumentOutOfRangeException(nameof(value)); + this.contextOwnership = value; + } + } + + /// Compatibility alias for ContextOwnership. public bool DisposeContext { - get; - set; - } + get => this.ContextOwnership == RestlingClientContextOwnership.Owned; + set => this.ContextOwnership = value + ? RestlingClientContextOwnership.Owned + : RestlingClientContextOwnership.Borrowed; + } /// /// Gets a value indicating whether the instance has been disposed. @@ -71,7 +85,7 @@ public bool EnableVerboseLogging /// Initializes a new instance of the class using a dedicated HttpClient with default values. /// public RestlingClient() - : this(BuildDefaultHttpClientContext(), null) + : this(BuildDefaultHttpClientContext(), null, RestlingClientContextOwnership.Owned) { } @@ -82,18 +96,62 @@ public RestlingClient() /// /// The logger instance used to log the messages from the client public RestlingClient(ILogger logger) - : this(BuildDefaultHttpClientContext(), logger) + : this(BuildDefaultHttpClientContext(), logger, RestlingClientContextOwnership.Owned) { } /// - /// Initializes a new instance of the class using a dedicated HttpClient build by the instance. + /// Initializes a new client that borrows an externally managed context. /// - /// The IHttpClientBuilder implementation instance that will be used to build the HttpClient associated to the current client. + /// The context that remains owned by the caller. + /// The optional logger used by this client. + public RestlingClient(HttpClientContext httpClientContext, ILogger? logger) + : this(httpClientContext, logger, RestlingClientContextOwnership.Borrowed) + { + } + + /// Initializes a client with an explicit context ownership contract. + /// The context used by the client. + /// The optional logger used by this client. + /// Whether the client borrows or owns the context. + public RestlingClient(HttpClientContext httpClientContext, + ILogger? logger, + RestlingClientContextOwnership contextOwnership) + { + ArgumentNullException.ThrowIfNull(httpClientContext); + this.httpClientContext = httpClientContext; + this.logger = logger; + this.httpExecutionPipeline = new(httpClientContext.ResolveHttpClient, + httpClientContext.InvalidateHttpClient, + httpClientContext.Codecs, + logger); + this.ContextOwnership = contextOwnership; + } + + /// Initializes a new client that borrows an externally managed context. + /// The context that remains owned by the caller. + public RestlingClient(HttpClientContext httpClientContext) + : this(httpClientContext, null, RestlingClientContextOwnership.Borrowed) + { + } + + /// + /// Initializes a new instance of the class using an owned context built by the supplied builder. + /// + /// The builder used to create the context owned by this client. public RestlingClient(IHttpClientContextBuilder httpClientBuilder) - : this(httpClientBuilder.Build(), null) - { + : this(BuildContext(httpClientBuilder), null, RestlingClientContextOwnership.Owned) + { + } + + /// Initializes a new client with an explicit context ownership contract. + /// The context used by the client. + /// Whether the client borrows or owns the context. + public RestlingClient(HttpClientContext httpClientContext, + RestlingClientContextOwnership contextOwnership) + : this(httpClientContext, null, contextOwnership) + { } /// @@ -104,7 +162,7 @@ public RestlingClient(IHttpClientContextBuilder httpClientBuilder) /// The logger instance used to log the messages from the client public RestlingClient(IHttpClientContextBuilder httpClientBuilder, ILogger logger) - : this(httpClientBuilder.Build(), logger) + : this(BuildContext(httpClientBuilder), logger, RestlingClientContextOwnership.Owned) { } @@ -122,58 +180,23 @@ public RestlingClient(IHttpClientContextBuilder httpClientBuilder, /// The value returned from the remote resource public async Task GetAsync(string uri, CancellationToken cancellationToken = default) { - HttpResponseMessage? resultHttpMessage = null; RestRequest restRequest; - RestRequestResult restRequestResult; - TimeSpan elapsed; - Stopwatch stopwatch = new(); - HttpResponseParser httpResponseParser = new(this.Logger); - - restRequest = new RestRequest(uri, - HttpMethod.Get, - null); - - try - { - if (this.Logger?.IsEnabled(LogLevel.Debug) == true) - this.Logger?.LogDebug("Executing GET REST request to {uri}", uri); - - stopwatch = Stopwatch.StartNew(); - resultHttpMessage = await this.httpClientContext.HttpClient.GetAsync(uri, cancellationToken); - stopwatch.Stop(); - if (this.Logger?.IsEnabled(LogLevel.Debug) == true) - this.Logger?.LogDebug("GET REST request to {uri} executed in {elapsed} ms", uri, stopwatch.ElapsedMilliseconds); - - } - catch (Exception exc) - { - if (stopwatch.IsRunning) - stopwatch.Stop(); - - if (this.Logger?.IsEnabled(LogLevel.Error) == true) - this.Logger?.LogError(exc, "Cannot execute GET REST request."); - - return new RestRequestResult(restRequest, exc, stopwatch.Elapsed); - } - finally - { - elapsed = stopwatch.Elapsed; - } - - restRequestResult = await httpResponseParser.DecodeAsync(resultHttpMessage, restRequest, elapsed, cancellationToken); - - try - { - resultHttpMessage?.Dispose(); - } - catch(Exception exc) - { - if(this.Logger?.IsEnabled(LogLevel.Trace) == true) - this.Logger?.LogTrace("Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } + restRequest = new RestRequest(uri, HttpMethod.Get); + return await this.httpExecutionPipeline.ExecuteAsync(restRequest, + () => this.BuildDirectHttpRequestMessage(restRequest), + cancellationToken); + } - return restRequestResult; + /// Executes a GET request with a per-request proxy selection. + public async Task GetAsync(string uri, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + RestRequest restRequest = new(uri, HttpMethod.Get) { ProxyOptions = proxyOptions }; + return await this.httpExecutionPipeline.ExecuteAsync(restRequest, + () => this.BuildDirectHttpRequestMessage(restRequest), + cancellationToken); } public async Task GetAsync(string uri, @@ -191,6 +214,16 @@ public async Task GetAsync(string uri, return restRequestResult; } + /// Executes a GET request with headers and a per-request proxy selection. + public async Task GetAsync(string uri, + RequestHeaders requestHeaders, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + RestRequest restRequest = new(uri, HttpMethod.Get, requestHeaders) { ProxyOptions = proxyOptions }; + return await this.ExecuteRequestAsync(restRequest, cancellationToken: cancellationToken); + } + public async Task> GetAsync(string uri, RequestHeaders requestHeaders, PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary = null, @@ -210,6 +243,19 @@ public async Task> GetAsync(string uri, return restRequestResult; } + /// Executes a typed GET request with headers and a per-request proxy selection. + public async Task> GetAsync(string uri, + RequestHeaders requestHeaders, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + RestRequest restRequest = new(uri, HttpMethod.Get, requestHeaders) { ProxyOptions = proxyOptions }; + if (forcePayloadJsonSerializerLibrary != null) + restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; + return await this.ExecuteRequestAsync(restRequest, cancellationToken: cancellationToken); + } + /// /// Execute a GET request to the specified URI and return the result as a instance. /// @@ -222,64 +268,29 @@ public async Task> GetAsync(string uri, PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary = null, CancellationToken cancellationToken = default) { - HttpResponseMessage? resultHttpMessage = null; RestRequest restRequest; - RestRequestResult restRequestResult; - TimeSpan elapsed; - Stopwatch stopwatch = new(); - HttpResponseParser httpResponseParser = new(this.Logger); - restRequest = new RestRequest(uri, - HttpMethod.Get, - null); + restRequest = new RestRequest(uri, HttpMethod.Get); if (forcePayloadJsonSerializerLibrary != null) - restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; - - try - { - if (this.Logger?.IsEnabled(LogLevel.Debug) == true) - this.Logger?.LogDebug("Executing GET REST request."); - - stopwatch = Stopwatch.StartNew(); - resultHttpMessage = await this.httpClientContext.HttpClient.GetAsync(uri, cancellationToken); - stopwatch.Stop(); - - if (this.Logger?.IsEnabled(LogLevel.Debug) == true) - this.Logger?.LogDebug("GET REST request executed in {elapsed} ms", stopwatch.ElapsedMilliseconds); - - } - catch (Exception exc) - { - if (stopwatch.IsRunning) - stopwatch.Stop(); - - if (this.Logger?.IsEnabled(LogLevel.Error) == true) - this.Logger?.LogError(exc, "Cannot execute GET REST request."); - return new RestRequestResult(restRequest, exc, stopwatch.Elapsed); - } - finally - { - elapsed = stopwatch.Elapsed; - } - - restRequestResult = await httpResponseParser.DecodeAsync(resultHttpMessage, - restRequest, - elapsed, - payloadJsonSerializerLibrary: restRequest.ForcePayloadJsonSerializerLibrary ?? this.SelectedDefaultSerializationLibrary, - cancellationToken); - - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception exc) - { - if (this.Logger?.IsEnabled(LogLevel.Trace) == true) - this.Logger?.LogTrace("Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } + restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; + return await this.ExecuteTypedRequestAsync(restRequest, + restRequest.ForcePayloadJsonSerializerLibrary ?? this.SelectedDefaultSerializationLibrary, + cancellationToken); + } - return restRequestResult; + /// Executes a typed GET request with a per-request proxy selection. + public async Task> GetAsync(string uri, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + RestRequest restRequest = new(uri, HttpMethod.Get) { ProxyOptions = proxyOptions }; + if (forcePayloadJsonSerializerLibrary != null) + restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; + return await this.ExecuteTypedRequestAsync(restRequest, + restRequest.ForcePayloadJsonSerializerLibrary ?? this.SelectedDefaultSerializationLibrary, + cancellationToken); } #endregion @@ -301,59 +312,28 @@ public async Task PostAsync(string uri, PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary = null, CancellationToken cancellationToken = default) { - HttpResponseMessage? resultHttpMessage = null; - RestRequest restRequest; - RestRequestResult restRequestResult; - TimeSpan elapsed; - Stopwatch stopwatch = new(); - HttpResponseParser httpResponseParser = new(this.Logger); + RestRequest restRequest; restRequest = new RestRequest(uri, HttpMethod.Post, - requestData); + requestData); if (forcePayloadJsonSerializerLibrary != null) restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; + return await this.ExecutePayloadRequestAsync(restRequest, cancellationToken); + } - try - { - HttpContent content = this.BuildJsonHttpContent(requestData, - payloadJsonSerializerLibrary: restRequest.ForcePayloadJsonSerializerLibrary); - stopwatch = Stopwatch.StartNew(); - resultHttpMessage = await this.httpClientContext.HttpClient.PostAsync(uri, content, cancellationToken); - stopwatch.Stop(); - } - catch (Exception exc) - { - if (stopwatch.IsRunning) - stopwatch.Stop(); - - if (this.Logger?.IsEnabled(LogLevel.Error) == true) - this.Logger?.LogError(exc, "Cannot execute POST REST request."); - - return new RestRequestResult(restRequest, exc, stopwatch.Elapsed); - } - finally - { - elapsed = stopwatch.Elapsed; - } - - restRequestResult = await httpResponseParser.DecodeAsync(resultHttpMessage, - restRequest, - elapsed, - cancellationToken); - - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception exc) - { - if (this.Logger?.IsEnabled(LogLevel.Trace) == true) - this.Logger?.LogTrace("Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } - - return restRequestResult; + /// Executes a POST request with a per-request proxy selection. + public async Task PostAsync(string uri, + T requestData, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + RestRequest restRequest = new(uri, HttpMethod.Post, requestData) { ProxyOptions = proxyOptions }; + if (forcePayloadJsonSerializerLibrary != null) + restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; + return await this.ExecutePayloadRequestAsync(restRequest, cancellationToken); } /// @@ -371,12 +351,7 @@ public async Task> PostAsync(string uri, PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary = null, CancellationToken cancellationToken = default) { - HttpResponseMessage? resultHttpMessage = null; - RestRequest restRequest; - RestRequestResult restRequestResult; - TimeSpan elapsed; - Stopwatch stopwatch = new(); - HttpResponseParser httpResponseParser = new(this.Logger); + RestRequest restRequest; restRequest = new RestRequest(uri, HttpMethod.Post, @@ -384,47 +359,24 @@ public async Task> PostAsync(string uri, if (forcePayloadJsonSerializerLibrary != null) restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; + return await this.ExecutePayloadRequestAsync(restRequest, + restRequest.ForcePayloadJsonSerializerLibrary ?? this.SelectedDefaultSerializationLibrary, + cancellationToken); + } - try - { - HttpContent content = this.BuildJsonHttpContent(requestData, - payloadJsonSerializerLibrary: restRequest.ForcePayloadJsonSerializerLibrary); - stopwatch = Stopwatch.StartNew(); - resultHttpMessage = await this.httpClientContext.HttpClient.PostAsync(uri, content, cancellationToken); - stopwatch.Stop(); - } - catch (Exception exc) - { - if (stopwatch.IsRunning) - stopwatch.Stop(); - - if (this.Logger?.IsEnabled(LogLevel.Error) == true) - this.Logger?.LogError(exc, "Cannot execute POST REST request."); - - return new RestRequestResult(restRequest, exc, stopwatch.Elapsed); - } - finally - { - elapsed = stopwatch.Elapsed; - } - - restRequestResult = await httpResponseParser.DecodeAsync(resultHttpMessage, - restRequest, - elapsed, - payloadJsonSerializerLibrary: restRequest.ForcePayloadJsonSerializerLibrary ?? this.SelectedDefaultSerializationLibrary, - cancellationToken: cancellationToken); - - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception exc) - { - if (this.Logger?.IsEnabled(LogLevel.Trace) == true) - this.Logger?.LogTrace("Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } - - return restRequestResult; + /// Executes a typed POST request with a per-request proxy selection. + public async Task> PostAsync(string uri, + T requestData, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + RestRequest restRequest = new(uri, HttpMethod.Post, requestData) { ProxyOptions = proxyOptions }; + if (forcePayloadJsonSerializerLibrary != null) + restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; + return await this.ExecutePayloadRequestAsync(restRequest, + restRequest.ForcePayloadJsonSerializerLibrary ?? this.SelectedDefaultSerializationLibrary, + cancellationToken); } public async Task PostAsync(string uri, @@ -444,11 +396,24 @@ public async Task PostAsync(string uri, if (forcePayloadJsonSerializerLibrary != null) restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; - restRequestResult = await this.ExecuteRequestAsync(restRequest, - cancellationToken: cancellationToken); + restRequestResult = await this.ExecuteHeaderPayloadRequestAsync(restRequest, cancellationToken); return restRequestResult; } + /// Executes a POST request with headers and a per-request proxy selection. + public async Task PostAsync(string uri, + T requestData, + RequestHeaders requestHeaders, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + RestRequest restRequest = new(uri, HttpMethod.Post, requestData, requestHeaders) { ProxyOptions = proxyOptions }; + if (forcePayloadJsonSerializerLibrary != null) + restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; + return await this.ExecuteHeaderPayloadRequestAsync(restRequest, cancellationToken); + } + public async Task> PostAsync(string uri, T requestData, RequestHeaders requestHeaders, @@ -470,6 +435,20 @@ public async Task> PostAsync(string uri, } + /// Executes a typed POST request with headers and a per-request proxy selection. + public async Task> PostAsync(string uri, + T requestData, + RequestHeaders requestHeaders, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + RestRequest restRequest = new(uri, HttpMethod.Post, requestData, requestHeaders) { ProxyOptions = proxyOptions }; + if (forcePayloadJsonSerializerLibrary != null) + restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; + return await this.ExecuteRequestAsync(restRequest, cancellationToken: cancellationToken); + } + #endregion #region PUT @@ -479,12 +458,7 @@ public async Task PutAsync(string uri, PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary = null, CancellationToken cancellationToken = default) { - HttpResponseMessage? resultHttpMessage = null; - RestRequest restRequest; - RestRequestResult restRequestResult; - TimeSpan elapsed; - Stopwatch stopwatch = new(); - HttpResponseParser httpResponseParser = new(this.Logger); + RestRequest restRequest; restRequest = new RestRequest(uri, HttpMethod.Put, @@ -492,38 +466,20 @@ public async Task PutAsync(string uri, if (forcePayloadJsonSerializerLibrary != null) restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; + return await this.ExecutePayloadRequestAsync(restRequest, cancellationToken); + } - try - { - HttpContent content = this.BuildJsonHttpContent(requestData, payloadJsonSerializerLibrary: restRequest.ForcePayloadJsonSerializerLibrary); - stopwatch = Stopwatch.StartNew(); - resultHttpMessage = await this.httpClientContext.HttpClient.PutAsync(uri, content, cancellationToken); - stopwatch.Stop(); - } - catch (Exception exc) - { - if (stopwatch.IsRunning) - stopwatch.Stop(); - this.Logger?.LogError(exc, "Cannot execute PUT REST request."); - return new RestRequestResult(restRequest, exc, stopwatch.Elapsed); - } - finally - { - elapsed = stopwatch.Elapsed; - } - - restRequestResult = await httpResponseParser.DecodeAsync(resultHttpMessage, restRequest, elapsed, cancellationToken); - - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception exc) - { - this.Logger?.LogTrace("Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } - - return restRequestResult; + /// Executes a PUT request with a per-request proxy selection. + public async Task PutAsync(string uri, + T requestData, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + RestRequest restRequest = new(uri, HttpMethod.Put, requestData) { ProxyOptions = proxyOptions }; + if (forcePayloadJsonSerializerLibrary != null) + restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; + return await this.ExecutePayloadRequestAsync(restRequest, cancellationToken); } /// @@ -540,12 +496,7 @@ public async Task> PutAsync(string uri, PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary = null, CancellationToken cancellationToken = default) { - HttpResponseMessage? resultHttpMessage = null; - RestRequest restRequest; - RestRequestResult restRequestResult; - TimeSpan elapsed; - Stopwatch stopwatch = new(); - HttpResponseParser httpResponseParser = new(this.Logger); + RestRequest restRequest; restRequest = new RestRequest(uri, HttpMethod.Put, @@ -553,43 +504,24 @@ public async Task> PutAsync(string uri, if (forcePayloadJsonSerializerLibrary != null) restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; + return await this.ExecutePayloadRequestAsync(restRequest, + restRequest.ForcePayloadJsonSerializerLibrary ?? this.SelectedDefaultSerializationLibrary, + cancellationToken); + } - try - { - HttpContent content = this.BuildJsonHttpContent(requestData, - payloadJsonSerializerLibrary: restRequest.ForcePayloadJsonSerializerLibrary); - stopwatch = Stopwatch.StartNew(); - resultHttpMessage = await this.httpClientContext.HttpClient.PutAsync(uri, content, cancellationToken); - stopwatch.Stop(); - } - catch (Exception exc) - { - if (stopwatch.IsRunning) - stopwatch.Stop(); - this.Logger?.LogError(exc, "Cannot execute PUT REST request."); - return new RestRequestResult(restRequest, exc, stopwatch.Elapsed); - } - finally - { - elapsed = stopwatch.Elapsed; - } - - restRequestResult = await httpResponseParser.DecodeAsync(resultHttpMessage, - restRequest, - elapsed, - payloadJsonSerializerLibrary: restRequest.ForcePayloadJsonSerializerLibrary ?? this.SelectedDefaultSerializationLibrary, - cancellationToken); - - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception exc) - { - this.Logger?.LogTrace("Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } - - return restRequestResult; + /// Executes a typed PUT request with a per-request proxy selection. + public async Task> PutAsync(string uri, + T requestData, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + RestRequest restRequest = new(uri, HttpMethod.Put, requestData) { ProxyOptions = proxyOptions }; + if (forcePayloadJsonSerializerLibrary != null) + restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; + return await this.ExecutePayloadRequestAsync(restRequest, + restRequest.ForcePayloadJsonSerializerLibrary ?? this.SelectedDefaultSerializationLibrary, + cancellationToken); } public async Task PutAsync(string uri, @@ -609,10 +541,24 @@ public async Task PutAsync(string uri, if (forcePayloadJsonSerializerLibrary != null) restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; - restRequestResult = await this.ExecuteRequestAsync(restRequest, cancellationToken: cancellationToken); + restRequestResult = await this.ExecuteHeaderPayloadRequestAsync(restRequest, cancellationToken); return restRequestResult; } + /// Executes a PUT request with headers and a per-request proxy selection. + public async Task PutAsync(string uri, + T requestData, + RequestHeaders requestHeaders, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + RestRequest restRequest = new(uri, HttpMethod.Put, requestData, requestHeaders) { ProxyOptions = proxyOptions }; + if (forcePayloadJsonSerializerLibrary != null) + restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; + return await this.ExecuteHeaderPayloadRequestAsync(restRequest, cancellationToken); + } + public async Task> PutAsync(string uri, T requestData, RequestHeaders requestHeaders, @@ -634,6 +580,20 @@ public async Task> PutAsync(string uri, return restRequestResult; } + /// Executes a typed PUT request with headers and a per-request proxy selection. + public async Task> PutAsync(string uri, + T requestData, + RequestHeaders requestHeaders, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + RestRequest restRequest = new(uri, HttpMethod.Put, requestData, requestHeaders) { ProxyOptions = proxyOptions }; + if (forcePayloadJsonSerializerLibrary != null) + restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; + return await this.ExecuteRequestAsync(restRequest, cancellationToken: cancellationToken); + } + #endregion #region DELETE @@ -646,45 +606,23 @@ public async Task> PutAsync(string uri, /// The value returned from the remote resource public async Task DeleteAsync(string uri, CancellationToken cancellationToken = default) { - HttpResponseMessage? resultHttpMessage = null; RestRequest restRequest; - RestRequestResult restRequestResult; - TimeSpan elapsed; - Stopwatch stopwatch = new(); - HttpResponseParser httpResponseParser = new(this.Logger); - - restRequest = new RestRequest(uri, - HttpMethod.Delete, - null); - try - { - stopwatch = Stopwatch.StartNew(); - resultHttpMessage = await this.httpClientContext.HttpClient.DeleteAsync(uri, cancellationToken); - stopwatch.Stop(); - } - catch (Exception exc) - { - if (stopwatch.IsRunning) - stopwatch.Stop(); - this.Logger?.LogError(exc, "Cannot execute DELETE REST request."); - return new RestRequestResult(restRequest, exc, stopwatch.Elapsed); - } - finally - { - elapsed = stopwatch.Elapsed; - } - restRequestResult = await httpResponseParser.DecodeAsync(resultHttpMessage, restRequest, elapsed, cancellationToken); - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception exc) - { - this.Logger?.LogTrace("Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } + restRequest = new RestRequest(uri, HttpMethod.Delete); + return await this.httpExecutionPipeline.ExecuteAsync(restRequest, + () => this.BuildDirectHttpRequestMessage(restRequest), + cancellationToken); + } - return restRequestResult; + /// Executes a DELETE request with a per-request proxy selection. + public async Task DeleteAsync(string uri, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + RestRequest restRequest = new(uri, HttpMethod.Delete) { ProxyOptions = proxyOptions }; + return await this.httpExecutionPipeline.ExecuteAsync(restRequest, + () => this.BuildDirectHttpRequestMessage(restRequest), + cancellationToken); } /// @@ -699,52 +637,29 @@ public async Task> DeleteAsync(string uri, PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary = null, CancellationToken cancellationToken = default) { - HttpResponseMessage? resultHttpMessage = null; - RestRequest restRequest; - RestRequestResult restRequestResult; - TimeSpan elapsed; - Stopwatch stopwatch = new(); - HttpResponseParser httpResponseParser = new(this.Logger); - restRequest = new RestRequest(uri, - HttpMethod.Delete, - null); - - if (forcePayloadJsonSerializerLibrary != null) - restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; - - try - { - stopwatch = Stopwatch.StartNew(); - resultHttpMessage = await this.httpClientContext.HttpClient.DeleteAsync(uri, cancellationToken); - stopwatch.Stop(); - } - catch (Exception exc) - { - if (stopwatch.IsRunning) - stopwatch.Stop(); - this.Logger?.LogError(exc, "Cannot execute DELETE REST request."); - return new RestRequestResult(restRequest, exc, stopwatch.Elapsed); - } - finally - { - elapsed = stopwatch.Elapsed; - } - restRequestResult = await httpResponseParser.DecodeAsync(resultHttpMessage, - restRequest, - elapsed, - payloadJsonSerializerLibrary: restRequest.ForcePayloadJsonSerializerLibrary ?? this.SelectedDefaultSerializationLibrary, - cancellationToken); + RestRequest restRequest; - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception exc) - { - this.Logger?.LogTrace("Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } + restRequest = new RestRequest(uri, HttpMethod.Delete); - return restRequestResult; + if (forcePayloadJsonSerializerLibrary != null) + restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; + return await this.ExecuteTypedRequestAsync(restRequest, + restRequest.ForcePayloadJsonSerializerLibrary ?? this.SelectedDefaultSerializationLibrary, + cancellationToken); + } + + /// Executes a typed DELETE request with a per-request proxy selection. + public async Task> DeleteAsync(string uri, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + RestRequest restRequest = new(uri, HttpMethod.Delete) { ProxyOptions = proxyOptions }; + if (forcePayloadJsonSerializerLibrary != null) + restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; + return await this.ExecuteTypedRequestAsync(restRequest, + restRequest.ForcePayloadJsonSerializerLibrary ?? this.SelectedDefaultSerializationLibrary, + cancellationToken); } public async Task DeleteAsync(string uri, @@ -762,6 +677,16 @@ public async Task DeleteAsync(string uri, return restRequestResult; } + /// Executes a DELETE request with headers and a per-request proxy selection. + public async Task DeleteAsync(string uri, + RequestHeaders requestHeaders, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + RestRequest restRequest = new(uri, HttpMethod.Delete, requestHeaders) { ProxyOptions = proxyOptions }; + return await this.ExecuteRequestAsync(restRequest, cancellationToken: cancellationToken); + } + public async Task> DeleteAsync(string uri, RequestHeaders requestHeaders, PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary = null, @@ -781,6 +706,19 @@ public async Task> DeleteAsync(string uri, return restRequestResult; } + /// Executes a typed DELETE request with headers and a per-request proxy selection. + public async Task> DeleteAsync(string uri, + RequestHeaders requestHeaders, + PayloadJsonSerializerLibrary? forcePayloadJsonSerializerLibrary, + RequestProxyOptions proxyOptions, + CancellationToken cancellationToken = default) + { + RestRequest restRequest = new(uri, HttpMethod.Delete, requestHeaders) { ProxyOptions = proxyOptions }; + if (forcePayloadJsonSerializerLibrary != null) + restRequest.ForcePayloadJsonSerializerLibrary = forcePayloadJsonSerializerLibrary; + return await this.ExecuteRequestAsync(restRequest, cancellationToken: cancellationToken); + } + #endregion /// @@ -801,6 +739,12 @@ public async Task ExecuteRequestAsync(RestRequest restRequest switch (restRequest) { + case MultipartRequest multipartRequest: + { + restRequestResult = await this.ExecuteMultipartRequestAsync(multipartRequest, cancellationToken); + } + break; + case FormUrlEncodedRequest formUrlEncodedRequest: { restRequestResult = await this.ExecuteFormUrlEncodedRequest(formUrlEncodedRequest, cancellationToken); @@ -866,6 +810,12 @@ public async Task> ExecuteRequestAsync(RestRequest restR switch (restRequest) { + case MultipartRequest multipartRequest: + { + restRequestResult = await this.ExecuteMultipartRequestAsync(multipartRequest, cancellationToken); + } + break; + case FormUrlEncodedRequest formUrlEncodedRequest: { restRequestResult = await this.ExecuteFormUrlEncodedRequest(formUrlEncodedRequest, cancellationToken); @@ -904,6 +854,87 @@ public async Task> ExecuteRequestAsync(RestRequest restR return restRequestResult; } + /// Executes a multipart request. + public async Task ExecuteMultipartRequestAsync(MultipartRequest multipartRequest, + CancellationToken cancellationToken = default) + { + HttpRequestMessage httpRequest; + RestRequestResult result; + + ArgumentNullException.ThrowIfNull(multipartRequest); + using (httpRequest = this.BuildHttpRequestMessage(multipartRequest)) + { + httpRequest.Content = this.BuildMultipartHttpContent(multipartRequest); + result = await this.ExecuteRequestInternalAsync(multipartRequest, + httpRequest, + cancellationToken: cancellationToken); + } + return result; + } + + /// Executes a multipart request and deserializes its response. + public async Task> ExecuteMultipartRequestAsync(MultipartRequest multipartRequest, + CancellationToken cancellationToken = default) + { + HttpRequestMessage httpRequest; + RestRequestResult result; + + ArgumentNullException.ThrowIfNull(multipartRequest); + using (httpRequest = this.BuildHttpRequestMessage(multipartRequest)) + { + httpRequest.Content = this.BuildMultipartHttpContent(multipartRequest); + result = await this.ExecuteRequestInternalAsync(multipartRequest, + httpRequest, + cancellationToken: cancellationToken); + } + return result; + } + + /// Streams parts from a multipart/x-mixed-replace response until cancellation or its closing boundary. + public async IAsyncEnumerable StreamMultipartMixedReplaceAsync(RestRequest restRequest, + MultipartOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ContentCodecContext codecContext; + HttpRequestMessage httpRequest; + MediaTypeHeaderValue contentType; + + ArgumentNullException.ThrowIfNull(restRequest); + options ??= new MultipartOptions(); + options.Validate(); + + using (httpRequest = this.BuildHttpRequestMessage(restRequest)) + using (HttpResponseLease lease = await this.httpExecutionPipeline.SendStreamingAsync(restRequest, + httpRequest, + cancellationToken)) + { + HttpResponseMessage response = lease.Response; + response.EnsureSuccessStatusCode(); + contentType = response.Content.Headers.ContentType ?? + throw new InvalidDataException("The multipart response has no Content-Type header."); + if (!string.Equals(contentType.MediaType, "multipart/x-mixed-replace", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("The response is not multipart/x-mixed-replace."); + + codecContext = new ContentCodecContext + { + Logger = this.Logger, + JsonSerializerLibrary = restRequest.ForcePayloadJsonSerializerLibrary ?? this.SelectedDefaultSerializationLibrary, + Codecs = this.Context.Codecs + }; + + using Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); + await foreach (MultipartPart part in MultipartMixedReplaceReader.ReadAsync(stream, + contentType, + this.Context.Codecs, + codecContext, + options, + cancellationToken)) + { + yield return part; + } + } + } + /// /// Execute a REST request and return the result as a instance. /// @@ -939,58 +970,15 @@ public async Task> ExecuteRequestAsync(RestRequest public async Task ExecuteFormUrlEncodedRequest(FormUrlEncodedRequest formUrlEncodedRequest, CancellationToken cancellationToken = default) { - HttpResponseParser httpResponseParser = new(this.Logger); RestRequestResult restRequestResult; - HttpResponseMessage? resultHttpMessage = null; - Stopwatch stopwatch = new(); - TimeSpan elapsed; HttpRequestMessage httpRequest; using (httpRequest = this.BuildHttpRequestMessage(formUrlEncodedRequest)) { httpRequest.Content = this.BuildFormUrlEncodedContent(formUrlEncodedRequest.Parameters); - - try - { - stopwatch = Stopwatch.StartNew(); - resultHttpMessage = await this.httpClientContext.HttpClient.SendAsync(httpRequest, cancellationToken); - stopwatch.Stop(); - } - catch (Exception exc) - { - if (stopwatch.IsRunning) - stopwatch.Stop(); - - this.Logger?.LogError(exc, "Cannot execute {method} REST request.", httpRequest.Method.Method); - - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception disposeExc) - { - this.Logger?.LogTrace(disposeExc, "Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } - - return new(formUrlEncodedRequest, exc, stopwatch.Elapsed); - } - finally - { - elapsed = stopwatch.Elapsed; - } - - restRequestResult = await httpResponseParser.DecodeAsync(resultHttpMessage, - formUrlEncodedRequest, - elapsed, - cancellationToken); - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception exc) - { - this.Logger?.LogTrace(exc, "Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } + restRequestResult = await this.httpExecutionPipeline.ExecuteAsync(formUrlEncodedRequest, + httpRequest, + cancellationToken); } return restRequestResult; @@ -999,60 +987,16 @@ public async Task ExecuteFormUrlEncodedRequest(FormUrlEncoded public async Task> ExecuteFormUrlEncodedRequest(FormUrlEncodedRequest formUrlEncodedRequest, CancellationToken cancellationToken = default) { - HttpResponseParser httpResponseParser = new(this.Logger); RestRequestResult restRequestResult; - HttpResponseMessage? resultHttpMessage = null; - Stopwatch stopwatch = new(); - TimeSpan elapsed; HttpRequestMessage httpRequest; using (httpRequest = this.BuildHttpRequestMessage(formUrlEncodedRequest)) { httpRequest.Content = this.BuildFormUrlEncodedContent(formUrlEncodedRequest.Parameters); - - try - { - stopwatch = Stopwatch.StartNew(); - resultHttpMessage = await this.httpClientContext.HttpClient.SendAsync(httpRequest, cancellationToken); - stopwatch.Stop(); - } - catch (Exception exc) - { - if (stopwatch.IsRunning) - stopwatch.Stop(); - - this.Logger?.LogError(exc, "Cannot execute {method} REST request.", httpRequest.Method.Method); - - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception disposeExc) - { - this.Logger?.LogTrace(disposeExc, "Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } - - return new(formUrlEncodedRequest, exc, stopwatch.Elapsed); - } - finally - { - elapsed = stopwatch.Elapsed; - } - - restRequestResult = await httpResponseParser.DecodeAsync(resultHttpMessage, - formUrlEncodedRequest, - elapsed, - formUrlEncodedRequest.ForcePayloadJsonSerializerLibrary, - cancellationToken); - - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception exc) - { - this.Logger?.LogTrace("Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } + restRequestResult = await this.httpExecutionPipeline.ExecuteAsync(formUrlEncodedRequest, + httpRequest, + formUrlEncodedRequest.ForcePayloadJsonSerializerLibrary, + cancellationToken); } return restRequestResult; @@ -1061,60 +1005,16 @@ public async Task> ExecuteFormUrlEncodedRequest(FormUrlE public async Task ExecuteRawRequestAsync(RestRawRequest restRawRequest, CancellationToken cancellationToken = default) { - HttpResponseParser httpResponseParser = new(this.Logger); RestRequestResult restRequestResult; - HttpResponseMessage? resultHttpMessage = null; - Stopwatch stopwatch = new(); - TimeSpan elapsed; HttpRequestMessage httpRequest; using (httpRequest = this.BuildHttpRequestMessage(restRawRequest)) { httpRequest.Content = this.BuildRawHttpContent(restRawRequest.Content, restRawRequest.ContentType); - - try - { - stopwatch = Stopwatch.StartNew(); - resultHttpMessage = await this.httpClientContext.HttpClient.SendAsync(httpRequest, cancellationToken); - stopwatch.Stop(); - } - catch (Exception exc) - { - if (stopwatch.IsRunning) - stopwatch.Stop(); - - if (this.Logger?.IsEnabled(LogLevel.Error) == true) - this.Logger?.LogError(exc, "Cannot execute {method} REST request.", httpRequest.Method.Method); - - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception disposeExc) - { - if (this.Logger?.IsEnabled(LogLevel.Trace) == true) - this.Logger?.LogTrace(disposeExc, "Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } - - return new(restRawRequest, exc, stopwatch.Elapsed); - } - finally - { - elapsed = stopwatch.Elapsed; - } - - restRequestResult = await httpResponseParser.DecodeAsync(resultHttpMessage, restRawRequest, elapsed, cancellationToken); - - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception exc) - { - if (this.Logger?.IsEnabled(LogLevel.Trace) == true) - this.Logger?.LogTrace("Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } + restRequestResult = await this.httpExecutionPipeline.ExecuteAsync(restRawRequest, + httpRequest, + cancellationToken); } return restRequestResult; @@ -1123,61 +1023,17 @@ public async Task ExecuteRawRequestAsync(RestRawRequest restR public async Task> ExecuteRawRequestAsync(RestRawRequest restRawRequest, CancellationToken cancellationToken = default) { - HttpResponseParser httpResponseParser = new(this.Logger); RestRequestResult restRequestResult; - HttpResponseMessage? resultHttpMessage = null; - Stopwatch stopwatch = new(); - TimeSpan elapsed; HttpRequestMessage httpRequest; using (httpRequest = this.BuildHttpRequestMessage(restRawRequest)) { httpRequest.Content = this.BuildRawHttpContent(restRawRequest.Content, restRawRequest.ContentType); - - try - { - stopwatch = Stopwatch.StartNew(); - resultHttpMessage = await this.httpClientContext.HttpClient.SendAsync(httpRequest, cancellationToken); - stopwatch.Stop(); - } - catch (Exception exc) - { - if (stopwatch.IsRunning) - stopwatch.Stop(); - - this.Logger?.LogError(exc, "Cannot execute {method} REST request.", httpRequest.Method.Method); - - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception disposeExc) - { - this.Logger?.LogTrace(disposeExc, "Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } - - return new(restRawRequest, exc, stopwatch.Elapsed); - } - finally - { - elapsed = stopwatch.Elapsed; - } - - restRequestResult = await httpResponseParser.DecodeAsync(resultHttpMessage, - restRawRequest, - elapsed, - restRawRequest.ForcePayloadJsonSerializerLibrary, - cancellationToken); - - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception exc) - { - this.Logger?.LogTrace("Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } + restRequestResult = await this.httpExecutionPipeline.ExecuteAsync(restRawRequest, + httpRequest, + restRawRequest.ForcePayloadJsonSerializerLibrary, + cancellationToken); } return restRequestResult; @@ -1194,65 +1050,15 @@ protected async Task ExecuteRequestInternalAsync(RestRequest bool throwOnGenerics = false, CancellationToken cancellationToken = default) { - HttpResponseParser httpResponseParser = new(this.Logger); - RestRequestResult restRequestResult; - HttpResponseMessage? resultHttpMessage = null; - Stopwatch stopwatch = new(); - TimeSpan elapsed; - - if (throwOnGenerics == true) - { - if (restRequest.GetType().IsGenericType == true) - throw new InvalidOperationException("The rest request contains generic type data. Cannot be executed with using a call without payload."); - } + if (throwOnGenerics && restRequest.GetType().IsGenericType) + throw new InvalidOperationException("The rest request contains generic type data. Cannot be executed with using a call without payload."); restRequest.ForcePayloadJsonSerializerLibrary = this.SelectedDefaultSerializationLibrary switch { PayloadJsonSerializerLibrary.Automatic => restRequest.ForcePayloadJsonSerializerLibrary, _ => this.SelectedDefaultSerializationLibrary }; - - try - { - stopwatch = Stopwatch.StartNew(); - resultHttpMessage = await this.httpClientContext.HttpClient.SendAsync(httpRequest, cancellationToken); - stopwatch.Stop(); - } - catch (Exception exc) - { - if (stopwatch.IsRunning) - stopwatch.Stop(); - - this.Logger?.LogError(exc, "Cannot execute {method} REST request.", httpRequest.Method.Method); - - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception disposeExc) - { - this.Logger?.LogTrace(disposeExc, "Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } - - return new RestRequestResult(restRequest, exc, stopwatch.Elapsed); - } - finally - { - elapsed = stopwatch.Elapsed; - } - - restRequestResult = await httpResponseParser.DecodeAsync(resultHttpMessage, restRequest, elapsed, cancellationToken); - - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception exc) - { - this.Logger?.LogTrace("Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } - - return restRequestResult; + return await this.httpExecutionPipeline.ExecuteAsync(restRequest, httpRequest, cancellationToken); } protected async Task> ExecuteRequestInternalAsync(RestRequest restRequest, @@ -1260,72 +1066,22 @@ protected async Task> ExecuteRequestInternalAsync(RestRe bool throwOnGenerics = false, CancellationToken cancellationToken = default) { - HttpResponseParser httpResponseParser = new(this.Logger); - RestRequestResult restRequestResult; - HttpResponseMessage? resultHttpMessage = null; - Stopwatch stopwatch = new(); - TimeSpan elapsed; - - if (throwOnGenerics == true) - { - if (restRequest.GetType().IsGenericType == true) - throw new InvalidOperationException("The rest request contains generic type data. Cannot be executed with using a call without payload."); - } + if (throwOnGenerics && restRequest.GetType().IsGenericType) + throw new InvalidOperationException("The rest request contains generic type data. Cannot be executed with using a call without payload."); restRequest.ForcePayloadJsonSerializerLibrary = this.SelectedDefaultSerializationLibrary switch { PayloadJsonSerializerLibrary.Automatic => restRequest.ForcePayloadJsonSerializerLibrary, _ => this.SelectedDefaultSerializationLibrary }; - - try - { - stopwatch = Stopwatch.StartNew(); - resultHttpMessage = await this.httpClientContext.HttpClient.SendAsync(httpRequest, cancellationToken); - stopwatch.Stop(); - } - catch (Exception exc) - { - if (stopwatch.IsRunning) - stopwatch.Stop(); - - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception disposeExc) - { - this.Logger?.LogTrace(disposeExc, "Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } - - this.Logger?.LogError(exc, "Cannot execute {method} REST request.", httpRequest.Method.Method); - return new RestRequestResult(restRequest, exc, stopwatch.Elapsed); - } - finally - { - elapsed = stopwatch.Elapsed; - } - - restRequestResult = await httpResponseParser.DecodeAsync(resultHttpMessage, - restRequest, - elapsed, - payloadJsonSerializerLibrary: restRequest.ForcePayloadJsonSerializerLibrary, - cancellationToken); - - try - { - resultHttpMessage?.Dispose(); - } - catch (Exception exc) - { - this.Logger?.LogTrace("Cannot dispose the HttpResponseMessage instance: {exc}", exc.Message); - } - - return restRequestResult; + return await this.httpExecutionPipeline.ExecuteAsync(restRequest, + httpRequest, + restRequest.ForcePayloadJsonSerializerLibrary, + cancellationToken); } /// - /// Dispose the HttpClient instance and all the handlers when disposing the RestlingClient instance, if is set to true. + /// Disposes the context only when ContextOwnership is Owned. /// protected virtual void Dispose(bool disposing) { @@ -1333,7 +1089,7 @@ protected virtual void Dispose(bool disposing) { if (disposing) { - if (this.DisposeContext) + if (this.ContextOwnership == RestlingClientContextOwnership.Owned) { this.httpClientContext.Dispose(); } @@ -1368,41 +1124,53 @@ protected HttpContent BuildFormUrlEncodedContent(IDictionary con return httpContent; } - protected HttpContent BuildJsonHttpContent(T requestData, + /// Builds multipart content using the current codec snapshot. + protected System.Net.Http.MultipartContent BuildMultipartHttpContent(MultipartRequest request) + { + ContentCodecContext context = new() + { + Logger = this.Logger, + JsonSerializerLibrary = request.ForcePayloadJsonSerializerLibrary ?? this.SelectedDefaultSerializationLibrary, + Codecs = this.Context.Codecs + }; + return request.BuildContent(this.Context.Codecs, context); + } + + /// Builds JSON content while preserving legacy media-type labels and null payloads. + protected HttpContent BuildJsonHttpContent(T requestData, string? requestContentMediaType = null, PayloadJsonSerializerLibrary? payloadJsonSerializerLibrary = null) { - HttpContent content; - - if (this.EnableVerboseLogging) - this.Logger?.LogTrace("Build http request content."); - if (requestData == null) - { - if (this.EnableVerboseLogging) - this.Logger?.LogTrace("Request data is null. An empty string content will be added to the request."); - - content = new StringContent(string.Empty); - } - else - { - JsonSerialization jsonSerialization = new(this.Logger); - string jsonContent = jsonSerialization.Serialize(requestData, payloadJsonSerializerLibrary); - string? mediaType = requestContentMediaType; - - if (string.IsNullOrWhiteSpace(mediaType)) - mediaType = HttpMediaType.ApplicationJson; - - if (this.EnableVerboseLogging) - { - this.Logger?.LogTrace("A content of type {mediaType} will be added to the request containing the serialization of the request data.", mediaType); - this.Logger?.LogTrace("Serialized request data: {serializedData}", jsonContent); - } - - content = new StringContent(jsonContent, Encoding.UTF8, mediaType); - } + return new StringContent(string.Empty); + + MediaTypeHeaderValue contentType = MediaTypeHeaderValue.Parse(string.IsNullOrWhiteSpace(requestContentMediaType) + ? HttpMediaType.ApplicationJson + : requestContentMediaType); + IContentCodec codec = this.Context.Codecs.FindWriter(HttpMediaType.ApplicationJson) + ?? throw new NotSupportedException("No JSON writer is registered."); + ContentCodecContext context = new() + { + Logger = this.Logger, + JsonSerializerLibrary = payloadJsonSerializerLibrary, + Codecs = this.Context.Codecs + }; + return codec.Serialize(requestData, contentType, context); + } - return content; + /// Builds a payload with the codec explicitly selected by its media type. + protected HttpContent BuildCodecHttpContent(RestRequest request) + { + MediaTypeHeaderValue contentType = MediaTypeHeaderValue.Parse(request.ContentMediaType ?? HttpMediaType.ApplicationJson); + IContentCodec codec = this.Context.Codecs.FindWriter(contentType.MediaType) + ?? throw new NotSupportedException($"No writer is registered for {contentType.MediaType}."); + ContentCodecContext context = new() + { + Logger = this.Logger, + JsonSerializerLibrary = request.ForcePayloadJsonSerializerLibrary ?? this.SelectedDefaultSerializationLibrary, + Codecs = this.Context.Codecs + }; + return codec.Serialize(request.RequestData, contentType, context); } protected HttpRequestMessage BuildHttpRequestMessage(RestRequest restRequest) @@ -1458,7 +1226,11 @@ protected HttpRequestMessage BuildHttpRequestMessageWithPayload(RestRequest(restRequest.RequestData, requestContentMediaType: restRequest.ContentMediaType, @@ -1474,6 +1246,80 @@ protected static HttpClientContext BuildDefaultHttpClientContext() return httpClientBuilder.Build(); } + /// Sends a payload with per-request headers and returns an untyped response. + private async Task ExecuteHeaderPayloadRequestAsync(RestRequest restRequest, + CancellationToken cancellationToken) + { + using HttpRequestMessage httpRequest = this.BuildHttpRequestMessageWithPayload(restRequest); + return await this.ExecuteRequestInternalAsync(restRequest, httpRequest, cancellationToken: cancellationToken); + } + + /// Preserves the direct overload's payload and preparation-error contract. + private Task ExecutePayloadRequestAsync(RestRequest restRequest, + CancellationToken cancellationToken) + { + return this.httpExecutionPipeline.ExecuteAsync(restRequest, + () => this.BuildDirectPayloadHttpRequestMessage(restRequest), + cancellationToken); + } + + /// Preserves direct payload serialization separately from response serializer selection. + private Task> ExecutePayloadRequestAsync(RestRequest restRequest, + PayloadJsonSerializerLibrary? serializerLibrary, + CancellationToken cancellationToken) + { + return this.httpExecutionPipeline.ExecuteAsync(restRequest, + () => this.BuildDirectPayloadHttpRequestMessage(restRequest), + serializerLibrary, + cancellationToken); + } + + /// Preserves direct bodyless response serializer selection. + private Task> ExecuteTypedRequestAsync(RestRequest restRequest, + PayloadJsonSerializerLibrary? serializerLibrary, + CancellationToken cancellationToken) + { + return this.httpExecutionPipeline.ExecuteAsync(restRequest, + () => this.BuildDirectHttpRequestMessage(restRequest), + serializerLibrary, + cancellationToken); + } + + /// Retains the HttpClient shortcut defaults used by direct convenience methods. + private HttpRequestMessage BuildDirectHttpRequestMessage(RestRequest restRequest) + { + return new HttpRequestMessage(new NetHttpMethod(restRequest.Method.ToString().ToUpperInvariant()), restRequest.Uri) + { + Version = this.httpClientContext.HttpClient.DefaultRequestVersion, + VersionPolicy = this.httpClientContext.HttpClient.DefaultVersionPolicy + }; + } + + /// Includes empty text content for null direct payloads, as in the original shortcuts. + private HttpRequestMessage BuildDirectPayloadHttpRequestMessage(RestRequest restRequest) + { + HttpRequestMessage httpRequest = this.BuildDirectHttpRequestMessage(restRequest); + + try + { + httpRequest.Content = this.BuildJsonHttpContent(restRequest.RequestData, + payloadJsonSerializerLibrary: restRequest.ForcePayloadJsonSerializerLibrary); + return httpRequest; + } + catch + { + httpRequest.Dispose(); + throw; + } + } + + /// Validates a builder before creating an owned context. + private static HttpClientContext BuildContext(IHttpClientContextBuilder httpClientBuilder) + { + ArgumentNullException.ThrowIfNull(httpClientBuilder); + return httpClientBuilder.Build(); + } + #endregion } } diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestlingClientContextOwnership.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestlingClientContextOwnership.cs new file mode 100644 index 0000000..cccfb56 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Core/RestlingClientContextOwnership.cs @@ -0,0 +1,12 @@ +namespace AMDevIT.Restling.Core +{ + /// Defines whether a Restling client owns the context supplied to it. + public enum RestlingClientContextOwnership + { + /// The context remains owned by the caller. + Borrowed, + + /// The context is disposed together with the client. + Owned + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Csv/AMDevIT.Restling.Csv.csproj b/Sources/AMDevIT.Restling/AMDevIT.Restling.Csv/AMDevIT.Restling.Csv.csproj new file mode 100644 index 0000000..6d7e15a --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Csv/AMDevIT.Restling.Csv.csproj @@ -0,0 +1,24 @@ + + + net10.0;net9.0;net8.0 + enable + enable + Restling.Csv + Optional CSV content codec for Restling, powered by CsvHelper. + MIT + README.md + https://github.com/AMDevIT/Restling.git + Restling CSV Content codec + + True + + + + + + True + \ + + + + diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Csv/CsvContentCodec.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Csv/CsvContentCodec.cs new file mode 100644 index 0000000..f842a53 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Csv/CsvContentCodec.cs @@ -0,0 +1,120 @@ +using AMDevIT.Restling.Core.Codecs; +using CsvHelper; +using CsvHelper.Configuration; +using System.Collections; +using System.Globalization; +using System.Net.Http.Headers; + +namespace AMDevIT.Restling.Csv +{ + /// Optional buffered CSV codec for arrays, lists and standard collection interfaces. + public sealed class CsvContentCodec : IContentCodec + { + #region Fields + + private readonly CsvContentCodecOptions options; + private readonly CultureInfo culture; + + #endregion + + #region Properties + + public bool IsBinary => false; + + #endregion + + #region .ctor + + /// Creates a CSV codec using invariant culture, comma separation and headers by default. + public CsvContentCodec(CsvContentCodecOptions? options = null) + { + this.options = options ?? new CsvContentCodecOptions(); + ArgumentNullException.ThrowIfNull(this.options.Culture); + ArgumentException.ThrowIfNullOrEmpty(this.options.Delimiter); + this.culture = CultureInfo.ReadOnly((CultureInfo)this.options.Culture.Clone()); + } + + #endregion + + #region Methods + + /// + public bool CanRead(string? mediaType) + { + return string.Equals(mediaType, "text/csv", StringComparison.OrdinalIgnoreCase); + } + + /// + public bool CanWrite(string? mediaType) => this.CanRead(mediaType); + + /// + public T? Deserialize(byte[] content, MediaTypeHeaderValue? contentType, ContentCodecContext context) + { + Type targetType = typeof(T); + Type recordType = GetRecordType(targetType); + IList records = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(recordType))!; + string text = context.DecodeText(content, contentType).TrimStart('\uFEFF'); + using StringReader input = new(text); + using CsvReader reader = new(input, this.CreateConfiguration()); + this.options.ConfigureContext?.Invoke(reader.Context); + + foreach (object record in reader.GetRecords(recordType)) + records.Add(record); + + if (targetType.IsArray) + { + Array array = Array.CreateInstance(recordType, records.Count); + records.CopyTo(array, 0); + return (T)(object)array; + } + return (T)(object)records; + } + + /// + public HttpContent Serialize(T value, MediaTypeHeaderValue contentType, ContentCodecContext context) + { + if (value is not IEnumerable records || value is string || value is byte[]) + throw new ArgumentException("CSV serialization requires a collection of records.", nameof(value)); + + using StringWriter output = new(this.culture); + using (CsvWriter writer = new(output, this.CreateConfiguration())) + { + this.options.ConfigureContext?.Invoke(writer.Context); + writer.WriteRecords(records); + } + return context.CreateTextContent(output.ToString(), contentType); + } + + /// Creates independent settings for each operation. + private CsvConfiguration CreateConfiguration() + { + CsvConfiguration configuration = new(this.culture) + { + Delimiter = this.options.Delimiter, + HasHeaderRecord = this.options.HasHeaderRecord, + ExceptionMessagesContainRawData = false + }; + this.options.Configure?.Invoke(configuration); + return configuration; + } + + /// Resolves supported collection targets, rejecting ambiguous single-record models. + private static Type GetRecordType(Type targetType) + { + if (targetType.IsArray && targetType.GetArrayRank() == 1) + return targetType.GetElementType()!; + + if (targetType.IsGenericType) + { + Type definition = targetType.GetGenericTypeDefinition(); + if (definition == typeof(List<>) || definition == typeof(IEnumerable<>) || + definition == typeof(ICollection<>) || definition == typeof(IList<>) || + definition == typeof(IReadOnlyCollection<>) || definition == typeof(IReadOnlyList<>)) + return targetType.GetGenericArguments()[0]; + } + throw new NotSupportedException("CSV responses require TRecord[], List or a standard collection interface."); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Csv/CsvContentCodecOptions.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Csv/CsvContentCodecOptions.cs new file mode 100644 index 0000000..17a5f92 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Csv/CsvContentCodecOptions.cs @@ -0,0 +1,24 @@ +using CsvHelper; +using CsvHelper.Configuration; +using System.Globalization; + +namespace AMDevIT.Restling.Csv +{ + /// CSV settings. Callbacks receive fresh per-operation configuration/context instances. + public sealed class CsvContentCodecOptions + { + #region Properties + + public CultureInfo Culture { get; init; } = CultureInfo.InvariantCulture; + public string Delimiter { get; init; } = ","; + public bool HasHeaderRecord { get; init; } = true; + + /// Configures validation, quoting and other CsvHelper settings. Must be safe for concurrent calls. + public Action? Configure { get; init; } + + /// Registers maps or converters per operation. Must be safe for concurrent calls. + public Action? ConfigureContext { get; init; } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Csv/README.md b/Sources/AMDevIT.Restling/AMDevIT.Restling.Csv/README.md new file mode 100644 index 0000000..e4623b7 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Csv/README.md @@ -0,0 +1,30 @@ +# Restling.Csv + +Optional buffered CSV codec for Restling, powered by CsvHelper. + +```csharp +using AMDevIT.Restling.Core; +using AMDevIT.Restling.Core.Network; +using AMDevIT.Restling.Core.Network.Builders; +using AMDevIT.Restling.Csv; + +HttpClientContextBuilder builder = new(); +builder.AddCodec(new CsvContentCodec()); + +RestlingClient client = new(builder); +RestRequestResult> result = await client.GetAsync>("https://api.example.com/products"); +``` + +CSV request bodies require explicit codec selection: + +```csharp +RestRequest> request = new("https://api.example.com/products", + AMDevIT.Restling.Core.HttpMethod.Post, + products) +{ + ContentMediaType = HttpMediaType.TextCsv, + UseContentCodec = true +}; +``` + +Use `CsvContentCodecOptions` to set culture, delimiter, header handling, CsvHelper configuration, maps, and converters. Response targets can be arrays, `List`, or standard generic collection interfaces. diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/AMDevIT.Restling.Tests.csproj b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/AMDevIT.Restling.Tests.csproj index a82b98d..a66ae74 100644 --- a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/AMDevIT.Restling.Tests.csproj +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/AMDevIT.Restling.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -7,33 +7,24 @@ enable true - - - - - - + + - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Codecs/UpperCaseContentCodec.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Codecs/UpperCaseContentCodec.cs new file mode 100644 index 0000000..85338c1 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Codecs/UpperCaseContentCodec.cs @@ -0,0 +1,38 @@ +using AMDevIT.Restling.Core.Codecs; +using System.Net.Http.Headers; + +namespace AMDevIT.Restling.Tests.Codecs +{ + internal sealed class UpperCaseContentCodec : IContentCodec + { + #region Properties + + public bool IsBinary => false; + + #endregion + + #region Methods + + /// + public bool CanRead(string? mediaType) => mediaType == "text/plain"; + + /// + public bool CanWrite(string? mediaType) => mediaType == "text/plain"; + + /// + public T? Deserialize(byte[] content, MediaTypeHeaderValue? contentType, ContentCodecContext context) + { + return typeof(T) == typeof(string) + ? (T)(object)context.DecodeText(content, contentType).ToUpperInvariant() + : default; + } + + /// + public HttpContent Serialize(T value, MediaTypeHeaderValue contentType, ContentCodecContext context) + { + return context.CreateTextContent(value?.ToString()?.ToUpperInvariant() ?? string.Empty, contentType); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/ContentCodecTests.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/ContentCodecTests.cs new file mode 100644 index 0000000..05eefba --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/ContentCodecTests.cs @@ -0,0 +1,137 @@ +using AMDevIT.Restling.Core; +using AMDevIT.Restling.Core.Codecs; +using AMDevIT.Restling.Csv; +using AMDevIT.Restling.Tests.Codecs; +using AMDevIT.Restling.Tests.Models; +using System.Net; +using System.Net.Http.Headers; +using System.Text; + +namespace AMDevIT.Restling.Tests +{ + [TestClass] + public sealed class ContentCodecTests + { + #region Methods + + /// Verifies the codecs available without explicit configuration. + [TestMethod] + public void DefaultRegistryContainsBackwardCompatibleCodecs() + { + ContentCodecRegistry registry = new(); + + Assert.IsInstanceOfType(registry.FindReader("application/json")); + Assert.IsInstanceOfType(registry.FindReader("application/hal+json")); + Assert.IsInstanceOfType(registry.FindReader("application/xml")); + Assert.IsInstanceOfType(registry.FindReader("text/plain")); + Assert.IsInstanceOfType(registry.FindReader("multipart/mixed")); + Assert.IsInstanceOfType(registry.FindReader("image/png")); + Assert.IsInstanceOfType(registry.FindWriter("image/png")); + Assert.IsNull(registry.Codecs.OfType().SingleOrDefault()); + Assert.IsNull(registry.Codecs.OfType().SingleOrDefault()); + Assert.IsInstanceOfType(registry.FindReader("application/problem+json")); + } + + /// Verifies first-match priority for custom codecs. + [TestMethod] + public void EarlierCustomCodecOverridesDefault() + { + ContentCodecRegistry registry = new ContentCodecRegistry().WithCodec(new UpperCaseContentCodec()); + IContentCodec codec = registry.FindReader("text/plain")!; + string? result = codec.Deserialize(Encoding.UTF8.GetBytes("hello"), + new MediaTypeHeaderValue("text/plain"), + new ContentCodecContext()); + + Assert.AreEqual("HELLO", result); + } + + /// Verifies the built-in structured text codecs. + [TestMethod] + public void JsonAndXmlRoundTripModels() + { + CodecTestModel model = new() { Id = 7, Name = "Restling" }; + ContentCodecContext context = new(); + + this.AssertRoundTrip(new JsonContentCodec(), model, "application/json", context); + this.AssertRoundTrip(new XmlContentCodec(), model, "application/xml", context); + } + + /// Verifies optional CSV list serialization and deserialization. + [TestMethod] + public void CsvRoundTripsLists() + { + List models = [new CodecTestModel { Id = 7, Name = "Restling" }]; + ContentCodecContext context = new(); + CsvContentCodec codec = new(); + MediaTypeHeaderValue mediaType = new("text/csv"); + HttpContent encoded = codec.Serialize(models, mediaType, context); + byte[] bytes = encoded.ReadAsByteArrayAsync().GetAwaiter().GetResult(); + List? decoded = codec.Deserialize>(bytes, mediaType, context); + + Assert.IsNotNull(decoded); + Assert.AreEqual(1, decoded.Count); + Assert.AreEqual(7, decoded[0].Id); + Assert.AreEqual("Restling", decoded[0].Name); + } + + /// Verifies RFC 9457 member validation and extensions. + [TestMethod] + public void ProblemCodecPreservesExtensionsAndIgnoresWrongStandardTypes() + { + const string json = "{\"type\":42,\"title\":\"Invalid\",\"status\":400,\"trace_id\":\"abc\"}"; + ProblemDetailsJsonCodec codec = new(); + RestProblemDetails? problem = codec.DeserializeProblem(Encoding.UTF8.GetBytes(json), + new MediaTypeHeaderValue("application/problem+json"), + new ContentCodecContext()); + + Assert.IsNotNull(problem); + Assert.AreEqual("about:blank", problem.Type); + Assert.AreEqual("Invalid", problem.Title); + Assert.AreEqual(400, problem.Status); + Assert.AreEqual("abc", problem.Extensions["trace_id"].GetString()); + } + +#if DEBUG + /// Verifies that a problem document does not populate the success model. + [TestMethod] + public async Task ParserKeepsProblemSeparateFromSuccessData() + { + HttpResponseMessage response = new(HttpStatusCode.BadRequest) + { + Content = new StringContent("{\"title\":\"Invalid\",\"status\":400}", Encoding.UTF8, "application/problem+json") + }; + HttpResponseParser parser = new(null) + { + Codecs = new ContentCodecRegistry().WithCodec(new ProblemDetailsJsonCodec()) + }; + RestRequest request = new("https://example.test", Core.HttpMethod.Get); + RestRequestResult result = await parser.DecodeAsync(response, + request, + TimeSpan.Zero); + + Assert.IsNull(result.Data); + Assert.IsNotNull(result.Problem); + Assert.AreEqual("Invalid", result.Problem.Title); + Assert.AreEqual(HttpStatusCode.BadRequest, result.StatusCode); + } +#endif + + /// Verifies a codec through its public request/response contract. + private void AssertRoundTrip(IContentCodec codec, + CodecTestModel model, + string mediaType, + ContentCodecContext context) + { + MediaTypeHeaderValue contentType = new(mediaType); + HttpContent encoded = codec.Serialize(model, contentType, context); + byte[] bytes = encoded.ReadAsByteArrayAsync().GetAwaiter().GetResult(); + CodecTestModel? decoded = codec.Deserialize(bytes, contentType, context); + + Assert.IsNotNull(decoded); + Assert.AreEqual(model.Id, decoded.Id); + Assert.AreEqual(model.Name, decoded.Name); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/CookieBuilderTests.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/CookieBuilderTests.cs new file mode 100644 index 0000000..a4d23a2 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/CookieBuilderTests.cs @@ -0,0 +1,204 @@ +using AMDevIT.Restling.Core; +using AMDevIT.Restling.Core.Cookies; +using AMDevIT.Restling.Core.Network.Builders; +using AMDevIT.Restling.Tests.Cookies; +using AMDevIT.Restling.Tests.Multipart; +using System.Net; + +namespace AMDevIT.Restling.Tests +{ + [TestClass] + public sealed class CookieBuilderTests + { + #region Methods + + /// Adopts native cookies and preserves an explicitly disabled native cookie policy. + [TestMethod] + [DataRow(false, false)] + [DataRow(false, true)] + [DataRow(true, false)] + [DataRow(true, true)] + public void NativeContainerAndSettingsArePreserved(bool useHttpClientHandler, bool useCookies) + { + Uri uri = new("http://example.test/"); + CookieContainer cookies = new(); + cookies.Add(uri, new Cookie("existing", "kept", "/")); + using HttpMessageHandler handler = CreateHandler(useHttpClientHandler, cookies, useCookies); + HttpClientContextBuilder builder = new(); + builder.AddHandler(handler).AddCookie(new HttpCookieData("added", "value", "example.test", "/")); + using HttpClientContext context = builder.Build(); + + Assert.AreSame(cookies, context.CookieContainer); + Assert.AreSame(cookies, NativeContainer(handler)); + Assert.AreEqual("kept", context.CookieContainer.GetCookies(uri)["existing"]?.Value); + Assert.AreEqual("value", cookies.GetCookies(uri)["added"]?.Value); + Assert.AreEqual(useCookies, NativeUseCookies(handler)); + Assert.IsFalse(handler is HttpClientHandler httpHandler ? httpHandler.AllowAutoRedirect : ((SocketsHttpHandler)handler).AllowAutoRedirect); + Assert.AreEqual(HttpClientContextOwnership.HttpClient, context.Ownership); + } + + /// Explicit cookie containers enable native cookie handling regardless of builder call order. + [TestMethod] + [DataRow(false, false)] + [DataRow(false, true)] + [DataRow(true, false)] + [DataRow(true, true)] + public void ExplicitContainerEnablesCookiesInEitherOrder(bool useHttpClientHandler, bool containerFirst) + { + CookieContainer cookies = new(); + using HttpMessageHandler handler = CreateHandler(useHttpClientHandler, new CookieContainer(), false); + HttpClientContextBuilder builder = new(); + if (containerFirst) + builder.AddCookieContainer(cookies).AddHandler(handler); + else + builder.AddHandler(handler).AddCookieContainer(cookies); + using HttpClientContext context = builder.Build(); + + Assert.AreSame(cookies, NativeContainer(handler)); + Assert.AreSame(cookies, context.CookieContainer); + Assert.IsTrue(NativeUseCookies(handler)); + } + + /// Newly configured handlers share an explicit container before and after the callback. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void ConfigureHandlerSharesExplicitContainer(bool containerFirst) + { + CookieContainer cookies = new(); + HttpClientContextBuilder builder = new(); + if (containerFirst) + builder.AddCookieContainer(cookies); + builder.ConfigureHandler(handler => + { + SocketsHttpHandler native = (SocketsHttpHandler)handler; + if (containerFirst) + Assert.AreSame(cookies, native.CookieContainer); + native.AllowAutoRedirect = false; + }); + if (!containerFirst) + builder.AddCookieContainer(cookies); + using HttpClientContext context = builder.Build(); + + Assert.AreSame(cookies, context.CookieContainer); + Assert.AreSame(cookies, NativeContainer(context.HttpMessageHandler)); + Assert.IsFalse(((SocketsHttpHandler)context.HttpMessageHandler).AllowAutoRedirect); + Assert.AreEqual(HttpClientContextOwnership.All, context.Ownership); + } + + /// Build does not undo a deliberate UseCookies change made after selecting a container. + [TestMethod] + public void ConfigureHandlerCanDisableExplicitCookies() + { + CookieContainer cookies = new(); + HttpClientContextBuilder builder = new(); + builder.AddCookieContainer(cookies).ConfigureHandler(handler => ((SocketsHttpHandler)handler).UseCookies = false); + using HttpClientContext context = builder.Build(); + + Assert.AreSame(cookies, context.CookieContainer); + Assert.IsFalse(NativeUseCookies(context.HttpMessageHandler)); + } + + /// Replacing a handler without an explicit jar adopts the replacement's own cookies. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void ReplacingNativeHandlerDoesNotOverwriteItsContainer(bool useHttpClientHandler) + { + CookieContainer firstCookies = new(); + CookieContainer secondCookies = new(); + using HttpMessageHandler first = CreateHandler(useHttpClientHandler, firstCookies, true); + using HttpMessageHandler second = CreateHandler(useHttpClientHandler, secondCookies, true); + HttpClientContextBuilder builder = new(); + using HttpClientContext firstContext = builder.AddHandler(first).Build(); + using HttpClientContext secondContext = builder.AddHandler(second).Build(); + + Assert.AreSame(firstCookies, firstContext.CookieContainer); + Assert.AreSame(secondCookies, secondContext.CookieContainer); + Assert.AreSame(secondCookies, NativeContainer(second)); + } + + /// A fallback jar for a custom handler must not replace a later native handler's cookies. + [TestMethod] + public void CustomHandlerFallbackIsNotAnExplicitContainer() + { + CookieContainer nativeCookies = new(); + using RecordingMessageHandler custom = new((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + using SocketsHttpHandler native = new() { CookieContainer = nativeCookies }; + HttpClientContextBuilder builder = new(); + using HttpClientContext customContext = builder.AddHandler(custom).Build(); + using HttpClientContext nativeContext = builder.AddHandler(native).Build(); + + Assert.AreSame(nativeCookies, nativeContext.CookieContainer); + Assert.AreNotSame(customContext.CookieContainer, nativeContext.CookieContainer); + } + + /// Clearing the cookie container selects a fresh native jar without erasing the old jar. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void ClearCookieContainerSelectsAFreshJar(bool useHttpClientHandler) + { + CookieContainer existing = new(); + existing.Add(new Uri("http://example.test/"), new Cookie("session", "kept", "/")); + using HttpMessageHandler handler = CreateHandler(useHttpClientHandler, existing, true); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).ClearCookieContainer().Build(); + + Assert.AreNotSame(existing, context.CookieContainer); + Assert.AreSame(context.CookieContainer, NativeContainer(handler)); + Assert.AreEqual(0, context.CookieContainer.Count); + Assert.AreEqual(1, existing.Count); + } + + /// Repeated builds can reuse an active handler without resetting its container or cookie state. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task RebuildingWithAnActiveHandlerPreservesCookies(bool useHttpClientHandler) + { + CookieContainer cookies = new(); + await using LoopbackCookieServer server = new(LoopbackCookieServer.Response(200, "Set-Cookie: session=retained; Path=/"), + LoopbackCookieServer.Response(200)); + using HttpMessageHandler handler = CreateHandler(useHttpClientHandler, cookies, true); + HttpClientContextBuilder builder = new(); + builder.AddCookieContainer(cookies).AddHandler(handler).SetTimeout(TimeSpan.FromSeconds(10)); + using (HttpClientContext firstContext = builder.Build()) + using (RestlingClient firstClient = new(firstContext)) + { + RestRequestResult initial = await firstClient.GetAsync(server.BaseUri.AbsoluteUri); + Assert.IsTrue(initial.IsSuccessful, initial.Exception?.ToString()); + } + + using HttpClientContext nextContext = builder.Build(); + using RestlingClient nextClient = new(nextContext); + RestRequestResult result = await nextClient.GetAsync(server.BaseUri.AbsoluteUri); + IReadOnlyList requests = await server.Requests; + + Assert.IsTrue(result.IsSuccessful, result.Exception?.ToString()); + Assert.AreSame(cookies, nextContext.CookieContainer); + StringAssert.Contains(requests[1].Headers["Cookie"], "session=retained"); + } + + /// Creates a native handler with explicit settings for preservation checks. + private static HttpMessageHandler CreateHandler(bool useHttpClientHandler, CookieContainer cookies, bool useCookies) + { + return useHttpClientHandler + ? new HttpClientHandler { CookieContainer = cookies, UseCookies = useCookies, AllowAutoRedirect = false, UseProxy = false } + : new SocketsHttpHandler { CookieContainer = cookies, UseCookies = useCookies, AllowAutoRedirect = false, UseProxy = false }; + } + + /// Reads the actual cookie jar used by a native handler. + private static CookieContainer NativeContainer(HttpMessageHandler handler) + { + return handler is HttpClientHandler httpHandler ? httpHandler.CookieContainer : ((SocketsHttpHandler)handler).CookieContainer; + } + + /// Reads the actual native cookie enablement setting. + private static bool NativeUseCookies(HttpMessageHandler handler) + { + return handler is HttpClientHandler httpHandler ? httpHandler.UseCookies : ((SocketsHttpHandler)handler).UseCookies; + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/CookiePersistenceTests.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/CookiePersistenceTests.cs new file mode 100644 index 0000000..2757217 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/CookiePersistenceTests.cs @@ -0,0 +1,194 @@ +using AMDevIT.Restling.Core; +using AMDevIT.Restling.Core.Cookies; +using AMDevIT.Restling.Core.Network; +using AMDevIT.Restling.Core.Network.Builders; +using AMDevIT.Restling.Tests.Cookies; +using System.Net; +using System.Text; + +namespace AMDevIT.Restling.Tests +{ + [TestClass] + public sealed class CookiePersistenceTests + { + #region Methods + + /// Verifies seeded and response cookies across POST/PUT calls and recreated clients. + [TestMethod] + [DataRow("default", "POST")] + [DataRow("default", "PUT")] + [DataRow("sockets", "POST")] + [DataRow("sockets", "PUT")] + [DataRow("http-client", "POST")] + [DataRow("http-client", "PUT")] + [DataRow("configured", "POST")] + [DataRow("configured", "PUT")] + public async Task CookiesSurviveConsecutiveCallsAndClientRecreation(string mode, string method) + { + await using LoopbackCookieServer server = new(LoopbackCookieServer.Response(200, "Set-Cookie: session=first; Path=/; HttpOnly"), + LoopbackCookieServer.Response(200, "Set-Cookie: session=second; Path=/; HttpOnly"), + LoopbackCookieServer.Response(200)); + using HttpClientContext context = CreateContext(mode); + context.HttpClient.Timeout = TimeSpan.FromSeconds(10); + using (RestlingClient firstClient = new(context)) + { + RestRequestResult initial = await firstClient.GetAsync(new Uri(server.BaseUri, "start").AbsoluteUri); + Assert.IsTrue(initial.IsSuccessful, initial.Exception?.ToString()); + } + + using RestlingClient nextClient = new(context); + RequestHeaders headers = new(); + headers.Headers.Add("X-Call", "next"); + RestRequestResult next = method == "POST" + ? await nextClient.PostAsync(new Uri(server.BaseUri, "next").AbsoluteUri, "payload", headers) + : await nextClient.PutAsync(new Uri(server.BaseUri, "next").AbsoluteUri, "payload", headers); + RestRequestResult last = await nextClient.GetAsync(new Uri(server.BaseUri, "last").AbsoluteUri); + IReadOnlyList requests = await server.Requests; + + Assert.IsTrue(next.IsSuccessful, next.Exception?.ToString()); + Assert.IsTrue(last.IsSuccessful, last.Exception?.ToString()); + StringAssert.Contains(CookieHeader(requests[0]), "seed=configured"); + StringAssert.Contains(CookieHeader(requests[1]), "session=first"); + StringAssert.Contains(CookieHeader(requests[2]), "session=second"); + StringAssert.Contains(CookieHeader(requests[2]), "seed=configured"); + Assert.AreEqual(method, requests[1].Method); + Assert.AreEqual("next", requests[1].Headers["X-Call"]); + Assert.AreEqual("\"payload\"", Encoding.UTF8.GetString(requests[1].Body)); + Assert.AreEqual("second", context.CookieContainer.GetCookies(server.BaseUri)["session"]?.Value); + } + + /// Verifies that cookies from a redirect are stored before a caller follows its location. + [TestMethod] + [DataRow(301)] + [DataRow(302)] + [DataRow(303)] + [DataRow(307)] + [DataRow(308)] + public async Task ManualRedirectPreservesResponseCookies(int statusCode) + { + await using LoopbackCookieServer server = new(LoopbackCookieServer.Response(statusCode, + "Location: /follow", + "Set-Cookie: redirected=yes; Path=/"), + LoopbackCookieServer.Response(200)); + using HttpClientContext context = CreateContext("default"); + using RestlingClient client = new(context); + + RestRequestResult redirect = await client.GetAsync(server.BaseUri.AbsoluteUri); + + Assert.AreEqual((HttpStatusCode)statusCode, redirect.StatusCode); + Assert.AreEqual("/follow", redirect.ResponseHeaders.RedirectLocation); + Assert.AreEqual("yes", context.CookieContainer.GetCookies(server.BaseUri)["redirected"]?.Value); + + RestRequestResult followed = await client.GetAsync(new Uri(server.BaseUri, redirect.ResponseHeaders.RedirectLocation!).AbsoluteUri); + IReadOnlyList requests = await server.Requests; + + Assert.IsTrue(followed.IsSuccessful, followed.Exception?.ToString()); + Assert.AreEqual("/follow", requests[1].Target); + StringAssert.Contains(CookieHeader(requests[1]), "redirected=yes"); + } + + /// Verifies cookie storage at every automatic redirect hop regardless of builder call order. + [TestMethod] + [DataRow(false, false)] + [DataRow(false, true)] + [DataRow(true, false)] + [DataRow(true, true)] + public async Task AutomaticRedirectSharesExplicitCookieContainer(bool useHttpClientHandler, bool containerFirst) + { + CookieContainer cookies = new(); + HttpClientContextBuilder builder = new(); + await using LoopbackCookieServer server = new(LoopbackCookieServer.Response(302, "Location: /follow", "Set-Cookie: hop=redirect; Path=/"), + LoopbackCookieServer.Response(200, "Set-Cookie: final=received; Path=/"), + LoopbackCookieServer.Response(200)); + cookies.Add(server.BaseUri, new Cookie("seed", "shared", "/")); + using HttpMessageHandler handler = useHttpClientHandler + ? new HttpClientHandler { AllowAutoRedirect = true, UseProxy = false } + : new SocketsHttpHandler { AllowAutoRedirect = true, UseProxy = false }; + if (containerFirst) + builder.AddCookieContainer(cookies).AddHandler(handler); + else + builder.AddHandler(handler).AddCookieContainer(cookies); + using HttpClientContext context = builder.Build(); + context.HttpClient.Timeout = TimeSpan.FromSeconds(10); + using RestlingClient client = new(context); + + RestRequestResult redirected = await client.GetAsync(server.BaseUri.AbsoluteUri); + RestRequestResult subsequent = await client.GetAsync(new Uri(server.BaseUri, "last").AbsoluteUri); + IReadOnlyList requests = await server.Requests; + + Assert.IsTrue(redirected.IsSuccessful, redirected.Exception?.ToString()); + Assert.IsTrue(subsequent.IsSuccessful, subsequent.Exception?.ToString()); + Assert.AreEqual("/follow", requests[1].Target); + StringAssert.Contains(CookieHeader(requests[0]), "seed=shared"); + StringAssert.Contains(CookieHeader(requests[1]), "hop=redirect"); + StringAssert.Contains(CookieHeader(requests[2]), "final=received"); + StringAssert.Contains(CookieHeader(requests[2]), "hop=redirect"); + Assert.AreSame(cookies, context.CookieContainer); + Assert.AreEqual("redirect", cookies.GetCookies(server.BaseUri)["hop"]?.Value); + Assert.AreEqual("received", cookies.GetCookies(server.BaseUri)["final"]?.Value); + } + + /// Verifies that native cookie domain/path/security and deletion rules are not bypassed. + [TestMethod] + public async Task CookieScopeAndDeletionAreRespected() + { + await using LoopbackCookieServer server = new(LoopbackCookieServer.Response(200, + "Set-Cookie: private=restricted; Path=/private", + "Set-Cookie: secure=secret; Path=/; Secure", + "Set-Cookie: session=temporary; Path=/"), + LoopbackCookieServer.Response(200, "Set-Cookie: session=deleted; Path=/; Max-Age=0"), + LoopbackCookieServer.Response(200)); + using HttpClientContext context = CreateContext("default"); + context.CookieContainer.Add(new Uri("http://unrelated.test/"), new Cookie("foreign", "secret", "/")); + using RestlingClient client = new(context); + + Assert.IsTrue((await client.GetAsync(server.BaseUri.AbsoluteUri)).IsSuccessful); + Assert.IsTrue((await client.GetAsync(new Uri(server.BaseUri, "public").AbsoluteUri)).IsSuccessful); + Assert.IsTrue((await client.GetAsync(new Uri(server.BaseUri, "private/resource").AbsoluteUri)).IsSuccessful); + IReadOnlyList requests = await server.Requests; + + StringAssert.Contains(CookieHeader(requests[1]), "session=temporary"); + Assert.IsFalse(CookieHeader(requests[1]).Contains("private=", StringComparison.Ordinal)); + StringAssert.Contains(CookieHeader(requests[2]), "private=restricted"); + Assert.IsFalse(CookieHeader(requests[2]).Contains("session=", StringComparison.Ordinal)); + foreach (LoopbackCookieServer.Request request in requests) + { + Assert.IsFalse(CookieHeader(request).Contains("secure=", StringComparison.Ordinal)); + Assert.IsFalse(CookieHeader(request).Contains("foreign=", StringComparison.Ordinal)); + } + } + + /// Creates a seeded context using the supported handler construction paths. + private static HttpClientContext CreateContext(string mode) + { + HttpClientContextBuilder builder = new(); + builder.AddCookie(new HttpCookieData("seed", "configured", "127.0.0.1", "/")); + switch (mode) + { + case "sockets": + builder.AddHandler(new SocketsHttpHandler { UseProxy = false }, HttpMessageHandlerOwnership.Owned); + break; + case "http-client": + builder.AddHandler(new HttpClientHandler { UseProxy = false }, HttpMessageHandlerOwnership.Owned); + break; + case "configured": + builder.ConfigureHandler(handler => ((SocketsHttpHandler)handler).UseProxy = false); + break; + } + + HttpClientContext context = builder.Build(); + if (mode == "default") + ((SocketsHttpHandler)context.HttpMessageHandler).UseProxy = false; + context.HttpClient.Timeout = TimeSpan.FromSeconds(10); + return context; + } + + /// Returns the cookie header observed by the real loopback server. + private static string CookieHeader(LoopbackCookieServer.Request request) + { + return request.Headers.TryGetValue("Cookie", out string? value) ? value : string.Empty; + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Cookies/LoopbackCookieServer.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Cookies/LoopbackCookieServer.cs new file mode 100644 index 0000000..9bd064a --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Cookies/LoopbackCookieServer.cs @@ -0,0 +1,133 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace AMDevIT.Restling.Tests.Cookies +{ + /// Serves a bounded HTTP script over loopback so tests exercise real transport cookie handling. + internal sealed class LoopbackCookieServer : IAsyncDisposable + { + #region Fields + + private readonly TcpListener listener; + private readonly CancellationTokenSource lifetime = new(TimeSpan.FromSeconds(15)); + + #endregion + + #region Properties + + public Uri BaseUri { get; } + public Task> Requests { get; } + + #endregion + + #region .ctor + + /// Binds an ephemeral loopback port and starts serving one response per connection. + public LoopbackCookieServer(params string[] responses) + { + this.listener = new TcpListener(IPAddress.Loopback, 0); + this.listener.Start(); + this.BaseUri = new Uri($"http://127.0.0.1:{((IPEndPoint)this.listener.LocalEndpoint).Port}/"); + this.Requests = this.ServeAsync(responses); + } + + #endregion + + #region Methods + + /// Builds a small response with separate header lines and a known body length. + public static string Response(int statusCode, params string[] headers) + { + return ResponseWithBody(statusCode, "ok", "text/plain; charset=utf-8", headers); + } + + /// Builds a small response with a caller-provided body and content type. + public static string ResponseWithBody(int statusCode, string body, string contentType, params string[] headers) + { + byte[] bodyBytes = Encoding.UTF8.GetBytes(body); + return $"HTTP/1.1 {statusCode} Test\r\n" + + string.Concat(headers.Select(header => header + "\r\n")) + + $"Content-Type: {contentType}\r\nContent-Length: {bodyBytes.Length}\r\nConnection: close\r\n\r\n{body}"; + } + + /// Cancels pending accepts and closes the listener even when a test fails early. + public async ValueTask DisposeAsync() + { + this.lifetime.Cancel(); + this.listener.Stop(); + try + { + await this.Requests; + } + catch (OperationCanceledException) + { + } + catch (SocketException) when (this.lifetime.IsCancellationRequested) + { + } + catch (ObjectDisposedException) when (this.lifetime.IsCancellationRequested) + { + } + finally + { + this.lifetime.Dispose(); + } + } + + /// Records requests and emits the configured responses without using external services. + private async Task> ServeAsync(string[] responses) + { + List requests = []; + + foreach (string response in responses) + { + using TcpClient connection = await this.listener.AcceptTcpClientAsync(this.lifetime.Token); + using NetworkStream stream = connection.GetStream(); + requests.Add(await ReadRequestAsync(stream, this.lifetime.Token)); + await stream.WriteAsync(Encoding.ASCII.GetBytes(response), this.lifetime.Token); + } + + return requests; + } + + /// Reads bounded HTTP headers and any fixed-length body sent by these tests. + private static async Task ReadRequestAsync(NetworkStream stream, CancellationToken cancellationToken) + { + List bytes = []; + byte[] next = new byte[1]; + Dictionary headers = new(StringComparer.OrdinalIgnoreCase); + + while (bytes.Count < 32768) + { + await stream.ReadExactlyAsync(next, cancellationToken); + bytes.Add(next[0]); + if (bytes.Count >= 4 && bytes[^4] == '\r' && bytes[^3] == '\n' && bytes[^2] == '\r' && bytes[^1] == '\n') + break; + } + + if (bytes.Count >= 32768) + throw new InvalidDataException("Loopback request headers exceeded the test limit."); + + string[] lines = Encoding.ASCII.GetString(bytes.ToArray()).Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + string[] requestLine = lines[0].Split(' '); + foreach (string line in lines.Skip(1)) + { + int separator = line.IndexOf(':'); + headers.Add(line[..separator], line[(separator + 1)..].Trim()); + } + + int length = headers.TryGetValue("Content-Length", out string? contentLength) ? int.Parse(contentLength) : 0; + if (length < 0 || length > 65536 || headers.ContainsKey("Transfer-Encoding")) + throw new InvalidDataException("Unsupported body framing for a loopback test request."); + byte[] body = new byte[length]; + await stream.ReadExactlyAsync(body, cancellationToken); + return new Request(requestLine[0], requestLine[1], headers, body); + } + + #endregion + + /// A captured request, independent of the lifetime of its TCP connection. + public sealed record Request(string Method, string Target, IReadOnlyDictionary Headers, byte[] Body); + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/HttpPipelineCompatibilityTests.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/HttpPipelineCompatibilityTests.cs new file mode 100644 index 0000000..5b919e8 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/HttpPipelineCompatibilityTests.cs @@ -0,0 +1,397 @@ +using AMDevIT.Restling.Core; +using AMDevIT.Restling.Core.Network; +using AMDevIT.Restling.Core.Network.Builders; +using AMDevIT.Restling.Core.Serialization; +using AMDevIT.Restling.Tests.Models; +using AMDevIT.Restling.Tests.Multipart; +using AMDevIT.Restling.Tests.Pipeline; +using System.Net; +using System.Net.Http.Headers; +using System.Text; + +namespace AMDevIT.Restling.Tests +{ + [TestClass] + public sealed class HttpPipelineCompatibilityTests + { + #region Methods + + /// Verifies the historical verb, URI and untyped result contract of direct bodyless methods. + [TestMethod] + [DataRow("GET")] + [DataRow("DELETE")] + public async Task DirectBodylessMethodsPreserveRequestAndResult(string method) + { + const string uri = "https://example.test/resource?value=7"; + string? capturedMethod = null; + Uri? capturedUri = null; + using RecordingMessageHandler handler = new((request, _) => + { + capturedMethod = request.Method.Method; + capturedUri = request.RequestUri; + return Task.FromResult(CreateTextResponse(HttpStatusCode.Accepted, "historic")); + }); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + using RestlingClient client = new(context) + { + SelectedDefaultSerializationLibrary = PayloadJsonSerializerLibrary.NewtonsoftJson + }; + RestRequestResult result = method == "GET" + ? await client.GetAsync(uri) + : await client.DeleteAsync(uri); + + Assert.AreEqual(method, capturedMethod); + Assert.AreEqual(uri, capturedUri?.AbsoluteUri); + Assert.AreEqual(HttpStatusCode.Accepted, result.StatusCode); + Assert.AreEqual("historic", result.Content); + Assert.IsTrue(result.IsSuccessful); + Assert.IsNull(result.Exception); + Assert.IsNull(result.Request.ForcePayloadJsonSerializerLibrary); + } + + /// Verifies the historical JSON payload and typed result contract of direct POST and PUT methods. + [TestMethod] + [DataRow("POST")] + [DataRow("PUT")] + public async Task DirectPayloadMethodsPreserveRequestAndTypedResult(string method) + { + const string uri = "https://example.test/resource"; + string? capturedBody = null; + string? capturedMethod = null; + string? capturedMediaType = null; + using RecordingMessageHandler handler = new(async (request, cancellationToken) => + { + capturedMethod = request.Method.Method; + capturedMediaType = request.Content?.Headers.ContentType?.MediaType; + capturedBody = await request.Content!.ReadAsStringAsync(cancellationToken); + return CreateJsonResponse(HttpStatusCode.OK, "{\"Id\":7,\"Name\":\"response\"}"); + }); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + using RestlingClient client = new(context); + CodecTestModel payload = new() { Id = 3, Name = "payload" }; + RestRequestResult result = method == "POST" + ? await client.PostAsync(uri, payload) + : await client.PutAsync(uri, payload); + + Assert.AreEqual(method, capturedMethod); + Assert.AreEqual("application/json", capturedMediaType); + StringAssert.Contains(capturedBody, "\"Id\":3"); + StringAssert.Contains(capturedBody, "\"Name\":\"payload\""); + Assert.AreEqual(HttpStatusCode.OK, result.StatusCode); + Assert.AreEqual(7, result.Data?.Id); + Assert.AreEqual("response", result.Data?.Name); + Assert.IsTrue(result.IsSuccessful); + } + + /// Verifies that request headers and authentication survive the centralized path. + [TestMethod] + public async Task HeaderOverloadsPreserveCustomAndAuthenticationHeaders() + { + string? authorization = null; + string? customHeader = null; + using RecordingMessageHandler handler = new((request, _) => + { + authorization = request.Headers.Authorization?.ToString(); + customHeader = request.Headers.GetValues("X-History").Single(); + return Task.FromResult(CreateTextResponse(HttpStatusCode.OK, "ok")); + }); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + using RestlingClient client = new(context); + RequestHeaders headers = new(new AuthenticationHeader("Bearer", "token")); + headers.Headers.Add("X-History", "kept"); + + RestRequestResult result = await client.GetAsync("https://example.test/headers", headers); + + Assert.AreEqual("Bearer token", authorization); + Assert.AreEqual("kept", customHeader); + Assert.IsTrue(result.IsSuccessful); + } + + /// Verifies that buffered send failures remain results rather than escaping as exceptions. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task SendFailureRemainsAnUnsuccessfulResult(bool typed) + { + HttpRequestException expected = new("network unavailable"); + using RecordingMessageHandler handler = new((_, _) => Task.FromException(expected)); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + using RestlingClient client = new(context); + + RestRequestResult result = typed + ? await client.GetAsync("https://example.test/failure") + : await client.GetAsync("https://example.test/failure"); + + Assert.AreSame(expected, result.Exception); + Assert.IsFalse(result.IsSuccessful); + Assert.IsNull(result.StatusCode); + Assert.IsTrue(result.Elapsed >= TimeSpan.Zero); + } + + /// Verifies that buffered cancellation retains the historical result-based contract. + [TestMethod] + [DataRow(false, false)] + [DataRow(false, true)] + [DataRow(true, false)] + [DataRow(true, true)] + public async Task CancellationRemainsAnUnsuccessfulResult(bool typed, bool cancelBeforeSend) + { + using CancellationTokenSource cancellationTokenSource = new(); + if (cancelBeforeSend) + cancellationTokenSource.Cancel(); + using RecordingMessageHandler handler = new((_, cancellationToken) => + { + cancellationTokenSource.Cancel(); + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + using RestlingClient client = new(context); + + RestRequestResult result = typed + ? await client.GetAsync("https://example.test/cancelled", cancellationToken: cancellationTokenSource.Token) + : await client.GetAsync("https://example.test/cancelled", cancellationTokenSource.Token); + + Assert.IsInstanceOfType(result.Exception); + Assert.IsFalse(result.IsSuccessful); + Assert.IsNull(result.StatusCode); + } + + /// Verifies that finite responses are disposed after their content has been decoded. + [TestMethod] + public async Task FiniteResponseIsDisposedAfterDecode() + { + TrackingResponseContent content = new(Encoding.UTF8.GetBytes("historic")); + content.Headers.ContentType = new MediaTypeHeaderValue("text/plain") { CharSet = "utf-8" }; + using RecordingMessageHandler handler = new((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = content + })); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + using RestlingClient client = new(context); + + RestRequestResult result = await client.GetAsync("https://example.test/disposal"); + + Assert.AreEqual("historic", result.Data); + Assert.IsTrue(content.Disposed); + } + + /// Verifies direct overload precedence between an explicit serializer and the client default. + [TestMethod] + [DataRow("GET")] + [DataRow("DELETE")] + [DataRow("POST")] + [DataRow("PUT")] + public async Task DirectTypedOverloadKeepsExplicitSerializerPrecedence(string method) + { + const string uri = "https://example.test/serializer"; + const PayloadJsonSerializerLibrary serializer = PayloadJsonSerializerLibrary.SystemTextJson; + SerializerSelectionModel payload = new() { Name = "payload" }; + string? body = null; + using RecordingMessageHandler handler = new(async (request, token) => + { + body = request.Content == null ? null : await request.Content.ReadAsStringAsync(token); + return CreateJsonResponse(HttpStatusCode.OK, "{\"system_name\":\"system\",\"newtonsoft_name\":\"newtonsoft\"}"); + }); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + using RestlingClient client = new(context) + { + SelectedDefaultSerializationLibrary = PayloadJsonSerializerLibrary.NewtonsoftJson + }; + + RestRequestResult result = method switch + { + "GET" => await client.GetAsync(uri, serializer), + "DELETE" => await client.DeleteAsync(uri, serializer), + "POST" => await client.PostAsync(uri, payload, serializer), + "PUT" => await client.PutAsync(uri, payload, serializer), + _ => throw new ArgumentOutOfRangeException(nameof(method)) + }; + + Assert.AreEqual("system", result.Data?.Name); + Assert.AreEqual(serializer, result.Request.ForcePayloadJsonSerializerLibrary); + if (method is "POST" or "PUT") + Assert.AreEqual("{\"system_name\":\"payload\"}", body); + } + + /// Verifies the historical client-default precedence of typed header overloads. + [TestMethod] + [DataRow("GET")] + [DataRow("DELETE")] + [DataRow("POST")] + [DataRow("PUT")] + public async Task HeaderTypedOverloadKeepsClientDefaultSerializerPrecedence(string method) + { + const string uri = "https://example.test/serializer"; + const PayloadJsonSerializerLibrary serializer = PayloadJsonSerializerLibrary.SystemTextJson; + SerializerSelectionModel payload = new() { Name = "payload" }; + RequestHeaders headers = new(); + string? body = null; + using RecordingMessageHandler handler = new(async (request, token) => + { + body = request.Content == null ? null : await request.Content.ReadAsStringAsync(token); + return CreateJsonResponse(HttpStatusCode.OK, "{\"system_name\":\"system\",\"newtonsoft_name\":\"newtonsoft\"}"); + }); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + using RestlingClient client = new(context) + { + SelectedDefaultSerializationLibrary = PayloadJsonSerializerLibrary.NewtonsoftJson + }; + + RestRequestResult result = method switch + { + "GET" => await client.GetAsync(uri, headers, serializer), + "DELETE" => await client.DeleteAsync(uri, headers, serializer), + "POST" => await client.PostAsync(uri, payload, headers, serializer), + "PUT" => await client.PutAsync(uri, payload, headers, serializer), + _ => throw new ArgumentOutOfRangeException(nameof(method)) + }; + + Assert.AreEqual("newtonsoft", result.Data?.Name); + Assert.AreEqual(PayloadJsonSerializerLibrary.NewtonsoftJson, result.Request.ForcePayloadJsonSerializerLibrary); + if (method is "POST" or "PUT") + Assert.AreEqual("{\"system_name\":\"payload\"}", body); + } + + /// Verifies that untyped header overloads send their payload without decoding a response model. + [TestMethod] + [DataRow("POST", PayloadJsonSerializerLibrary.SystemTextJson)] + [DataRow("POST", PayloadJsonSerializerLibrary.NewtonsoftJson)] + [DataRow("PUT", PayloadJsonSerializerLibrary.SystemTextJson)] + [DataRow("PUT", PayloadJsonSerializerLibrary.NewtonsoftJson)] + public async Task UntypedHeaderPayloadOverloadsSendBody(string method, PayloadJsonSerializerLibrary serializer) + { + const string uri = "https://example.test/payload"; + string? body = null; + string? contentType = null; + string? authorization = null; + string? header = null; + string? verb = null; + Uri? requestUri = null; + SerializerSelectionModel payload = new() { Name = "payload" }; + RequestHeaders headers = new(new AuthenticationHeader("Bearer", "test-token")); + headers.Headers.Add("X-Request", "kept"); + using RecordingMessageHandler handler = new(async (request, token) => + { + body = request.Content == null ? null : await request.Content.ReadAsStringAsync(token); + contentType = request.Content?.Headers.ContentType?.ToString(); + authorization = request.Headers.Authorization?.ToString(); + header = request.Headers.GetValues("X-Request").Single(); + verb = request.Method.Method; + requestUri = request.RequestUri; + return new HttpResponseMessage(HttpStatusCode.Created) + { + Content = new StringContent("not an XML model", Encoding.UTF8, "application/xml") + }; + }); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + using RestlingClient client = new(context) + { + SelectedDefaultSerializationLibrary = serializer == PayloadJsonSerializerLibrary.SystemTextJson + ? PayloadJsonSerializerLibrary.NewtonsoftJson + : PayloadJsonSerializerLibrary.SystemTextJson + }; + + RestRequestResult result = method == "POST" + ? await client.PostAsync(uri, payload, headers, serializer) + : await client.PutAsync(uri, payload, headers, serializer); + + Assert.AreEqual(serializer == PayloadJsonSerializerLibrary.SystemTextJson + ? "{\"system_name\":\"payload\"}" + : "{\"newtonsoft_name\":\"payload\"}", body); + Assert.AreEqual("application/json; charset=utf-8", contentType); + Assert.AreEqual("Bearer test-token", authorization); + Assert.AreEqual("kept", header); + Assert.AreEqual(method, verb); + Assert.AreEqual(uri, requestUri?.AbsoluteUri); + Assert.AreEqual(typeof(RestRequestResult), result.GetType()); + Assert.AreEqual("not an XML model", result.Content); + Assert.AreEqual(HttpStatusCode.Created, result.StatusCode); + Assert.IsTrue(result.IsSuccessful); + Assert.IsNull(result.Exception); + Assert.AreSame(payload, ((RestRequest)result.Request).RequestData); + Assert.AreEqual(client.SelectedDefaultSerializationLibrary, result.Request.ForcePayloadJsonSerializerLibrary); + } + + /// Verifies that header overloads accept null payloads without creating a typed result. + [TestMethod] + [DataRow("POST")] + [DataRow("PUT")] + public async Task UntypedHeaderPayloadOverloadsAcceptNull(string method) + { + bool hasBody = true; + using RecordingMessageHandler handler = new((request, _) => + { + hasBody = request.Content != null; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent)); + }); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + using RestlingClient client = new(context); + + RestRequestResult result = method == "POST" + ? await client.PostAsync("https://example.test/null", null, new RequestHeaders()) + : await client.PutAsync("https://example.test/null", null, new RequestHeaders()); + + Assert.IsFalse(hasBody); + Assert.IsTrue(result.IsSuccessful); + Assert.AreEqual(HttpStatusCode.NoContent, result.StatusCode); + Assert.AreEqual(typeof(RestRequestResult), result.GetType()); + } + + /// Verifies that the corrected header path retains result-based send errors and cancellation. + [TestMethod] + [DataRow("POST", false)] + [DataRow("POST", true)] + [DataRow("PUT", false)] + [DataRow("PUT", true)] + public async Task UntypedHeaderPayloadOverloadsPreserveFailures(string method, bool cancel) + { + int sends = 0; + HttpRequestException expected = new("network unavailable"); + using CancellationTokenSource cancellation = new(); + using RecordingMessageHandler handler = new((_, token) => + { + sends++; + token.ThrowIfCancellationRequested(); + return Task.FromException(expected); + }); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + using RestlingClient client = new(context); + if (cancel) + cancellation.Cancel(); + + RestRequestResult result = method == "POST" + ? await client.PostAsync("https://example.test/failure", "payload", new RequestHeaders(), cancellationToken: cancellation.Token) + : await client.PutAsync("https://example.test/failure", "payload", new RequestHeaders(), cancellationToken: cancellation.Token); + + Assert.AreEqual(1, sends); + Assert.IsFalse(result.IsSuccessful); + Assert.IsNull(result.StatusCode); + Assert.AreEqual(typeof(RestRequestResult), result.GetType()); + if (cancel) + Assert.IsInstanceOfType(result.Exception); + else + Assert.AreSame(expected, result.Exception); + } + + /// Creates a deterministic JSON response. + private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, string content) + { + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(content, Encoding.UTF8, "application/json") + }; + } + + /// Creates a deterministic text response. + private static HttpResponseMessage CreateTextResponse(HttpStatusCode statusCode, string content) + { + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(content, Encoding.UTF8, "text/plain") + }; + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/HttpPipelineEdgeCaseTests.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/HttpPipelineEdgeCaseTests.cs new file mode 100644 index 0000000..2bee239 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/HttpPipelineEdgeCaseTests.cs @@ -0,0 +1,283 @@ +using AMDevIT.Restling.Core; +using AMDevIT.Restling.Core.Multipart; +using AMDevIT.Restling.Core.Network.Builders; +using AMDevIT.Restling.Core.Serialization; +using AMDevIT.Restling.Tests.Models; +using AMDevIT.Restling.Tests.Multipart; +using AMDevIT.Restling.Tests.Pipeline; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using RestlingHttpMethod = AMDevIT.Restling.Core.HttpMethod; + +namespace AMDevIT.Restling.Tests +{ + [TestClass] + public sealed class HttpPipelineEdgeCaseTests + { + #region Methods + + /// Retains empty text content for null payloads in all direct POST and PUT overloads. + [TestMethod] + [DataRow("POST", false)] + [DataRow("POST", true)] + [DataRow("PUT", false)] + [DataRow("PUT", true)] + public async Task NullDirectPayloadRetainsEmptyTextContent(string method, bool typed) + { + string? body = null; + string? mediaType = null; + using RecordingMessageHandler handler = new(async (request, token) => + { + mediaType = request.Content?.Headers.ContentType?.ToString(); + body = await request.Content!.ReadAsStringAsync(token); + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + using RestlingClient client = new(context); + + RestRequestResult result = await ExecuteDirectPayloadAsync(client, method, typed, null); + + Assert.AreEqual(string.Empty, body); + Assert.AreEqual("text/plain; charset=utf-8", mediaType); + Assert.AreEqual(HttpStatusCode.NoContent, result.StatusCode); + Assert.IsTrue(result.IsSuccessful); + } + + /// Retains result-based serialization errors without issuing a network request. + [TestMethod] + [DataRow("POST", false)] + [DataRow("POST", true)] + [DataRow("PUT", false)] + [DataRow("PUT", true)] + public async Task DirectSerializationFailureRemainsAResult(string method, bool typed) + { + int sends = 0; + Dictionary cycle = []; + cycle["self"] = cycle; + using RecordingMessageHandler handler = new((_, _) => + { + sends++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + using RestlingClient client = new(context); + + RestRequestResult result = await ExecuteDirectPayloadAsync(client, method, typed, cycle); + + Assert.AreEqual(0, sends); + Assert.IsInstanceOfType(result.Exception); + Assert.IsFalse(result.IsSuccessful); + Assert.IsNull(result.StatusCode); + Assert.AreEqual(TimeSpan.Zero, result.Elapsed); + } + + /// Retains raw/form payloads and their independent response serializer selection. + [TestMethod] + [DataRow(false, false, false)] + [DataRow(false, false, true)] + [DataRow(false, true, false)] + [DataRow(false, true, true)] + [DataRow(true, false, false)] + [DataRow(true, false, true)] + [DataRow(true, true, false)] + [DataRow(true, true, true)] + public async Task RawAndFormPreserveBodyAndSerializer(bool form, bool typed, bool forceSystem) + { + const string uri = "https://example.test/resource"; + string? body = null; + string? mediaType = null; + PayloadJsonSerializerLibrary? serializer = forceSystem ? PayloadJsonSerializerLibrary.SystemTextJson : null; + RestRequest request = form + ? new FormUrlEncodedRequest(uri, RestlingHttpMethod.Post, new Dictionary { ["q"] = "a b&c" }) + : new RestRawRequest(uri, RestlingHttpMethod.Post, content: "a b&c", contentType: "text/plain"); + request.ForcePayloadJsonSerializerLibrary = serializer; + using RecordingMessageHandler handler = new(async (message, token) => + { + body = await message.Content!.ReadAsStringAsync(token); + mediaType = message.Content.Headers.ContentType?.MediaType; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"system_name\":\"system\",\"newtonsoft_name\":\"newtonsoft\"}", Encoding.UTF8, "application/json") + }; + }); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + using RestlingClient client = new(context) + { + SelectedDefaultSerializationLibrary = forceSystem + ? PayloadJsonSerializerLibrary.NewtonsoftJson + : PayloadJsonSerializerLibrary.SystemTextJson + }; + + RestRequestResult result = typed + ? await client.ExecuteRequestAsync(request) + : await client.ExecuteRequestAsync(request); + + Assert.AreEqual(form ? "q=a+b%26c" : "a b&c", body); + Assert.AreEqual(form ? "application/x-www-form-urlencoded" : "text/plain", mediaType); + Assert.AreSame(request, result.Request); + Assert.AreEqual(serializer, request.ForcePayloadJsonSerializerLibrary); + Assert.IsTrue(result.IsSuccessful); + if (typed) + Assert.AreEqual(forceSystem ? "system" : "newtonsoft", ((RestRequestResult)result).Data?.Name); + } + + /// Retains legacy JSON default-on-error and status-dependent XML decode errors. + [TestMethod] + [DataRow("application/json", HttpStatusCode.OK, false)] + [DataRow("application/json", HttpStatusCode.BadRequest, false)] + [DataRow("application/xml", HttpStatusCode.OK, true)] + [DataRow("application/xml", HttpStatusCode.BadRequest, false)] + public async Task DecodeErrorsPreserveStatusBodyAndDisposal(string mediaType, HttpStatusCode statusCode, bool hasException) + { + byte[] bytes = Encoding.UTF8.GetBytes("invalid document"); + TrackingResponseContent content = new(bytes); + content.Headers.ContentType = new MediaTypeHeaderValue(mediaType); + using RecordingMessageHandler handler = new((_, _) => + { + HttpResponseMessage response = new(statusCode) { Content = content }; + response.Headers.Add("X-Response", "kept"); + return Task.FromResult(response); + }); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + using RestlingClient client = new(context); + + RestRequestResult result = await client.GetAsync("https://example.test/decode"); + + Assert.AreEqual(statusCode, result.StatusCode); + Assert.AreEqual(hasException, result.Exception != null); + Assert.AreEqual(statusCode == HttpStatusCode.OK && !hasException, result.IsSuccessful); + Assert.IsNull(result.Data); + CollectionAssert.AreEqual(bytes, result.RawContent); + Assert.AreEqual("kept", result.ResponseHeaders.Headers["X-Response"].Single()); + Assert.IsTrue(content.Disposed); + } + + /// Preserves direct shortcut HTTP version defaults, unlike explicitly constructed requests. + [TestMethod] + [DataRow("GET")] + [DataRow("DELETE")] + [DataRow("POST")] + [DataRow("PUT")] + public async Task DirectMethodsPreserveHttpClientVersionDefaults(string method) + { + Version? version = null; + HttpVersionPolicy? policy = null; + using RecordingMessageHandler handler = new((request, _) => + { + version = request.Version; + policy = request.VersionPolicy; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent)); + }); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + context.HttpClient.DefaultRequestVersion = HttpVersion.Version20; + context.HttpClient.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact; + using RestlingClient client = new(context); + + RestRequestResult result = method switch + { + "GET" => await client.GetAsync("https://example.test/version"), + "DELETE" => await client.DeleteAsync("https://example.test/version"), + _ => await ExecuteDirectPayloadAsync(client, method, false, "payload") + }; + + Assert.IsTrue(result.IsSuccessful); + Assert.AreEqual(HttpVersion.Version20, version); + Assert.AreEqual(HttpVersionPolicy.RequestVersionExact, policy); + } + + /// Verifies header-only completion and response disposal when streaming is stopped early. + [TestMethod] + public async Task StreamingRemainsUnbufferedAndDisposesOnEarlyBreak() + { + const string body = "--frame\r\nContent-Type: text/plain\r\n\r\none\r\n--frame\r\nContent-Type: text/plain\r\n\r\ntwo\r\n--frame--\r\n"; + TrackingResponseContent content = new(Encoding.UTF8.GetBytes(body)); + content.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/x-mixed-replace; boundary=frame"); + using RecordingMessageHandler handler = new((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content })); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + context.HttpClient.MaxResponseContentBufferSize = 1; + using RestlingClient client = new(context); + RestRequest request = new("https://example.test/stream", RestlingHttpMethod.Get); + string? first = null; + + await foreach (MultipartPart part in client.StreamMultipartMixedReplaceAsync(request)) + { + first = part.Deserialize(); + break; + } + + Assert.AreEqual("one", first); + Assert.IsTrue(content.Disposed); + } + + /// Preserves thrown preparation failures for explicitly constructed payload requests. + [TestMethod] + public async Task ExplicitPayloadRequestRetainsThrownSerializationError() + { + int sends = 0; + Exception? failure = null; + Dictionary cycle = []; + cycle["self"] = cycle; + RestRequest> request = new("https://example.test/resource", RestlingHttpMethod.Post, cycle); + using RecordingMessageHandler handler = new((_, _) => + { + sends++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + using RestlingClient client = new(context); + + try + { + await client.ExecuteRequestAsync>(request); + } + catch (Exception exception) + { + failure = exception; + } + + Assert.IsInstanceOfType(failure); + Assert.AreEqual(0, sends); + } + + /// Preserves exception propagation for the dedicated streaming API. + [TestMethod] + public async Task StreamingSendFailureStillThrows() + { + HttpRequestException expected = new("stream unavailable"); + Exception? failure = null; + using RecordingMessageHandler handler = new((_, _) => Task.FromException(expected)); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + using RestlingClient client = new(context); + RestRequest request = new("https://example.test/stream", RestlingHttpMethod.Get); + + try + { + await foreach (MultipartPart part in client.StreamMultipartMixedReplaceAsync(request)) + Assert.Fail("A failed stream must not yield a part."); + } + catch (Exception exception) + { + failure = exception; + } + + Assert.AreSame(expected, failure); + } + + /// Exercises all direct payload overloads with the same test data. + private static async Task ExecuteDirectPayloadAsync(RestlingClient client, string method, bool typed, T payload) + { + const string uri = "https://example.test/resource"; + return (method, typed) switch + { + ("POST", false) => await client.PostAsync(uri, payload), + ("POST", true) => await client.PostAsync(uri, payload), + ("PUT", false) => await client.PutAsync(uri, payload), + ("PUT", true) => await client.PutAsync(uri, payload), + _ => throw new ArgumentOutOfRangeException(nameof(method)) + }; + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Models/CodecTestModel.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Models/CodecTestModel.cs new file mode 100644 index 0000000..4c98044 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Models/CodecTestModel.cs @@ -0,0 +1,15 @@ +using System.Xml.Serialization; + +namespace AMDevIT.Restling.Tests.Models +{ + [XmlRoot("item")] + public sealed class CodecTestModel + { + #region Properties + + public int Id { get; set; } + public string? Name { get; set; } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Models/SerializerSelectionModel.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Models/SerializerSelectionModel.cs new file mode 100644 index 0000000..4b7e77f --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Models/SerializerSelectionModel.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; +using System.Text.Json.Serialization; + +namespace AMDevIT.Restling.Tests.Models +{ + public sealed class SerializerSelectionModel + { + #region Properties + + [JsonProperty("newtonsoft_name")] + [JsonPropertyName("system_name")] + public string? Name { get; set; } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Multipart/RecordingMessageHandler.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Multipart/RecordingMessageHandler.cs new file mode 100644 index 0000000..a2d6f31 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Multipart/RecordingMessageHandler.cs @@ -0,0 +1,22 @@ +namespace AMDevIT.Restling.Tests.Multipart +{ + internal sealed class RecordingMessageHandler(Func> responseFactory) + : HttpMessageHandler + { + #region Fields + + private readonly Func> responseFactory = responseFactory; + + #endregion + + #region Methods + + /// + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return this.responseFactory(request, cancellationToken); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/MultipartTests.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/MultipartTests.cs new file mode 100644 index 0000000..34218b4 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/MultipartTests.cs @@ -0,0 +1,160 @@ +using AMDevIT.Restling.Core; +using AMDevIT.Restling.Core.Codecs; +using AMDevIT.Restling.Core.Multipart; +using AMDevIT.Restling.Core.Network.Builders; +using AMDevIT.Restling.Tests.Multipart; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using RestlingHttpMethod = AMDevIT.Restling.Core.HttpMethod; + +namespace AMDevIT.Restling.Tests +{ + [TestClass] + public sealed class MultipartTests + { + #region Methods + + /// Verifies ordered duplicate fields, binary data and per-part codec decoding. + [TestMethod] + public void MultipartCodecPreservesPartsAndDecodesTheirContent() + { + const string body = "preamble\r\n--sample\r\nContent-Disposition: form-data; name=\"value\"\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nfirst\r\n--sample\r\nContent-Disposition: form-data; name=\"value\"\r\nContent-Type: application/json\r\n\r\n{\"name\":\"second\"}\r\n--sample--\r\nepilogue"; + ContentCodecRegistry registry = new(); + MultipartContentCodec codec = new(); + ContentCodecContext context = new() { Codecs = registry }; + MediaTypeHeaderValue contentType = MediaTypeHeaderValue.Parse("multipart/form-data; boundary=sample"); + + MultipartDocument? document = codec.Deserialize(Encoding.UTF8.GetBytes(body), + contentType, + context); + + Assert.IsNotNull(document); + Assert.AreEqual(2, document.Parts.Count); + Assert.AreEqual("value", document.Parts[0].Name); + Assert.AreEqual("value", document.Parts[1].Name); + Assert.AreEqual("first", document.Parts[0].Deserialize()); + Dictionary? decoded = document.Parts[1].Deserialize>(); + Assert.IsNotNull(decoded); + Assert.AreEqual("second", decoded["name"]); + CollectionAssert.AreEqual(Encoding.UTF8.GetBytes("preamble"), document.Preamble); + CollectionAssert.AreEqual(Encoding.UTF8.GetBytes("epilogue"), document.Epilogue); + } + + /// Verifies nested multipart parsing and multipart/related root selection. + [TestMethod] + public void MultipartCodecParsesNestedRelatedContent() + { + const string body = "--outer\r\nContent-ID: \r\nContent-Type: application/json\r\n\r\n{}\r\n--outer\r\nContent-ID: \r\nContent-Type: multipart/mixed; boundary=inner\r\n\r\n--inner\r\nContent-Type: text/plain\r\n\r\nchild\r\n--inner--\r\n\r\n--outer--\r\n"; + ContentCodecRegistry registry = new(); + ContentCodecContext context = new() { Codecs = registry }; + MultipartContentCodec codec = new(); + MediaTypeHeaderValue contentType = MediaTypeHeaderValue.Parse("multipart/related; boundary=outer; start=\"\""); + + MultipartDocument? document = codec.Deserialize(Encoding.UTF8.GetBytes(body), + contentType, + context); + + Assert.IsNotNull(document); + Assert.AreEqual("nested", document.RootPart?.ContentId); + Assert.IsNotNull(document.RootPart?.NestedContent); + Assert.AreEqual("child", document.RootPart.NestedContent.Parts[0].Deserialize()); + } + + /// Verifies that malformed multipart bodies fail deterministically. + [TestMethod] + public void MultipartCodecRejectsMissingClosingBoundary() + { + const string body = "--broken\r\nContent-Type: text/plain\r\n\r\ncontent"; + MultipartContentCodec codec = new(); + ContentCodecRegistry registry = new(); + ContentCodecContext context = new() { Codecs = registry }; + MediaTypeHeaderValue contentType = MediaTypeHeaderValue.Parse("multipart/mixed; boundary=broken"); + bool exceptionThrown = false; + + try + { + codec.Deserialize(Encoding.UTF8.GetBytes(body), contentType, context); + } + catch (InvalidDataException) + { + exceptionThrown = true; + } + + Assert.IsTrue(exceptionThrown); + } + + /// Verifies typed range metadata for multipart/byteranges. + [TestMethod] + public void MultipartCodecParsesByteRanges() + { + const string body = "--range\r\nContent-Type: application/octet-stream\r\nContent-Range: bytes 0-2/10\r\n\r\nabc\r\n--range--\r\n"; + MultipartContentCodec codec = new(); + ContentCodecRegistry registry = new(); + ContentCodecContext context = new() { Codecs = registry }; + MediaTypeHeaderValue contentType = MediaTypeHeaderValue.Parse("multipart/byteranges; boundary=range"); + + MultipartDocument? document = codec.Deserialize(Encoding.ASCII.GetBytes(body), + contentType, + context); + + Assert.IsNotNull(document); + Assert.AreEqual(0L, document.Parts[0].ContentRange?.From); + Assert.AreEqual(2L, document.Parts[0].ContentRange?.To); + Assert.AreEqual(10L, document.Parts[0].ContentRange?.Length); + } + + /// Verifies multipart request creation and codec-backed object parts. + [TestMethod] + public async Task ClientSendsMultipartRequest() + { + string? capturedBody = null; + string? capturedType = null; + RecordingMessageHandler handler = new(async (request, cancellationToken) => + { + capturedType = request.Content?.Headers.ContentType?.MediaType; + capturedBody = await request.Content!.ReadAsStringAsync(cancellationToken); + return new HttpResponseMessage(HttpStatusCode.NoContent) { Content = new ByteArrayContent([]) }; + }); + HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + RestlingClient client = new(context); + MultipartRequest request = new("https://example.test/upload", RestlingHttpMethod.Post); + request.AddText("description", "sample") + .AddObject("metadata", new Dictionary { ["kind"] = "document" }, "application/json") + .AddBytes("file", [1, 2, 3], "sample.bin"); + + await client.ExecuteMultipartRequestAsync(request); + + Assert.AreEqual("multipart/form-data", capturedType); + StringAssert.Contains(capturedBody, "description"); + StringAssert.Contains(capturedBody, "metadata"); + StringAssert.Contains(capturedBody, "sample.bin"); + context.Dispose(); + } + + /// Verifies that x-mixed-replace yields complete parts incrementally. + [TestMethod] + public async Task ClientStreamsMixedReplaceParts() + { + const string body = "--frame\r\nContent-Type: text/plain\r\n\r\none\r\n--frame\r\nContent-Type: text/plain\r\n\r\ntwo\r\n--frame--\r\n"; + RecordingMessageHandler handler = new((_, _) => + { + StreamContent content = new(new MemoryStream(Encoding.UTF8.GetBytes(body))); + content.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/x-mixed-replace; boundary=frame"); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + }); + HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + RestlingClient client = new(context); + RestRequest request = new("https://example.test/events", RestlingHttpMethod.Get); + List values = []; + + await foreach (MultipartPart part in client.StreamMultipartMixedReplaceAsync(request)) + values.Add(part.Deserialize()); + + CollectionAssert.AreEqual(new[] { "one", "two" }, values); + context.Dispose(); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Ownership/TrackingMessageHandler.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Ownership/TrackingMessageHandler.cs new file mode 100644 index 0000000..818991b --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Ownership/TrackingMessageHandler.cs @@ -0,0 +1,31 @@ +using System.Net; + +namespace AMDevIT.Restling.Tests.Ownership +{ + internal sealed class TrackingMessageHandler : HttpMessageHandler + { + #region Properties + + public bool Disposed { get; private set; } + + #endregion + + #region Methods + + /// + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + this.Disposed = true; + base.Dispose(disposing); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/OwnershipTests.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/OwnershipTests.cs new file mode 100644 index 0000000..039ce28 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/OwnershipTests.cs @@ -0,0 +1,139 @@ +using AMDevIT.Restling.Core; +using AMDevIT.Restling.Core.Network.Builders; +using AMDevIT.Restling.Tests.Ownership; +using System.Net; + +namespace AMDevIT.Restling.Tests +{ + [TestClass] + public sealed class OwnershipTests + { + #region Methods + + /// Verifies that the legacy context constructor retains ownership of both resources. + [TestMethod] + public void LegacyContextConstructorOwnsAllResources() + { + TrackingMessageHandler handler = new(); + HttpClient httpClient = new(handler, disposeHandler: false); + HttpClientContext context = new(httpClient, handler, new CookieContainer()); + + context.Dispose(); + + Assert.AreEqual(HttpClientContextOwnership.All, context.Ownership); + Assert.IsTrue(context.Disposed); + Assert.IsTrue(handler.Disposed); + } + + /// Verifies that explicit HttpClient ownership does not dispose a borrowed handler. + [TestMethod] + public void ContextCanBorrowHandler() + { + TrackingMessageHandler handler = new(); + HttpClient httpClient = new(handler, disposeHandler: false); + HttpClientContext context = new(httpClient, + handler, + new CookieContainer(), + HttpClientContextOwnership.HttpClient); + + context.Dispose(); + + Assert.IsTrue(context.Disposed); + Assert.IsFalse(handler.Disposed); + handler.Dispose(); + } + + /// Verifies builder ownership for borrowed and owned handlers. + [TestMethod] + public void BuilderPreservesHandlerOwnershipChoice() + { + TrackingMessageHandler borrowedHandler = new(); + TrackingMessageHandler ownedHandler = new(); + HttpClientContext borrowedContext = new HttpClientContextBuilder().AddHandler(borrowedHandler).Build(); + HttpClientContext ownedContext = new HttpClientContextBuilder().AddHandler(ownedHandler, + HttpMessageHandlerOwnership.Owned) + .Build(); + + borrowedContext.Dispose(); + ownedContext.Dispose(); + + Assert.AreEqual(HttpClientContextOwnership.HttpClient, borrowedContext.Ownership); + Assert.AreEqual(HttpClientContextOwnership.All, ownedContext.Ownership); + Assert.IsFalse(borrowedHandler.Disposed); + Assert.IsTrue(ownedHandler.Disposed); + borrowedHandler.Dispose(); + } + + /// Verifies that the legacy boolean overload maps to owned handler semantics. + [TestMethod] + public void BuilderPreservesLegacyHandlerOwnershipChoice() + { + TrackingMessageHandler handler = new(); + HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler, diposeHandler: true).Build(); + + context.Dispose(); + + Assert.AreEqual(HttpClientContextOwnership.All, context.Ownership); + Assert.IsTrue(handler.Disposed); + } + + /// Verifies that a client borrows an externally supplied context by default. + [TestMethod] + public void ClientBorrowsExternalContext() + { + TrackingMessageHandler handler = new(); + HttpClient httpClient = new(handler, disposeHandler: false); + HttpClientContext context = new(httpClient, + handler, + new CookieContainer(), + HttpClientContextOwnership.All); + RestlingClient client = new(context); + + client.Dispose(); + + Assert.AreEqual(RestlingClientContextOwnership.Borrowed, client.ContextOwnership); + Assert.IsFalse(context.Disposed); + context.Dispose(); + } + + /// Verifies explicit ownership and the backward-compatible boolean alias. + [TestMethod] + public void ClientCanOwnExternalContextExplicitly() + { + TrackingMessageHandler handler = new(); + HttpClient httpClient = new(handler, disposeHandler: false); + HttpClientContext context = new(httpClient, + handler, + new CookieContainer(), + HttpClientContextOwnership.All); + RestlingClient client = new(context, RestlingClientContextOwnership.Borrowed) + { + DisposeContext = true + }; + + client.Dispose(); + + Assert.AreEqual(RestlingClientContextOwnership.Owned, client.ContextOwnership); + Assert.IsTrue(context.Disposed); + Assert.IsTrue(handler.Disposed); + } + + /// Verifies that constructors creating a context also own it. + [TestMethod] + public void ClientOwnsBuilderContext() + { + TrackingMessageHandler handler = new(); + RestlingClient client = new(new HttpClientContextBuilder().AddHandler(handler)); + HttpClientContext context = client.ClientContext; + + client.Dispose(); + + Assert.AreEqual(RestlingClientContextOwnership.Owned, client.ContextOwnership); + Assert.IsTrue(context.Disposed); + Assert.IsFalse(handler.Disposed); + handler.Dispose(); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Pipeline/TrackingResponseContent.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Pipeline/TrackingResponseContent.cs new file mode 100644 index 0000000..1e2db47 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Pipeline/TrackingResponseContent.cs @@ -0,0 +1,44 @@ +using System.Net; + +namespace AMDevIT.Restling.Tests.Pipeline +{ + internal sealed class TrackingResponseContent(byte[] content) + : HttpContent + { + #region Fields + + private readonly byte[] content = content; + + #endregion + + #region Properties + + public bool Disposed { get; private set; } + + #endregion + + #region Methods + + /// + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + return stream.WriteAsync(this.content).AsTask(); + } + + /// + protected override bool TryComputeLength(out long length) + { + length = this.content.Length; + return true; + } + + /// + protected override void Dispose(bool disposing) + { + this.Disposed = true; + base.Dispose(disposing); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Proxy/TrackingHttpClientHandler.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Proxy/TrackingHttpClientHandler.cs new file mode 100644 index 0000000..b30cf7e --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/Proxy/TrackingHttpClientHandler.cs @@ -0,0 +1,23 @@ +namespace AMDevIT.Restling.Tests.Proxy +{ + /// Observes handler ownership without changing native transport behavior. + internal sealed class TrackingHttpClientHandler : HttpClientHandler + { + #region Properties + + public bool Disposed { get; private set; } + + #endregion + + #region Methods + + /// Records disposal before releasing native resources. + protected override void Dispose(bool disposing) + { + this.Disposed = true; + base.Dispose(disposing); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/ProxyBuilderTests.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/ProxyBuilderTests.cs new file mode 100644 index 0000000..6f44a9d --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/ProxyBuilderTests.cs @@ -0,0 +1,304 @@ +using AMDevIT.Restling.Core; +using AMDevIT.Restling.Core.Network.Builders; +using AMDevIT.Restling.Tests.Cookies; +using AMDevIT.Restling.Tests.Multipart; +using System.Net; + +namespace AMDevIT.Restling.Tests +{ + [TestClass] + public sealed class ProxyBuilderTests + { + #region Methods + + /// The interface exposes proxy configuration and internally created handlers remain owned. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void DefaultHandlerUsesExplicitProxy(bool allowAutoRedirect) + { + IHttpClientContextBuilder builder = new HttpClientContextBuilder(); + Assert.AreSame(builder, builder.AddProxy("http://localhost:8080", allowAutoRedirect)); + using HttpClientContext context = builder.Build(); + + AssertProxy(context.HttpMessageHandler, "http://localhost:8080/", allowAutoRedirect); + Assert.AreEqual(HttpClientContextOwnership.All, context.Ownership); + } + + /// Existing native proxy settings remain unchanged when AddProxy is not used. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void ExistingProxySettingsAreUnchangedWithoutAddProxy(bool useHttpClientHandler) + { + WebProxy proxy = new("http://localhost:8080"); + using HttpMessageHandler handler = useHttpClientHandler + ? new HttpClientHandler { Proxy = proxy, UseProxy = true, AllowAutoRedirect = false } + : new SocketsHttpHandler { Proxy = proxy, UseProxy = true, AllowAutoRedirect = false }; + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + + Assert.AreSame(proxy, handler is HttpClientHandler native ? native.Proxy : ((SocketsHttpHandler)handler).Proxy); + AssertProxy(handler, "http://localhost:8080/", false); + } + + /// The default builder keeps its historical redirect policy and native proxy defaults. + [TestMethod] + public void DefaultBuilderIsUnchangedWithoutAddProxy() + { + using HttpClientContext context = new HttpClientContextBuilder().Build(); + SocketsHttpHandler handler = (SocketsHttpHandler)context.HttpMessageHandler; + + Assert.IsNull(handler.Proxy); + Assert.IsTrue(handler.UseProxy); + Assert.IsFalse(handler.AllowAutoRedirect); + } + + /// Proxy configuration preserves native cookies and ownership in either call order. + [TestMethod] + [DataRow(false, false, false)] + [DataRow(false, false, true)] + [DataRow(false, true, false)] + [DataRow(false, true, true)] + [DataRow(true, false, false)] + [DataRow(true, false, true)] + [DataRow(true, true, false)] + [DataRow(true, true, true)] + public void SuppliedHandlerPreservesCookiesAndOwnership(bool useHttpClientHandler, bool proxyFirst, bool owned) + { + CookieContainer cookies = new(); + HttpMessageHandlerOwnership ownership = owned ? HttpMessageHandlerOwnership.Owned : HttpMessageHandlerOwnership.Borrowed; + using HttpMessageHandler handler = CreateHandler(useHttpClientHandler, cookies); + HttpClientContextBuilder builder = new(); + if (proxyFirst) + builder.AddProxy("http://localhost:8080", false).AddHandler(handler, ownership); + else + builder.AddHandler(handler, ownership).AddProxy("http://localhost:8080", false); + using HttpClientContext context = builder.Build(); + + Assert.AreSame(handler, context.HttpMessageHandler); + Assert.AreSame(cookies, context.CookieContainer); + Assert.IsFalse(handler is HttpClientHandler native ? native.UseCookies : ((SocketsHttpHandler)handler).UseCookies); + Assert.AreEqual(owned ? HttpClientContextOwnership.All : HttpClientContextOwnership.HttpClient, context.Ownership); + AssertProxy(handler, "http://localhost:8080/", false); + } + + /// Proxy registration and explicit cookie binding coexist in either order. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void ExplicitCookieContainerIsRetained(bool proxyFirst) + { + CookieContainer cookies = new(); + HttpClientContextBuilder builder = new(); + if (proxyFirst) + builder.AddProxy("http://localhost:8080", false).AddCookieContainer(cookies); + else + builder.AddCookieContainer(cookies).AddProxy("http://localhost:8080", false); + using HttpClientContext context = builder.Build(); + + Assert.AreSame(cookies, context.CookieContainer); + Assert.AreSame(cookies, ((SocketsHttpHandler)context.HttpMessageHandler).CookieContainer); + AssertProxy(context.HttpMessageHandler, "http://localhost:8080/", false); + } + + /// All supported native proxy schemes can be configured without contacting a server. + [TestMethod] + [DataRow("http")] + [DataRow("https")] + [DataRow("socks4")] + [DataRow("socks4a")] + [DataRow("socks5")] + public void NativeProxySchemesAreAccepted(string scheme) + { + string address = $"{scheme}://localhost:8080/"; + using HttpClientContext context = new HttpClientContextBuilder().AddProxy(address, false).Build(); + AssertProxy(context.HttpMessageHandler, address, false); + } + + /// Invalid or ambiguous addresses fail before mutating an existing handler. + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("localhost:8080")] + [DataRow("/relative")] + [DataRow("http://")] + [DataRow("ftp://localhost:8080")] + [DataRow("http://user:password@localhost:8080")] + [DataRow("http://localhost:8080/path")] + [DataRow("http://localhost:8080?query")] + [DataRow("http://localhost:8080#fragment")] + public void InvalidProxyUriDoesNotChangeExistingSettings(string? address) + { + HttpClientContextBuilder builder = new(); + using HttpClientContext context = builder.AddProxy("http://localhost:8080", false).Build(); + if (address == null) + Assert.ThrowsExactly(() => builder.AddProxy(address!, true)); + else + Assert.ThrowsExactly(() => builder.AddProxy(address, true)); + AssertProxy(context.HttpMessageHandler, "http://localhost:8080/", false); + } + + /// New and existing configured handlers receive the proxy without losing other settings. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void ConfigureHandlerWorksInEitherOrder(bool proxyFirst) + { + HttpClientContextBuilder builder = new(); + if (proxyFirst) + builder.AddProxy("http://localhost:8080", false); + builder.ConfigureHandler(handler => + { + if (proxyFirst) + AssertProxy(handler, "http://localhost:8080/", false); + ((SocketsHttpHandler)handler).PooledConnectionLifetime = TimeSpan.FromMinutes(2); + }); + if (!proxyFirst) + builder.AddProxy("http://localhost:8080", false); + using HttpClientContext context = builder.Build(); + + AssertProxy(context.HttpMessageHandler, "http://localhost:8080/", false); + Assert.AreEqual(TimeSpan.FromMinutes(2), ((SocketsHttpHandler)context.HttpMessageHandler).PooledConnectionLifetime); + } + + /// Proxy credentials configured through the callback are not lost during Build. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void ConfigureHandlerCanSetProxyCredentials(bool useHttpClientHandler) + { + NetworkCredential credentials = new("proxy-user", "test-password"); + using HttpMessageHandler handler = CreateHandler(useHttpClientHandler, new CookieContainer()); + HttpClientContextBuilder builder = new(); + builder.AddHandler(handler).AddProxy("http://localhost:8080", false).ConfigureHandler(selected => + { + IWebProxy proxy = (selected is HttpClientHandler native ? native.Proxy : ((SocketsHttpHandler)selected).Proxy)!; + proxy.Credentials = credentials; + }); + using HttpClientContext context = builder.Build(); + IWebProxy configured = (handler is HttpClientHandler native ? native.Proxy : ((SocketsHttpHandler)handler).Proxy)!; + + Assert.AreSame(credentials, configured.Credentials); + } + + /// Build retains deliberate changes made by the callback after AddProxy. + [TestMethod] + public void LaterConfigurationCanOverrideProxySettings() + { + HttpClientContextBuilder builder = new(); + builder.AddProxy("http://localhost:8080", false).ConfigureHandler(handler => + { + SocketsHttpHandler native = (SocketsHttpHandler)handler; + native.UseProxy = false; + native.AllowAutoRedirect = true; + }); + using HttpClientContext context = builder.Build(); + + Assert.IsFalse(((SocketsHttpHandler)context.HttpMessageHandler).UseProxy); + Assert.IsTrue(((SocketsHttpHandler)context.HttpMessageHandler).AllowAutoRedirect); + } + + /// An opaque handler cannot silently bypass an explicitly requested proxy. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void UnsupportedHandlerIsRejectedWithoutReplacingState(bool proxyFirst) + { + using RecordingMessageHandler handler = new((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + HttpClientContextBuilder builder = new(); + if (proxyFirst) + { + builder.AddProxy("http://localhost:8080", false); + Assert.ThrowsExactly(() => builder.AddHandler(handler)); + using HttpClientContext context = builder.Build(); + AssertProxy(context.HttpMessageHandler, "http://localhost:8080/", false); + } + else + { + builder.AddHandler(handler); + Assert.ThrowsExactly(() => builder.AddProxy("http://localhost:8080", false)); + using HttpClientContext context = builder.Build(); + Assert.AreSame(handler, context.HttpMessageHandler); + } + } + + /// The latest proxy selection applies to a replacement native handler. + [TestMethod] + public void ReplacementHandlerUsesLatestProxy() + { + using SocketsHttpHandler first = new(); + using HttpClientHandler second = new(); + HttpClientContextBuilder builder = new(); + builder.AddHandler(first).AddProxy("http://localhost:8080", false).AddProxy("http://localhost:8081", true); + using HttpClientContext context = builder.AddHandler(second).Build(); + + AssertProxy(first, "http://localhost:8081/", true); + AssertProxy(second, "http://localhost:8081/", true); + } + + /// Real proxy requests obey redirects and retain cookies across calls and context recreation. + [TestMethod] + [DataRow(false, false)] + [DataRow(false, true)] + [DataRow(true, false)] + [DataRow(true, true)] + public async Task ProxyTransportPreservesRedirectsCookiesAndRebuilds(bool useHttpClientHandler, bool allowAutoRedirect) + { + string start = "http://127.0.0.1:1/start"; + string next = "http://127.0.0.1:1/next"; + string redirect = LoopbackCookieServer.Response(302, $"Location: {next}", "Set-Cookie: session=retained; Path=/"); + string[] responses = allowAutoRedirect + ? [redirect, LoopbackCookieServer.Response(200), LoopbackCookieServer.Response(200)] + : [redirect, LoopbackCookieServer.Response(200)]; + CookieContainer cookies = new(); + await using LoopbackCookieServer proxy = new(responses); + using HttpMessageHandler handler = CreateHandler(useHttpClientHandler, cookies); + HttpClientContextBuilder builder = new(); + builder.AddHandler(handler).AddCookieContainer(cookies).AddProxy(proxy.BaseUri.AbsoluteUri, allowAutoRedirect).SetTimeout(TimeSpan.FromSeconds(10)); + using (HttpClientContext firstContext = builder.Build()) + using (RestlingClient firstClient = new(firstContext)) + { + RestRequestResult initial = await firstClient.GetAsync(start); + Assert.IsNull(initial.Exception, initial.Exception?.ToString()); + Assert.AreEqual(allowAutoRedirect ? HttpStatusCode.OK : HttpStatusCode.Found, initial.StatusCode); + Assert.ThrowsExactly(() => builder.AddProxy("http://localhost:8080", true)); + } + + using HttpClientContext nextContext = builder.Build(); + using RestlingClient nextClient = new(nextContext); + RestRequestResult result = await nextClient.GetAsync(next); + Assert.IsTrue(result.IsSuccessful, result.Exception?.ToString()); + IReadOnlyList requests = await proxy.Requests; + + Assert.AreEqual(allowAutoRedirect ? 3 : 2, requests.Count); + Assert.AreEqual(start, requests[0].Target); + foreach (LoopbackCookieServer.Request request in requests.Skip(1)) + { + Assert.AreEqual(next, request.Target); + StringAssert.Contains(request.Headers["Cookie"], "session=retained"); + } + Assert.AreSame(cookies, nextContext.CookieContainer); + } + + /// Creates a borrowed native handler with disabled policies to verify explicit changes. + private static HttpMessageHandler CreateHandler(bool useHttpClientHandler, CookieContainer cookies) + { + return useHttpClientHandler + ? new HttpClientHandler { CookieContainer = cookies, UseCookies = false, UseProxy = false } + : new SocketsHttpHandler { CookieContainer = cookies, UseCookies = false, UseProxy = false }; + } + + /// Checks the effective native proxy and redirect policy. + private static void AssertProxy(HttpMessageHandler handler, string address, bool allowAutoRedirect) + { + IWebProxy? proxy = handler is HttpClientHandler native ? native.Proxy : ((SocketsHttpHandler)handler).Proxy; + Assert.IsInstanceOfType(proxy); + Assert.AreEqual(new Uri(address), ((WebProxy)proxy).Address); + Assert.IsTrue(handler is HttpClientHandler httpHandler ? httpHandler.UseProxy : ((SocketsHttpHandler)handler).UseProxy); + Assert.AreEqual(allowAutoRedirect, handler is HttpClientHandler clientHandler ? clientHandler.AllowAutoRedirect : ((SocketsHttpHandler)handler).AllowAutoRedirect); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/RequestProxyOverrideTests.cs b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/RequestProxyOverrideTests.cs new file mode 100644 index 0000000..8053862 --- /dev/null +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.Tests/RequestProxyOverrideTests.cs @@ -0,0 +1,388 @@ +using AMDevIT.Restling.Core; +using AMDevIT.Restling.Core.Network; +using AMDevIT.Restling.Core.Network.Builders; +using AMDevIT.Restling.Core.Multipart; +using AMDevIT.Restling.Tests.Cookies; +using AMDevIT.Restling.Tests.Multipart; +using AMDevIT.Restling.Tests.Proxy; +using System.Net; +using HttpMethod = AMDevIT.Restling.Core.HttpMethod; + +namespace AMDevIT.Restling.Tests +{ + [TestClass] + public sealed class RequestProxyOverrideTests + { + #region Fields + + private static readonly string[] expected = ["http://127.0.0.1:1/raw", "http://127.0.0.1:1/form", "http://127.0.0.1:1/multipart"]; + + #endregion + + #region Methods + + /// Default, custom proxy, and direct calls use independent routes and one shared cookie jar. + [TestMethod] + public async Task RoutingModesShareCookiesWithoutInterfering() + { + string responseWithCookie = LoopbackCookieServer.Response(200, "Set-Cookie: session=shared; Path=/"); + await using LoopbackCookieServer contextProxy = new(responseWithCookie); + await using LoopbackCookieServer requestProxy = new(LoopbackCookieServer.Response(200)); + await using LoopbackCookieServer origin = new(LoopbackCookieServer.Response(200)); + HttpClientContextBuilder builder = new(); + builder.AddProxy(contextProxy.BaseUri.AbsoluteUri, false) + .AddDefaultHeader("X-Restling-Test", "preserved") + .SetTimeout(TimeSpan.FromSeconds(10)); + using HttpClientContext context = builder.Build(); + using RestlingClient client = new(context); + RestRequest defaultRequest = new(origin.BaseUri.AbsoluteUri, HttpMethod.Get); + RestRequest customRequest = new(origin.BaseUri.AbsoluteUri, HttpMethod.Get) + { + ProxyOptions = RequestProxyOptions.Custom(requestProxy.BaseUri.AbsoluteUri) + }; + RestRequest directRequest = new(origin.BaseUri.AbsoluteUri, HttpMethod.Get) + { + ProxyOptions = RequestProxyOptions.Direct() + }; + + Assert.IsTrue((await client.ExecuteRequestAsync(defaultRequest)).IsSuccessful); + Assert.IsTrue((await client.ExecuteRequestAsync(customRequest)).IsSuccessful); + Assert.IsTrue((await client.ExecuteRequestAsync(directRequest)).IsSuccessful); + IReadOnlyList contextRequests = await contextProxy.Requests; + IReadOnlyList customRequests = await requestProxy.Requests; + IReadOnlyList originRequests = await origin.Requests; + + Assert.AreEqual(origin.BaseUri.AbsoluteUri, contextRequests[0].Target); + Assert.AreEqual(origin.BaseUri.AbsoluteUri, customRequests[0].Target); + Assert.AreEqual("/", originRequests[0].Target); + StringAssert.Contains(customRequests[0].Headers["Cookie"], "session=shared"); + StringAssert.Contains(originRequests[0].Headers["Cookie"], "session=shared"); + Assert.AreEqual("preserved", customRequests[0].Headers["X-Restling-Test"]); + Assert.AreEqual("preserved", originRequests[0].Headers["X-Restling-Test"]); + Assert.AreSame(context.CookieContainer, ((SocketsHttpHandler)context.HttpMessageHandler).CookieContainer); + } + + /// Equivalent overrides reuse their handler while different redirect policies use separate transports. + [TestMethod] + public async Task EquivalentOptionsReuseAlternativeTransport() + { + int factoryCalls = 0; + await using LoopbackCookieServer proxy = new(LoopbackCookieServer.Response(200), + LoopbackCookieServer.Response(200), + LoopbackCookieServer.Response(200)); + using RecordingMessageHandler defaultHandler = new((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + HttpClientContextBuilder builder = new(); + builder.AddHandler(defaultHandler).AddRequestHandlerFactory(cookies => + { + Interlocked.Increment(ref factoryCalls); + return new SocketsHttpHandler { CookieContainer = cookies }; + }); + using HttpClientContext context = builder.Build(); + using RestlingClient client = new(context); + RequestProxyOptions firstOptions = RequestProxyOptions.Custom(proxy.BaseUri.AbsoluteUri, false); + RequestProxyOptions equivalentOptions = RequestProxyOptions.Custom(proxy.BaseUri.AbsoluteUri, false); + RestRequest first = new("http://127.0.0.1:1/one", HttpMethod.Get) { ProxyOptions = firstOptions }; + RestRequest second = new("http://127.0.0.1:1/two", HttpMethod.Get) { ProxyOptions = equivalentOptions }; + RestRequest third = new("http://127.0.0.1:1/three", HttpMethod.Get) + { + ProxyOptions = RequestProxyOptions.Custom(proxy.BaseUri.AbsoluteUri, true) + }; + + Assert.IsTrue((await client.ExecuteRequestAsync(first)).IsSuccessful); + Assert.IsTrue((await client.ExecuteRequestAsync(second)).IsSuccessful); + Assert.IsTrue((await client.ExecuteRequestAsync(third)).IsSuccessful); + await proxy.Requests; + Assert.AreEqual(2, factoryCalls); + } + + /// A truncated response evicts the failed alternative transport before the caller retries. + [TestMethod] + public async Task ResponseEndedInvalidatesAlternativeTransport() + { + int factoryCalls = 0; + string truncatedResponse = "HTTP/1.1 200 Test\r\nContent-Type: application/octet-stream\r\n" + + "Content-Length: 10\r\nConnection: close\r\n\r\nshort"; + await using LoopbackCookieServer proxy = new(truncatedResponse, LoopbackCookieServer.Response(200)); + using RecordingMessageHandler defaultHandler = new((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + List alternativeHandlers = []; + HttpClientContextBuilder builder = new(); + builder.AddHandler(defaultHandler).AddRequestHandlerFactory(cookies => + { + TrackingHttpClientHandler handler = new() { CookieContainer = cookies }; + factoryCalls++; + alternativeHandlers.Add(handler); + return handler; + }); + using HttpClientContext context = builder.Build(); + using RestlingClient client = new(context); + RequestProxyOptions options = RequestProxyOptions.Custom(proxy.BaseUri.AbsoluteUri); + RestRequest firstRequest = new("http://127.0.0.1:1/first", HttpMethod.Get) { ProxyOptions = options }; + RestRequest secondRequest = new("http://127.0.0.1:1/second", HttpMethod.Get) { ProxyOptions = options }; + + RestRequestResult firstResult = await client.ExecuteRequestAsync(firstRequest); + RestRequestResult secondResult = await client.ExecuteRequestAsync(secondRequest); + await proxy.Requests; + + Assert.IsFalse(firstResult.IsSuccessful); + Assert.IsInstanceOfType(firstResult.Exception); + Assert.IsTrue(secondResult.IsSuccessful); + Assert.AreEqual(2, factoryCalls); + Assert.AreEqual(2, alternativeHandlers.Count); + Assert.IsTrue(alternativeHandlers[0].Disposed); + Assert.IsFalse(alternativeHandlers[1].Disposed); + } + + /// Each custom override independently controls automatic HTTP redirects. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task CustomOverrideControlsRedirects(bool allowAutoRedirect) + { + string start = "http://127.0.0.1:1/start"; + string next = "http://127.0.0.1:1/next"; + string redirect = LoopbackCookieServer.Response(302, $"Location: {next}"); + string[] responses = allowAutoRedirect + ? [redirect, LoopbackCookieServer.Response(200)] + : [redirect]; + await using LoopbackCookieServer proxy = new(responses); + using HttpClientContext context = new HttpClientContextBuilder().Build(); + using RestlingClient client = new(context); + RestRequest request = new(start, HttpMethod.Get) + { + ProxyOptions = RequestProxyOptions.Custom(proxy.BaseUri.AbsoluteUri, allowAutoRedirect) + }; + + RestRequestResult result = await client.ExecuteRequestAsync(request); + IReadOnlyList requests = await proxy.Requests; + + Assert.AreEqual(allowAutoRedirect ? HttpStatusCode.OK : HttpStatusCode.Found, result.StatusCode); + Assert.AreEqual(allowAutoRedirect ? 2 : 1, requests.Count); + Assert.AreEqual(start, requests[0].Target); + if (allowAutoRedirect) + Assert.AreEqual(next, requests[1].Target); + } + + /// A supplied handler requires an explicit alternative-handler factory only when an override is used. + [TestMethod] + public async Task MissingFactoryDoesNotAffectDefaultRequests() + { + using RecordingMessageHandler handler = new((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + using HttpClientContext context = new HttpClientContextBuilder().AddHandler(handler).Build(); + using RestlingClient client = new(context); + RestRequest defaultRequest = new("http://example.test/default", HttpMethod.Get); + RestRequest directRequest = new("http://example.test/direct", HttpMethod.Get) + { + ProxyOptions = RequestProxyOptions.Direct() + }; + + Assert.IsTrue((await client.ExecuteRequestAsync(defaultRequest)).IsSuccessful); + RestRequestResult result = await client.ExecuteRequestAsync(directRequest); + Assert.IsInstanceOfType(result.Exception); + } + + /// A bad factory becomes a normal buffered request failure instead of silently bypassing routing. + [TestMethod] + public async Task UnsupportedFactoryHandlerReturnsFailure() + { + using RecordingMessageHandler defaultHandler = new((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + RecordingMessageHandler? alternative = null; + HttpClientContextBuilder builder = new(); + builder.AddHandler(defaultHandler).AddRequestHandlerFactory(_ => + { + alternative = new RecordingMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + return alternative; + }); + using HttpClientContext context = builder.Build(); + using RestlingClient client = new(context); + RestRequest request = new("http://example.test/direct", HttpMethod.Get) + { + ProxyOptions = RequestProxyOptions.Direct() + }; + + RestRequestResult result = await client.ExecuteRequestAsync(request); + Assert.IsInstanceOfType(result.Exception); + Assert.IsNotNull(alternative); + } + + /// The context owns and disposes handlers created for request overrides. + [TestMethod] + public async Task ContextDisposesAlternativeHandlers() + { + TrackingHttpClientHandler? alternative = null; + await using LoopbackCookieServer origin = new(LoopbackCookieServer.Response(200)); + using RecordingMessageHandler defaultHandler = new((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + HttpClientContextBuilder builder = new(); + builder.AddHandler(defaultHandler).AddRequestHandlerFactory(cookies => + { + alternative = new TrackingHttpClientHandler { CookieContainer = cookies }; + return alternative; + }); + HttpClientContext context = builder.Build(); + using RestlingClient client = new(context); + RestRequest request = new(origin.BaseUri.AbsoluteUri, HttpMethod.Get) + { + ProxyOptions = RequestProxyOptions.Direct() + }; + + Assert.IsTrue((await client.ExecuteRequestAsync(request)).IsSuccessful); + await origin.Requests; + Assert.IsNotNull(alternative); + Assert.IsFalse(alternative.Disposed); + context.Dispose(); + Assert.IsTrue(alternative.Disposed); + } + + /// Credentials seeded by a factory are transferred to the request-selected proxy address. + [TestMethod] + public void FactoryProxyCredentialsArePreserved() + { + NetworkCredential credentials = new("request-user", "test-password"); + SocketsHttpHandler? alternative = null; + using RecordingMessageHandler defaultHandler = new((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + HttpClientContextBuilder builder = new(); + builder.AddHandler(defaultHandler).AddRequestHandlerFactory(cookies => + { + alternative = new SocketsHttpHandler + { + CookieContainer = cookies, + Proxy = new WebProxy("http://placeholder.test") { Credentials = credentials } + }; + return alternative; + }); + using HttpClientContext context = builder.Build(); + RequestProxyOptions options = RequestProxyOptions.Custom("http://localhost:8080"); + + context.ResolveHttpClient(options); + + Assert.IsNotNull(alternative); + Assert.IsInstanceOfType(alternative.Proxy); + Assert.AreEqual(new Uri("http://localhost:8080/"), ((WebProxy)alternative.Proxy).Address); + Assert.AreSame(credentials, alternative.Proxy.Credentials); + } + + /// Raw, form, and buffered multipart executions all honor the inherited request override. + [TestMethod] + public async Task SpecializedBufferedRequestsUseSelectedProxy() + { + await using LoopbackCookieServer proxy = new(LoopbackCookieServer.Response(200), + LoopbackCookieServer.Response(200), + LoopbackCookieServer.Response(200)); + using HttpClientContext context = new HttpClientContextBuilder().Build(); + using RestlingClient client = new(context); + RequestProxyOptions options = RequestProxyOptions.Custom(proxy.BaseUri.AbsoluteUri); + RestRawRequest raw = new("http://127.0.0.1:1/raw", HttpMethod.Post, "raw") { ProxyOptions = options }; + FormUrlEncodedRequest form = new("http://127.0.0.1:1/form", HttpMethod.Post, + new Dictionary { ["name"] = "value" }) + { + ProxyOptions = options + }; + MultipartRequest multipart = new("http://127.0.0.1:1/multipart", HttpMethod.Post) + { + ProxyOptions = options + }; + multipart.AddText("name", "value"); + + Assert.IsTrue((await client.ExecuteRawRequestAsync(raw)).IsSuccessful); + Assert.IsTrue((await client.ExecuteFormUrlEncodedRequest(form)).IsSuccessful); + Assert.IsTrue((await client.ExecuteMultipartRequestAsync(multipart)).IsSuccessful); + IReadOnlyList requests = await proxy.Requests; + CollectionAssert.AreEqual(expected, + requests.Select(request => request.Target).ToArray()); + } + + /// Mixed-replace streaming keeps the selected transport alive through enumeration. + [TestMethod] + public async Task StreamingRequestUsesSelectedProxy() + { + string body = "--frame\r\nContent-Type: text/plain\r\n\r\none\r\n--frame--\r\n"; + string response = LoopbackCookieServer.ResponseWithBody(200, + body, + "multipart/x-mixed-replace; boundary=frame"); + await using LoopbackCookieServer proxy = new(response); + using HttpClientContext context = new HttpClientContextBuilder().Build(); + using RestlingClient client = new(context); + RestRequest request = new("http://127.0.0.1:1/stream", HttpMethod.Get) + { + ProxyOptions = RequestProxyOptions.Custom(proxy.BaseUri.AbsoluteUri) + }; + List parts = []; + + await foreach (MultipartPart part in client.StreamMultipartMixedReplaceAsync(request)) + parts.Add(part); + IReadOnlyList requests = await proxy.Requests; + + Assert.AreEqual(1, parts.Count); + Assert.AreEqual("one", parts[0].Deserialize()); + Assert.AreEqual("http://127.0.0.1:1/stream", requests[0].Target); + } + + /// Request proxy values validate schemes and remain immutable value objects. + [TestMethod] + [DataRow("http")] + [DataRow("https")] + [DataRow("socks4")] + [DataRow("socks4a")] + [DataRow("socks5")] + public void CustomOptionsAcceptSupportedSchemes(string scheme) + { + RequestProxyOptions options = RequestProxyOptions.Custom($"{scheme}://localhost:8080", true); + Assert.AreEqual(RequestProxyMode.Custom, options.Mode); + Assert.AreEqual(new Uri($"{scheme}://localhost:8080/"), options.ProxyUri); + Assert.IsTrue(options.AllowAutoRedirect); + } + + /// Direct options never carry a proxy address. + [TestMethod] + public void DirectOptionsAreExplicitAndValueComparable() + { + Assert.AreEqual(RequestProxyOptions.Direct(true), RequestProxyOptions.Direct(true)); + Assert.AreEqual(RequestProxyMode.Direct, RequestProxyOptions.Direct().Mode); + Assert.IsNull(RequestProxyOptions.Direct().ProxyUri); + Assert.AreSame(RequestProxyOptions.Default, RequestProxyOptions.Default); + } + + /// Every direct convenience family exposes proxy options with CancellationToken last. + [TestMethod] + public async Task DirectConvenienceOverloadsRouteThroughSelectedProxy() + { + string[] responses = Enumerable.Repeat(LoopbackCookieServer.Response(200), 16).ToArray(); + await using LoopbackCookieServer proxy = new(responses); + using HttpClientContext context = new HttpClientContextBuilder().Build(); + using RestlingClient concreteClient = new(context); + IRestlingClient client = concreteClient; + RequestProxyOptions options = RequestProxyOptions.Custom(proxy.BaseUri.AbsoluteUri); + RequestHeaders headers = new(); + CancellationToken cancellationToken = CancellationToken.None; + List successful = + [ + (await client.GetAsync("http://127.0.0.1:1/get", options, cancellationToken)).IsSuccessful, + (await client.GetAsync("http://127.0.0.1:1/get-headers", headers, options, cancellationToken)).IsSuccessful, + (await client.GetAsync("http://127.0.0.1:1/get-typed", null, options, cancellationToken)).IsSuccessful, + (await client.GetAsync("http://127.0.0.1:1/get-typed-headers", headers, null, options, cancellationToken)).IsSuccessful, + (await client.PostAsync("http://127.0.0.1:1/post", "data", null, options, cancellationToken)).IsSuccessful, + (await client.PostAsync("http://127.0.0.1:1/post-headers", "data", headers, null, options, cancellationToken)).IsSuccessful, + (await client.PostAsync("http://127.0.0.1:1/post-typed", "data", null, options, cancellationToken)).IsSuccessful, + (await client.PostAsync("http://127.0.0.1:1/post-typed-headers", "data", headers, null, options, cancellationToken)).IsSuccessful, + (await client.PutAsync("http://127.0.0.1:1/put", "data", null, options, cancellationToken)).IsSuccessful, + (await client.PutAsync("http://127.0.0.1:1/put-headers", "data", headers, null, options, cancellationToken)).IsSuccessful, + (await client.PutAsync("http://127.0.0.1:1/put-typed", "data", null, options, cancellationToken)).IsSuccessful, + (await client.PutAsync("http://127.0.0.1:1/put-typed-headers", "data", headers, null, options, cancellationToken)).IsSuccessful, + (await client.DeleteAsync("http://127.0.0.1:1/delete", options, cancellationToken)).IsSuccessful, + (await client.DeleteAsync("http://127.0.0.1:1/delete-headers", headers, options, cancellationToken)).IsSuccessful, + (await client.DeleteAsync("http://127.0.0.1:1/delete-typed", null, options, cancellationToken)).IsSuccessful, + (await client.DeleteAsync("http://127.0.0.1:1/delete-typed-headers", headers, null, options, cancellationToken)).IsSuccessful + ]; + IReadOnlyList requests = await proxy.Requests; + + Assert.IsTrue(successful.All(value => value)); + Assert.AreEqual(16, requests.Count); + Assert.IsTrue(requests.All(request => request.Target.StartsWith("http://127.0.0.1:1/", StringComparison.Ordinal))); + IEnumerable overloads = typeof(IRestlingClient).GetMethods() + .Where(method => method.GetParameters().Any(parameter => parameter.ParameterType == typeof(RequestProxyOptions))); + Assert.AreEqual(16, overloads.Count()); + Assert.IsTrue(overloads.All(method => method.GetParameters()[^1].ParameterType == typeof(CancellationToken))); + } + + #endregion + } +} diff --git a/Sources/AMDevIT.Restling/AMDevIT.Restling.sln b/Sources/AMDevIT.Restling/AMDevIT.Restling.sln index 120e73b..b32ddf7 100644 --- a/Sources/AMDevIT.Restling/AMDevIT.Restling.sln +++ b/Sources/AMDevIT.Restling/AMDevIT.Restling.sln @@ -1,16 +1,23 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.12.35707.178 +# Visual Studio Version 18 +VisualStudioVersion = 18.9.12112.369 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AMDevIT.Restling.Core", "AMDevIT.Restling.Core\AMDevIT.Restling.Core.csproj", "{F2071889-BDF6-4E1F-ACF1-8C35BA432F1A}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AMDevIT.Restling.Tests", "AMDevIT.Restling.Tests\AMDevIT.Restling.Tests.csproj", "{98ECACE1-BE5A-4248-BFA3-09E3B2DEDD51}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AMDevIT.Restling.Csv", "AMDevIT.Restling.Csv\AMDevIT.Restling.Csv.csproj", "{E62C125C-3D75-48E3-973C-B5A081F7168B}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libraries", "Libraries", "{6C83A596-D32D-B35F-00DD-F0C99B673980}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{E0F14E3E-D4C9-4300-864E-AE9EA109A97A}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Elementi di soluzione", "Elementi di soluzione", "{881A2515-9E82-47A3-9E4C-AFE3DCCEFF3D}" + ProjectSection(SolutionItems) = preProject + Directory.Build.props = Directory.Build.props + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -25,6 +32,10 @@ Global {98ECACE1-BE5A-4248-BFA3-09E3B2DEDD51}.Debug|Any CPU.Build.0 = Debug|Any CPU {98ECACE1-BE5A-4248-BFA3-09E3B2DEDD51}.Release|Any CPU.ActiveCfg = Release|Any CPU {98ECACE1-BE5A-4248-BFA3-09E3B2DEDD51}.Release|Any CPU.Build.0 = Release|Any CPU + {E62C125C-3D75-48E3-973C-B5A081F7168B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E62C125C-3D75-48E3-973C-B5A081F7168B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E62C125C-3D75-48E3-973C-B5A081F7168B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E62C125C-3D75-48E3-973C-B5A081F7168B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -32,5 +43,9 @@ Global GlobalSection(NestedProjects) = preSolution {F2071889-BDF6-4E1F-ACF1-8C35BA432F1A} = {6C83A596-D32D-B35F-00DD-F0C99B673980} {98ECACE1-BE5A-4248-BFA3-09E3B2DEDD51} = {E0F14E3E-D4C9-4300-864E-AE9EA109A97A} + {E62C125C-3D75-48E3-973C-B5A081F7168B} = {6C83A596-D32D-B35F-00DD-F0C99B673980} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {6E3A2A63-98A9-4DF3-A7D4-E467DE8E4A92} EndGlobalSection EndGlobal diff --git a/Sources/AMDevIT.Restling/Directory.Build.props b/Sources/AMDevIT.Restling/Directory.Build.props new file mode 100644 index 0000000..8ffea62 --- /dev/null +++ b/Sources/AMDevIT.Restling/Directory.Build.props @@ -0,0 +1,39 @@ + + + true + + + + + $(MSBuildThisFileDirectory)..\..\artifacts\packages + $(MSBuildThisFileDirectory)..\..\artifacts\publish\$(MSBuildProjectName)\$(TargetFramework)\$(RuntimeIdentifier) + + + + net10.0;net9.0;net8.0 + enable + enable + RestlingIcon.png + https://github.com/AMDevIT/Restling + https://github.com/AMDevIT/Restling.git + rest;http;httpclient;api;apiclient;client;restclient;dotnet;csharp;networking;web;request;response;json;xml;serialization;deserialization;async;await;typed-client;fluent;builder;wrapper + True + $(VersionPrefix)$(VersionSuffix) + false + true + true + snupkg + true + true + true + true + true + Alessandro Morvillo + AMDev.IT di Alessandro Morvillo + © 2025 Alessandro Morvillo + 1.55.6.0 + + $(VersionPrefix) + $(AssemblyVersion) + +