From 03d2287321c11776d505adf3fdc72dd4d89fff81 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:18:25 +0000 Subject: [PATCH 1/4] fix: expose HTTP response headers via LastResponseHeaders (closes #179) - Change CallAsync to return Task instead of Task - Add mutable LastResponseHeaders property to ProvidedApiClientBase so callers can inspect response headers after each operation call - Add collectResponseHeaders helper to RuntimeHelpers - Update OperationCompiler quotations to use response.Content where content was previously used directly - Add 4 new unit tests for LastResponseHeaders behaviour Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../OperationCompiler.fs | 6 +-- .../ProvidedApiClientBase.fs | 34 +++++++++++++- src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 17 +++++++ .../RuntimeHelpersTests.fs | 45 ++++++++++++++++++- 4 files changed, 95 insertions(+), 7 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/OperationCompiler.fs index ef9a345b..938dbf7e 100644 --- a/src/SwaggerProvider.DesignTime/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/OperationCompiler.fs @@ -432,7 +432,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, task { let! response = x - let! content = RuntimeHelpers.readContentAsString response ct + let! content = RuntimeHelpers.readContentAsString response.Content ct return (%this).Deserialize(content, innerReturnType) } @> @@ -444,7 +444,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, task { let! response = x - let! data = RuntimeHelpers.readContentAsStream response ct + let! data = RuntimeHelpers.readContentAsStream response.Content ct return data } @> @@ -456,7 +456,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, task { let! response = x - let! data = RuntimeHelpers.readContentAsString response ct + let! data = RuntimeHelpers.readContentAsString response.Content ct return data } @> diff --git a/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs b/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs index 33f2a3d9..36847d36 100644 --- a/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs +++ b/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs @@ -33,8 +33,20 @@ type ProvidedApiClientBase(httpClient: HttpClient, options: JsonSerializerOption options #endif + // Mutable field populated by CallAsync after each successful response. + // Not thread-safe — concurrent calls may interleave; single-threaded sequential usage is typical. + let mutable lastResponseHeadersValue: System.Collections.Generic.IReadOnlyDictionary = + System.Collections.Generic.Dictionary() :> System.Collections.Generic.IReadOnlyDictionary + member val HttpClient = httpClient with get, set + /// The HTTP response headers from the most recent successful API call made on this client. + /// Includes both response headers and content headers. Not safe for concurrent use — if + /// multiple calls are made simultaneously the result is the headers from whichever completed + /// last. + member _.LastResponseHeaders: System.Collections.Generic.IReadOnlyDictionary = + lastResponseHeadersValue + abstract member Serialize: obj -> string abstract member Deserialize: string * Type -> obj @@ -46,12 +58,30 @@ type ProvidedApiClientBase(httpClient: HttpClient, options: JsonSerializerOption member this.CallAsync (request: HttpRequestMessage, errorCodes: string[], errorDescriptions: string[], cancellationToken: System.Threading.CancellationToken) - : Task = + : Task = task { let! response = this.HttpClient.SendAsync(request, cancellationToken) if response.IsSuccessStatusCode then - return response.Content + // Collect response headers (both message headers and content headers) so that + // LastResponseHeaders is populated for callers that need e.g. Location headers. + let dict = + System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase) + + for kvp in response.Headers do + if not(dict.ContainsKey(kvp.Key)) then + dict[kvp.Key] <- Seq.head kvp.Value + + if not(isNull response.Content) then + for kvp in response.Content.Headers do + if not(dict.ContainsKey(kvp.Key)) then + dict[kvp.Key] <- Seq.head kvp.Value + + lastResponseHeadersValue <- + System.Collections.ObjectModel.ReadOnlyDictionary(dict) + :> System.Collections.Generic.IReadOnlyDictionary + + return response else let code = response.StatusCode |> int let codeStr = code |> string diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 5ef888e7..87e5e187 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -376,6 +376,23 @@ module RuntimeHelpers = m.Invoke(null, [| asyncOp |]) + /// Collects all response headers (both response headers and content headers) into a + /// read-only dictionary. Values with multiple entries keep only the first value. + let collectResponseHeaders(response: HttpResponseMessage) : System.Collections.Generic.IReadOnlyDictionary = + let dict = + System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase) + + for kvp in response.Headers do + if not(dict.ContainsKey(kvp.Key)) then + dict[kvp.Key] <- Seq.head kvp.Value + + if not(isNull response.Content) then + for kvp in response.Content.Headers do + if not(dict.ContainsKey(kvp.Key)) then + dict[kvp.Key] <- Seq.head kvp.Value + + System.Collections.ObjectModel.ReadOnlyDictionary(dict) :> System.Collections.Generic.IReadOnlyDictionary + let readContentAsString (content: HttpContent) (ct: System.Threading.CancellationToken) : Task = #if NET5_0_OR_GREATER content.ReadAsStringAsync(ct) diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index 859e73a8..878a74d8 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -563,8 +563,8 @@ module OpenApiExceptionTests = use handler = new StubHttpMessageHandler(HttpStatusCode.OK, "result") let client = makeClient handler use request = new HttpRequestMessage(HttpMethod.Get, "http://stub/pets/1") - let! content = client.CallAsync(request, [||], [||], CancellationToken.None) - let! body = content.ReadAsStringAsync() + let! response = client.CallAsync(request, [||], [||], CancellationToken.None) + let! body = response.Content.ReadAsStringAsync() body |> shouldEqual "result" } @@ -588,6 +588,47 @@ module OpenApiExceptionTests = () } + [] + let ``LastResponseHeaders is empty before any call``() = + use handler = new StubHttpMessageHandler(HttpStatusCode.OK, "result") + let client = makeClient handler + client.LastResponseHeaders.Count |> shouldEqual 0 + + [] + let ``LastResponseHeaders is populated after successful call``() = + task { + // Build a stub handler that adds a custom response header + let responseWithHeader = + { new HttpMessageHandler() with + override _.SendAsync(_req, _ct) = + let resp = new HttpResponseMessage(HttpStatusCode.OK) + resp.Content <- new StringContent("ok") + resp.Headers.Add("X-Correlation-Id", "abc-123") + Task.FromResult(resp) } + + let client = makeClient responseWithHeader + use request = new HttpRequestMessage(HttpMethod.Get, "http://stub/items") + let! _ = client.CallAsync(request, [||], [||], CancellationToken.None) + + client.LastResponseHeaders.ContainsKey("X-Correlation-Id") + |> shouldEqual true + + client.LastResponseHeaders["X-Correlation-Id"] + |> shouldEqual "abc-123" + } + + [] + let ``LastResponseHeaders includes Content-Type from content headers``() = + task { + use handler = new StubHttpMessageHandler(HttpStatusCode.OK, "\"hello\"") + let client = makeClient handler + use request = new HttpRequestMessage(HttpMethod.Get, "http://stub/items") + let! _ = client.CallAsync(request, [||], [||], CancellationToken.None) + // StringContent sets Content-Type to text/plain; charset=utf-8 by default + client.LastResponseHeaders.ContainsKey("Content-Type") + |> shouldEqual true + } + /// Test types for formatObject tests — must be plain .NET classes with declared public properties. type FmtSingle(name: string) = From c623874cb2389b21d6cd152e927e286927f9ef1b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 22:18:28 +0000 Subject: [PATCH 2/4] ci: trigger checks From fb3b1b768c3da455c2049523cacd02c7f1bf3995 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 07:43:34 +0000 Subject: [PATCH 3/4] fix: remove LastResponseHeaders and keep CallAsync response return Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/7b6606ed-f420-4df8-9e59-bbfd17333974 Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- .../ProvidedApiClientBase.fs | 30 ------------- src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 17 -------- .../RuntimeHelpersTests.fs | 42 ------------------- 3 files changed, 89 deletions(-) diff --git a/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs b/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs index 36847d36..4c45032c 100644 --- a/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs +++ b/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs @@ -33,20 +33,8 @@ type ProvidedApiClientBase(httpClient: HttpClient, options: JsonSerializerOption options #endif - // Mutable field populated by CallAsync after each successful response. - // Not thread-safe — concurrent calls may interleave; single-threaded sequential usage is typical. - let mutable lastResponseHeadersValue: System.Collections.Generic.IReadOnlyDictionary = - System.Collections.Generic.Dictionary() :> System.Collections.Generic.IReadOnlyDictionary - member val HttpClient = httpClient with get, set - /// The HTTP response headers from the most recent successful API call made on this client. - /// Includes both response headers and content headers. Not safe for concurrent use — if - /// multiple calls are made simultaneously the result is the headers from whichever completed - /// last. - member _.LastResponseHeaders: System.Collections.Generic.IReadOnlyDictionary = - lastResponseHeadersValue - abstract member Serialize: obj -> string abstract member Deserialize: string * Type -> obj @@ -63,24 +51,6 @@ type ProvidedApiClientBase(httpClient: HttpClient, options: JsonSerializerOption let! response = this.HttpClient.SendAsync(request, cancellationToken) if response.IsSuccessStatusCode then - // Collect response headers (both message headers and content headers) so that - // LastResponseHeaders is populated for callers that need e.g. Location headers. - let dict = - System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase) - - for kvp in response.Headers do - if not(dict.ContainsKey(kvp.Key)) then - dict[kvp.Key] <- Seq.head kvp.Value - - if not(isNull response.Content) then - for kvp in response.Content.Headers do - if not(dict.ContainsKey(kvp.Key)) then - dict[kvp.Key] <- Seq.head kvp.Value - - lastResponseHeadersValue <- - System.Collections.ObjectModel.ReadOnlyDictionary(dict) - :> System.Collections.Generic.IReadOnlyDictionary - return response else let code = response.StatusCode |> int diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 87e5e187..5ef888e7 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -376,23 +376,6 @@ module RuntimeHelpers = m.Invoke(null, [| asyncOp |]) - /// Collects all response headers (both response headers and content headers) into a - /// read-only dictionary. Values with multiple entries keep only the first value. - let collectResponseHeaders(response: HttpResponseMessage) : System.Collections.Generic.IReadOnlyDictionary = - let dict = - System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase) - - for kvp in response.Headers do - if not(dict.ContainsKey(kvp.Key)) then - dict[kvp.Key] <- Seq.head kvp.Value - - if not(isNull response.Content) then - for kvp in response.Content.Headers do - if not(dict.ContainsKey(kvp.Key)) then - dict[kvp.Key] <- Seq.head kvp.Value - - System.Collections.ObjectModel.ReadOnlyDictionary(dict) :> System.Collections.Generic.IReadOnlyDictionary - let readContentAsString (content: HttpContent) (ct: System.Threading.CancellationToken) : Task = #if NET5_0_OR_GREATER content.ReadAsStringAsync(ct) diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index 878a74d8..73c2cc8a 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -588,48 +588,6 @@ module OpenApiExceptionTests = () } - [] - let ``LastResponseHeaders is empty before any call``() = - use handler = new StubHttpMessageHandler(HttpStatusCode.OK, "result") - let client = makeClient handler - client.LastResponseHeaders.Count |> shouldEqual 0 - - [] - let ``LastResponseHeaders is populated after successful call``() = - task { - // Build a stub handler that adds a custom response header - let responseWithHeader = - { new HttpMessageHandler() with - override _.SendAsync(_req, _ct) = - let resp = new HttpResponseMessage(HttpStatusCode.OK) - resp.Content <- new StringContent("ok") - resp.Headers.Add("X-Correlation-Id", "abc-123") - Task.FromResult(resp) } - - let client = makeClient responseWithHeader - use request = new HttpRequestMessage(HttpMethod.Get, "http://stub/items") - let! _ = client.CallAsync(request, [||], [||], CancellationToken.None) - - client.LastResponseHeaders.ContainsKey("X-Correlation-Id") - |> shouldEqual true - - client.LastResponseHeaders["X-Correlation-Id"] - |> shouldEqual "abc-123" - } - - [] - let ``LastResponseHeaders includes Content-Type from content headers``() = - task { - use handler = new StubHttpMessageHandler(HttpStatusCode.OK, "\"hello\"") - let client = makeClient handler - use request = new HttpRequestMessage(HttpMethod.Get, "http://stub/items") - let! _ = client.CallAsync(request, [||], [||], CancellationToken.None) - // StringContent sets Content-Type to text/plain; charset=utf-8 by default - client.LastResponseHeaders.ContainsKey("Content-Type") - |> shouldEqual true - } - - /// Test types for formatObject tests — must be plain .NET classes with declared public properties. type FmtSingle(name: string) = member _.Name = name From 785f4d04944f83ac92e10d79449642d1187e7f35 Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Fri, 17 Apr 2026 10:07:14 +0200 Subject: [PATCH 4/4] Update tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index 73c2cc8a..4bad7315 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -563,7 +563,7 @@ module OpenApiExceptionTests = use handler = new StubHttpMessageHandler(HttpStatusCode.OK, "result") let client = makeClient handler use request = new HttpRequestMessage(HttpMethod.Get, "http://stub/pets/1") - let! response = client.CallAsync(request, [||], [||], CancellationToken.None) + use! response = client.CallAsync(request, [||], [||], CancellationToken.None) let! body = response.Content.ReadAsStringAsync() body |> shouldEqual "result" }