V14: Merge 13 to 14 (#16340)

* Updates JSON schema for Umbraco 10 with latest references for Forms and Deploy (#15918)

* Ported over #15928 changes for 13.3 RC (#16023)

* Ported over #15928 changes for 13.3 RC

* Use GetOrAdd()

* Lock dictionary initialization

---------

Co-authored-by: Jason Elkin <jasonelkin86@gmail.com>

* Make the API content response builder extendable (#16056)

* Make the API content response builder extendable

* DeliveryApiJsonTypeResolver needs to be extendable too

* bump rc to regular

* Bump to next minor

* Add blocks in RTE telemetry (#16104)

* Add blocks telemetry

* Use constants and update tests

* V13: Add property type information to telemetry (#16109)

* Add property type counts to telemetry

* Use constants and fix tests

* Update description

* V10: Fix for fallback file upload (#14892) (#15868)

* Fix for fallback file upload (#14892)

* Added check for file type

* Removed unneeded null checks and fixed tabs

* Cleaning

* Cleanups, cleanups, and removal of unneeded null checks

* Reverted removal of relationshipservice

* Revert null check removals (too risky)

---------

Co-authored-by: Ambert van Unen <AvanUnen@ilionx.com>
Co-authored-by: Laura Neto <12862535+lauraneto@users.noreply.github.com>

(cherry picked from commit 0b5d1f8aa6)

* Fix up formatting

---------

Co-authored-by: Ambert van Unen <ambertvu@gmail.com>

* Implementors using Umbraco.Tests.Integration won't have to override GetLocalizedTextService

(cherry picked from commit b0016687eb)
(cherry picked from commit 2bb56f1b81)

* Fix logic for retrieving lastKnownElement

(cherry picked from commit cae106bfe8)

* bump version

* Bump version

* Bump version

* Since v13 properties can sometimes be of type IRichTextEditorIntermediateValue - this was unexpected in the XPath navigator code (#16121)

* Webhook log improvements (#16200)

* fix: include all headers in webhook log

* feat: return webhook log status from server

* feat: make webhook logs deep linkable

* feat: add webhook log pagination

* feat: improve webhook request/response body preview

* V13: Optimize custom MVC routing (#16218)

* Introduce EagerMatcherPolicy to conditionally bypass content routing

* Ensure that the candidate we disable dynamic routing for is valid

* Skip Umbraco endpoints

* Simplify logic a bit

* Move install logic to matcher

* Ensure that dynamic routing is still skipped when in upgrade state

* Fixup comments

* Reduce nesting a bit

* Don't show maintenance page when statically routed controllers are hít

* Remove excess check, since installer requests are statically routed

* V13: Optimize custom MVC routing (#16218)

* Introduce EagerMatcherPolicy to conditionally bypass content routing

* Ensure that the candidate we disable dynamic routing for is valid

* Skip Umbraco endpoints

* Simplify logic a bit

* Move install logic to matcher

* Ensure that dynamic routing is still skipped when in upgrade state

* Fixup comments

* Reduce nesting a bit

* Don't show maintenance page when statically routed controllers are hít

* Remove excess check, since installer requests are statically routed

(cherry picked from commit ba9ddd11da)

* Property source level variation should only be applied when configured (#16270)

* Property source level variation should only be applied when configured (#16270)

(cherry picked from commit ab32bac5d9)

* Merge pull request from GHSA-j74q-mv2c-rxmp

* Merge pull request from GHSA-j74q-mv2c-rxmp

* Merge pull request from GHSA-j74q-mv2c-rxmp

* Fix up after merge

* Remove obselete test

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
Co-authored-by: Jason Elkin <jasonelkin86@gmail.com>
Co-authored-by: Sven Geusens <sge@umbraco.dk>
Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>
Co-authored-by: Ambert van Unen <ambertvu@gmail.com>
Co-authored-by: Lars-Erik <lars-erik@aabech.no>
Co-authored-by: Joshua Daniel Pratt Nielsen <jdpnielsen@gmail.com>
Co-authored-by: Bjarke Berg <mail@bergmania.dk>
Co-authored-by: Sebastiaan Janssen <sebastiaan@umbraco.com>
Co-authored-by: Rasmus John Pedersen <mail@rjp.dk>
This commit is contained in:
Nikolaj Geisle
2024-05-22 12:05:41 +02:00
committed by GitHub
parent 8ddb911a52
commit a4a9a274bb
23 changed files with 556 additions and 153 deletions

View File

@@ -12,23 +12,36 @@ public class DeliveryApiJsonTypeResolver : DefaultJsonTypeInfoResolver
{
JsonTypeInfo jsonTypeInfo = base.GetTypeInfo(type, options);
if (jsonTypeInfo.Type == typeof(IApiContent))
Type[] derivedTypes = GetDerivedTypes(jsonTypeInfo);
if (derivedTypes.Length > 0)
{
ConfigureJsonPolymorphismOptions(jsonTypeInfo, typeof(ApiContent));
}
else if (jsonTypeInfo.Type == typeof(IApiContentResponse))
{
ConfigureJsonPolymorphismOptions(jsonTypeInfo, typeof(ApiContentResponse));
}
else if (jsonTypeInfo.Type == typeof(IRichTextElement))
{
ConfigureJsonPolymorphismOptions(jsonTypeInfo, typeof(RichTextRootElement), typeof(RichTextGenericElement), typeof(RichTextTextElement));
ConfigureJsonPolymorphismOptions(jsonTypeInfo, derivedTypes);
}
return jsonTypeInfo;
}
private void ConfigureJsonPolymorphismOptions(JsonTypeInfo jsonTypeInfo, params Type[] derivedTypes)
protected virtual Type[] GetDerivedTypes(JsonTypeInfo jsonTypeInfo)
{
if (jsonTypeInfo.Type == typeof(IApiContent))
{
return new[] { typeof(ApiContent) };
}
if (jsonTypeInfo.Type == typeof(IApiContentResponse))
{
return new[] { typeof(ApiContentResponse) };
}
if (jsonTypeInfo.Type == typeof(IRichTextElement))
{
return new[] { typeof(RichTextRootElement), typeof(RichTextGenericElement), typeof(RichTextTextElement) };
}
return Array.Empty<Type>();
}
protected void ConfigureJsonPolymorphismOptions(JsonTypeInfo jsonTypeInfo, params Type[] derivedTypes)
{
jsonTypeInfo.PolymorphismOptions = new JsonPolymorphismOptions
{

View File

@@ -35,5 +35,10 @@ public static partial class Constants
public static string WebhookTotal = $"{WebhookPrefix}Total";
public static string WebhookCustomHeaders = $"{WebhookPrefix}CustomHeaders";
public static string WebhookCustomEvent = $"{WebhookPrefix}CustomEvent";
public static string RichTextEditorCount = "RichTextEditorCount";
public static string RichTextBlockCount = "RichTextBlockCount";
public static string TotalPropertyCount = "TotalPropertyCount";
public static string HighestPropertyCount = "HighestPropertyCount";
public static string TotalCompositions = "TotalCompositions";
}
}

View File

@@ -62,6 +62,7 @@ public static partial class Constants
public const string ControllerToken = "controller";
public const string ActionToken = "action";
public const string AreaToken = "area";
public const string DynamicRoutePattern = "/{**umbracoSlug}";
}
public static class RoutePath

View File

@@ -1,11 +1,10 @@
using Umbraco.Cms.Core.Models.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Routing;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.DeliveryApi;
public sealed class ApiContentResponseBuilder : ApiContentBuilderBase<IApiContentResponse>, IApiContentResponseBuilder
public class ApiContentResponseBuilder : ApiContentBuilderBase<IApiContentResponse>, IApiContentResponseBuilder
{
private readonly IApiContentRouteBuilder _apiContentRouteBuilder;
@@ -14,6 +13,12 @@ public sealed class ApiContentResponseBuilder : ApiContentBuilderBase<IApiConten
=> _apiContentRouteBuilder = apiContentRouteBuilder;
protected override IApiContentResponse Create(IPublishedContent content, string name, IApiContentRoute route, IDictionary<string, object?> properties)
{
IDictionary<string, IApiContentRoute> cultures = GetCultures(content);
return new ApiContentResponse(content.Key, name, content.ContentType.Alias, content.CreateDate, content.UpdateDate, route, properties, cultures);
}
protected virtual IDictionary<string, IApiContentRoute> GetCultures(IPublishedContent content)
{
var routesByCulture = new Dictionary<string, IApiContentRoute>();
@@ -35,6 +40,6 @@ public sealed class ApiContentResponseBuilder : ApiContentBuilderBase<IApiConten
routesByCulture[publishedCultureInfo.Culture] = cultureRoute;
}
return new ApiContentResponse(content.Key, name, content.ContentType.Alias, content.CreateDate, content.UpdateDate, route, properties, routesByCulture);
return routesByCulture;
}
}

View File

@@ -476,7 +476,7 @@
<key alias="detailedLevelDescription"><![CDATA[We will send:
<ul>
<li>Anonymized site ID, Umbraco version, and packages installed.</li>
<li>Number of: Root nodes, Content nodes, Macros, Media, Document Types, Templates, Languages, Domains, User Group, Users, Members, Backoffice external login providers, Webhooks, and Property Editors in use.</li>
<li>Number of: Root nodes, Content nodes, Macros, Media, Document Types, Property Types, Compositions, Templates, Languages, Domains, User Group, Users, Members, Backoffice external login providers, Webhooks, rich text datatypes, blocks used in rich text datatypes, and Property Editors in use.</li>
<li>System information: Webserver, server OS, server framework, server OS language, and database provider.</li>
<li>Configuration settings: Modelsbuilder mode, if custom Umbraco path exists, ASP environment, whether the delivery API is enabled, and allows public access, and if you are in debug mode.</li>
</ul>

View File

@@ -470,7 +470,7 @@
We will send:
<ul>
<li>Anonymized site ID, Umbraco version, and packages installed.</li>
<li>Number of: Root nodes, Content nodes, Macros, Media, Document Types, Templates, Languages, Domains, User Group, Users, Members, Backoffice external login providers, and Property Editors in use.</li>
<li>Number of: Root nodes, Content nodes, Macros, Media, Document Types, Property Types, Compositions, Templates, Languages, Domains, User Group, Users, Members, Backoffice external login providers, Webhooks, rich text datatypes, blocks used in rich text datatypes, and Property Editors in use.</li>
<li>System information: Webserver, server OS, server framework, server OS language, and database provider.</li>
<li>Configuration settings: Modelsbuilder mode, if custom Umbraco path exists, ASP environment, whether the delivery API is enabled, and allows public access, and if you are in debug mode.</li>
</ul>

View File

@@ -27,4 +27,6 @@ public class WebhookLog
public string ResponseBody { get; set; } = string.Empty;
public bool ExceptionOccured { get; set; }
public bool IsSuccessStatusCode { get; set; }
}

View File

@@ -143,7 +143,7 @@ public class UmbracoRequestPaths
/// <summary>
/// Checks if the current uri is an install request
/// </summary>
public bool IsInstallerRequest(string absPath) => absPath.InvariantStartsWith(_installPath);
public bool IsInstallerRequest(string absPath) => absPath.InvariantStartsWith(_managementApiPath);
/// <summary>
/// Rudimentary check to see if it's not a server side request

View File

@@ -50,4 +50,28 @@ public class WebPath
return sb.ToString();
}
/// <summary>
/// Determines whether the provided web path is well-formed according to the specified UriKind.
/// </summary>
/// <param name="webPath">The web path to check. This can be null.</param>
/// <param name="uriKind">The kind of Uri (Absolute, Relative, or RelativeOrAbsolute).</param>
/// <returns>
/// true if <paramref name="webPath"/> is well-formed; otherwise, false.
/// </returns>
public static bool IsWellFormedWebPath(string? webPath, UriKind uriKind)
{
if (string.IsNullOrWhiteSpace(webPath))
{
return false;
}
if (webPath.StartsWith("//"))
{
return uriKind is not UriKind.Relative;
}
return Uri.IsWellFormedUriString(webPath, uriKind);
}
}

View File

@@ -16,7 +16,7 @@ public class WebhookLogFactory : IWebhookLogFactory
Url = webhook.Url,
WebhookKey = webhook.Key,
RetryCount = retryCount,
RequestHeaders = requestMessage.Headers.ToString(),
RequestHeaders = $"{requestMessage.Content?.Headers}{requestMessage.Headers}",
RequestBody = await requestMessage.Content?.ReadAsStringAsync(cancellationToken)!,
ExceptionOccured = exception is not null,
};
@@ -24,7 +24,8 @@ public class WebhookLogFactory : IWebhookLogFactory
if (httpResponseMessage is not null)
{
log.StatusCode = MapStatusCodeToMessage(httpResponseMessage.StatusCode);
log.ResponseHeaders = httpResponseMessage.Headers.ToString();
log.IsSuccessStatusCode = httpResponseMessage.IsSuccessStatusCode;
log.ResponseHeaders = $"{httpResponseMessage.Content.Headers}{httpResponseMessage.Headers}";
log.ResponseBody = await httpResponseMessage.Content.ReadAsStringAsync(cancellationToken);
}
else if (exception is HttpRequestException httpRequestException)

View File

@@ -20,6 +20,7 @@ public static class UmbracoBuilder_TelemetryProviders
builder.Services.AddTransient<IDetailedTelemetryProvider, SystemTroubleshootingInformationTelemetryProvider>();
builder.Services.AddTransient<IDetailedTelemetryProvider, DeliveryApiTelemetryProvider>();
builder.Services.AddTransient<IDetailedTelemetryProvider, WebhookTelemetryProvider>();
builder.Services.AddTransient<IDetailedTelemetryProvider, BlocksInRichTextTelemetryProvider>();
return builder;
}
}

View File

@@ -1,4 +1,5 @@
using Umbraco.Cms.Core.Models;
using System.Text.RegularExpressions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Webhooks;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
@@ -33,6 +34,7 @@ internal static class WebhookLogFactory
ResponseBody = dto.ResponseBody,
RetryCount = dto.RetryCount,
StatusCode = dto.StatusCode,
IsSuccessStatusCode = Regex.IsMatch(dto.StatusCode, "^.*\\(2(\\d{2})\\)$"),
Key = dto.Key,
Id = dto.Id,
Url = dto.Url,

View File

@@ -0,0 +1,42 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Telemetry.Interfaces;
namespace Umbraco.Cms.Infrastructure.Telemetry.Providers;
public class BlocksInRichTextTelemetryProvider : IDetailedTelemetryProvider
{
private readonly IDataTypeService _dataTypeService;
public BlocksInRichTextTelemetryProvider(IDataTypeService dataTypeService)
{
_dataTypeService = dataTypeService;
}
public IEnumerable<UsageInformation> GetInformation()
{
IEnumerable<IDataType> richTextDataTypes = _dataTypeService.GetByEditorAliasAsync(Constants.PropertyEditors.Aliases.RichText).GetAwaiter().GetResult().ToArray();
int registeredBlocks = 0;
yield return new UsageInformation(Constants.Telemetry.RichTextEditorCount, richTextDataTypes.Count());
foreach (IDataType richTextDataType in richTextDataTypes)
{
if (richTextDataType.ConfigurationObject is not RichTextConfiguration richTextConfiguration)
{
// Might be some custom data type, skip it
continue;
}
if (richTextConfiguration.Blocks is null)
{
continue;
}
registeredBlocks += richTextConfiguration.Blocks.Length;
}
yield return new UsageInformation(Constants.Telemetry.RichTextBlockCount, registeredBlocks);
}
}

View File

@@ -16,11 +16,19 @@ public class PropertyEditorTelemetryProvider : IDetailedTelemetryProvider
{
IEnumerable<IContentType> contentTypes = _contentTypeService.GetAll();
var propertyTypes = new HashSet<string>();
var propertyTypeCounts = new List<int>();
var totalCompositions = 0;
foreach (IContentType contentType in contentTypes)
{
propertyTypes.UnionWith(contentType.PropertyTypes.Select(x => x.PropertyEditorAlias));
propertyTypeCounts.Add(contentType.CompositionPropertyTypes.Count());
totalCompositions += contentType.CompositionAliases().Count();
}
yield return new UsageInformation(Constants.Telemetry.Properties, propertyTypes);
yield return new UsageInformation(Constants.Telemetry.TotalPropertyCount, propertyTypeCounts.Sum());
yield return new UsageInformation(Constants.Telemetry.HighestPropertyCount, propertyTypeCounts.Count > 0 ? propertyTypeCounts.Max() : 0);
yield return new UsageInformation(Constants.Telemetry.TotalCompositions, totalCompositions);
}
}

View File

@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Xml.Serialization;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Collections;
@@ -19,13 +20,12 @@ internal class Property : PublishedPropertyBase
private readonly bool _isMember;
private readonly bool _isPreviewing;
private readonly object _locko = new();
private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor;
// the invariant-neutral source and inter values
private readonly object? _sourceValue;
private readonly ContentVariation _variations;
private bool _sourceValueIsInvariant;
private readonly ContentVariation _sourceVariations;
// the variant and non-variant object values
private CacheValues? _cacheValues;
@@ -33,7 +33,8 @@ internal class Property : PublishedPropertyBase
private object? _interValue;
// the variant source and inter values
private Dictionary<CompositeStringStringKey, SourceInterValue>? _sourceValues;
private readonly object _locko = new();
private ConcurrentDictionary<CompositeStringStringKey, SourceInterValue>? _sourceValues;
private string? _valuesCacheKey;
@@ -66,12 +67,9 @@ internal class Property : PublishedPropertyBase
}
else
{
if (_sourceValues == null)
{
_sourceValues = new Dictionary<CompositeStringStringKey, SourceInterValue>();
}
EnsureSourceValuesInitialized();
_sourceValues[new CompositeStringStringKey(sourceValue.Culture, sourceValue.Segment)]
_sourceValues![new CompositeStringStringKey(sourceValue.Culture, sourceValue.Segment)]
= new SourceInterValue
{
Culture = sourceValue.Culture,
@@ -90,7 +88,7 @@ internal class Property : PublishedPropertyBase
// this variable is used for contextualizing the variation level when calculating property values.
// it must be set to the union of variance (the combination of content type and property type variance).
_variations = propertyType.Variations | content.ContentType.Variations;
_sourceValueIsInvariant = propertyType.Variations is ContentVariation.Nothing;
_sourceVariations = propertyType.Variations;
}
// clone for previewing as draft a published content that is published and has no draft
@@ -106,7 +104,7 @@ internal class Property : PublishedPropertyBase
_isMember = origin._isMember;
_publishedSnapshotAccessor = origin._publishedSnapshotAccessor;
_variations = origin._variations;
_sourceValueIsInvariant = origin._sourceValueIsInvariant;
_sourceVariations = origin._sourceVariations;
}
// used to cache the CacheValues of this property
@@ -125,54 +123,53 @@ internal class Property : PublishedPropertyBase
return hasValue.Value;
}
lock (_locko)
value = GetInterValue(culture, segment);
hasValue = PropertyType.IsValue(value, PropertyValueLevel.Inter);
if (hasValue.HasValue)
{
value = GetInterValue(culture, segment);
hasValue = PropertyType.IsValue(value, PropertyValueLevel.Inter);
if (hasValue.HasValue)
{
return hasValue.Value;
}
CacheValue cacheValues = GetCacheValues(PropertyType.CacheLevel).For(culture, segment);
// initial reference cache level always is .Content
const PropertyCacheLevel initialCacheLevel = PropertyCacheLevel.Element;
if (!cacheValues.ObjectInitialized)
{
cacheValues.ObjectValue =
PropertyType.ConvertInterToObject(_content, initialCacheLevel, value, _isPreviewing);
cacheValues.ObjectInitialized = true;
}
value = cacheValues.ObjectValue;
return PropertyType.IsValue(value, PropertyValueLevel.Object) ?? false;
return hasValue.Value;
}
CacheValue cacheValues = GetCacheValues(PropertyType.CacheLevel).For(culture, segment);
// initial reference cache level always is .Content
const PropertyCacheLevel initialCacheLevel = PropertyCacheLevel.Element;
if (!cacheValues.ObjectInitialized)
{
cacheValues.ObjectValue =
PropertyType.ConvertInterToObject(_content, initialCacheLevel, value, _isPreviewing);
cacheValues.ObjectInitialized = true;
}
value = cacheValues.ObjectValue;
return PropertyType.IsValue(value, PropertyValueLevel.Object) ?? false;
}
public override object? GetSourceValue(string? culture = null, string? segment = null)
{
_content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, ref culture, ref segment);
_content.VariationContextAccessor.ContextualizeVariation(_sourceVariations, _content.Id, ref culture, ref segment);
if (_sourceValueIsInvariant || (culture == string.Empty && segment == string.Empty))
// source values are tightly bound to the property/schema culture and segment configurations, so we need to
// sanitize the contextualized culture/segment states before using them to access the source values.
culture = _sourceVariations.VariesByCulture() ? culture : string.Empty;
segment = _sourceVariations.VariesBySegment() ? segment : string.Empty;
if (culture == string.Empty && segment == string.Empty)
{
return _sourceValue;
}
lock (_locko)
if (_sourceValues == null)
{
if (_sourceValues == null)
{
return null;
}
return _sourceValues.TryGetValue(
new CompositeStringStringKey(culture, segment),
out SourceInterValue? sourceValue)
? sourceValue.SourceValue
: null;
return null;
}
return _sourceValues.TryGetValue(
new CompositeStringStringKey(culture, segment),
out SourceInterValue? sourceValue)
? sourceValue.SourceValue
: null;
}
private CacheValues GetCacheValues(PropertyCacheLevel cacheLevel)
@@ -227,7 +224,6 @@ internal class Property : PublishedPropertyBase
return (CacheValues)cache.Get(ValuesCacheKey, () => new CacheValues())!;
}
// this is always invoked from within a lock, so does not require its own lock
private object? GetInterValue(string? culture, string? segment)
{
if (culture == string.Empty && segment == string.Empty)
@@ -242,21 +238,17 @@ internal class Property : PublishedPropertyBase
return _interValue;
}
if (_sourceValues == null)
{
_sourceValues = new Dictionary<CompositeStringStringKey, SourceInterValue>();
}
EnsureSourceValuesInitialized();
var k = new CompositeStringStringKey(culture, segment);
if (!_sourceValues.TryGetValue(k, out SourceInterValue? vvalue))
{
_sourceValues[k] = vvalue = new SourceInterValue
SourceInterValue vvalue = _sourceValues!.GetOrAdd(k, _ =>
new SourceInterValue
{
Culture = culture,
Segment = segment,
SourceValue = GetSourceValue(culture, segment),
};
}
});
if (vvalue.InterInitialized)
{
@@ -273,23 +265,20 @@ internal class Property : PublishedPropertyBase
_content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, ref culture, ref segment);
object? value;
lock (_locko)
CacheValue cacheValues = GetCacheValues(PropertyType.CacheLevel).For(culture, segment);
// initial reference cache level always is .Content
const PropertyCacheLevel initialCacheLevel = PropertyCacheLevel.Element;
if (cacheValues.ObjectInitialized)
{
CacheValue cacheValues = GetCacheValues(PropertyType.CacheLevel).For(culture, segment);
// initial reference cache level always is .Content
const PropertyCacheLevel initialCacheLevel = PropertyCacheLevel.Element;
if (cacheValues.ObjectInitialized)
{
return cacheValues.ObjectValue;
}
cacheValues.ObjectValue = PropertyType.ConvertInterToObject(_content, initialCacheLevel, GetInterValue(culture, segment), _isPreviewing);
cacheValues.ObjectInitialized = true;
value = cacheValues.ObjectValue;
return cacheValues.ObjectValue;
}
cacheValues.ObjectValue = PropertyType.ConvertInterToObject(_content, initialCacheLevel, GetInterValue(culture, segment), _isPreviewing);
cacheValues.ObjectInitialized = true;
value = cacheValues.ObjectValue;
return value;
}
@@ -298,18 +287,16 @@ internal class Property : PublishedPropertyBase
_content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, ref culture, ref segment);
object? value;
lock (_locko)
{
CacheValue cacheValues = GetCacheValues(expanding ? PropertyType.DeliveryApiCacheLevelForExpansion : PropertyType.DeliveryApiCacheLevel).For(culture, segment);
CacheValue cacheValues = GetCacheValues(expanding ? PropertyType.DeliveryApiCacheLevelForExpansion : PropertyType.DeliveryApiCacheLevel).For(culture, segment);
// initial reference cache level always is .Content
const PropertyCacheLevel initialCacheLevel = PropertyCacheLevel.Element;
object? GetDeliveryApiObject() => PropertyType.ConvertInterToDeliveryApiObject(_content, initialCacheLevel, GetInterValue(culture, segment), _isPreviewing, expanding);
value = expanding
? GetDeliveryApiExpandedObject(cacheValues, GetDeliveryApiObject)
: GetDeliveryApiDefaultObject(cacheValues, GetDeliveryApiObject);
}
// initial reference cache level always is .Content
const PropertyCacheLevel initialCacheLevel = PropertyCacheLevel.Element;
object? GetDeliveryApiObject() => PropertyType.ConvertInterToDeliveryApiObject(_content, initialCacheLevel, GetInterValue(culture, segment), _isPreviewing, expanding);
value = expanding
? GetDeliveryApiExpandedObject(cacheValues, GetDeliveryApiObject)
: GetDeliveryApiDefaultObject(cacheValues, GetDeliveryApiObject);
return value;
}
@@ -359,9 +346,9 @@ internal class Property : PublishedPropertyBase
private class CacheValues : CacheValue
{
private Dictionary<CompositeStringStringKey, CacheValue>? _values;
private readonly object _locko = new();
private ConcurrentDictionary<CompositeStringStringKey, CacheValue>? _values;
// this is always invoked from within a lock, so does not require its own lock
public CacheValue For(string? culture, string? segment)
{
if (culture == string.Empty && segment == string.Empty)
@@ -371,14 +358,15 @@ internal class Property : PublishedPropertyBase
if (_values == null)
{
_values = new Dictionary<CompositeStringStringKey, CacheValue>();
lock (_locko)
{
_values ??= InitializeConcurrentDictionary<CompositeStringStringKey, CacheValue>();
}
}
var k = new CompositeStringStringKey(culture, segment);
if (!_values.TryGetValue(k, out CacheValue? value))
{
_values[k] = value = new CacheValue();
}
CacheValue value = _values.GetOrAdd(k, _ => new CacheValue());
return value;
}
@@ -408,5 +396,22 @@ internal class Property : PublishedPropertyBase
public object? InterValue { get; set; }
}
private static ConcurrentDictionary<TKey, TValue> InitializeConcurrentDictionary<TKey, TValue>()
where TKey : notnull
=> new(-1, 5);
private void EnsureSourceValuesInitialized()
{
if (_sourceValues is not null)
{
return;
}
lock (_locko)
{
_sourceValues ??= InitializeConcurrentDictionary<CompositeStringStringKey, SourceInterValue>();
}
}
#endregion
}

View File

@@ -64,6 +64,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IUmbracoVirtualPageRoute, UmbracoVirtualPageRoute>();
builder.Services.AddSingleton<IUmbracoRouteValuesFactory, UmbracoRouteValuesFactory>();
builder.Services.AddSingleton<IRoutableDocumentFilter, RoutableDocumentFilter>();
builder.Services.AddSingleton<MatcherPolicy, EagerMatcherPolicy>();
builder.Services.AddSingleton<MatcherPolicy, SurfaceControllerMatcherPolicy>();
builder.Services.AddSingleton<FrontEndRoutes>();

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core;
using Umbraco.Cms.Web.Common.ApplicationBuilder;
using Umbraco.Cms.Web.Common.Middleware;
using Umbraco.Cms.Web.Website.Routing;
@@ -39,7 +40,7 @@ public static class UmbracoApplicationBuilderExtensions
FrontEndRoutes surfaceRoutes = builder.ApplicationServices.GetRequiredService<FrontEndRoutes>();
surfaceRoutes.CreateRoutes(builder.EndpointRouteBuilder);
builder.EndpointRouteBuilder.MapDynamicControllerRoute<UmbracoRouteValueTransformer>("/{**slug}");
builder.EndpointRouteBuilder.MapDynamicControllerRoute<UmbracoRouteValueTransformer>(Constants.Web.Routing.DynamicRoutePattern);
return builder;
}

View File

@@ -0,0 +1,228 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Matching;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.Controllers;
using Umbraco.Extensions;
namespace Umbraco.Cms.Web.Website.Routing;
/**
* A matcher policy that discards the catch-all (slug) route if there are any other valid routes with a lower order.
*
* The purpose of this is to skip our expensive <see cref="UmbracoRouteValueTransformer"/> if it's not required,
* for instance if there's a statically routed endpoint registered before the dynamic route,
* for more information see: https://github.com/umbraco/Umbraco-CMS/issues/16015.
* The core reason why this is necessary is that ALL routes get evaluated:
* "
* all routes get evaluated, they get to produce candidates and then the best candidate is selected.
* Since you have a dynamic route, it needs to run to produce the final endpoints and
* then those are ranked in along with the rest of the candidates to choose the final endpoint.
* "
* From: https://github.com/dotnet/aspnetcore/issues/45175#issuecomment-1322497958
*
* This also handles rerouting under install/upgrade states.
*/
internal class EagerMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy
{
private readonly IRuntimeState _runtimeState;
private readonly EndpointDataSource _endpointDataSource;
private readonly UmbracoRequestPaths _umbracoRequestPaths;
private GlobalSettings _globalSettings;
private readonly Lazy<Endpoint> _installEndpoint;
private readonly Lazy<Endpoint> _renderEndpoint;
public EagerMatcherPolicy(
IRuntimeState runtimeState,
EndpointDataSource endpointDataSource,
UmbracoRequestPaths umbracoRequestPaths,
IOptionsMonitor<GlobalSettings> globalSettings)
{
_runtimeState = runtimeState;
_endpointDataSource = endpointDataSource;
_umbracoRequestPaths = umbracoRequestPaths;
_globalSettings = globalSettings.CurrentValue;
globalSettings.OnChange(settings => _globalSettings = settings);
_installEndpoint = new Lazy<Endpoint>(GetInstallEndpoint);
_renderEndpoint = new Lazy<Endpoint>(GetRenderEndpoint);
}
// We want this to run as the very first policy, so we can discard the UmbracoRouteValueTransformer before the framework runs it.
public override int Order => int.MinValue + 10;
// We know we don't have to run this matcher against the backoffice endpoints.
public bool AppliesToEndpoints(IReadOnlyList<Endpoint> endpoints) => true;
public async Task ApplyAsync(HttpContext httpContext, CandidateSet candidates)
{
if (_runtimeState.Level != RuntimeLevel.Run)
{
var handled = await HandleInstallUpgrade(httpContext, candidates);
if (handled)
{
return;
}
}
// If there's only one candidate, we don't need to do anything.
if (candidates.Count < 2)
{
return;
}
// If there are multiple candidates, we want to discard the catch-all (slug)
// IF there is any candidates with a lower order. Since this will be a statically routed endpoint registered before the dynamic route.
// Which means that we don't have to run our UmbracoRouteValueTransformer to route dynamically (expensive).
var lowestOrder = int.MaxValue;
int? dynamicId = null;
RouteEndpoint? dynamicEndpoint = null;
for (var i = 0; i < candidates.Count; i++)
{
CandidateState candidate = candidates[i];
// If it's not a RouteEndpoint there's not much we can do to count it in the order.
if (candidate.Endpoint is not RouteEndpoint routeEndpoint)
{
continue;
}
if (routeEndpoint.Order < lowestOrder)
{
// We have to ensure that the route is valid for the current request method.
// This is because attribute routing will always have an order of 0.
// This means that you could attribute route a POST to /example, but also have an umbraco page at /example
// This would then result in a 404, because we'd see the attribute route with order 0, and always consider that the lowest order
// We'd then disable the dynamic endpoint since another endpoint has a lower order, and end up with only 1 invalid endpoint.
// (IsValidCandidate does not take this into account since the candidate itself is still valid)
HttpMethodMetadata? methodMetaData = routeEndpoint.Metadata.GetMetadata<HttpMethodMetadata>();
if (methodMetaData?.HttpMethods.Contains(httpContext.Request.Method) is false)
{
continue;
}
lowestOrder = routeEndpoint.Order;
}
// We only want to consider our dynamic route, this way it's still possible to register your own custom route before ours.
if (routeEndpoint.DisplayName != Constants.Web.Routing.DynamicRoutePattern)
{
continue;
}
dynamicEndpoint = routeEndpoint;
dynamicId = i;
}
// Invalidate the dynamic route if another route has a lower order.
// This means that if you register your static route after the dynamic route, the dynamic route will take precedence
// This more closely resembles the existing behaviour.
if (dynamicEndpoint is not null && dynamicId is not null && dynamicEndpoint.Order > lowestOrder)
{
candidates.SetValidity(dynamicId.Value, false);
}
}
/// <summary>
/// Replaces the first endpoint candidate with the specified endpoint, invalidating all other candidates,
/// guaranteeing that the specified endpoint will be hit.
/// </summary>
/// <param name="candidates">The candidate set to manipulate.</param>
/// <param name="endpoint">The target endpoint that will be hit.</param>
/// <param name="routeValueDictionary"></param>
private static void SetEndpoint(CandidateSet candidates, Endpoint endpoint, RouteValueDictionary routeValueDictionary)
{
candidates.ReplaceEndpoint(0, endpoint, routeValueDictionary);
for (int i = 1; i < candidates.Count; i++)
{
candidates.SetValidity(1, false);
}
}
private Endpoint GetInstallEndpoint()
{
Endpoint endpoint = _endpointDataSource.Endpoints.First(x =>
{
ControllerActionDescriptor? descriptor = x.Metadata.GetMetadata<ControllerActionDescriptor>();
return descriptor?.ControllerTypeInfo.Name == "BackOfficeDefaultController"
&& descriptor.ActionName == "Index";
});
return endpoint;
}
private Endpoint GetRenderEndpoint()
{
Endpoint endpoint = _endpointDataSource.Endpoints.First(x =>
{
ControllerActionDescriptor? descriptor = x.Metadata.GetMetadata<ControllerActionDescriptor>();
return descriptor?.ControllerTypeInfo == typeof(RenderController)
&& descriptor.ActionName == nameof(RenderController.Index);
});
return endpoint;
}
private Task<bool> HandleInstallUpgrade(HttpContext httpContext, CandidateSet candidates)
{
if (_runtimeState.Level != RuntimeLevel.Upgrade)
{
// We need to let the installer API requests through
// Currently we do this with a check for the installer path
// Ideally we should do this in a more robust way, for instance with a dedicated attribute we can then check for.
if (_umbracoRequestPaths.IsInstallerRequest(httpContext.Request.Path))
{
return Task.FromResult(true);
}
SetEndpoint(candidates, _installEndpoint.Value, new RouteValueDictionary
{
[Constants.Web.Routing.ControllerToken] = "BackOfficeDefault",
[Constants.Web.Routing.ActionToken] = "Index",
});
return Task.FromResult(true);
}
// Check if maintenance page should be shown
// Current behaviour is that statically routed endpoints still work in upgrade state
// This means that IF there is a static route, we should not show the maintenance page.
// And instead carry on as we normally would.
var hasStaticRoute = false;
for (var i = 0; i < candidates.Count; i++)
{
CandidateState candidate = candidates[i];
IDynamicEndpointMetadata? dynamicEndpointMetadata = candidate.Endpoint.Metadata.GetMetadata<IDynamicEndpointMetadata>();
if (dynamicEndpointMetadata is null || dynamicEndpointMetadata.IsDynamic is false)
{
hasStaticRoute = true;
break;
}
}
if (_runtimeState.Level != RuntimeLevel.Upgrade
|| _globalSettings.ShowMaintenancePageWhenInUpgradeState is false
|| hasStaticRoute)
{
return Task.FromResult(false);
}
// Otherwise we'll re-route to the render controller (this will in turn show the maintenance page through a filter)
// With this approach however this could really just be a plain old endpoint instead of a filter.
SetEndpoint(candidates, _renderEndpoint.Value, new RouteValueDictionary
{
[Constants.Web.Routing.ControllerToken] = ControllerExtensions.GetControllerName<RenderController>(),
[Constants.Web.Routing.ActionToken] = nameof(RenderController.Index),
});
return Task.FromResult(true);
}
}

View File

@@ -7,12 +7,10 @@ using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Web.Common.Controllers;
using Umbraco.Cms.Web.Common.Routing;
using Umbraco.Cms.Web.Common.Security;
using Umbraco.Cms.Web.Website.Controllers;
@@ -85,22 +83,6 @@ public class UmbracoRouteValueTransformer : DynamicRouteValueTransformer
public override async ValueTask<RouteValueDictionary> TransformAsync(
HttpContext httpContext, RouteValueDictionary values)
{
// If we aren't running, then we have nothing to route. We allow the frontend to continue while in upgrade mode.
if (_runtime.Level != RuntimeLevel.Run && _runtime.Level != RuntimeLevel.Upgrade)
{
if (_runtime.Level == RuntimeLevel.Install && !httpContext.Request.IsClientSideRequest())
{
return new RouteValueDictionary()
{
//TODO figure out constants
[ControllerToken] = "BackOfficeDefault",
[ActionToken] = "Index"
};
}
return null!;
}
// will be null for any client side requests like JS, etc...
if (!_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext))
{
@@ -117,17 +99,6 @@ public class UmbracoRouteValueTransformer : DynamicRouteValueTransformer
return null!;
}
// Check if the maintenance page should be shown
if (_runtime.Level == RuntimeLevel.Upgrade && _globalSettings.ShowMaintenancePageWhenInUpgradeState)
{
return new RouteValueDictionary
{
// Redirects to the RenderController who handles maintenance page in a filter, instead of having a dedicated controller
[ControllerToken] = ControllerExtensions.GetControllerName<RenderController>(),
[ActionToken] = nameof(RenderController.Index),
};
}
// Check if there is no existing content and return the no content controller
if (!umbracoContext.Content?.HasContent() ?? false)
{

View File

@@ -57,6 +57,11 @@ public class TelemetryServiceTests : UmbracoIntegrationTest
Constants.Telemetry.WebhookTotal,
Constants.Telemetry.WebhookCustomHeaders,
Constants.Telemetry.WebhookCustomEvent,
Constants.Telemetry.RichTextEditorCount,
Constants.Telemetry.RichTextBlockCount,
Constants.Telemetry.TotalPropertyCount,
Constants.Telemetry.HighestPropertyCount,
Constants.Telemetry.TotalCompositions,
};
// Add the default webhook events.

View File

@@ -76,6 +76,28 @@ public class PublishedContentVarianceTests
Assert.AreEqual(expectedValue, value);
}
[TestCase(DaCulture, Segment1, "DaDk property value")]
[TestCase(DaCulture, Segment2, "DaDk property value")]
[TestCase(EnCulture, Segment1, "EnUs property value")]
[TestCase(EnCulture, Segment2, "EnUs property value")]
public void Content_Culture_And_Segment_Variation_Can_Get_Culture_Variant_Property(string culture, string segment, string expectedValue)
{
var content = CreatePublishedContent(ContentVariation.CultureAndSegment, ContentVariation.Culture, variationContextCulture: culture, variationContextSegment: segment);
var value = GetPropertyValue(content);
Assert.AreEqual(expectedValue, value);
}
[TestCase(DaCulture, Segment1, "Segment1 property value")]
[TestCase(DaCulture, Segment2, "Segment2 property value")]
[TestCase(EnCulture, Segment1, "Segment1 property value")]
[TestCase(EnCulture, Segment2, "Segment2 property value")]
public void Content_Culture_And_Segment_Variation_Can_Get_Segment_Variant_Property(string culture, string segment, string expectedValue)
{
var content = CreatePublishedContent(ContentVariation.CultureAndSegment, ContentVariation.Segment, variationContextCulture: culture, variationContextSegment: segment);
var value = GetPropertyValue(content);
Assert.AreEqual(expectedValue, value);
}
private object? GetPropertyValue(IPublishedContent content) => content.GetProperty(PropertyTypeAlias)!.GetValue();
private IPublishedContent CreatePublishedContent(ContentVariation contentTypeVariation, ContentVariation propertyTypeVariation, string? variationContextCulture = null, string? variationContextSegment = null)

View File

@@ -97,23 +97,6 @@ public class UmbracoRequestPathsTests
Assert.AreEqual(expected, umbracoRequestPaths.IsBackOfficeRequest(source.AbsolutePath));
}
[TestCase("http://www.domain.com/install", true)]
[TestCase("http://www.domain.com/Install/", true)]
[TestCase("http://www.domain.com/install/default.aspx", true)]
[TestCase("http://www.domain.com/install/test/test", true)]
[TestCase("http://www.domain.com/Install/test/test.aspx", true)]
[TestCase("http://www.domain.com/install/test/test.js", true)]
[TestCase("http://www.domain.com/instal", false)]
[TestCase("http://www.domain.com/umbraco", false)]
[TestCase("http://www.domain.com/umbraco/umbraco", false)]
public void Is_Installer_Request(string input, bool expected)
{
var source = new Uri(input);
var hostingEnvironment = CreateHostingEnvironment();
var umbracoRequestPaths = new UmbracoRequestPaths(Options.Create(_globalSettings), hostingEnvironment, Options.Create(_umbracoRequestPathsOptions));
Assert.AreEqual(expected, umbracoRequestPaths.IsInstallerRequest(source.AbsolutePath));
}
[TestCase("http://www.domain.com/some/path", false)]
[TestCase("http://www.domain.com/umbraco/surface/blah", false)]
[TestCase("http://www.domain.com/umbraco/api/blah", false)]

View File

@@ -30,4 +30,87 @@ public class WebPathTests
[Test]
public void Combine_must_handle_null() => Assert.Throws<ArgumentNullException>(() => WebPath.Combine(null));
[Test]
[TestCase("ftp://hello.com/", UriKind.Absolute, ExpectedResult = true)]
[TestCase("file:///hello.com/", UriKind.Absolute, ExpectedResult = true)]
[TestCase("ws://hello.com/", UriKind.Absolute, ExpectedResult = true)]
[TestCase("wss://hello.com/", UriKind.Absolute, ExpectedResult = true)]
[TestCase("https://hello.com:8080/", UriKind.Absolute, ExpectedResult = true)]
[TestCase("http://hello.com:8080/", UriKind.Absolute, ExpectedResult = true)]
[TestCase("https://hello.com/path", UriKind.Absolute, ExpectedResult = true)]
[TestCase("http://hello.com/path", UriKind.Absolute, ExpectedResult = true)]
[TestCase("https://hello.com/path?query=param", UriKind.Absolute, ExpectedResult = true)]
[TestCase("http://hello.com/path?query=param", UriKind.Absolute, ExpectedResult = true)]
[TestCase("https://hello.com/path#fragment", UriKind.Absolute, ExpectedResult = true)]
[TestCase("http://hello.com/path#fragment", UriKind.Absolute, ExpectedResult = true)]
[TestCase("https://hello.com/path?query=param#fragment", UriKind.Absolute, ExpectedResult = true)]
[TestCase("http://hello.com/path?query=param#fragment", UriKind.Absolute, ExpectedResult = true)]
[TestCase("https://hello.com:8080/path?query=param#fragment", UriKind.Absolute, ExpectedResult = true)]
[TestCase("http://hello.com:8080/path?query=param#fragment", UriKind.Absolute, ExpectedResult = true)]
[TestCase("//hello.com:8080/path?query=param#fragment", UriKind.Absolute, ExpectedResult = true)]
[TestCase("//hello.com:8080/path", UriKind.Absolute, ExpectedResult = true)]
[TestCase("//hello.com:8080", UriKind.Absolute, ExpectedResult = true)]
[TestCase("//hello.com", UriKind.Absolute, ExpectedResult = true)]
[TestCase("/test/test.jpg", UriKind.Absolute, ExpectedResult = false)]
[TestCase("/test", UriKind.Absolute, ExpectedResult = false)]
[TestCase("test", UriKind.Absolute, ExpectedResult = false)]
[TestCase("", UriKind.Absolute, ExpectedResult = false)]
[TestCase(null, UriKind.Absolute, ExpectedResult = false)]
[TestCase("this is not welformed", UriKind.Absolute, ExpectedResult = false)]
[TestCase("ftp://hello.com/", UriKind.Relative, ExpectedResult = false)]
[TestCase("file:///hello.com/", UriKind.Relative, ExpectedResult = false)]
[TestCase("ws://hello.com/", UriKind.Relative, ExpectedResult = false)]
[TestCase("wss://hello.com/", UriKind.Relative, ExpectedResult = false)]
[TestCase("https://hello.com:8080/", UriKind.Relative, ExpectedResult = false)]
[TestCase("http://hello.com:8080/", UriKind.Relative, ExpectedResult = false)]
[TestCase("https://hello.com/path", UriKind.Relative, ExpectedResult = false)]
[TestCase("http://hello.com/path", UriKind.Relative, ExpectedResult = false)]
[TestCase("https://hello.com/path?query=param", UriKind.Relative, ExpectedResult = false)]
[TestCase("http://hello.com/path?query=param", UriKind.Relative, ExpectedResult = false)]
[TestCase("https://hello.com/path#fragment", UriKind.Relative, ExpectedResult = false)]
[TestCase("http://hello.com/path#fragment", UriKind.Relative, ExpectedResult = false)]
[TestCase("https://hello.com/path?query=param#fragment", UriKind.Relative, ExpectedResult = false)]
[TestCase("http://hello.com/path?query=param#fragment", UriKind.Relative, ExpectedResult = false)]
[TestCase("https://hello.com:8080/path?query=param#fragment", UriKind.Relative, ExpectedResult = false)]
[TestCase("http://hello.com:8080/path?query=param#fragment", UriKind.Relative, ExpectedResult = false)]
[TestCase("//hello.com:8080/path?query=param#fragment", UriKind.Relative, ExpectedResult = false)]
[TestCase("//hello.com:8080/path", UriKind.Relative, ExpectedResult = false)]
[TestCase("//hello.com:8080", UriKind.Relative, ExpectedResult = false)]
[TestCase("//hello.com", UriKind.Relative, ExpectedResult = false)]
[TestCase("/test/test.jpg", UriKind.Relative, ExpectedResult = true)]
[TestCase("/test", UriKind.Relative, ExpectedResult = true)]
[TestCase("test", UriKind.Relative, ExpectedResult = true)]
[TestCase("", UriKind.Relative, ExpectedResult = false)]
[TestCase(null, UriKind.Relative, ExpectedResult = false)]
[TestCase("this is not welformed", UriKind.Relative, ExpectedResult = false)]
[TestCase("ftp://hello.com/", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("file:///hello.com/", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("ws://hello.com/", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("wss://hello.com/", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("https://hello.com:8080/", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("http://hello.com:8080/", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("https://hello.com/path", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("http://hello.com/path", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("https://hello.com/path?query=param", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("http://hello.com/path?query=param", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("https://hello.com/path#fragment", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("http://hello.com/path#fragment", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("https://hello.com/path?query=param#fragment", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("http://hello.com/path?query=param#fragment", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("https://hello.com:8080/path?query=param#fragment", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("http://hello.com:8080/path?query=param#fragment", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("//hello.com:8080/path?query=param#fragment", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("//hello.com:8080/path", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("//hello.com:8080", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("//hello.com", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("/test/test.jpg", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("/test", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("test", UriKind.RelativeOrAbsolute, ExpectedResult = true)]
[TestCase("", UriKind.RelativeOrAbsolute, ExpectedResult = false)]
[TestCase(null, UriKind.RelativeOrAbsolute, ExpectedResult = false)]
[TestCase("this is not welformed", UriKind.RelativeOrAbsolute, ExpectedResult = false)]
public bool IsWellFormedWebPath(string? webPath, UriKind uriKind) => WebPath.IsWellFormedWebPath(webPath, uriKind);
}