Add JSON headers automatically to Body in HTTP client (#395)

This commit is contained in:
Patrick Stevens
2025-06-18 16:46:14 +01:00
committed by GitHub
parent 9473a080ff
commit 4fe4e3f277
7 changed files with 411 additions and 34 deletions

View File

@@ -1,5 +1,10 @@
Notable changes are recorded here. Notable changes are recorded here.
# WoofWare.Myriad.Plugins 8.0.3
The RestEase-style HTTP client generator now automatically adds the `application/json` content type header to requests which are POSTing a body that is known to be JSON-serialised.
You can override this by setting the `[<RestEase.Header ("Content-Type", "desired content type")>]` header manually on any affected member.
# WoofWare.Myriad.Plugins 7.0.1 # WoofWare.Myriad.Plugins 7.0.1
All generators should now be compatible with `<Nullable>enable</Nullable>`. All generators should now be compatible with `<Nullable>enable</Nullable>`.

View File

@@ -375,7 +375,9 @@ module PureGymApi =
match node with match node with
| None -> "null" | None -> "null"
| Some node -> node.ToJsonString () | Some node -> node.ToJsonString ()
) ),
null,
"application/json"
) )
do httpMessage.Content <- queryParams do httpMessage.Content <- queryParams
@@ -667,7 +669,9 @@ module PureGymApi =
let queryParams = let queryParams =
new System.Net.Http.StringContent ( new System.Net.Http.StringContent (
user |> PureGym.Member.toJsonNode |> (fun node -> node.ToJsonString ()) user |> PureGym.Member.toJsonNode |> (fun node -> node.ToJsonString ()),
null,
"application/json"
) )
do httpMessage.Content <- queryParams do httpMessage.Content <- queryParams
@@ -710,7 +714,9 @@ module PureGymApi =
) )
| field -> field) | field -> field)
) )
|> (fun node -> node.ToJsonString ()) |> (fun node -> node.ToJsonString ()),
null,
"application/json"
) )
do httpMessage.Content <- queryParams do httpMessage.Content <- queryParams
@@ -753,7 +759,9 @@ module PureGymApi =
) )
| field -> field) | field -> field)
) )
|> (fun node -> node.ToJsonString ()) |> (fun node -> node.ToJsonString ()),
null,
"application/json"
) )
do httpMessage.Content <- queryParams do httpMessage.Content <- queryParams
@@ -1801,3 +1809,174 @@ module ApiWithHeaders2 =
} }
|> (fun a -> Async.StartAsTask (a, ?cancellationToken = ct)) |> (fun a -> Async.StartAsTask (a, ?cancellationToken = ct))
} }
namespace PureGym
open System
open System.Threading
open System.Threading.Tasks
open System.IO
open System.Net
open System.Net.Http
open RestEase
/// Module for constructing a REST client.
[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix) ; RequireQualifiedAccess>]
module ClientWithJsonBody =
/// Create a REST client.
let make (client : System.Net.Http.HttpClient) : IClientWithJsonBody =
{ new IClientWithJsonBody with
member _.GetPathParam (parameter : string, mem : PureGym.Member, ct : CancellationToken option) =
async {
let! ct = Async.CancellationToken
let uri =
System.Uri (
(match client.BaseAddress with
| null ->
raise (
System.ArgumentNullException (
nameof (client.BaseAddress),
"No base address was supplied on the type, and no BaseAddress was on the HttpClient."
)
)
| v -> v),
System.Uri (
"endpoint/{param}"
.Replace ("{param}", parameter.ToString () |> System.Uri.EscapeDataString),
System.UriKind.Relative
)
)
let httpMessage =
new System.Net.Http.HttpRequestMessage (
Method = System.Net.Http.HttpMethod.Post,
RequestUri = uri
)
let queryParams =
new System.Net.Http.StringContent (
mem |> PureGym.Member.toJsonNode |> (fun node -> node.ToJsonString ()),
null,
"application/json"
)
do httpMessage.Content <- queryParams
let! response = client.SendAsync (httpMessage, ct) |> Async.AwaitTask
let response = response.EnsureSuccessStatusCode ()
let! responseString = response.Content.ReadAsStringAsync ct |> Async.AwaitTask
return responseString
}
|> (fun a -> Async.StartAsTask (a, ?cancellationToken = ct))
}
namespace PureGym
open System
open System.Threading
open System.Threading.Tasks
open System.IO
open System.Net
open System.Net.Http
open RestEase
/// Module for constructing a REST client.
[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix) ; RequireQualifiedAccess>]
module ClientWithJsonBodyOverridden =
/// Create a REST client.
let make (client : System.Net.Http.HttpClient) : IClientWithJsonBodyOverridden =
{ new IClientWithJsonBodyOverridden with
member _.GetPathParam (parameter : string, mem : PureGym.Member, ct : CancellationToken option) =
async {
let! ct = Async.CancellationToken
let uri =
System.Uri (
(match client.BaseAddress with
| null ->
raise (
System.ArgumentNullException (
nameof (client.BaseAddress),
"No base address was supplied on the type, and no BaseAddress was on the HttpClient."
)
)
| v -> v),
System.Uri (
"endpoint/{param}"
.Replace ("{param}", parameter.ToString () |> System.Uri.EscapeDataString),
System.UriKind.Relative
)
)
let httpMessage =
new System.Net.Http.HttpRequestMessage (
Method = System.Net.Http.HttpMethod.Post,
RequestUri = uri
)
let queryParams =
new System.Net.Http.StringContent (
mem |> PureGym.Member.toJsonNode |> (fun node -> node.ToJsonString ()),
null,
"application/ecmascript"
)
do httpMessage.Content <- queryParams
let! response = client.SendAsync (httpMessage, ct) |> Async.AwaitTask
let response = response.EnsureSuccessStatusCode ()
let! responseString = response.Content.ReadAsStringAsync ct |> Async.AwaitTask
return responseString
}
|> (fun a -> Async.StartAsTask (a, ?cancellationToken = ct))
}
namespace PureGym
open System
open System.Threading
open System.Threading.Tasks
open System.IO
open System.Net
open System.Net.Http
open RestEase
/// Module for constructing a REST client.
[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix) ; RequireQualifiedAccess>]
module ClientWithStringBody =
/// Create a REST client.
let make (client : System.Net.Http.HttpClient) : IClientWithStringBody =
{ new IClientWithStringBody with
member _.GetPathParam (parameter : string, mem : string, ct : CancellationToken option) =
async {
let! ct = Async.CancellationToken
let uri =
System.Uri (
(match client.BaseAddress with
| null ->
raise (
System.ArgumentNullException (
nameof (client.BaseAddress),
"No base address was supplied on the type, and no BaseAddress was on the HttpClient."
)
)
| v -> v),
System.Uri (
"endpoint/{param}"
.Replace ("{param}", parameter.ToString () |> System.Uri.EscapeDataString),
System.UriKind.Relative
)
)
let httpMessage =
new System.Net.Http.HttpRequestMessage (
Method = System.Net.Http.HttpMethod.Post,
RequestUri = uri
)
let queryParams = new System.Net.Http.StringContent (mem)
do httpMessage.Content <- queryParams
let! response = client.SendAsync (httpMessage, ct) |> Async.AwaitTask
let response = response.EnsureSuccessStatusCode ()
let! responseString = response.Content.ReadAsStringAsync ct |> Async.AwaitTask
return responseString
}
|> (fun a -> Async.StartAsTask (a, ?cancellationToken = ct))
}

View File

@@ -203,3 +203,35 @@ type IApiWithHeaders2 =
[<Get "endpoint/{param}">] [<Get "endpoint/{param}">]
abstract GetPathParam : abstract GetPathParam :
[<WoofWare.Myriad.Plugins.RestEase.Path "param">] parameter : string * ?ct : CancellationToken -> Task<string> [<WoofWare.Myriad.Plugins.RestEase.Path "param">] parameter : string * ?ct : CancellationToken -> Task<string>
[<WoofWare.Myriad.Plugins.HttpClient>]
type IClientWithJsonBody =
// As a POST request of a JSON-serialised body, we automatically set Content-Type: application/json.
[<Post "endpoint/{param}">]
abstract GetPathParam :
[<RestEase.Path "param">] parameter : string *
[<WoofWare.Myriad.Plugins.RestEase.Body>] mem : PureGym.Member *
?ct : CancellationToken ->
Task<string>
[<WoofWare.Myriad.Plugins.HttpClient>]
type IClientWithJsonBodyOverridden =
// As a POST request of a JSON-serialised body, we *would* automatically set Content-Type: application/json,
// but this method has overridden it.
[<Post "endpoint/{param}">]
[<Header("Content-Type", "application/ecmascript")>]
abstract GetPathParam :
[<RestEase.Path "param">] parameter : string *
[<WoofWare.Myriad.Plugins.RestEase.Body>] mem : PureGym.Member *
?ct : CancellationToken ->
Task<string>
[<WoofWare.Myriad.Plugins.HttpClient>]
type IClientWithStringBody =
// As a POST request of a bare string body, we don't override the Content-Type.
[<Post "endpoint/{param}">]
abstract GetPathParam :
[<RestEase.Path "param">] parameter : string *
[<WoofWare.Myriad.Plugins.RestEase.Body>] mem : string *
?ct : CancellationToken ->
Task<string>

View File

@@ -3,10 +3,12 @@ namespace WoofWare.Myriad.Plugins.Test
open System open System
open System.Net open System.Net
open System.Net.Http open System.Net.Http
open System.Text.Json.Nodes
open System.Threading open System.Threading
open NUnit.Framework open NUnit.Framework
open FsUnitTyped open FsUnitTyped
open PureGym open PureGym
open WoofWare.Expect
[<TestFixture>] [<TestFixture>]
module TestVariableHeader = module TestVariableHeader =
@@ -50,15 +52,17 @@ module TestVariableHeader =
someHeaderCount.Value |> shouldEqual 10 someHeaderCount.Value |> shouldEqual 10
someOtherHeaderCount.Value |> shouldEqual -100 someOtherHeaderCount.Value |> shouldEqual -100
api.GetPathParam("param").Result.Split "\n" expect {
|> Array.sort snapshotJson
|> shouldEqual @"[
[| ""Authorization: -99"",
"Authorization: -99" ""Header-Name: Header-Value"",
"Header-Name: Header-Value" ""Something-Else: val"",
"Something-Else: val" ""X-Foo: 11""
"X-Foo: 11" ]"
|]
return api.GetPathParam("param").Result.Split "\n" |> Array.sort
}
someHeaderCount.Value |> shouldEqual 11 someHeaderCount.Value |> shouldEqual 11
someOtherHeaderCount.Value |> shouldEqual -99 someOtherHeaderCount.Value |> shouldEqual -99
@@ -102,25 +106,156 @@ module TestVariableHeader =
someHeaderCount.Value |> shouldEqual 10 someHeaderCount.Value |> shouldEqual 10
someOtherHeaderCount.Value |> shouldEqual -100 someOtherHeaderCount.Value |> shouldEqual -100
api.GetPathParam("param").Result.Split "\n" expect {
|> Array.sort snapshotJson
|> shouldEqual @"[
[| ""Authorization: -99"",
"Authorization: -99" ""Header-Name: Header-Value"",
"Header-Name: Header-Value" ""Something-Else: val"",
"Something-Else: val" ""X-Foo: 11""
"X-Foo: 11" ]"
|]
api.GetPathParam("param").Result.Split "\n" return api.GetPathParam("param").Result.Split "\n" |> Array.sort
|> Array.sort }
|> shouldEqual
[| expect {
"Authorization: -98" snapshotJson
"Header-Name: Header-Value" @"[
"Something-Else: val" ""Authorization: -98"",
"X-Foo: 12" ""Header-Name: Header-Value"",
|] ""Something-Else: val"",
""X-Foo: 12""
]"
return api.GetPathParam("param").Result.Split "\n" |> Array.sort
}
someHeaderCount.Value |> shouldEqual 12 someHeaderCount.Value |> shouldEqual 12
someOtherHeaderCount.Value |> shouldEqual -98 someOtherHeaderCount.Value |> shouldEqual -98
let pureGymMember =
{
Id = 3
CompoundMemberId = "compound"
FirstName = "Patrick"
LastName = "Stevens"
HomeGymId = 1223
HomeGymName = "Arnie's Temple o' Gainz"
EmailAddress = "patrick@home"
GymAccessPin = "1234"
DateOfBirth = DateOnly (1992, 03, 04)
MobileNumber = "number"
Postcode = "postcode"
MembershipName = "member"
MembershipLevel = 9999
SuspendedReason = -1
MemberStatus = 100
}
[<Test>]
let ``Content-Type header is automatically inserted`` () =
let proc (message : HttpRequestMessage) : HttpResponseMessage Async =
async {
message.Method |> shouldEqual HttpMethod.Post
message.RequestUri.ToString ()
|> shouldEqual "https://example.com/endpoint/param"
let headers =
[
for h in message.Content.Headers do
yield $"%s{h.Key}: %s{Seq.exactlyOne h.Value}"
]
|> String.concat "\n"
let! ct = Async.CancellationToken
let! content = message.Content.ReadAsStringAsync ct |> Async.AwaitTask
content |> JsonNode.Parse |> Member.jsonParse |> shouldEqual pureGymMember
let content = new StringContent (headers)
let resp = new HttpResponseMessage (HttpStatusCode.OK)
resp.Content <- content
return resp
}
use client = HttpClientMock.make (Uri "https://example.com") proc
let api = ClientWithJsonBody.make client
let result = api.GetPathParam ("param", pureGymMember) |> _.Result
expect {
snapshot @"Content-Type: application/json; charset=utf-8"
return result
}
[<Test>]
let ``Content-Type header is respected if overridden`` () =
let proc (message : HttpRequestMessage) : HttpResponseMessage Async =
async {
message.Method |> shouldEqual HttpMethod.Post
message.RequestUri.ToString ()
|> shouldEqual "https://example.com/endpoint/param"
let headers =
[
for h in message.Content.Headers do
yield $"%s{h.Key}: %s{Seq.exactlyOne h.Value}"
]
|> String.concat "\n"
let! ct = Async.CancellationToken
let! content = message.Content.ReadAsStringAsync ct |> Async.AwaitTask
content |> JsonNode.Parse |> Member.jsonParse |> shouldEqual pureGymMember
let content = new StringContent (headers)
let resp = new HttpResponseMessage (HttpStatusCode.OK)
resp.Content <- content
return resp
}
use client = HttpClientMock.make (Uri "https://example.com") proc
let api = ClientWithJsonBodyOverridden.make client
let result = api.GetPathParam ("param", pureGymMember) |> _.Result
expect {
snapshot @"Content-Type: application/ecmascript; charset=utf-8"
return result
}
[<Test>]
let ``Content-Type header is the default for strings`` () =
let proc (message : HttpRequestMessage) : HttpResponseMessage Async =
async {
message.Method |> shouldEqual HttpMethod.Post
message.RequestUri.ToString ()
|> shouldEqual "https://example.com/endpoint/param"
let headers =
[
for h in message.Content.Headers do
yield $"%s{h.Key}: %s{Seq.exactlyOne h.Value}"
]
|> String.concat "\n"
let! ct = Async.CancellationToken
let! content = message.Content.ReadAsStringAsync ct |> Async.AwaitTask
content |> shouldEqual "hello!"
let content = new StringContent (headers)
let resp = new HttpResponseMessage (HttpStatusCode.OK)
resp.Content <- content
return resp
}
use client = HttpClientMock.make (Uri "https://example.com") proc
let api = ClientWithStringBody.make client
let result = api.GetPathParam ("param", "hello!") |> _.Result
expect {
snapshot @"Content-Type: text/plain; charset=utf-8"
return result
}

View File

@@ -48,6 +48,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="NUnit" Version="4.3.2"/> <PackageReference Include="NUnit" Version="4.3.2"/>
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0"/> <PackageReference Include="NUnit3TestAdapter" Version="5.0.0"/>
<PackageReference Include="WoofWare.Expect" Version="0.4.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -453,15 +453,20 @@ module internal HttpClientGenerator =
let contentTypeHeader, memberHeaders = let contentTypeHeader, memberHeaders =
info.Headers info.Headers
|> List.partition (fun (headerName, headerValue) -> |> List.partition (fun (headerName, _headerValue) ->
match headerName |> SynExpr.stripOptionalParen with match headerName |> SynExpr.stripOptionalParen with
| SynExpr.Const (SynConst.String ("Content-Type", _, _), _) -> true | SynExpr.Const (SynConst.String (s, _, _), _) ->
System.String.Equals (s, "Content-Type", System.StringComparison.OrdinalIgnoreCase)
| _ -> false | _ -> false
) )
let contentTypeHeader = let contentTypeHeader =
match contentTypeHeader with match contentTypeHeader with
| [] -> None | [] ->
// Set application/json if we *know* we're sending JSON
match bodyParam with
| Some (BodyParamMethods.Serialise _, _) -> Some (SynExpr.CreateConst "application/json")
| _ -> None
| [ _, ct ] -> Some (SynExpr.stripOptionalParen ct) | [ _, ct ] -> Some (SynExpr.stripOptionalParen ct)
| _ -> failwith "Unexpectedly got multiple Content-Type headers" | _ -> failwith "Unexpectedly got multiple Content-Type headers"

View File

@@ -44,6 +44,11 @@
"version": "9.0.202", "version": "9.0.202",
"hash": "sha256-64Gub0qemmCoMa1tDus6TeTuB1+5sHfE6KD2j4o84mA=" "hash": "sha256-64Gub0qemmCoMa1tDus6TeTuB1+5sHfE6KD2j4o84mA="
}, },
{
"pname": "FSharp.SystemTextJson",
"version": "1.4.36",
"hash": "sha256-zZEhjP0mdc5E3fBPS4/lqD7sxoaoT5SOspP546RWYdc="
},
{ {
"pname": "FsUnit", "pname": "FsUnit",
"version": "7.0.1", "version": "7.0.1",
@@ -364,16 +369,31 @@
"version": "4.5.0", "version": "4.5.0",
"hash": "sha256-BkUYNguz0e4NJp1kkW7aJBn3dyH9STwB5N8XqnlCsmY=" "hash": "sha256-BkUYNguz0e4NJp1kkW7aJBn3dyH9STwB5N8XqnlCsmY="
}, },
{
"pname": "System.Text.Json",
"version": "6.0.10",
"hash": "sha256-UijYh0dxFjFinMPSTJob96oaRkNm+Wsa+7Ffg6mRnsc="
},
{ {
"pname": "System.Text.Json", "pname": "System.Text.Json",
"version": "8.0.5", "version": "8.0.5",
"hash": "sha256-yKxo54w5odWT6nPruUVsaX53oPRe+gKzGvLnnxtwP68=" "hash": "sha256-yKxo54w5odWT6nPruUVsaX53oPRe+gKzGvLnnxtwP68="
}, },
{
"pname": "System.Text.Json",
"version": "9.0.0",
"hash": "sha256-aM5Dh4okLnDv940zmoFAzRmqZre83uQBtGOImJpoIqk="
},
{ {
"pname": "TypeEquality", "pname": "TypeEquality",
"version": "0.3.0", "version": "0.3.0",
"hash": "sha256-V50xAOzzyUJrY+MYPRxtnqW5MVeATXCes89wPprv1r4=" "hash": "sha256-V50xAOzzyUJrY+MYPRxtnqW5MVeATXCes89wPprv1r4="
}, },
{
"pname": "WoofWare.Expect",
"version": "0.4.2",
"hash": "sha256-CaVcj9Fo0VSMgfKIukM9WHGufPWHDqMO1D4VYVdJKJk="
},
{ {
"pname": "WoofWare.Whippet.Fantomas", "pname": "WoofWare.Whippet.Fantomas",
"version": "0.6.3", "version": "0.6.3",