Skip to content

Commit 448792c

Browse files
committed
Further ETag testing and changes.
1 parent bd74f3a commit 448792c

File tree

11 files changed

+120
-35
lines changed

11 files changed

+120
-35
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Represents the **NuGet** versions.
1010
- *Fixed*: Validation extensions `Exists` and `ExistsAsync` which expect a non-null resultant value have been renamed to `ValueExists` and `ValueExistsAsync` to improve usability; also they are `IResult` aware and will act accordingly.
1111
- *Fixed*: The `ETag` HTTP handling has been updated to correctly output and expect the weak `W/"xxxx"` format.
1212
- *Fixed*: The `ETagGenerator` implementation has been further optimized to minimize unneccessary string allocations.
13+
- *Fixed*: The `ValueContentResult` will only generate a response header ETag (`ETagGenerator`) for a `GET` or `HEAD` request. The underlying result `IETag.ETag` is used as-is where there is no query string; otherwise, generates as assumes query string will alter result (i.e. filtering, paging, sorting, etc.). The result `IETag.ETag` is unchanged so the consumer can still use as required for a further operation.
1314
- *Fixed*: The `SettingsBase` has been optimized. The internal recursion checking has been removed and as such an endless loop (`StackOverflowException`) may occur where misconfigured; given frequency of `IConfiguration` usage the resulting performance is deemed more important. Additionally, `prefixes` are now optional.
1415
- The existing support of referencing a settings property by name (`settings.GetValue<T>("NamedProperty")`) and it using reflection to find before querying the `IConfiguration` has been removed. This was not a common, or intended usage, and was somewhat magical, and finally was non-performant.
1516

src/CoreEx.AspNetCore/WebApis/ValueContentResult.cs

+24-7
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
using System;
1111
using System.Collections;
1212
using System.Collections.Generic;
13-
using System.Diagnostics.CodeAnalysis;
1413
using System.Linq;
1514
using System.Net;
1615
using System.Net.Mime;
@@ -149,22 +148,39 @@ public static bool TryCreateValueContentResult<T>(T value, HttpStatusCode status
149148
throw new InvalidOperationException("Function has not returned a result; no AlternateStatusCode has been configured to return.");
150149
}
151150

151+
// Where there is etag support and it is null (assumes auto-generation) then generate from the full value JSON contents as the baseline value.
152+
var isTextSerializationEnabled = ExecutionContext.HasCurrent && ExecutionContext.Current.IsTextSerializationEnabled;
153+
var etag = value is IETag vetag ? vetag.ETag : null;
154+
if (etag is null)
155+
{
156+
if (isTextSerializationEnabled)
157+
ExecutionContext.Current.IsTextSerializationEnabled = false;
158+
159+
etag = ETagGenerator.Generate(jsonSerializer, value);
160+
if (value is IETag vetag2)
161+
vetag2.ETag = etag;
162+
}
163+
152164
// Where IncludeText is selected then enable before serialization occurs.
153165
if (requestOptions.IncludeText && ExecutionContext.HasCurrent)
154166
ExecutionContext.Current.IsTextSerializationEnabled = true;
155167

156-
// Serialize and generate the etag whilst also applying any filtering of the data where selected.
168+
// Serialize the value performing any filtering as per the request options.
157169
string? json = null;
158-
159170
if (requestOptions.IncludeFields != null && requestOptions.IncludeFields.Length > 0)
160171
jsonSerializer.TryApplyFilter(val, requestOptions.IncludeFields, out json, JsonPropertyFilter.Include);
161172
else if (requestOptions.ExcludeFields != null && requestOptions.ExcludeFields.Length > 0)
162173
jsonSerializer.TryApplyFilter(val, requestOptions.ExcludeFields, out json, JsonPropertyFilter.Exclude);
163174
else
164175
json = jsonSerializer.Serialize(val);
165176

177+
// Generate the etag from the final JSON serialization and check for not-modified.
166178
var result = GenerateETag(requestOptions, val, json, jsonSerializer);
167179

180+
// Reset the text serialization flag.
181+
if (ExecutionContext.HasCurrent)
182+
ExecutionContext.Current.IsTextSerializationEnabled = isTextSerializationEnabled;
183+
168184
// Check for not-modified and return status accordingly.
169185
if (checkForNotModified && result.etag == requestOptions.ETag)
170186
{
@@ -174,7 +190,7 @@ public static bool TryCreateValueContentResult<T>(T value, HttpStatusCode status
174190
}
175191

176192
// Create and return the ValueContentResult.
177-
primaryResult = new ValueContentResult(result.json!, statusCode, result.etag, paging, location);
193+
primaryResult = new ValueContentResult(result.json!, statusCode, result.etag ?? etag, paging, location);
178194
alternateResult = null;
179195
return true;
180196
}
@@ -189,15 +205,16 @@ public static bool TryCreateValueContentResult<T>(T value, HttpStatusCode status
189205
/// <returns>The etag and serialized JSON (where performed).</returns>
190206
internal static (string? etag, string? json) GenerateETag<T>(WebApiRequestOptions requestOptions, T value, string? json, IJsonSerializer jsonSerializer)
191207
{
208+
// Where not a GET or HEAD then no etag is generated; just use what we have.
209+
if (!HttpMethods.IsGet(requestOptions.Request.Method) && !HttpMethods.IsHead(requestOptions.Request.Method))
210+
return (value is IETag etag ? etag.ETag : null, json);
211+
192212
// Where no query string and there is an etag then that value should be leveraged as the fast-path.
193213
if (!requestOptions.HasQueryString)
194214
{
195215
if (value is IETag etag && etag.ETag != null)
196216
return (etag.ETag, json);
197217

198-
if (ExecutionContext.HasCurrent && ExecutionContext.Current.ResultETag != null)
199-
return (ExecutionContext.Current.ResultETag, json);
200-
201218
// Where there is a collection then we need to generate a hash that represents the collection.
202219
if (json is null && value is not string && value is IEnumerable coll)
203220
{

src/CoreEx.AspNetCore/WebApis/WebApi.cs

+18-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx
22

3+
using CoreEx.Abstractions;
34
using CoreEx.AspNetCore.Http;
45
using CoreEx.Configuration;
56
using CoreEx.Entities;
@@ -710,17 +711,29 @@ private async Task<IActionResult> PutInternalAsync<TValue>(HttpRequest request,
710711
/// </summary>
711712
private ConcurrencyException? ConcurrencyETagMatching<TValue>(WebApiParam wap, TValue getValue, TValue putValue, bool autoConcurrency)
712713
{
713-
var et = putValue as IETag;
714-
if (et != null || autoConcurrency)
714+
var ptag = putValue as IETag;
715+
if (ptag != null || autoConcurrency)
715716
{
716-
string? etag = et?.ETag ?? wap.RequestOptions.ETag;
717+
string? etag = wap.RequestOptions.ETag ?? ptag?.ETag;
717718
if (string.IsNullOrEmpty(etag))
718719
return new ConcurrencyException($"An 'If-Match' header is required for an HTTP {wap.Request.Method} where the underlying entity supports concurrency (ETag).");
719720

720721
if (etag != null)
721722
{
722-
var getEt = ValueContentResult.GenerateETag(wap.RequestOptions, getValue!, null, JsonSerializer);
723-
if (etag != getEt.etag)
723+
var gtag = getValue is IETag getag ? getag.ETag : null;
724+
if (gtag is null)
725+
{
726+
var isTextSerializationEnabled = ExecutionContext.HasCurrent && ExecutionContext.Current.IsTextSerializationEnabled;
727+
if (isTextSerializationEnabled)
728+
ExecutionContext.Current.IsTextSerializationEnabled = false;
729+
730+
gtag = ETagGenerator.Generate(JsonSerializer, getValue);
731+
732+
if (ExecutionContext.HasCurrent)
733+
ExecutionContext.Current.IsTextSerializationEnabled = isTextSerializationEnabled;
734+
}
735+
736+
if (etag != gtag)
724737
return new ConcurrencyException();
725738
}
726739
}

src/CoreEx.Database/DatabaseRecord.cs

+8-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
using CoreEx.Entities;
44
using System;
5-
using System.Data;
65
using System.Data.Common;
76

87
namespace CoreEx.Database
@@ -36,7 +35,14 @@ public class DatabaseRecord(IDatabase database, DbDataReader dataReader)
3635
/// </summary>
3736
/// <param name="ordinal">The ordinal index.</param>
3837
/// <returns>The value.</returns>
39-
public object? GetValue(int ordinal) => DataReader.IsDBNull(ordinal) ? default! : DataReader.GetValue(ordinal);
38+
public object? GetValue(int ordinal)
39+
{
40+
if (DataReader.IsDBNull(ordinal))
41+
return default;
42+
43+
var val = DataReader.GetValue(ordinal);
44+
return val is DateTime dt ? Cleaner.Clean(dt, Database.DateTimeTransform) : val;
45+
}
4046

4147
/// <summary>
4248
/// Gets the named column value.

src/CoreEx/Abstractions/ETagGenerator.cs

+14-2
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,22 @@ public static class ETagGenerator
9090
}
9191

9292
/// <summary>
93-
/// Parses an <see cref="IETag.ETag"/> by removing double quotes character bookends; for example '<c>"abc"</c>' would be formatted as '<c>abc</c>'.
93+
/// Parses an <see cref="IETag.ETag"/> by removing any weak prefix ('<c>W/</c>') double quotes character bookends; for example '<c>"abc"</c>' would be formatted as '<c>abc</c>'.
9494
/// </summary>
9595
/// <param name="etag">The <see cref="IETag.ETag"/> to unformat.</param>
9696
/// <returns>The unformatted value.</returns>
97-
public static string? ParseETag(string? etag) => etag is not null && etag.Length > 1 && etag.StartsWith("\"", StringComparison.InvariantCultureIgnoreCase) && etag.EndsWith("\"", StringComparison.InvariantCultureIgnoreCase) ? etag[1..^1] : etag;
97+
public static string? ParseETag(string? etag)
98+
{
99+
if (string.IsNullOrEmpty(etag))
100+
return null;
101+
102+
if (etag.StartsWith('\"') && etag.EndsWith('\"'))
103+
return etag[1..^1];
104+
105+
if (etag.StartsWith("W/\"") && etag.EndsWith('\"'))
106+
return etag[2..^1];
107+
108+
return etag;
109+
}
98110
}
99111
}

src/CoreEx/ExecutionContext.cs

+3-10
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ public static object GetRequiredService(Type type)
132132
/// <summary>
133133
/// Gets the <see cref="ServiceProvider"/>.
134134
/// </summary>
135-
/// <remarks>This is automatically set via the <see cref="Microsoft.Extensions.DependencyInjection.IServiceCollectionExtensions.AddExecutionContext(IServiceCollection, Func{IServiceProvider, ExecutionContext}?)"/>.</remarks>
135+
/// <remarks>This is automatically set via the <see cref="IServiceCollectionExtensions.AddExecutionContext(IServiceCollection, Func{IServiceProvider, ExecutionContext}?)"/>.</remarks>
136136
public IServiceProvider? ServiceProvider { get; set; }
137137

138138
/// <summary>
@@ -151,11 +151,6 @@ public static object GetRequiredService(Type type)
151151
/// </summary>
152152
public bool IsTextSerializationEnabled { get; set; }
153153

154-
/// <summary>
155-
/// Gets or sets the <b>result</b> entity tag (used where the value does not explicitly implement <see cref="IETag"/>).
156-
/// </summary>
157-
public string? ResultETag { get; set; }
158-
159154
/// <summary>
160155
/// Gets or sets the corresponding user name.
161156
/// </summary>
@@ -202,15 +197,14 @@ public virtual ExecutionContext CreateCopy()
202197
{
203198
var ec = Create == null ? throw new InvalidOperationException($"The {nameof(Create)} function must not be null to create a copy.") : Create();
204199
ec._timestamp = _timestamp;
205-
ec._referenceDataContext = _referenceDataContext;
206200
ec._messages = _messages;
207201
ec._properties = _properties;
202+
ec._referenceDataContext = _referenceDataContext;
208203
ec._roles = _roles;
209204
ec.ServiceProvider = ServiceProvider;
210205
ec.CorrelationId = CorrelationId;
211206
ec.OperationType = OperationType;
212207
ec.IsTextSerializationEnabled = IsTextSerializationEnabled;
213-
ec.ResultETag = ResultETag;
214208
ec.UserName = UserName;
215209
ec.UserId = UserId;
216210
ec.TenantId = TenantId;
@@ -229,7 +223,6 @@ public void Dispose()
229223
if (!_disposed)
230224
{
231225
_disposed = true;
232-
Reset();
233226
Dispose(true);
234227
}
235228
}
@@ -243,7 +236,7 @@ public void Dispose()
243236
/// Releases the unmanaged resources used by the <see cref="ExecutionContext"/> and optionally releases the managed resources.
244237
/// </summary>
245238
/// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
246-
protected virtual void Dispose(bool disposing) { }
239+
protected virtual void Dispose(bool disposing) => Reset();
247240

248241
#region Security
249242

src/CoreEx/Http/HttpExtensions.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ public static HttpRequestMessage ApplyRequestOptions(this HttpRequestMessage htt
8989
/// <param name="httpRequest">The <see cref="HttpRequestMessage"/>.</param>
9090
/// <param name="etag">The <i>ETag</i> value.</param>
9191
/// <returns>The <see cref="HttpRequestMessage"/> to support fluent-style method-chaining.</returns>
92-
/// <remarks>Automatically adds quoting to be ETag format compliant.</remarks>
92+
/// <remarks>Automatically adds quoting to be ETag format compliant and sets the ETag as weak ('<c>W/</c>').</remarks>
9393
public static HttpRequestMessage ApplyETag(this HttpRequestMessage httpRequest, string? etag)
9494
{
9595
// Apply the ETag header.

src/CoreEx/Http/TypedHttpClientBase.cs

-1
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,6 @@ private async Task<HttpRequestMessage> CreateRequestInternalAsync(HttpMethod met
105105

106106
// Access the query string.
107107
var uri = new Uri(requestUri, UriKind.RelativeOrAbsolute);
108-
109108
var ub = new UriBuilder(uri.IsAbsoluteUri ? uri : new Uri(new Uri("https://coreex"), requestUri));
110109
var qs = HttpUtility.ParseQueryString(ub.Query);
111110

src/CoreEx/Json/IJsonPreFilterInspector.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ namespace CoreEx.Json
99
public interface IJsonPreFilterInspector
1010
{
1111
/// <summary>
12-
/// Gets the underlying JSON object (as per the underlying implementation).
12+
/// Gets the underlying JSON object (as per the underlying <see cref="IJsonSerializer"/> implementation).
1313
/// </summary>
1414
object Json { get; }
1515

tests/CoreEx.Test/Framework/WebApis/WebApiTest.cs

+25-3
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,24 @@ public void GetAsync_WithETagValueNotModified()
220220

221221
[Test]
222222
public void GetAsync_WithGenETagValue()
223+
{
224+
using var test = FunctionTester.Create<Startup>();
225+
var vcr = test.Type<WebApi>()
226+
.Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget"), r => Task.FromResult(new Person { Id = 1, Name = "Angela" })))
227+
.ToActionResultAssertor()
228+
.AssertOK()
229+
.Result as ValueContentResult;
230+
231+
Assert.That(vcr, Is.Not.Null);
232+
Assert.That(vcr!.ETag, Is.EqualTo("iVsGVb/ELj5dvXpe3ImuOy/vxLIJnUtU2b8nIfpX5PM="));
233+
234+
var p = test.JsonSerializer.Deserialize<Person>(vcr.Content!);
235+
Assert.That(p, Is.Not.Null);
236+
Assert.That(p.ETag, Is.EqualTo("iVsGVb/ELj5dvXpe3ImuOy/vxLIJnUtU2b8nIfpX5PM="));
237+
}
238+
239+
[Test]
240+
public void GetAsync_WithGenETagValue_QueryString()
223241
{
224242
using var test = FunctionTester.Create<Startup>();
225243
var vcr = test.Type<WebApi>()
@@ -229,15 +247,19 @@ public void GetAsync_WithGenETagValue()
229247
.Result as ValueContentResult;
230248

231249
Assert.That(vcr, Is.Not.Null);
232-
Assert.That(vcr!.ETag, Is.EqualTo("F/BIL6G5jbvZxc4fnCc5BekkmFM9/BuXSSl/i5bQj5Q="));
250+
Assert.That(vcr!.ETag, Is.EqualTo("cpDn3xugV1xKSHF9AY4kQRNQ1yC/SU49xC66C92WZbE="));
251+
252+
var p = test.JsonSerializer.Deserialize<Person>(vcr.Content!);
253+
Assert.That(p, Is.Not.Null);
254+
Assert.That(p.ETag, Is.EqualTo("iVsGVb/ELj5dvXpe3ImuOy/vxLIJnUtU2b8nIfpX5PM="));
233255
}
234256

235257
[Test]
236258
public void GetAsync_WithGenETagValueNotModified()
237259
{
238260
using var test = FunctionTester.Create<Startup>();
239261
var hr = test.CreateHttpRequest(HttpMethod.Get, "https://unittest/testget?fruit=apples");
240-
hr.Headers.Add(HeaderNames.IfMatch, "\\W\"F/BIL6G5jbvZxc4fnCc5BekkmFM9/BuXSSl/i5bQj5Q=\"");
262+
hr.Headers.Add(HeaderNames.IfMatch, "\\W\"cpDn3xugV1xKSHF9AY4kQRNQ1yC/SU49xC66C92WZbE=\"");
241263

242264
test.Type<WebApi>()
243265
.Run(f => f.GetAsync(hr, r => Task.FromResult(new Person { Id = 1, Name = "Angela" })))
@@ -650,7 +672,7 @@ public void PatchAsync_AutoConcurrency_Matched()
650672
.Run(f => f.PatchAsync(hr, get: _ => Task.FromResult<Person?>(new Person { Id = 13, Name = "Deano" }), put: _ => Task.FromResult<Person>(new Person { Id = 13, Name = "Gazza" }), simulatedConcurrency: true))
651673
.ToActionResultAssertor()
652674
.AssertOK()
653-
.AssertValue(new Person { Id = 13, Name = "Gazza" });
675+
.AssertValue(new Person { Id = 13, Name = "Gazza", ETag = "tEEokPXk+4Q5MoiGqyAs1+6A00e2ww59Zm57LJgvBcg=" });
654676
}
655677

656678
private static HttpRequest CreatePatchRequest(UnitTestEx.NUnit.Internal.FunctionTester<Startup> test, string? json, string? etag = null)

0 commit comments

Comments
 (0)