diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bcf23b5..7aaa970 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,8 +8,15 @@ name: Release # when no tag exists yet), so never has to be edited by hand. Each # successful publish creates the matching v tag and a GitHub Release. # -# Requires a repository secret NUGET_API_KEY (Settings > Secrets and variables > Actions) -# with push rights for the SimpleHttpClient package. +# Publishing uses NuGet trusted publishing (OIDC) — no long-lived API key. The job +# requests a short-lived key at runtime via the NuGet/login action, so it needs +# `id-token: write` permission and a repository secret NUGET_USER holding the +# nuget.org username (profile name, NOT an email). +# +# Trusted publishing setup (one-time, on nuget.org > your account > Trusted Publishing): +# Repository Owner: Mako88 Repository: SimpleHttpClient +# Workflow File: release.yml Environment: (leave empty) +# Docs: https://learn.microsoft.com/en-us/nuget/nuget-org/trusted-publishing # # First-time setup: seed a tag matching the last published version so the first bump is # correct, e.g. git tag v4.1.0 && git push origin v4.1.0 @@ -33,6 +40,7 @@ concurrency: permissions: contents: write + id-token: write # request the OIDC token used for NuGet trusted publishing jobs: release: @@ -118,9 +126,18 @@ jobs: if: steps.bump.outputs.proceed == 'true' run: dotnet build src/SimpleHttpClient/SimpleHttpClient.csproj -c Release -p:Version=${{ steps.version.outputs.version }} + # Exchange the GitHub OIDC token for a short-lived nuget.org API key (valid ~1h). + # Done right before push so it doesn't expire mid-build. + - name: NuGet login (OIDC -> short-lived API key) + if: steps.bump.outputs.proceed == 'true' + uses: NuGet/login@v1 + id: nuget-login + with: + user: ${{ secrets.NUGET_USER }} + - name: Push to NuGet if: steps.bump.outputs.proceed == 'true' - run: dotnet nuget push "src/SimpleHttpClient/bin/Release/*.nupkg" -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json --skip-duplicate + run: dotnet nuget push "src/SimpleHttpClient/bin/Release/*.nupkg" --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate - name: Tag and create GitHub Release if: steps.bump.outputs.proceed == 'true' diff --git a/README.md b/README.md index 8b01b11..f32b54d 100644 --- a/README.md +++ b/README.md @@ -233,14 +233,14 @@ request.SerializerOverride = new SimpleHttpDefaultJsonSerializer(); ``` You can supply your own serializer by implementing `ISimpleHttpSerializer`. -#### System.Text.Json -The default JSON serializer uses `Newtonsoft.Json`. A `System.Text.Json`-based serializer is also included and can be opted into the same way: -```csharp -client.Serializer = new SimpleHttpSystemTextJsonSerializer(); -``` -Its settings mirror the default (camelCase names, null values omitted, indented output, case-insensitive deserialization), so it's a drop-in for most payloads. Note that `System.Text.Json` is stricter than `Newtonsoft.Json` — most notably it can't use a non-public parameterless constructor when deserializing, so such types need a public constructor or a `[JsonConstructor]`. - -> **Heads up:** `SimpleHttpSystemTextJsonSerializer` is slated to become the default in the next major version (v5), at which point the `Newtonsoft.Json` dependency will be removed. +#### JSON serialization +The default JSON serializer (`SimpleHttpDefaultJsonSerializer`) is backed by `System.Text.Json`. It serializes with camelCase names, omits null values, writes indented output, and deserializes case-insensitively. For smoother interop it also reads numbers from JSON strings (e.g. `"123"`) and tolerates trailing commas and comments while reading. The equivalent `SimpleHttpSystemTextJsonSerializer` is also available for callers who reference it explicitly. + +> **Upgrading from v4?** As of **v5.0.0** the default serializer moved from `Newtonsoft.Json` to `System.Text.Json` and the `Newtonsoft.Json` dependency was removed. The defaults above cover the most common differences, but `System.Text.Json` is stricter in two ways it won't soften: +> - **Non-public parameterless constructors** aren't used — add a public constructor or a `[JsonConstructor]`. +> - **Wrong-shape values aren't coerced** — a field that's sometimes a string and sometimes an object (and similar) threw nothing under Newtonsoft but throws here. For such fields, attach a custom `JsonConverter` to the property. +> +> If you'd rather keep the old behavior wholesale, implement `ISimpleHttpSerializer` with your own `Newtonsoft.Json` serializer and set it on the client. ### Logging You can log requests and responses by setting the `LogRequest` and `LogResponse` delegates (called immediately before a request is sent and immediately after a response is received), or by providing an `ISimpleHttpLogger`: diff --git a/src/SimpleHttpClient.Tests.Integration/HttpClientProviderTests.cs b/src/SimpleHttpClient.Tests.Integration/HttpClientProviderTests.cs new file mode 100644 index 0000000..9126e79 --- /dev/null +++ b/src/SimpleHttpClient.Tests.Integration/HttpClientProviderTests.cs @@ -0,0 +1,67 @@ +using Moq; +using System; +using System.Net.Http; +using System.Threading.Tasks; + +namespace SimpleHttpClient.Tests +{ + public class HttpClientProviderTests + { + [Fact] + public void Factory_Create_ReturnsProviderForTargetFramework() + { + using var provider = HttpClientProviderFactory.Create(null); + +#if NETFRAMEWORK + // The netstandard2.0 asset (used by .NET Framework) rotates a cached client. + Assert.IsType(provider); +#else + // The net8.0 asset relies on SocketsHttpHandler's pooled connection lifetime. + Assert.IsType(provider); +#endif + } + + [Fact] + public void GetClient_WithoutFactory_ReturnsConfiguredClient_CachedAcrossCalls() + { + using var provider = HttpClientProviderFactory.Create(null); + + var first = provider.GetClient(); + var second = provider.GetClient(); + + Assert.NotNull(first); + // With no factory the provider owns and caches a single client. + Assert.Same(first, second); + // Configured with an infinite timeout - SimpleClient applies its own per-request timeout. + Assert.Equal(System.Threading.Timeout.InfiniteTimeSpan, first.Timeout); + } + + [Fact] + public void GetClient_WithFactory_DelegatesToFactory() + { + using var factoryClient = new HttpClient(); + var factory = new Mock(); + factory.Setup(f => f.CreateClient(Constants.HttpClientNameString)).Returns(factoryClient); + + using var provider = HttpClientProviderFactory.Create(factory.Object); + + var client = provider.GetClient(); + + Assert.Same(factoryClient, client); + factory.Verify(f => f.CreateClient(Constants.HttpClientNameString), Times.AtLeastOnce); + } + + [Fact] + public async Task Dispose_DisposesOwnedClient_AndIsIdempotent() + { + var provider = HttpClientProviderFactory.Create(null); + var client = provider.GetClient(); + + provider.Dispose(); + provider.Dispose(); // idempotent: a second dispose must not throw + + // The client the provider owns is disposed along with it. + await Assert.ThrowsAsync(() => client.GetAsync("http://localhost")); + } + } +} diff --git a/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpDefaultJsonSerializerTests.cs b/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpDefaultJsonSerializerTests.cs index 44a3249..f07c55b 100644 --- a/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpDefaultJsonSerializerTests.cs +++ b/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpDefaultJsonSerializerTests.cs @@ -11,6 +11,14 @@ public class SimpleHttpDefaultJsonSerializerTests ""property3"": true }"; + [Fact] + public void DefaultSerializer_IsBackedBy_SystemTextJson() + { + // As of v5.0.0 the default JSON serializer is System.Text.Json-based + // (it derives from SimpleHttpSystemTextJsonSerializer). + Assert.IsAssignableFrom(new SimpleHttpDefaultJsonSerializer()); + } + [Fact] public void RoundTrip_Succeeds() { @@ -37,7 +45,9 @@ public void Serialization_Succeeds() var serialized = testObject.Serialize(objectToSerialize); - Assert.Equal(TestSerializationString, serialized); + // Normalize line endings so the comparison doesn't depend on the + // platform's newline or the checkout's git autocrlf setting. + Assert.Equal(TestSerializationString.Replace("\r\n", "\n"), serialized.Replace("\r\n", "\n")); } [Fact] diff --git a/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs b/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs index 6085450..1b37095 100644 --- a/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs +++ b/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs @@ -4,8 +4,7 @@ namespace SimpleHttpClient.Tests.Serialization { public class SimpleHttpSystemTextJsonSerializerTests { - // Same shape and formatting the Newtonsoft-based default produces, so the - // System.Text.Json serializer is verified to be a drop-in for typical payloads. + // The camelCase, indented shape the serializer is expected to produce. private const string TestSerializationString = @"{ ""property1"": ""property1 value"", @@ -78,6 +77,85 @@ public void Serialization_OmitsNullValues() Assert.Contains("\"value\": 1", serialized); } + [Fact] + public void RoundTrip_WithNestedObjectCollectionAndEnum_PreservesValues() + { + var serializer = new SimpleHttpSystemTextJsonSerializer(); + + var original = new ComplexObject + { + Name = "outer", + Status = SampleStatus.Active, + Tags = new List { "a", "b" }, + Child = new TestSerializationObject(), + }; + + var roundTripped = serializer.Deserialize(serializer.Serialize(original)); + + Assert.NotNull(roundTripped); + Assert.Equal("outer", roundTripped.Name); + Assert.Equal(SampleStatus.Active, roundTripped.Status); + Assert.Equal(new[] { "a", "b" }, roundTripped.Tags); + Assert.Equal("property1 value", roundTripped.Child?.Property1); + } + + [Fact] + public void Serialization_WritesEnumsAsNumbers() + { + // Matches Newtonsoft's default (numeric enums), so migrating to System.Text.Json + // doesn't silently change the enum wire format. + var serializer = new SimpleHttpSystemTextJsonSerializer(); + + var serialized = serializer.Serialize(new ComplexObject { Status = SampleStatus.Active }); + + Assert.Contains("\"status\": 1", serialized); + Assert.DoesNotContain("Active", serialized); + } + + [Fact] + public void Deserialization_IntoTypeWithOnlyNonPublicConstructor_Throws() + { + // The headline strictness difference from Newtonsoft: System.Text.Json will not + // invoke a non-public parameterless constructor. Documented in the README migration + // notes; pinned here so the behavior (and the docs) stay honest. + var serializer = new SimpleHttpSystemTextJsonSerializer(); + + Assert.ThrowsAny( + () => serializer.Deserialize("{\"value\":1}")); + } + + [Fact] + public void Deserialization_ReadsNumbersFromStrings() + { + // A quoted number must bind to a numeric property (common API interop case, + // and Newtonsoft's default behavior). + var serializer = new SimpleHttpSystemTextJsonSerializer(); + + var result = serializer.Deserialize("{\"property2\": \"42\"}"); + + Assert.NotNull(result); + Assert.Equal(42, result.Property2); + } + + [Fact] + public void Deserialization_ToleratesTrailingCommasAndComments() + { + var serializer = new SimpleHttpSystemTextJsonSerializer(); + + const string json = +@"{ + // leading comment + ""property1"": ""value"", + ""property2"": 7, +}"; + + var result = serializer.Deserialize(json); + + Assert.NotNull(result); + Assert.Equal("value", result.Property1); + Assert.Equal(7, result.Property2); + } + private static string Normalize(string value) => value.Replace("\r\n", "\n"); private class NullableSerializationObject @@ -86,5 +164,31 @@ private class NullableSerializationObject public int Value { get; set; } = 1; } + + private enum SampleStatus + { + None = 0, + Active = 1, + } + + private class ComplexObject + { + public string? Name { get; set; } + + public SampleStatus Status { get; set; } + + public List? Tags { get; set; } + + public TestSerializationObject? Child { get; set; } + } + + private class NonPublicCtorObject + { + private NonPublicCtorObject() + { + } + + public int Value { get; set; } + } } } diff --git a/src/SimpleHttpClient.Tests.Integration/SimpleClientTests.cs b/src/SimpleHttpClient.Tests.Integration/SimpleClientTests.cs index 05eba09..f5c0116 100644 --- a/src/SimpleHttpClient.Tests.Integration/SimpleClientTests.cs +++ b/src/SimpleHttpClient.Tests.Integration/SimpleClientTests.cs @@ -1,5 +1,7 @@ using Moq; -using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using SimpleHttpClient.Logging; using SimpleHttpClient.Models; using SimpleHttpClient.Serialization; @@ -113,7 +115,7 @@ public async Task UntypedResponse_Succeeds() var response = await client.MakeRequest(request); - var responseJson = JObject.Parse(response.StringBody); + var responseJson = JsonNode.Parse(response.StringBody); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("value1", responseJson?["args"]?["param1"]?.ToString()); @@ -349,7 +351,7 @@ public async Task ContentTypeHeader_IsSetOnBodyContent() var response = await client.MakeRequest(request); - var body = JToken.Parse(response.StringBody); + var body = JsonNode.Parse(response.StringBody)!; Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("text/json", request.ContentType); @@ -368,7 +370,7 @@ public async Task ContentTypeHeader_DoesNotOverride_RequestContentType() var response = await client.MakeRequest(request); - var body = JToken.Parse(response.StringBody); + var body = JsonNode.Parse(response.StringBody)!; Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("text/json", request.ContentType); @@ -416,7 +418,7 @@ public async Task StringBody_IsSet_ToSerializedBody() await client.MakeRequest(request); - var body = JToken.Parse(request.StringBody); + var body = JsonNode.Parse(request.StringBody)!; Assert.Equal("value1", body["param1"]?.ToString()); Assert.Equal("value2", body["param2"]?.ToString()); @@ -623,9 +625,15 @@ public class PostmanEchoResponse public Args? Form { get; set; } + // postman-echo returns "data" as the posted object for JSON bodies, but as an + // empty string for form posts. System.Text.Json (unlike Newtonsoft) won't coerce + // a string into a complex type, so the converter maps the non-object case to null + // while keeping Data strongly typed (so the JSON-body tests still verify that + // properties deserialize onto the typed object). + [JsonConverter(typeof(EmptyStringTolerantConverter))] public Args? Data { get; set; } - public JToken? Headers { get; set; } + public JsonNode? Headers { get; set; } } public class Args @@ -633,4 +641,26 @@ public class Args public string? Param1 { get; set; } public string? Param2 { get; set; } } + + // Deserializes an object normally, but yields null for any non-object JSON value + // (e.g. the empty string postman-echo returns for the "data" field on form posts), + // rather than letting System.Text.Json throw and fail the whole response. + public class EmptyStringTolerantConverter : JsonConverter where T : class + { + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + reader.Skip(); + return null; + } + + // No converter is registered for T in options (this one is applied per-property), + // so this deserializes with the default object handling rather than recursing. + return JsonSerializer.Deserialize(ref reader, options); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) => + JsonSerializer.Serialize(writer, value, options); + } } \ No newline at end of file diff --git a/src/SimpleHttpClient.Tests.Integration/SimpleRequestTests.cs b/src/SimpleHttpClient.Tests.Integration/SimpleRequestTests.cs index ff87de7..35876c0 100644 --- a/src/SimpleHttpClient.Tests.Integration/SimpleRequestTests.cs +++ b/src/SimpleHttpClient.Tests.Integration/SimpleRequestTests.cs @@ -1,6 +1,6 @@ using Moq; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; using SimpleHttpClient.Models; using SimpleHttpClient.Serialization; using System.Net; @@ -57,7 +57,7 @@ public async Task UrlOverride_OverridesHostAndPath(string host, string path) var response = await client.MakeRequest(request); - var responseJson = JObject.Parse(response.StringBody); + var responseJson = JsonNode.Parse(response.StringBody); Assert.Equal("value1", responseJson?["args"]?["param1"]?.ToString()); Assert.Equal("value2", responseJson?["args"]?["param2"]?.ToString()); @@ -67,8 +67,8 @@ public async Task UrlOverride_OverridesHostAndPath(string host, string path) public async Task SerializerOverride_OverridesClientSerializer() { var serializer = new Mock(MockBehavior.Loose); - serializer.Setup(x => x.Serialize(It.IsAny())).Returns((object obj) => JsonConvert.SerializeObject(obj)); - serializer.Setup(x => x.Deserialize(It.IsAny())).Returns((string str) => JsonConvert.DeserializeObject(str)!); + serializer.Setup(x => x.Serialize(It.IsAny())).Returns((object obj) => JsonSerializer.Serialize(obj)); + serializer.Setup(x => x.Deserialize(It.IsAny())).Returns((string str) => JsonSerializer.Deserialize(str)!); var serializer2 = new Mock(MockBehavior.Loose); @@ -160,7 +160,7 @@ public async Task ContentEncoding_IsSet() var response = await client.MakeRequest(request); - var body = JToken.Parse(response.StringBody); + var body = JsonNode.Parse(response.StringBody)!; Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Contains("ascii", body["headers"]?["content-type"]?.ToString()); diff --git a/src/SimpleHttpClient.Tests.Integration/SimpleResponseTests.cs b/src/SimpleHttpClient.Tests.Integration/SimpleResponseTests.cs index d1cebbe..dd681b0 100644 --- a/src/SimpleHttpClient.Tests.Integration/SimpleResponseTests.cs +++ b/src/SimpleHttpClient.Tests.Integration/SimpleResponseTests.cs @@ -1,5 +1,5 @@ using Moq; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; using SimpleHttpClient.Models; using SimpleHttpClient.Serialization; using System.Net; @@ -52,7 +52,7 @@ public async Task StringBody_IsSet() var response = await client.MakeRequest(request); - var body = JToken.Parse(response.StringBody); + var body = JsonNode.Parse(response.StringBody)!; Assert.Equal("value1", body["data"]?["param1"]?.ToString()); Assert.Equal("value2", body["data"]?["param2"]?.ToString()); @@ -70,7 +70,7 @@ public async Task ByteBody_IsSet() var response = await client.MakeRequest(request); - var body = JToken.Parse(Encoding.UTF8.GetString(response.ByteBody)); + var body = JsonNode.Parse(Encoding.UTF8.GetString(response.ByteBody))!; Assert.NotNull(response.ByteBody); Assert.NotEmpty(response.ByteBody); diff --git a/src/SimpleHttpClient/Serialization/SimpleHttpDefaultJsonSerializer.cs b/src/SimpleHttpClient/Serialization/SimpleHttpDefaultJsonSerializer.cs index bddf419..28f73b4 100644 --- a/src/SimpleHttpClient/Serialization/SimpleHttpDefaultJsonSerializer.cs +++ b/src/SimpleHttpClient/Serialization/SimpleHttpDefaultJsonSerializer.cs @@ -1,29 +1,11 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; - namespace SimpleHttpClient.Serialization { /// - /// The default Json serializer - uses Newtonsoft.Json. + /// The default JSON serializer. As of v5.0.0 it is backed by System.Text.Json + /// (it used Newtonsoft.Json in earlier versions) and is equivalent to + /// . /// - public class SimpleHttpDefaultJsonSerializer : ISimpleHttpSerializer + public class SimpleHttpDefaultJsonSerializer : SimpleHttpSystemTextJsonSerializer { - /// - /// Serialize the given object into a string. - /// - public string Serialize(object obj) => JsonConvert.SerializeObject(obj, new JsonSerializerSettings - { - ContractResolver = new CamelCasePropertyNamesContractResolver(), - DefaultValueHandling = DefaultValueHandling.Include, - TypeNameHandling = TypeNameHandling.None, - NullValueHandling = NullValueHandling.Ignore, - Formatting = Formatting.Indented, - ConstructorHandling = ConstructorHandling.AllowNonPublicDefaultConstructor - }); - - /// - /// Deserialize the given string into an object of type T. - /// - public T Deserialize(string data) => JsonConvert.DeserializeObject(data); } } diff --git a/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs b/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs index b85158f..9417467 100644 --- a/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs +++ b/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs @@ -4,17 +4,19 @@ namespace SimpleHttpClient.Serialization { /// - /// A JSON serializer backed by System.Text.Json. Opt in by setting it on the - /// client (or per-request) instead of the Newtonsoft-based default. Its settings - /// mirror the default serializer's behavior (camelCase names, null values omitted, - /// indented output, case-insensitive deserialization) so it's a drop-in for most - /// payloads. + /// A JSON serializer backed by System.Text.Json. This is the default serializer + /// ( derives from it); the type is kept + /// for callers who reference it explicitly. It serializes with camelCase names, omits + /// null values, writes indented output, and deserializes case-insensitively. To ease + /// interop with real-world APIs it also reads numbers from JSON strings (e.g. "123") + /// and tolerates trailing commas and comments while reading. /// /// - /// System.Text.Json is stricter than Newtonsoft.Json. Notably, it cannot use a - /// non-public parameterless constructor when deserializing; such types need a public - /// constructor or a . This serializer is slated - /// to become the default in a future major version. + /// System.Text.Json is stricter than Newtonsoft.Json in ways these options don't soften. + /// Notably, it cannot use a non-public parameterless constructor when deserializing (such + /// types need a public constructor or a ), and it + /// won't coerce a JSON value of the wrong shape (e.g. a string where an object is expected). + /// For fields whose shape varies, attach a custom to the property. /// public class SimpleHttpSystemTextJsonSerializer : ISimpleHttpSerializer { @@ -27,6 +29,11 @@ public class SimpleHttpSystemTextJsonSerializer : ISimpleHttpSerializer DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNameCaseInsensitive = true, WriteIndented = true, + // Read leniencies that bring the defaults closer to Newtonsoft's, easing the + // v5 migration without masking genuine type mismatches. + NumberHandling = JsonNumberHandling.AllowReadingFromString, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, }; /// diff --git a/src/SimpleHttpClient/SimpleHttpClient.csproj b/src/SimpleHttpClient/SimpleHttpClient.csproj index bb3dd5e..16aa162 100644 --- a/src/SimpleHttpClient/SimpleHttpClient.csproj +++ b/src/SimpleHttpClient/SimpleHttpClient.csproj @@ -7,7 +7,7 @@ A_Future_Pilot An easy-to-use .NET wrapper for HttpClient. No extension methods, and included interfaces allow for easy unit test mocking, and straightforward properties allows for easier debugging. http;rest;httpclient;client - 4.2.0 + 5.0.0 https://github.com/Mako88/SimpleHttpClient https://github.com/Mako88/SimpleHttpClient MIT @@ -15,6 +15,10 @@ True + + + + True @@ -24,7 +28,6 @@ -