diff --git a/src/Umbraco.Cms.Api.Delivery/Json/DeliveryApiJsonTypeResolver.cs b/src/Umbraco.Cms.Api.Delivery/Json/DeliveryApiJsonTypeResolver.cs index 10f052485aef..b22e7c9341a3 100644 --- a/src/Umbraco.Cms.Api.Delivery/Json/DeliveryApiJsonTypeResolver.cs +++ b/src/Umbraco.Cms.Api.Delivery/Json/DeliveryApiJsonTypeResolver.cs @@ -12,23 +12,36 @@ public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions option { JsonTypeInfo jsonTypeInfo = base.GetTypeInfo(type, options); + Type[] derivedTypes = GetDerivedTypes(jsonTypeInfo); + if (derivedTypes.Length > 0) + { + ConfigureJsonPolymorphismOptions(jsonTypeInfo, derivedTypes); + } + + return jsonTypeInfo; + } + + protected virtual Type[] GetDerivedTypes(JsonTypeInfo jsonTypeInfo) + { if (jsonTypeInfo.Type == typeof(IApiContent)) { - ConfigureJsonPolymorphismOptions(jsonTypeInfo, typeof(ApiContent)); + return new[] { typeof(ApiContent) }; } - else if (jsonTypeInfo.Type == typeof(IApiContentResponse)) + + if (jsonTypeInfo.Type == typeof(IApiContentResponse)) { - ConfigureJsonPolymorphismOptions(jsonTypeInfo, typeof(ApiContentResponse)); + return new[] { typeof(ApiContentResponse) }; } - else if (jsonTypeInfo.Type == typeof(IRichTextElement)) + + if (jsonTypeInfo.Type == typeof(IRichTextElement)) { - ConfigureJsonPolymorphismOptions(jsonTypeInfo, typeof(RichTextRootElement), typeof(RichTextGenericElement), typeof(RichTextTextElement)); + return new[] { typeof(RichTextRootElement), typeof(RichTextGenericElement), typeof(RichTextTextElement) }; } - return jsonTypeInfo; + return Array.Empty(); } - private void ConfigureJsonPolymorphismOptions(JsonTypeInfo jsonTypeInfo, params Type[] derivedTypes) + protected void ConfigureJsonPolymorphismOptions(JsonTypeInfo jsonTypeInfo, params Type[] derivedTypes) { jsonTypeInfo.PolymorphismOptions = new JsonPolymorphismOptions { diff --git a/src/Umbraco.Core/Constants-Telemetry.cs b/src/Umbraco.Core/Constants-Telemetry.cs index b054f78be369..f1c6a6c52a32 100644 --- a/src/Umbraco.Core/Constants-Telemetry.cs +++ b/src/Umbraco.Core/Constants-Telemetry.cs @@ -35,5 +35,10 @@ public static class Telemetry 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"; } } diff --git a/src/Umbraco.Core/Constants-Web.cs b/src/Umbraco.Core/Constants-Web.cs index acd1bb50efc3..283ec6eaa374 100644 --- a/src/Umbraco.Core/Constants-Web.cs +++ b/src/Umbraco.Core/Constants-Web.cs @@ -62,6 +62,7 @@ public static class Routing public const string ControllerToken = "controller"; public const string ActionToken = "action"; public const string AreaToken = "area"; + public const string DynamicRoutePattern = "/{**umbracoSlug}"; } public static class RoutePath diff --git a/src/Umbraco.Core/DeliveryApi/ApiContentResponseBuilder.cs b/src/Umbraco.Core/DeliveryApi/ApiContentResponseBuilder.cs index a551115a1ec3..68bb01c01282 100644 --- a/src/Umbraco.Core/DeliveryApi/ApiContentResponseBuilder.cs +++ b/src/Umbraco.Core/DeliveryApi/ApiContentResponseBuilder.cs @@ -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, IApiContentResponseBuilder +public class ApiContentResponseBuilder : ApiContentBuilderBase, IApiContentResponseBuilder { private readonly IApiContentRouteBuilder _apiContentRouteBuilder; @@ -14,6 +13,12 @@ public ApiContentResponseBuilder(IApiContentNameProvider apiContentNameProvider, => _apiContentRouteBuilder = apiContentRouteBuilder; protected override IApiContentResponse Create(IPublishedContent content, string name, IApiContentRoute route, IDictionary properties) + { + IDictionary cultures = GetCultures(content); + return new ApiContentResponse(content.Key, name, content.ContentType.Alias, content.CreateDate, content.UpdateDate, route, properties, cultures); + } + + protected virtual IDictionary GetCultures(IPublishedContent content) { var routesByCulture = new Dictionary(); @@ -35,6 +40,6 @@ protected override IApiContentResponse Create(IPublishedContent content, string routesByCulture[publishedCultureInfo.Culture] = cultureRoute; } - return new ApiContentResponse(content.Key, name, content.ContentType.Alias, content.CreateDate, content.UpdateDate, route, properties, routesByCulture); + return routesByCulture; } } diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index 5c7b6fb28b15..aaccac86070e 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -476,7 +476,7 @@
  • Anonymized site ID, Umbraco version, and packages installed.
  • -
  • 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.
  • +
  • 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.
  • System information: Webserver, server OS, server framework, server OS language, and database provider.
  • 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.
  • diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml index 60c3e7dfa939..01482e386347 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml @@ -470,7 +470,7 @@ We will send:
    • Anonymized site ID, Umbraco version, and packages installed.
    • -
    • 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.
    • +
    • 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.
    • System information: Webserver, server OS, server framework, server OS language, and database provider.
    • 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.
    diff --git a/src/Umbraco.Core/Models/WebhookLog.cs b/src/Umbraco.Core/Models/WebhookLog.cs index aba3b1571355..5340b0f73888 100644 --- a/src/Umbraco.Core/Models/WebhookLog.cs +++ b/src/Umbraco.Core/Models/WebhookLog.cs @@ -27,4 +27,6 @@ public class WebhookLog public string ResponseBody { get; set; } = string.Empty; public bool ExceptionOccured { get; set; } + + public bool IsSuccessStatusCode { get; set; } } diff --git a/src/Umbraco.Core/Routing/UmbracoRequestPaths.cs b/src/Umbraco.Core/Routing/UmbracoRequestPaths.cs index 82416c4f4176..a5083a77c974 100644 --- a/src/Umbraco.Core/Routing/UmbracoRequestPaths.cs +++ b/src/Umbraco.Core/Routing/UmbracoRequestPaths.cs @@ -143,7 +143,7 @@ private static bool IsPluginControllerRoute(string path) /// /// Checks if the current uri is an install request /// - public bool IsInstallerRequest(string absPath) => absPath.InvariantStartsWith(_installPath); + public bool IsInstallerRequest(string absPath) => absPath.InvariantStartsWith(_managementApiPath); /// /// Rudimentary check to see if it's not a server side request diff --git a/src/Umbraco.Core/Routing/WebPath.cs b/src/Umbraco.Core/Routing/WebPath.cs index 7ecafff8a324..47c6b158718d 100644 --- a/src/Umbraco.Core/Routing/WebPath.cs +++ b/src/Umbraco.Core/Routing/WebPath.cs @@ -50,4 +50,28 @@ public static string Combine(params string[]? paths) return sb.ToString(); } + + + /// + /// Determines whether the provided web path is well-formed according to the specified UriKind. + /// + /// The web path to check. This can be null. + /// The kind of Uri (Absolute, Relative, or RelativeOrAbsolute). + /// + /// true if is well-formed; otherwise, false. + /// + 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); + } } diff --git a/src/Umbraco.Core/Services/WebhookLogFactory.cs b/src/Umbraco.Core/Services/WebhookLogFactory.cs index 2c80f15e276e..3c260c017ff6 100644 --- a/src/Umbraco.Core/Services/WebhookLogFactory.cs +++ b/src/Umbraco.Core/Services/WebhookLogFactory.cs @@ -16,7 +16,7 @@ public async Task CreateAsync(string eventAlias, HttpRequestMessage 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 async Task CreateAsync(string eventAlias, HttpRequestMessage 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) diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.TelemetryProviders.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.TelemetryProviders.cs index a498ee7d0578..fd5468cedad3 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.TelemetryProviders.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.TelemetryProviders.cs @@ -20,6 +20,7 @@ public static IUmbracoBuilder AddTelemetryProviders(this IUmbracoBuilder builder builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); return builder; } } diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/WebhookLogFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/WebhookLogFactory.cs index f060525624a6..886ee58eaf93 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/WebhookLogFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/WebhookLogFactory.cs @@ -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 @@ public static WebhookLog DtoToEntity(WebhookLogDto dto) => 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, diff --git a/src/Umbraco.Infrastructure/Telemetry/Providers/BlocksInRichTextTelemetryProvider.cs b/src/Umbraco.Infrastructure/Telemetry/Providers/BlocksInRichTextTelemetryProvider.cs new file mode 100644 index 000000000000..89e2b998d6d7 --- /dev/null +++ b/src/Umbraco.Infrastructure/Telemetry/Providers/BlocksInRichTextTelemetryProvider.cs @@ -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 GetInformation() + { + IEnumerable 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); + } +} diff --git a/src/Umbraco.Infrastructure/Telemetry/Providers/PropertyEditorTelemetryProvider.cs b/src/Umbraco.Infrastructure/Telemetry/Providers/PropertyEditorTelemetryProvider.cs index 1c8e0af1ab49..8bd735ec1c7d 100644 --- a/src/Umbraco.Infrastructure/Telemetry/Providers/PropertyEditorTelemetryProvider.cs +++ b/src/Umbraco.Infrastructure/Telemetry/Providers/PropertyEditorTelemetryProvider.cs @@ -16,11 +16,19 @@ public IEnumerable GetInformation() { IEnumerable contentTypes = _contentTypeService.GetAll(); var propertyTypes = new HashSet(); + var propertyTypeCounts = new List(); + 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); } } diff --git a/src/Umbraco.PublishedCache.NuCache/Property.cs b/src/Umbraco.PublishedCache.NuCache/Property.cs index aa30fc9570cc..afe3b54484ee 100644 --- a/src/Umbraco.PublishedCache.NuCache/Property.cs +++ b/src/Umbraco.PublishedCache.NuCache/Property.cs @@ -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? _sourceValues; + private readonly object _locko = new(); + private ConcurrentDictionary? _sourceValues; private string? _valuesCacheKey; @@ -66,12 +67,9 @@ public Property( } else { - if (_sourceValues == null) - { - _sourceValues = new Dictionary(); - } + EnsureSourceValuesInitialized(); - _sourceValues[new CompositeStringStringKey(sourceValue.Culture, sourceValue.Segment)] + _sourceValues![new CompositeStringStringKey(sourceValue.Culture, sourceValue.Segment)] = new SourceInterValue { Culture = sourceValue.Culture, @@ -90,7 +88,7 @@ _sourceValues[new CompositeStringStringKey(sourceValue.Culture, sourceValue.Segm // 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 @@ public Property(Property origin, PublishedContent content) _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 @@ public override bool HasValue(string? culture = null, string? segment = null) 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); + return hasValue.Value; + } - // initial reference cache level always is .Content - const PropertyCacheLevel initialCacheLevel = PropertyCacheLevel.Element; + CacheValue cacheValues = GetCacheValues(PropertyType.CacheLevel).For(culture, segment); - if (!cacheValues.ObjectInitialized) - { - cacheValues.ObjectValue = - PropertyType.ConvertInterToObject(_content, initialCacheLevel, value, _isPreviewing); - cacheValues.ObjectInitialized = true; - } + // initial reference cache level always is .Content + const PropertyCacheLevel initialCacheLevel = PropertyCacheLevel.Element; - value = cacheValues.ObjectValue; - return PropertyType.IsValue(value, PropertyValueLevel.Object) ?? false; + 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); + + // 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 (_sourceValueIsInvariant || (culture == string.Empty && 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 @@ private CacheValues GetCacheValues(IAppCache? cache) 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 @@ private CacheValues GetCacheValues(IAppCache? cache) return _interValue; } - if (_sourceValues == null) - { - _sourceValues = new Dictionary(); - } + 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 @@ private CacheValues GetCacheValues(IAppCache? cache) _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, ref culture, ref segment); object? value; - lock (_locko) - { - CacheValue cacheValues = GetCacheValues(PropertyType.CacheLevel).For(culture, segment); + CacheValue cacheValues = GetCacheValues(PropertyType.CacheLevel).For(culture, segment); - // initial reference cache level always is .Content - const PropertyCacheLevel initialCacheLevel = PropertyCacheLevel.Element; + // 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; + if (cacheValues.ObjectInitialized) + { + 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 @@ private CacheValues GetCacheValues(IAppCache? cache) _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 @@ private class CacheValue private class CacheValues : CacheValue { - private Dictionary? _values; + private readonly object _locko = new(); + private ConcurrentDictionary? _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 @@ public CacheValue For(string? culture, string? segment) if (_values == null) { - _values = new Dictionary(); + lock (_locko) + { + _values ??= InitializeConcurrentDictionary(); + } } 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 @@ public string? Segment public object? InterValue { get; set; } } + private static ConcurrentDictionary InitializeConcurrentDictionary() + where TKey : notnull + => new(-1, 5); + + private void EnsureSourceValuesInitialized() + { + if (_sourceValues is not null) + { + return; + } + + lock (_locko) + { + _sourceValues ??= InitializeConcurrentDictionary(); + } + } + #endregion } diff --git a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs index dc98c5b813e0..f182bda7b6f2 100644 --- a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs @@ -64,6 +64,7 @@ public static IUmbracoBuilder AddWebsite(this IUmbracoBuilder builder) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Web.Website/Extensions/UmbracoApplicationBuilder.Website.cs b/src/Umbraco.Web.Website/Extensions/UmbracoApplicationBuilder.Website.cs index 549c0844ff25..e527724addb7 100644 --- a/src/Umbraco.Web.Website/Extensions/UmbracoApplicationBuilder.Website.cs +++ b/src/Umbraco.Web.Website/Extensions/UmbracoApplicationBuilder.Website.cs @@ -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 IUmbracoEndpointBuilderContext UseWebsiteEndpoints(this IUmbracoEn FrontEndRoutes surfaceRoutes = builder.ApplicationServices.GetRequiredService(); surfaceRoutes.CreateRoutes(builder.EndpointRouteBuilder); - builder.EndpointRouteBuilder.MapDynamicControllerRoute("/{**slug}"); + builder.EndpointRouteBuilder.MapDynamicControllerRoute(Constants.Web.Routing.DynamicRoutePattern); return builder; } diff --git a/src/Umbraco.Web.Website/Routing/EagerMatcherPolicy.cs b/src/Umbraco.Web.Website/Routing/EagerMatcherPolicy.cs new file mode 100644 index 000000000000..4098b219281d --- /dev/null +++ b/src/Umbraco.Web.Website/Routing/EagerMatcherPolicy.cs @@ -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 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 _installEndpoint; + private readonly Lazy _renderEndpoint; + + public EagerMatcherPolicy( + IRuntimeState runtimeState, + EndpointDataSource endpointDataSource, + UmbracoRequestPaths umbracoRequestPaths, + IOptionsMonitor globalSettings) + { + _runtimeState = runtimeState; + _endpointDataSource = endpointDataSource; + _umbracoRequestPaths = umbracoRequestPaths; + _globalSettings = globalSettings.CurrentValue; + globalSettings.OnChange(settings => _globalSettings = settings); + _installEndpoint = new Lazy(GetInstallEndpoint); + _renderEndpoint = new Lazy(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 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(); + 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); + } + } + + /// + /// Replaces the first endpoint candidate with the specified endpoint, invalidating all other candidates, + /// guaranteeing that the specified endpoint will be hit. + /// + /// The candidate set to manipulate. + /// The target endpoint that will be hit. + /// + 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(); + return descriptor?.ControllerTypeInfo.Name == "BackOfficeDefaultController" + && descriptor.ActionName == "Index"; + }); + + return endpoint; + } + + private Endpoint GetRenderEndpoint() + { + Endpoint endpoint = _endpointDataSource.Endpoints.First(x => + { + ControllerActionDescriptor? descriptor = x.Metadata.GetMetadata(); + return descriptor?.ControllerTypeInfo == typeof(RenderController) + && descriptor.ActionName == nameof(RenderController.Index); + }); + + return endpoint; + } + + private Task 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(); + 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(), + [Constants.Web.Routing.ActionToken] = nameof(RenderController.Index), + }); + + return Task.FromResult(true); + + } +} diff --git a/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs b/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs index 790d99952a52..6754577968e3 100644 --- a/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs +++ b/src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs @@ -7,12 +7,10 @@ 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 UmbracoRouteValueTransformer( public override async ValueTask 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 override async ValueTask TransformAsync( 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(), - [ActionToken] = nameof(RenderController.Index), - }; - } - // Check if there is no existing content and return the no content controller if (!umbracoContext.Content?.HasContent() ?? false) { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Telemetry/TelemetryServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Telemetry/TelemetryServiceTests.cs index 0ce5e013f38a..20dd4ecb463a 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Telemetry/TelemetryServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Telemetry/TelemetryServiceTests.cs @@ -57,6 +57,11 @@ public async Task Expected_Detailed_Telemetry_Exists() 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. diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Published/PublishedContentVarianceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Published/PublishedContentVarianceTests.cs index 7d117b96c5a5..a4a15b8f2255 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Published/PublishedContentVarianceTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Published/PublishedContentVarianceTests.cs @@ -76,6 +76,28 @@ public void Content_Culture_And_Segment_Variation_Can_Get_Culture_And_Segment_Va 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) diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/UmbracoRequestPathsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/UmbracoRequestPathsTests.cs index 16769d42b859..3d17117924d9 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/UmbracoRequestPathsTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/UmbracoRequestPathsTests.cs @@ -97,23 +97,6 @@ public void Is_Back_Office_Request(string input, string virtualPath, bool expect 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)] diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/WebPathTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/WebPathTests.cs index 4072e3df8bd4..72ab9150bc4a 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/WebPathTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/WebPathTests.cs @@ -30,4 +30,87 @@ public void Combine_must_handle_empty_array() => [Test] public void Combine_must_handle_null() => Assert.Throws(() => 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); + }