diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..c7a8fdbc --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: tmenier diff --git a/src/Flurl.CodeGen/Program.cs b/src/Flurl.CodeGen/Program.cs index 0d3f1467..db0da26a 100644 --- a/src/Flurl.CodeGen/Program.cs +++ b/src/Flurl.CodeGen/Program.cs @@ -178,6 +178,7 @@ private static void WriteHttpExtensionMethod(CodeWriter writer, HttpExtensionMet args.Add("cancellationToken"); if (xm.RequestBodyType != null) { + writer.WriteLine("request.EnsureClient();"); // might need to serialize body, and client might provide custom serializer writer.WriteLine("var content = new Captured@0Content(@1);", xm.RequestBodyType, xm.RequestBodyType == "String" ? "body" : $"request.Settings.{xm.RequestBodyType}Serializer.Serialize(body)"); diff --git a/src/Flurl.Http.Newtonsoft/Flurl.Http.Newtonsoft.csproj b/src/Flurl.Http.Newtonsoft/Flurl.Http.Newtonsoft.csproj index f6d3e546..8b7cf13b 100644 --- a/src/Flurl.Http.Newtonsoft/Flurl.Http.Newtonsoft.csproj +++ b/src/Flurl.Http.Newtonsoft/Flurl.Http.Newtonsoft.csproj @@ -4,10 +4,10 @@ 9.0 True Flurl.Http.Newtonsoft - 0.9.0 + 0.9.1 Todd Menier A Newtonsoft-based JSON serializer for Flurl.Http 4.0 and above. - Copyright (c) Todd Menier 2023. + Copyright (c) Todd Menier 2024. https://flurl.dev icon.png https://github.com/tmenier/Flurl.git diff --git a/src/Flurl.Http/Flurl.Http.csproj b/src/Flurl.Http/Flurl.Http.csproj index 8e1dede3..773828c1 100644 --- a/src/Flurl.Http/Flurl.Http.csproj +++ b/src/Flurl.Http/Flurl.Http.csproj @@ -4,10 +4,10 @@ 9.0 True Flurl.Http - 4.0.0 + 4.0.1 Todd Menier - 4.0 contains breaking changes! See flurl.dev/upgrade - Copyright (c) Todd Menier 2023. + A fluent, testable HTTP client library. + Copyright (c) Todd Menier 2024. https://flurl.dev icon.png README.md diff --git a/src/Flurl.Http/FlurlRequest.cs b/src/Flurl.Http/FlurlRequest.cs index 9a20926a..ca9762b4 100644 --- a/src/Flurl.Http/FlurlRequest.cs +++ b/src/Flurl.Http/FlurlRequest.cs @@ -53,6 +53,12 @@ public interface IFlurlRequest : ISettingsContainer, IHeadersContainer, IEventHa /// FlurlCall RedirectedFrom { get; set; } + /// + /// If Client property is null, selects (or creates) a FlurlClient from the global FlurlHttp.Clients cache. Called + /// automatically just before a request is sent, so in most cases there is no need to call explicitly. + /// + IFlurlClient EnsureClient(); + /// /// Asynchronously sends the HTTP request. Mainly used to implement higher-level extension methods (GetJsonAsync, etc). /// @@ -138,12 +144,14 @@ public CookieJar CookieJar { set => ApplyCookieJar(value); } + /// + public IFlurlClient EnsureClient() => Client ??= FlurlHttp.GetClientForRequest(this); + /// public Task SendAsync(HttpMethod verb, HttpContent content = null, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken cancellationToken = default) { Verb = verb; Content = content; - Client ??= FlurlHttp.GetClientForRequest(this); - return Client.SendAsync(this, completionOption, cancellationToken); + return EnsureClient().SendAsync(this, completionOption, cancellationToken); } internal static void SyncHeaders(IFlurlClient client, IFlurlRequest request) { diff --git a/src/Flurl.Http/GeneratedExtensions.cs b/src/Flurl.Http/GeneratedExtensions.cs index 397e2bd2..75b39ab3 100644 --- a/src/Flurl.Http/GeneratedExtensions.cs +++ b/src/Flurl.Http/GeneratedExtensions.cs @@ -25,6 +25,7 @@ public static class GeneratedExtensions /// The token to monitor for cancellation requests. /// A Task whose result is the received IFlurlResponse. public static Task SendJsonAsync(this IFlurlRequest request, HttpMethod verb, object body, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken cancellationToken = default) { + request.EnsureClient(); var content = new CapturedJsonContent(request.Settings.JsonSerializer.Serialize(body)); return request.SendAsync(verb, content, completionOption, cancellationToken); } @@ -39,6 +40,7 @@ public static Task SendJsonAsync(this IFlurlRequest request, Htt /// The token to monitor for cancellation requests. /// A Task whose result is the received IFlurlResponse. public static Task SendStringAsync(this IFlurlRequest request, HttpMethod verb, string body, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken cancellationToken = default) { + request.EnsureClient(); var content = new CapturedStringContent(body); return request.SendAsync(verb, content, completionOption, cancellationToken); } @@ -53,6 +55,7 @@ public static Task SendStringAsync(this IFlurlRequest request, H /// The token to monitor for cancellation requests. /// A Task whose result is the received IFlurlResponse. public static Task SendUrlEncodedAsync(this IFlurlRequest request, HttpMethod verb, object body, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken cancellationToken = default) { + request.EnsureClient(); var content = new CapturedUrlEncodedContent(request.Settings.UrlEncodedSerializer.Serialize(body)); return request.SendAsync(verb, content, completionOption, cancellationToken); } @@ -133,6 +136,7 @@ public static Task PostAsync(this IFlurlRequest request, HttpCon /// The token to monitor for cancellation requests. /// A Task whose result is the received IFlurlResponse. public static Task PostJsonAsync(this IFlurlRequest request, object body, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken cancellationToken = default) { + request.EnsureClient(); var content = new CapturedJsonContent(request.Settings.JsonSerializer.Serialize(body)); return request.SendAsync(HttpMethod.Post, content, completionOption, cancellationToken); } @@ -146,6 +150,7 @@ public static Task PostJsonAsync(this IFlurlRequest request, obj /// The token to monitor for cancellation requests. /// A Task whose result is the received IFlurlResponse. public static Task PostStringAsync(this IFlurlRequest request, string body, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken cancellationToken = default) { + request.EnsureClient(); var content = new CapturedStringContent(body); return request.SendAsync(HttpMethod.Post, content, completionOption, cancellationToken); } @@ -159,6 +164,7 @@ public static Task PostStringAsync(this IFlurlRequest request, s /// The token to monitor for cancellation requests. /// A Task whose result is the received IFlurlResponse. public static Task PostUrlEncodedAsync(this IFlurlRequest request, object body, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken cancellationToken = default) { + request.EnsureClient(); var content = new CapturedUrlEncodedContent(request.Settings.UrlEncodedSerializer.Serialize(body)); return request.SendAsync(HttpMethod.Post, content, completionOption, cancellationToken); } @@ -195,6 +201,7 @@ public static Task PutAsync(this IFlurlRequest request, HttpCont /// The token to monitor for cancellation requests. /// A Task whose result is the received IFlurlResponse. public static Task PutJsonAsync(this IFlurlRequest request, object body, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken cancellationToken = default) { + request.EnsureClient(); var content = new CapturedJsonContent(request.Settings.JsonSerializer.Serialize(body)); return request.SendAsync(HttpMethod.Put, content, completionOption, cancellationToken); } @@ -208,6 +215,7 @@ public static Task PutJsonAsync(this IFlurlRequest request, obje /// The token to monitor for cancellation requests. /// A Task whose result is the received IFlurlResponse. public static Task PutStringAsync(this IFlurlRequest request, string body, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken cancellationToken = default) { + request.EnsureClient(); var content = new CapturedStringContent(body); return request.SendAsync(HttpMethod.Put, content, completionOption, cancellationToken); } @@ -244,6 +252,7 @@ public static Task PatchAsync(this IFlurlRequest request, HttpCo /// The token to monitor for cancellation requests. /// A Task whose result is the received IFlurlResponse. public static Task PatchJsonAsync(this IFlurlRequest request, object body, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken cancellationToken = default) { + request.EnsureClient(); var content = new CapturedJsonContent(request.Settings.JsonSerializer.Serialize(body)); return request.SendAsync(new HttpMethod("PATCH"), content, completionOption, cancellationToken); } @@ -257,6 +266,7 @@ public static Task PatchJsonAsync(this IFlurlRequest request, ob /// The token to monitor for cancellation requests. /// A Task whose result is the received IFlurlResponse. public static Task PatchStringAsync(this IFlurlRequest request, string body, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken cancellationToken = default) { + request.EnsureClient(); var content = new CapturedStringContent(body); return request.SendAsync(new HttpMethod("PATCH"), content, completionOption, cancellationToken); } diff --git a/src/Flurl/Flurl.csproj b/src/Flurl/Flurl.csproj index 91686e9f..dab02777 100644 --- a/src/Flurl/Flurl.csproj +++ b/src/Flurl/Flurl.csproj @@ -6,7 +6,7 @@ 4.0.0 Todd Menier A fluent, portable URL builder. To make HTTP calls off the fluent chain, check out Flurl.Http. - Copyright (c) Todd Menier 2023. + Copyright (c) Todd Menier 2024. https://flurl.dev icon.png https://github.com/tmenier/Flurl.git diff --git a/test/Flurl.Test/Http/HttpTestFixtureBase.cs b/test/Flurl.Test/Http/HttpTestFixtureBase.cs index 1050ea2d..179978b9 100644 --- a/test/Flurl.Test/Http/HttpTestFixtureBase.cs +++ b/test/Flurl.Test/Http/HttpTestFixtureBase.cs @@ -12,14 +12,12 @@ public abstract class HttpTestFixtureBase [SetUp] public void SetUp() { - HttpTest = CreateHttpTest(); + HttpTest = new HttpTest(); } [TearDown] public void TearTown() { HttpTest.Dispose(); } - - protected virtual HttpTest CreateHttpTest() => new HttpTest(); } } diff --git a/test/Flurl.Test/Http/NewtonsoftTests.cs b/test/Flurl.Test/Http/NewtonsoftTests.cs index 34aed71d..21e6028a 100644 --- a/test/Flurl.Test/Http/NewtonsoftTests.cs +++ b/test/Flurl.Test/Http/NewtonsoftTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Net.Http; using NUnit.Framework; using Flurl.Http; @@ -7,43 +8,143 @@ using System.Threading.Tasks; using Flurl.Http.Configuration; using Newtonsoft.Json; -using NUnit.Framework.Constraints; +using Newtonsoft.Json.Linq; namespace Flurl.Test.Http { - // These inherit from GetTests and PostTests and swap out the JSON serializer - // in play, which gets us a lot of free tests but also a lot of redundant ones. - // Maybe worth refactoring someday, but they're fast so it's tolerable for now. - - [TestFixture, Parallelizable] - public class NewtonsoftGetTests : GetTests + [TestFixture] + public class NewtonsoftTests { - protected override HttpTest CreateHttpTest() => base.CreateHttpTest() - .WithSettings(settings => settings.JsonSerializer = new NewtonsoftJsonSerializer()); + private readonly JsonSerializerSettings _jsonSettings = new JsonSerializerSettings { + DateFormatString = "M/d/yy" + }; + + // JObject is from Newtsonsoft; STJ doesn't know how to serialize correctly + private readonly object _requestObject = new JObject { + ["test"] = "test", + ["number"] = 1, + ["date"] = new DateTime(2024, 1, 2) + }; + private readonly string _requestJson = "{\"test\":\"test\",\"number\":1,\"date\":\"1/2/24\"}"; + + private class ResponseModel + { + // JsonProperty from Newtonsoft; STJ will ignore + [JsonProperty("newtonsoft_prop")] + public string SomeProp { get; set; } + } + private readonly ResponseModel _responseObject = new ResponseModel { SomeProp = "xyz" }; + private readonly string _responseJson = "{\"newtonsoft_prop\":\"xyz\"}"; + + [Test] + public void test_data_incompatible_with_default_serializer() { + // confirms our test data works only with Newtonsoft + + var stj = new DefaultJsonSerializer(); + Assert.AreNotEqual(_requestJson, stj.Serialize(_requestObject)); + Assert.AreNotEqual(_responseObject.SomeProp, stj.Deserialize(_responseJson).SomeProp); + + var njs = new NewtonsoftJsonSerializer(_jsonSettings); + Assert.AreEqual(_requestJson, njs.Serialize(_requestObject)); + Assert.AreEqual(_responseObject.SomeProp, njs.Deserialize(_responseJson).SomeProp); + } + + private async Task AssertCallAsync(Func> call, bool hasRequestBody) { + using var test = new HttpTest(); + test.RespondWith(_responseJson); + + var resp = await call(); + + Assert.AreEqual(_responseObject.SomeProp, resp.SomeProp); + if (hasRequestBody) + test.ShouldHaveMadeACall().WithRequestBody(_requestJson); + } + + [Test, NonParallelizable] + public async Task works_with_clientless() { + try { + FlurlHttp.Clients.Clear(); + FlurlHttp.Clients.UseNewtonsoft(_jsonSettings); + + Assert.IsInstanceOf(FlurlHttp.GetClientForRequest(new FlurlRequest("http://api.com")).Settings.JsonSerializer); + + await AssertCallAsync(() => "http://api.com".GetJsonAsync(), false); + await AssertCallAsync(() => "http://api.com".PostJsonAsync(_requestObject).ReceiveJson(), true); + await AssertCallAsync(() => "http://api.com".PutJsonAsync(_requestObject).ReceiveJson(), true); + await AssertCallAsync(() => "http://api.com".PatchJsonAsync(_requestObject).ReceiveJson(), true); + } + finally { + FlurlHttp.Clients.Clear(); + FlurlHttp.Clients.WithDefaults(b => b.Settings.ResetDefaults()); + } + } + + [Test] + public async Task works_with_cached_clients() { + var cache = new FlurlClientCache().UseNewtonsoft(_jsonSettings); + + await AssertCallAsync(() => cache.GetOrAdd("c1").Request("http://api1.com").GetJsonAsync(), false); + await AssertCallAsync(() => cache.GetOrAdd("c2").Request("http://api2.com").PostJsonAsync(_requestObject).ReceiveJson(), true); + await AssertCallAsync(() => cache.GetOrAdd("c3").Request("http://api3.com").PutJsonAsync(_requestObject).ReceiveJson(), true); + await AssertCallAsync(() => cache.GetOrAdd("c4").Request("http://api4.com").PatchJsonAsync(_requestObject).ReceiveJson(), true); + } + + [Test] + public async Task works_with_individual_cached_client() { + var cache = new FlurlClientCache(); + var cli = cache.GetOrAdd("foo", null, builder => builder.UseNewtonsoft(_jsonSettings)); + + await AssertCallAsync(() => cli.Request("http://api.com").GetJsonAsync(), false); + await AssertCallAsync(() => cli.Request("http://api.com").PostJsonAsync(_requestObject).ReceiveJson(), true); + await AssertCallAsync(() => cli.Request("http://api.com").PutJsonAsync(_requestObject).ReceiveJson(), true); + await AssertCallAsync(() => cli.Request("http://api.com").PatchJsonAsync(_requestObject).ReceiveJson(), true); + } + + [Test] + public async Task works_with_individual_client() { + var cli = new FlurlClient().WithSettings(s => s.JsonSerializer = new NewtonsoftJsonSerializer(_jsonSettings)); + + await AssertCallAsync(() => cli.Request("http://api.com").GetJsonAsync(), false); + await AssertCallAsync(() => cli.Request("http://api.com").PostJsonAsync(_requestObject).ReceiveJson(), true); + await AssertCallAsync(() => cli.Request("http://api.com").PutJsonAsync(_requestObject).ReceiveJson(), true); + await AssertCallAsync(() => cli.Request("http://api.com").PatchJsonAsync(_requestObject).ReceiveJson(), true); + } [Test] public async Task can_get_dynamic() { - HttpTest.RespondWithJson(new { id = 1, name = "Frank" }); + using var test = new HttpTest(); + test.RespondWithJson(new { id = 1, name = "Frank" }); - var data = await "http://some-api.com".GetJsonAsync(); + var cli = new FlurlClient().WithSettings(s => s.JsonSerializer = new NewtonsoftJsonSerializer()); - Assert.AreEqual(1, data.id); - Assert.AreEqual("Frank", data.name); + AssertResponse(await cli.Request("https://api.com").GetJsonAsync()); + AssertResponse(await cli.Request("https://api.com").PostJsonAsync(new { a = 1, b = 2 }).ReceiveJson()); + + void AssertResponse(dynamic resp) { + Assert.AreEqual(1, resp.id); + Assert.AreEqual("Frank", resp.name); + } } [Test] public async Task can_get_dynamic_list() { - HttpTest.RespondWithJson(new[] { + using var test = new HttpTest(); + test.RespondWithJson(new[] { new { id = 1, name = "Frank" }, new { id = 2, name = "Claire" } }); - var data = await "http://some-api.com".GetJsonListAsync(); + var cli = new FlurlClient().WithSettings(s => s.JsonSerializer = new NewtonsoftJsonSerializer()); - Assert.AreEqual(1, data[0].id); - Assert.AreEqual("Frank", data[0].name); - Assert.AreEqual(2, data[1].id); - Assert.AreEqual("Claire", data[1].name); + AssertResponse(await cli.Request("https://api.com").GetJsonListAsync()); + AssertResponse(await cli.Request("https://api.com").PatchJsonAsync(new { a = 1, b = 2 }).ReceiveJsonList()); + + void AssertResponse(IList resp) { + Assert.AreEqual(1, resp[0].id); + Assert.AreEqual("Frank", resp[0].name); + Assert.AreEqual(2, resp[1].id); + Assert.AreEqual("Claire", resp[1].name); + } } [Test] @@ -70,10 +171,13 @@ public async Task null_response_returns_null_dynamic() { [TestCase(false)] [TestCase(true)] public async Task can_get_error_json_untyped(bool useShortcut) { - HttpTest.RespondWithJson(new { code = 999, message = "our server crashed" }, 500); + using var test = new HttpTest(); + test.RespondWithJson(new { code = 999, message = "our server crashed" }, 500); + + var cli = new FlurlClient().WithSettings(s => s.JsonSerializer = new NewtonsoftJsonSerializer()); try { - await "http://api.com".GetStringAsync(); + await cli.Request("http://api.com").GetStringAsync(); } catch (FlurlHttpException ex) { var error = useShortcut ? // error is a dynamic this time @@ -85,65 +189,4 @@ await ex.GetResponseJsonAsync() : } } } - - [TestFixture, Parallelizable] - public class NewtonsoftPostTests : PostTests - { - protected override HttpTest CreateHttpTest() => base.CreateHttpTest() - .WithSettings(settings => settings.JsonSerializer = new NewtonsoftJsonSerializer()); - - [Test] - public async Task can_receive_json_dynamic() { - HttpTest.RespondWithJson(new { id = 1, name = "Frank" }); - - var data = await "http://some-api.com".PostJsonAsync(new { a = 1, b = 2 }).ReceiveJson(); - - Assert.AreEqual(1, data.id); - Assert.AreEqual("Frank", data.name); - } - - [Test] - public async Task can_receive_json_dynamic_list() { - HttpTest.RespondWithJson(new[] { - new { id = 1, name = "Frank" }, - new { id = 2, name = "Claire" } - }); - - var data = await "http://some-api.com".PostJsonAsync(new { a = 1, b = 2 }).ReceiveJsonList(); - - Assert.AreEqual(1, data[0].id); - Assert.AreEqual("Frank", data[0].name); - Assert.AreEqual(2, data[1].id); - Assert.AreEqual("Claire", data[1].name); - } - } - - [TestFixture, Parallelizable] - public class NewtonsoftConfigTests - { - [Test] - public void can_register_with_builder() { - var cache = new FlurlClientCache(); - var cli = cache.GetOrAdd("foo", null, builder => builder.UseNewtonsoft(new JsonSerializerSettings { DateFormatString = "1234" })); - - Assert.IsInstanceOf(cli.Settings.JsonSerializer); - - var obj = new { Date = DateTime.Now }; - var json = cli.Settings.JsonSerializer.Serialize(obj); - Assert.AreEqual("{\"Date\":\"1234\"}", json); - } - - [Test] - public void can_register_with_cache() { - var cache = new FlurlClientCache().UseNewtonsoft(new JsonSerializerSettings { DateFormatString = "1234" }); - var cli = cache.GetOrAdd("foo"); - - Assert.IsInstanceOf(cli.Settings.JsonSerializer); - - var obj = new { Date = DateTime.Now }; - var json = cli.Settings.JsonSerializer.Serialize(obj); - Assert.AreEqual("{\"Date\":\"1234\"}", json); - } - - } }