Skip to content

Commit

Permalink
v3.13.0 (#92)
Browse files Browse the repository at this point in the history
* Changes as decribed in the log.

* Further ETag testing and changes.
  • Loading branch information
chullybun authored Mar 4, 2024
1 parent 7e45c7e commit 2a26d55
Show file tree
Hide file tree
Showing 65 changed files with 1,020 additions and 379 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

Represents the **NuGet** versions.

## v3.13.0
- *Enhancement*: Added `DatabaseMapperEx` enabling extended/explicit mapping where performance is critical versus existing that uses reflection and compiled expressions; can offer up to 40%+ improvement in some scenarios.
- *Enhancement*: The `AddMappers<TAssembly>()` and `AddValidators<TAssembly>()` extension methods now also support two or three assembly specification overloads.
- *Enhancement*: A `WorkState.UserName` has been added to enable the tracking of the user that initiated the work; this is then checked to ensure that only the initiating user can interact with their own work state.
- *Fixed:* The `ReferenceDataOrchestrator.GetByTypeAsync` has had the previous sync-over-async corrected to be fully async.
- *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.
- *Fixed*: The `ETag` HTTP handling has been updated to correctly output and expect the weak `W/"xxxx"` format.
- *Fixed*: The `ETagGenerator` implementation has been further optimized to minimize unneccessary string allocations.
- *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.
- *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.
- 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.

## v3.12.0
- *Enhancement*: Added new `CoreEx.Database.Postgres` project/package to support [PostgreSQL](https://www.postgresql.org/) database capabilities. Primarily encapsulates the open-source [`Npqsql`](https://www.npgsql.org/) .NET ADO database provider for PostgreSQL.
- Added `EncodedStringToUInt32Converter` to support PostgreSQL `xmin` column encoding as the row version/etag.
Expand Down
2 changes: 1 addition & 1 deletion Common.targets
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>3.12.0</Version>
<Version>3.13.0</Version>
<LangVersion>preview</LangVersion>
<Authors>Avanade</Authors>
<Company>Avanade</Company>
Expand Down
143 changes: 58 additions & 85 deletions src/CoreEx.AspNetCore/WebApis/ValueContentResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@
using Microsoft.Net.Http.Headers;
using System;
using System.Collections;
using System.Diagnostics.CodeAnalysis;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Mime;
using System.Text;
using System.Threading.Tasks;

namespace CoreEx.AspNetCore.WebApis
Expand Down Expand Up @@ -70,7 +69,7 @@ public override Task ExecuteResultAsync(ActionContext context)

var headers = context.HttpContext.Response.GetTypedHeaders();
if (ETag != null)
headers.ETag = new EntityTagHeaderValue(ETagGenerator.FormatETag(ETag));
headers.ETag = new EntityTagHeaderValue(ETagGenerator.FormatETag(ETag), true);

if (Location != null)
headers.Location = Location;
Expand Down Expand Up @@ -149,127 +148,101 @@ public static bool TryCreateValueContentResult<T>(T value, HttpStatusCode status
throw new InvalidOperationException("Function has not returned a result; no AlternateStatusCode has been configured to return.");
}

// Where there is etag support and it is null (assumes auto-generation) then generate from the full value JSON contents as the baseline value.
var isTextSerializationEnabled = ExecutionContext.HasCurrent && ExecutionContext.Current.IsTextSerializationEnabled;
var etag = value is IETag vetag ? vetag.ETag : null;
if (etag is null)
{
if (isTextSerializationEnabled)
ExecutionContext.Current.IsTextSerializationEnabled = false;

etag = ETagGenerator.Generate(jsonSerializer, value);
if (value is IETag vetag2)
vetag2.ETag = etag;
}

// Where IncludeText is selected then enable before serialization occurs.
if (requestOptions.IncludeText && ExecutionContext.HasCurrent)
ExecutionContext.Current.IsTextSerializationEnabled = true;

// Serialize and generate the etag whilst also applying any filtering of the data where selected.
// Serialize the value performing any filtering as per the request options.
string? json = null;
bool hasETag = TryGetETag(val, out var etag);

Action<IJsonPreFilterInspector>? inspector;
if (requestOptions.IncludeFields != null && requestOptions.IncludeFields.Length > 0)
{
inspector = hasETag ? null : fi => etag = GenerateETag(requestOptions, val, fi.ToJsonString(), jsonSerializer);
jsonSerializer.TryApplyFilter(val, requestOptions.IncludeFields, out json, JsonPropertyFilter.Include, preFilterInspector: inspector);
}
jsonSerializer.TryApplyFilter(val, requestOptions.IncludeFields, out json, JsonPropertyFilter.Include);
else if (requestOptions.ExcludeFields != null && requestOptions.ExcludeFields.Length > 0)
{
inspector = hasETag ? null : fi => etag = GenerateETag(requestOptions, val, fi.ToJsonString(), jsonSerializer);
jsonSerializer.TryApplyFilter(val, requestOptions.ExcludeFields, out json, JsonPropertyFilter.Exclude, preFilterInspector: inspector);
}
jsonSerializer.TryApplyFilter(val, requestOptions.ExcludeFields, out json, JsonPropertyFilter.Exclude);
else
{
json = jsonSerializer.Serialize(val);
if (!hasETag)
etag = GenerateETag(requestOptions, val, json, jsonSerializer);
}

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

// Reset the text serialization flag.
if (ExecutionContext.HasCurrent)
ExecutionContext.Current.IsTextSerializationEnabled = isTextSerializationEnabled;

// Check for not-modified and return status accordingly.
if (checkForNotModified && etag == requestOptions.ETag)
if (checkForNotModified && result.etag == requestOptions.ETag)
{
primaryResult = null;
alternateResult = new StatusCodeResult((int)HttpStatusCode.NotModified);
return false;
}

// Create and return the ValueContentResult.
primaryResult = new ValueContentResult(json, statusCode, etag, paging, location);
primaryResult = new ValueContentResult(result.json!, statusCode, result.etag ?? etag, paging, location);
alternateResult = null;
return true;
}

/// <summary>
/// Determines whether an <see cref="IETag.ETag"/> or <see cref="ExecutionContext.ETag"/> value exists and returns where found.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="etag">The ETag for the value where it exists.</param>
/// <returns><c>true</c> indicates that the ETag value exists; otherwise, <c>false</c> to generate.</returns>
internal static bool TryGetETag(object value, [NotNullWhen(true)] out string? etag)
{
if (value is IETag ietag && ietag.ETag != null)
{
etag = ietag.ETag;
return true;
}

if (ExecutionContext.HasCurrent && ExecutionContext.Current.ETag != null)
{
etag = ExecutionContext.Current.ETag;
return true;
}

etag = null;
return false;
}

/// <summary>
/// Establish the ETag for the value/json.
/// Establish (use existing or generate) the ETag for the value/json.
/// </summary>
/// <param name="requestOptions">The <see cref="WebApiRequestOptions"/>.</param>
/// <param name="value">The value.</param>
/// <param name="json">The value serialized to JSON.</param>
/// <param name="jsonSerializer">The <see cref="IJsonSerializer"/>.</param>
/// <remarks>It is expected that <see cref="TryGetETag(object, out string?)"/> is invoked prior to this to determine whether generation is required.</remarks>
internal static string GenerateETag(WebApiRequestOptions requestOptions, object value, string? json, IJsonSerializer jsonSerializer)
/// <returns>The etag and serialized JSON (where performed).</returns>
internal static (string? etag, string? json) GenerateETag<T>(WebApiRequestOptions requestOptions, T value, string? json, IJsonSerializer jsonSerializer)
{
if (value is IETag etag && etag.ETag != null)
return etag.ETag;
// Where not a GET or HEAD then no etag is generated; just use what we have.
if (!HttpMethods.IsGet(requestOptions.Request.Method) && !HttpMethods.IsHead(requestOptions.Request.Method))
return (value is IETag etag ? etag.ETag : null, json);

if (ExecutionContext.HasCurrent && ExecutionContext.Current.ETag != null)
return ExecutionContext.Current.ETag;

StringBuilder? sb = null;
if (value is not string && value is IEnumerable coll)
// Where no query string and there is an etag then that value should be leveraged as the fast-path.
if (!requestOptions.HasQueryString)
{
sb = new StringBuilder();
var hasEtags = true;
if (value is IETag etag && etag.ETag != null)
return (etag.ETag, json);

foreach (var item in coll)
// Where there is a collection then we need to generate a hash that represents the collection.
if (json is null && value is not string && value is IEnumerable coll)
{
if (item is IETag cetag && cetag.ETag != null)
{
if (sb.Length > 0)
sb.Append(ETagGenerator.DividerCharacter);
var hasEtags = true;
var list = new List<string>();

sb.Append(cetag.ETag);
continue;
foreach (var item in coll)
{
if (item is IETag cetag && cetag.ETag is not null)
{
list.Add(cetag.ETag);
continue;
}

// No longer can fast-path as there is no ETag.
hasEtags = false;
break;
}

hasEtags = false;
break;
}

if (!hasEtags)
{
sb.Clear();
sb.Append(json ??= jsonSerializer.Serialize(value));
}

// A GET with a collection result should include path and query with the etag.
if (HttpMethods.IsGet(requestOptions.Request.Method))
{
sb.Append(ETagGenerator.DividerCharacter);

if (requestOptions.Request.Path.HasValue)
sb.Append(requestOptions.Request.Path.Value);

sb.Append(requestOptions.Request.QueryString.ToString());
// Where fast-path then return the hash for the etag list.
if (hasEtags)
return (ETagGenerator.GenerateHash([.. list]), json);
}
}

// Generate a hash to represent the ETag.
return ETagGenerator.GenerateHash(sb != null && sb.Length > 0 ? sb.ToString() : json ?? jsonSerializer.Serialize(value));
// Serialize and then generate a hash to represent the etag.
json ??= jsonSerializer.Serialize(value);
return (ETagGenerator.GenerateHash(requestOptions.HasQueryString ? [json, requestOptions.Request.QueryString.ToString()] : [json]), json);
}
}
}
23 changes: 17 additions & 6 deletions src/CoreEx.AspNetCore/WebApis/WebApi.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx

using CoreEx.Abstractions;
using CoreEx.AspNetCore.Http;
using CoreEx.Configuration;
using CoreEx.Entities;
Expand Down Expand Up @@ -710,19 +711,29 @@ private async Task<IActionResult> PutInternalAsync<TValue>(HttpRequest request,
/// </summary>
private ConcurrencyException? ConcurrencyETagMatching<TValue>(WebApiParam wap, TValue getValue, TValue putValue, bool autoConcurrency)
{
var et = putValue as IETag;
if (et != null || autoConcurrency)
var ptag = putValue as IETag;
if (ptag != null || autoConcurrency)
{
string? etag = et?.ETag ?? wap.RequestOptions.ETag;
string? etag = wap.RequestOptions.ETag ?? ptag?.ETag;
if (string.IsNullOrEmpty(etag))
return new ConcurrencyException($"An 'If-Match' header is required for an HTTP {wap.Request.Method} where the underlying entity supports concurrency (ETag).");

if (etag != null)
{
if (!ValueContentResult.TryGetETag(getValue!, out var getEt))
getEt = ValueContentResult.GenerateETag(wap.RequestOptions, getValue!, null, JsonSerializer);
var gtag = getValue is IETag getag ? getag.ETag : null;
if (gtag is null)
{
var isTextSerializationEnabled = ExecutionContext.HasCurrent && ExecutionContext.Current.IsTextSerializationEnabled;
if (isTextSerializationEnabled)
ExecutionContext.Current.IsTextSerializationEnabled = false;

gtag = ETagGenerator.Generate(JsonSerializer, getValue);

if (ExecutionContext.HasCurrent)
ExecutionContext.Current.IsTextSerializationEnabled = isTextSerializationEnabled;
}

if (etag != getEt)
if (etag != gtag)
return new ConcurrencyException();
}
}
Expand Down
31 changes: 16 additions & 15 deletions src/CoreEx.AspNetCore/WebApis/WebApiRequestOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
using CoreEx.Http;
using CoreEx.RefData;
using Microsoft.AspNetCore.Http;
using Microsoft.Net.Http.Headers;
using System;
using System.Collections.Generic;
using System.Linq;
Expand All @@ -25,28 +24,29 @@ public class WebApiRequestOptions
public WebApiRequestOptions(HttpRequest httpRequest)
{
Request = httpRequest.ThrowIfNull(nameof(httpRequest));
GetQueryStringOptions(Request.Query);

if (httpRequest.Headers != null && httpRequest.Headers.Count > 0)
{
if (httpRequest.Headers.TryGetValue(HeaderNames.IfNoneMatch, out var vals) || httpRequest.Headers.TryGetValue(HeaderNames.IfMatch, out vals))
{
var etag = vals.FirstOrDefault()?.Trim();
if (!string.IsNullOrEmpty(etag))
ETag = etag.Trim('\"');
}
}
HasQueryString = GetQueryStringOptions(Request.Query);

// Get the raw ETag from the request headers.
var rth = httpRequest.GetTypedHeaders();
var etag = rth.IfNoneMatch.FirstOrDefault()?.Tag ?? rth.IfMatch.FirstOrDefault()?.Tag;
if (etag.HasValue)
ETag = etag.Value.Substring(1, etag.Value.Length - 2);
}

/// <summary>
/// Gets the originating <see cref="HttpRequest"/>.
/// </summary>
public HttpRequest Request { get; }

/// <summary>
/// Indicates whether the <see cref="Request"/> has a query string.
/// </summary>
public bool HasQueryString { get; }

/// <summary>
/// Gets or sets the entity tag that was passed as either a <c>If-None-Match</c> header where <see cref="HttpMethod.Get"/>; otherwise, an <c>If-Match</c> header.
/// </summary>
/// <remarks>Automatically adds quoting to be ETag format compliant.</remarks>
/// <remarks>Represents the underlying ray value; i.e. is stripped of any <c>W/"xxxx"</c> formatting.</remarks>
public string? ETag { get; set; }

/// <summary>
Expand Down Expand Up @@ -79,10 +79,10 @@ public WebApiRequestOptions(HttpRequest httpRequest)
/// <summary>
/// Gets the options from the <see cref="IQueryCollection"/>.
/// </summary>
private void GetQueryStringOptions(IQueryCollection query)
private bool GetQueryStringOptions(IQueryCollection query)
{
if (query == null || query.Count == 0)
return;
return false;

var fields = GetNamedQueryString(query, HttpConsts.IncludeFieldsQueryStringNames);
if (!string.IsNullOrEmpty(fields))
Expand All @@ -96,6 +96,7 @@ private void GetQueryStringOptions(IQueryCollection query)
IncludeInactive = HttpExtensions.ParseBoolValue(GetNamedQueryString(query, HttpConsts.IncludeInactiveQueryStringNames, "true"));

Paging = GetPagingArgs(query);
return true;
}

/// <summary>
Expand Down
2 changes: 2 additions & 0 deletions src/CoreEx.Azure/Storage/TableWorkStatePersistence.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public WorkStateEntity(WorkState state) : this()
TypeName = state.TypeName;
Key = state.Key;
CorrelationId = state.CorrelationId;
UserName = state.UserName;
Status = state.Status;
Created = state.Created;
Expiry = state.Expiry;
Expand Down Expand Up @@ -161,6 +162,7 @@ public WorkDataEntity(BinaryData data) : this()
TypeName = er.Value.TypeName,
Key = er.Value.Key,
CorrelationId = er.Value.CorrelationId,
UserName = er.Value.UserName,
Status = er.Value.Status,
Created = er.Value.Created,
Expiry = er.Value.Expiry,
Expand Down
21 changes: 21 additions & 0 deletions src/CoreEx.Database/DatabaseRecord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,27 @@ public class DatabaseRecord(IDatabase database, DbDataReader dataReader)
/// </summary>
public DbDataReader DataReader { get; } = dataReader.ThrowIfNull(nameof(dataReader));

/// <summary>
/// Gets the named column value.
/// </summary>
/// <param name="columnName">The column name.</param>
/// <returns>The value.</returns>
public object? GetValue(string columnName) => GetValue(DataReader.GetOrdinal(columnName.ThrowIfNull(nameof(columnName))));

/// <summary>
/// Gets the specified column value.
/// </summary>
/// <param name="ordinal">The ordinal index.</param>
/// <returns>The value.</returns>
public object? GetValue(int ordinal)
{
if (DataReader.IsDBNull(ordinal))
return default;

var val = DataReader.GetValue(ordinal);
return val is DateTime dt ? Cleaner.Clean(dt, Database.DateTimeTransform) : val;
}

/// <summary>
/// Gets the named column value.
/// </summary>
Expand Down
Loading

0 comments on commit 2a26d55

Please sign in to comment.