Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

v3.13.0 #92

Merged
merged 2 commits into from
Mar 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading