From cb15a61c7951190ddf1a6624f164c41f602303f6 Mon Sep 17 00:00:00 2001 From: Makaopior <152356597+Makaopior@users.noreply.github.com> Date: Sat, 12 Apr 2025 19:18:43 +0900 Subject: [PATCH 01/27] Added GeedoProductSearch bot (#66) Co-authored-by: Mazov Sergey --- src/MyCSharp.HttpUserAgentParser/HttpUserAgentStatics.cs | 3 ++- .../HttpUserAgentParserTests.cs | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/MyCSharp.HttpUserAgentParser/HttpUserAgentStatics.cs b/src/MyCSharp.HttpUserAgentParser/HttpUserAgentStatics.cs index 310c136..ae4208d 100644 --- a/src/MyCSharp.HttpUserAgentParser/HttpUserAgentStatics.cs +++ b/src/MyCSharp.HttpUserAgentParser/HttpUserAgentStatics.cs @@ -279,7 +279,8 @@ public static readonly (string Key, string Value)[] Robots = ( "ImagesiftBot", "ImagesiftBot" ), ( "Cotoyogi", "Cotoyogi" ), ( "Applebot", "Applebot" ), - ( "360Spider", "360Spider" ) + ( "360Spider", "360Spider" ), + ( "GeedoProductSearch", "GeedoProductSearch" ) ]; /// diff --git a/tests/MyCSharp.HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs b/tests/MyCSharp.HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs index 5115dba..a8b475a 100644 --- a/tests/MyCSharp.HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs +++ b/tests/MyCSharp.HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs @@ -155,6 +155,7 @@ public void BrowserTests(string ua, string name, string version, string platform [InlineData("Mozilla/5.0 (compatible; Cotoyogi/4.0; +https://ds.rois.ac.jp/center8/crawler/)", "Cotoyogi")] [InlineData("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15 (Applebot/0.1; +http://www.apple.com/go/applebot)", "Applebot")] [InlineData("Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.102 Safari/537.36; 360Spider", "360Spider")] + [InlineData("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; GeedoProductSearch; +https://geedo.com/product-search.html) Chrome/134.0.0.0 Safari/537.36", "GeedoProductSearch")] public void BotTests(string ua, string name) { HttpUserAgentInformation uaInfo = HttpUserAgentInformation.Parse(ua); From 239f343a05c40391df82398a4a6d55623cf5ca46 Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Sat, 12 Apr 2025 12:48:47 +0200 Subject: [PATCH 02/27] add refactor for structure and overhead files (#67) --- .editorconfig | 181 +++++++++--------- .github/workflows/ci.yml | 7 +- Directory.Build.props | 168 +++++++++++----- Directory.Packages.props | 43 +++-- MyCSharp.HttpUserAgentParser.sln | 16 +- global.json | 6 +- .../HttpUserAgentParser.Benchmarks.csproj} | 2 +- .../HttpUserAgentParserBenchmarks.cs | 0 .../LibraryComparisonBenchmarks.cs | 85 ++++++++ .../Program.cs | 1 - .../LibraryComparisonBenchmarks.cs | 86 --------- ...serDependencyInjectionOptionsExtensions.cs | 0 .../HttpContextExtensions.cs | 0 .../HttpUserAgentParser.AspNetCore.csproj | 27 +++ .../HttpUserAgentParserAccessor.cs | 0 .../IHttpUserAgentParserAccessor.cs | 0 .../LICENSE.txt | 0 .../readme.md | 0 ...rMemoryCacheServiceCollectionExtensions.cs | 29 +++ .../HttpUserAgentParser.MemoryCache.csproj} | 17 +- ...HttpUserAgentParserMemoryCachedProvider.cs | 8 +- ...rAgentParserMemoryCachedProviderOptions.cs | 4 +- .../LICENSE.txt | 0 .../readme.md | 0 ...erAgentParserDependencyInjectionOptions.cs | 0 ...rAgentParserServiceCollectionExtensions.cs | 0 .../HttpUserAgentInformation.cs | 4 +- .../HttpUserAgentInformationExtensions.cs | 0 .../HttpUserAgentParser.cs | 3 + .../HttpUserAgentParser.csproj} | 15 -- .../HttpUserAgentPlatformInformation.cs | 0 .../HttpUserAgentPlatformType.cs | 0 .../HttpUserAgentStatics.cs | 11 +- .../HttpUserAgentType.cs | 0 .../LICENSE.txt | 0 .../HttpUserAgentParserCachedProvider.cs | 2 +- .../HttpUserAgentParserDefaultProvider.cs | 0 .../Providers/IHttpUserAgentParserProvider.cs | 0 .../readme.md | 0 ...harp.HttpUserAgentParser.AspNetCore.csproj | 42 ---- ...rMemoryCacheServiceCollectionExtensions.cs | 30 --- ...tParserServiceCollectionExtensionsTests.cs | 0 .../HttpContextTestHelpers.cs | 0 ...serAgentParser.AspNetCore.UnitTests.csproj | 16 ++ .../HttpUserAgentParserAccessorTests.cs | 0 ...yCacheServiceCollectionExtensionssTests.cs | 0 ...erAgentParser.MemoryCache.UnitTests.csproj | 16 ++ ...tParserMemoryCachedProviderOptionsTests.cs | 0 ...serAgentParserMemoryCachedProviderTests.cs | 0 .../HttpUserAgentParser.TestHelpers.csproj | 7 + ...erAgentParserDependencyInjectionOptions.cs | 0 ...tParserServiceCollectionExtensionsTests.cs | 2 +- ...HttpUserAgentInformationExtensionsTests.cs | 0 .../HttpUserAgentInformationTests.cs | 13 +- .../HttpUserAgentParser.UnitTests.csproj | 15 ++ .../HttpUserAgentParserTests.cs | 0 .../HttpUserAgentPlatformInformationTests.cs | 2 +- .../HttpUserAgentPlatformTypeTests.cs | 0 .../HttpUserAgentTypeTests.cs | 0 .../HttpUserAgentParserCachedProviderTests.cs | 0 ...HttpUserAgentParserDefaultProviderTests.cs | 0 ...serAgentParser.AspNetCore.UnitTests.csproj | 45 ----- ...erAgentParser.MemoryCache.UnitTests.csproj | 44 ----- ...arp.HttpUserAgentParser.TestHelpers.csproj | 26 --- ...Sharp.HttpUserAgentParser.UnitTests.csproj | 44 ----- version.json | 33 ++-- 66 files changed, 495 insertions(+), 555 deletions(-) rename perf/{MyCSharp.HttpUserAgentParser.Benchmarks/MyCSharp.HttpUserAgentParser.Benchmarks.csproj => HttpUserAgentParser.Benchmarks/HttpUserAgentParser.Benchmarks.csproj} (89%) rename perf/{MyCSharp.HttpUserAgentParser.Benchmarks => HttpUserAgentParser.Benchmarks}/HttpUserAgentParserBenchmarks.cs (100%) create mode 100644 perf/HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs rename perf/{MyCSharp.HttpUserAgentParser.Benchmarks => HttpUserAgentParser.Benchmarks}/Program.cs (99%) delete mode 100644 perf/MyCSharp.HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs rename src/{MyCSharp.HttpUserAgentParser.AspNetCore => HttpUserAgentParser.AspNetCore}/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsExtensions.cs (100%) rename src/{MyCSharp.HttpUserAgentParser.AspNetCore => HttpUserAgentParser.AspNetCore}/HttpContextExtensions.cs (100%) create mode 100644 src/HttpUserAgentParser.AspNetCore/HttpUserAgentParser.AspNetCore.csproj rename src/{MyCSharp.HttpUserAgentParser.AspNetCore => HttpUserAgentParser.AspNetCore}/HttpUserAgentParserAccessor.cs (100%) rename src/{MyCSharp.HttpUserAgentParser.AspNetCore => HttpUserAgentParser.AspNetCore}/IHttpUserAgentParserAccessor.cs (100%) rename src/{MyCSharp.HttpUserAgentParser.AspNetCore => HttpUserAgentParser.AspNetCore}/LICENSE.txt (100%) rename src/{MyCSharp.HttpUserAgentParser.AspNetCore => HttpUserAgentParser.AspNetCore}/readme.md (100%) create mode 100644 src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensions.cs rename src/{MyCSharp.HttpUserAgentParser.MemoryCache/MyCSharp.HttpUserAgentParser.MemoryCache.csproj => HttpUserAgentParser.MemoryCache/HttpUserAgentParser.MemoryCache.csproj} (50%) rename src/{MyCSharp.HttpUserAgentParser.MemoryCache => HttpUserAgentParser.MemoryCache}/HttpUserAgentParserMemoryCachedProvider.cs (83%) rename src/{MyCSharp.HttpUserAgentParser.MemoryCache => HttpUserAgentParser.MemoryCache}/HttpUserAgentParserMemoryCachedProviderOptions.cs (92%) rename src/{MyCSharp.HttpUserAgentParser.MemoryCache => HttpUserAgentParser.MemoryCache}/LICENSE.txt (100%) rename src/{MyCSharp.HttpUserAgentParser.MemoryCache => HttpUserAgentParser.MemoryCache}/readme.md (100%) rename src/{MyCSharp.HttpUserAgentParser => HttpUserAgentParser}/DependencyInjection/HttpUserAgentParserDependencyInjectionOptions.cs (100%) rename src/{MyCSharp.HttpUserAgentParser => HttpUserAgentParser}/DependencyInjection/HttpUserAgentParserServiceCollectionExtensions.cs (100%) rename src/{MyCSharp.HttpUserAgentParser => HttpUserAgentParser}/HttpUserAgentInformation.cs (94%) rename src/{MyCSharp.HttpUserAgentParser => HttpUserAgentParser}/HttpUserAgentInformationExtensions.cs (100%) rename src/{MyCSharp.HttpUserAgentParser => HttpUserAgentParser}/HttpUserAgentParser.cs (97%) rename src/{MyCSharp.HttpUserAgentParser/MyCSharp.HttpUserAgentParser.csproj => HttpUserAgentParser/HttpUserAgentParser.csproj} (52%) rename src/{MyCSharp.HttpUserAgentParser => HttpUserAgentParser}/HttpUserAgentPlatformInformation.cs (100%) rename src/{MyCSharp.HttpUserAgentParser => HttpUserAgentParser}/HttpUserAgentPlatformType.cs (100%) rename src/{MyCSharp.HttpUserAgentParser => HttpUserAgentParser}/HttpUserAgentStatics.cs (97%) rename src/{MyCSharp.HttpUserAgentParser => HttpUserAgentParser}/HttpUserAgentType.cs (100%) rename src/{MyCSharp.HttpUserAgentParser => HttpUserAgentParser}/LICENSE.txt (100%) rename src/{MyCSharp.HttpUserAgentParser => HttpUserAgentParser}/Providers/HttpUserAgentParserCachedProvider.cs (94%) rename src/{MyCSharp.HttpUserAgentParser => HttpUserAgentParser}/Providers/HttpUserAgentParserDefaultProvider.cs (100%) rename src/{MyCSharp.HttpUserAgentParser => HttpUserAgentParser}/Providers/IHttpUserAgentParserProvider.cs (100%) rename src/{MyCSharp.HttpUserAgentParser => HttpUserAgentParser}/readme.md (100%) delete mode 100644 src/MyCSharp.HttpUserAgentParser.AspNetCore/MyCSharp.HttpUserAgentParser.AspNetCore.csproj delete mode 100644 src/MyCSharp.HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensions.cs rename tests/{MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests => HttpUserAgentParser.AspNetCore.UnitTests}/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs (100%) rename tests/{MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests => HttpUserAgentParser.AspNetCore.UnitTests}/HttpContextTestHelpers.cs (100%) create mode 100644 tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpUserAgentParser.AspNetCore.UnitTests.csproj rename tests/{MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests => HttpUserAgentParser.AspNetCore.UnitTests}/HttpUserAgentParserAccessorTests.cs (100%) rename tests/{MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests => HttpUserAgentParser.MemoryCache.UnitTests}/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensionssTests.cs (100%) create mode 100644 tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParser.MemoryCache.UnitTests.csproj rename tests/{MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests => HttpUserAgentParser.MemoryCache.UnitTests}/HttpUserAgentParserMemoryCachedProviderOptionsTests.cs (100%) rename tests/{MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests => HttpUserAgentParser.MemoryCache.UnitTests}/HttpUserAgentParserMemoryCachedProviderTests.cs (100%) create mode 100644 tests/HttpUserAgentParser.TestHelpers/HttpUserAgentParser.TestHelpers.csproj rename tests/{MyCSharp.HttpUserAgentParser.UnitTests => HttpUserAgentParser.UnitTests}/DependencyInjection/HttpUserAgentParserDependencyInjectionOptions.cs (100%) rename tests/{MyCSharp.HttpUserAgentParser.UnitTests => HttpUserAgentParser.UnitTests}/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs (97%) rename tests/{MyCSharp.HttpUserAgentParser.UnitTests => HttpUserAgentParser.UnitTests}/HttpUserAgentInformationExtensionsTests.cs (100%) rename tests/{MyCSharp.HttpUserAgentParser.UnitTests => HttpUserAgentParser.UnitTests}/HttpUserAgentInformationTests.cs (83%) create mode 100644 tests/HttpUserAgentParser.UnitTests/HttpUserAgentParser.UnitTests.csproj rename tests/{MyCSharp.HttpUserAgentParser.UnitTests => HttpUserAgentParser.UnitTests}/HttpUserAgentParserTests.cs (100%) rename tests/{MyCSharp.HttpUserAgentParser.UnitTests => HttpUserAgentParser.UnitTests}/HttpUserAgentPlatformInformationTests.cs (90%) rename tests/{MyCSharp.HttpUserAgentParser.UnitTests => HttpUserAgentParser.UnitTests}/HttpUserAgentPlatformTypeTests.cs (100%) rename tests/{MyCSharp.HttpUserAgentParser.UnitTests => HttpUserAgentParser.UnitTests}/HttpUserAgentTypeTests.cs (100%) rename tests/{MyCSharp.HttpUserAgentParser.UnitTests => HttpUserAgentParser.UnitTests}/Providers/HttpUserAgentParserCachedProviderTests.cs (100%) rename tests/{MyCSharp.HttpUserAgentParser.UnitTests => HttpUserAgentParser.UnitTests}/Providers/HttpUserAgentParserDefaultProviderTests.cs (100%) delete mode 100644 tests/MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests/MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests.csproj delete mode 100644 tests/MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests/MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests.csproj delete mode 100644 tests/MyCSharp.HttpUserAgentParser.TestHelpers/MyCSharp.HttpUserAgentParser.TestHelpers.csproj delete mode 100644 tests/MyCSharp.HttpUserAgentParser.UnitTests/MyCSharp.HttpUserAgentParser.UnitTests.csproj diff --git a/.editorconfig b/.editorconfig index 9636fb7..c22c07c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,25 +1,28 @@ +# EditorConfig is awesome:http://EditorConfig.org +# From https://raw.githubusercontent.com/dotnet/roslyn/master/.editorconfig +# https://github.com/BenjaminAbt/templates/blob/main/editorconfig/.editorconfig + ############################### # Core EditorConfig Options # ############################### +# top-most EditorConfig file root = true # stop .editorconfig files search on current file. # All files +# Don't use tabs for indentation. [*] -charset = utf-8 indent_style = space -indent_size = 4 trim_trailing_whitespace = true # Remove trailing whitespace insert_final_newline = true # Ensure file ends with a newline max_line_length = 120 # Maximum line length for readability -end_of_line = lf -tab_width = 4 ############################### # Markdown # ############################### [*.md] +indent_size = 4 trim_trailing_whitespace = false max_line_length = off @@ -83,11 +86,43 @@ max_line_length = off indent_size = 2 max_line_length = off +############################### +# PowerShell # +############################### + +[*.ps1] +indent_size = 2 + +############################### +# Shell # +############################### + +[*.sh] +end_of_line = lf + +[*.{cmd,bat}] +end_of_line = crlf + +############################### +# .NET project files # +############################### + +# Xml project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 2 + +# Xml config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 + ############################### # C# / VB # ############################### -[*.{cs,vb}] +# Code files +[*.{cs,csx,vb,vbx}] +indent_size = 4 + # Organize usings dotnet_sort_system_directives_first = true @@ -98,8 +133,8 @@ dotnet_style_qualification_for_method = false:silent dotnet_style_qualification_for_event = false:silent # Language keywords vs BCL types preferences -dotnet_style_predefined_type_for_locals_parameters_members = true:silent -dotnet_style_predefined_type_for_member_access = true:silent +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion # Parentheses preferences dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent @@ -124,11 +159,7 @@ dotnet_style_prefer_auto_properties = true:suggestion dotnet_style_prefer_conditional_expression_over_assignment = true:silent dotnet_style_prefer_conditional_expression_over_return = true:silent -############################### -# Naming Conventions # -############################### # Style Definitions - dotnet_naming_rule.interface_should_be_begins_with_i.severity = warning dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i @@ -230,10 +261,8 @@ csharp_preferred_modifier_order = public,private,protected,internal,static,exter csharp_prefer_braces = true:suggestion csharp_style_deconstructed_variable_declaration = true:suggestion csharp_prefer_simple_default_expression = true:suggestion -csharp_style_prefer_local_over_anonymous_function = true:suggestion csharp_style_inlined_variable_declaration = true:suggestion -# ------------------------------------------------------ # New line preferences csharp_new_line_before_open_brace = all csharp_new_line_before_else = true @@ -243,13 +272,11 @@ csharp_new_line_before_members_in_object_initializers = true csharp_new_line_before_members_in_anonymous_types = true csharp_new_line_between_query_expression_clauses = true -# ------------------------------------------------------ # Indentation preferences csharp_indent_case_contents = true csharp_indent_switch_labels = true csharp_indent_labels = one_less_than_current -# ------------------------------------------------------ # Space preferences csharp_space_after_cast = false csharp_space_after_keywords_in_control_flow_statements = true @@ -263,7 +290,6 @@ csharp_space_between_method_declaration_empty_parameter_list_parentheses = false csharp_space_between_method_call_name_and_opening_parenthesis = false csharp_space_between_method_call_empty_parameter_list_parentheses = false -# ------------------------------------------------------ # Wrapping preferences csharp_preserve_single_line_statements = true csharp_preserve_single_line_blocks = true @@ -294,54 +320,6 @@ csharp_style_prefer_pattern_matching = true:silent csharp_style_prefer_not_pattern = true:suggestion csharp_style_prefer_extended_property_pattern = true:suggestion -# ------------------------------------------------------ -# Naming rules - -dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion -dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface -dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i - -dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion -dotnet_naming_rule.types_should_be_pascal_case.symbols = types -dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case - -dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion -dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members -dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case - -# ------------------------------------------------------ -# Symbol specifications - -dotnet_naming_symbols.interface.applicable_kinds = interface -dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.interface.required_modifiers = - -dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum -dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.types.required_modifiers = - -dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method -dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.non_field_members.required_modifiers = - -# ------------------------------------------------------ -# Naming styles - -dotnet_naming_style.begins_with_i.required_prefix = I -dotnet_naming_style.begins_with_i.required_suffix = -dotnet_naming_style.begins_with_i.word_separator = -dotnet_naming_style.begins_with_i.capitalization = pascal_case - -dotnet_naming_style.pascal_case.required_prefix = -dotnet_naming_style.pascal_case.required_suffix = -dotnet_naming_style.pascal_case.word_separator = -dotnet_naming_style.pascal_case.capitalization = pascal_case - -dotnet_naming_style.pascal_case.required_prefix = -dotnet_naming_style.pascal_case.required_suffix = -dotnet_naming_style.pascal_case.word_separator = -dotnet_naming_style.pascal_case.capitalization = pascal_case - # ------------------------------------------------------ # CA Style @@ -349,108 +327,129 @@ dotnet_naming_style.pascal_case.capitalization = pascal_case dotnet_diagnostic.CA1050.severity = warning # CA1507: Use nameof in place of string +# Avoiding hard-coded strings in code improves maintainability and reduces the risk of errors during refactoring. dotnet_diagnostic.CA1507.severity = warning -# CA1825: Avoid unnecessary zero-length array allocations. Use Array.Empty() instead. +# CA1825: Avoid unnecessary zero-length array allocations. Use Array.Empty() instead. +# Array.Empty() is more memory-efficient as it reuses a single cached empty array instance rather than creating new ones. dotnet_diagnostic.CA1825.severity = warning # CA1850: It is more efficient to use the static 'HashData' method over creating and managing a HashAlgorithm instance to call 'ComputeHash'. +# This improves performance by avoiding unnecessary instance creation and lifecycle management of HashAlgorithm objects. dotnet_diagnostic.CA1850.severity = warning # CA1860: Prefer using 'IsEmpty', 'Count' or 'Length' properties whichever available, rather than calling 'Enumerable.Any()'. -# The intent is clearer and it is more performant than using 'Enumerable.Any()' extension method. -dotnet_diagnostic.CA1860.severity = warning - -# CA1860: Prefer using 'IsEmpty', 'Count' or 'Length' properties whichever available, rather than calling 'Enumerable.Any()'. -# The intent is clearer and it is more performant than using 'Enumerable.Any()' extension method. +# These direct property accesses are more performant and communicate intent more clearly than using LINQ extension methods. dotnet_diagnostic.CA1860.severity = warning # CS1998: This async method lacks 'await' operators and will run synchronously. -# Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread. +# Setting this to error prevents misleading code that suggests asynchronous behavior but actually runs synchronously. dotnet_diagnostic.CS1998.severity = error # CA2016: Forward the CancellationToken parameter to methods that take one +# Ensuring proper propagation of cancellation tokens throughout the call chain is critical for responsive and cancellable operations. dotnet_diagnostic.CA2016.severity = error - -# CA2211: Static fields that are neither constants nor read-only are not thread-safe. -# Access to such a field must be carefully controlled and requires advanced programming techniques to synchronize access to the class object. -dotnet_diagnostic.CA2211.severity = silent - # ------------------------------------------------------ # IDE # IDE0060: Avoid unused parameters in your code. -# If the parameter cannot be removed, then change its name so it starts with an underscore and is optionally followed by an -# integer, such as '_', '_1', '_2', etc. These are treated as special discard symbol names. +# Silenced to allow for interface implementations where not all parameters may be needed in every implementation or like URL design in ASP.NET Core. dotnet_diagnostic.IDE0060.severity = silent # IDE0130: Namespace does not match folder structure +# Enforces consistent organization where namespaces reflect folder structure, improving code discoverability. dotnet_diagnostic.IDE0130.severity = warning -# IDE0290: Use primary constructor -dotnet_diagnostic.IDE0290.severity = none -csharp_style_prefer_primary_constructors = false:suggestion +# IDE0039: Use local function instead of lambda +# Local functions improve readability and performance over lambdas for method-local callable code. +dotnet_diagnostic.IDE0039.severity = warning -# IDE1006: Naming rule violation: These words must begin with upper case characters: accessToken +# IDE0270: Null check can be simplified +# Disabled to allow developers to choose their preferred null-checking style based on context and readability. +dotnet_diagnostic.IDE0270.severity = none + +# IDE0305: Use collection expression for fluent +# Silenced because collection expression style is often a matter of preference and readability context. +dotnet_diagnostic.IDE0305.severity = silent + +# IDE1006: Naming rule violation: These words must begin with upper case characters +# Enforces consistent naming conventions across the codebase for better readability and maintainability. dotnet_diagnostic.IDE1006.severity = warning # ------------------------------------------------------ # Roslyn +# RCS0063: Remove unnecessary blank line +# Promotes cleaner, more consistent code formatting by eliminating superfluous whitespace. +dotnet_diagnostic.RCS0063.severity = warning + # RCS1021: Use expression-bodied lambda. +# Silenced to allow both statement and expression-bodied lambda syntax based on complexity and readability. dotnet_diagnostic.RCS1021.severity = silent -# RCS1036: Remove unnecessary blank line -dotnet_diagnostic.RCS1036.severity = warning - # RCS1049: Simplify boolean comparison -# we usually prefer is vs ! +# Silenced to allow explicit boolean comparisons (e.g., x == true) when they improve readability. dotnet_diagnostic.RCS1049.severity = silent # RCS1123: Add parentheses when necessary +# Enforces explicit operator precedence through parentheses, preventing subtle bugs and improving readability. dotnet_diagnostic.RCS1123.severity = warning # RCS1163: Unused parameter -# Often this warning comes also from parameters we want to have for readability +# Silenced because parameters may be kept for API consistency or documentation purposes even when unused or URL design in ASP.NET Core. dotnet_diagnostic.RCS1163.severity = silent # RCS1194: Implement exception constructors +# Silenced to allow custom exception classes with only the constructors needed for the specific use case. dotnet_diagnostic.RCS1194.severity = silent # ------------------------------------------------------ # Meziantou.Analyzer +# MA0007: Add a comma after the last value +# Disabled as trailing commas in C# are not conventional and would make the code less familiar to most developers. +dotnet_diagnostic.MA0007.severity = none + # MA0016: Prefer using collection abstraction instead of implementation +# Disabled to allow direct use of concrete collection types when their specific capabilities are needed. dotnet_diagnostic.MA0016.severity = none # MA0017: Abstract types should not have public or internal constructors +# Disabled to permit protected constructors in abstract classes which are valid for inheritance scenarios. dotnet_diagnostic.MA0017.severity = none -# MA0018: Do not declare static members on generic types (deprecated; use CA1000 instead) +# MA0018: Do not declare static members on generic types +# Disabled in favor of using the standard CA1000 (Do not declare static members on generic types) rule to handle this case. dotnet_diagnostic.MA0018.severity = none # MA0029: Combine LINQ methods -# Often these recommendations have a performance impact, so it is recommended to review them before applying them. +# Disabled because LINQ method chaining can be more readable and potential performance impacts need case-by-case review. dotnet_diagnostic.MA0029.severity = none # MA0040: Forward the CancellationToken parameter to methods that take one +# Enforces proper cancellation token propagation for responsive applications and services. dotnet_diagnostic.MA0040.severity = warning # MA0048: File name must match type name +# Silenced to allow flexibility in file naming, particularly for partial classes or multiple types in one file. dotnet_diagnostic.MA0048.severity = silent -# MA0051: Method is too long (80 lines; maximum allowed: 60) +# MA0051: Method is too long +# Set as suggestion to encourage smaller, more focused methods while allowing flexibility for complex logic. dotnet_diagnostic.MA0051.severity = suggestion # MA0154: Use langword in XML comment +# Disabled to allow flexibility in documentation style and format. dotnet_diagnostic.MA0154.severity = none # ------------------------------------------------------ # Xunit # xUnit1006: Theory methods should have parameters +# Silenced to allow theories that might dynamically generate test cases without explicit parameters. dotnet_diagnostic.xUnit1006.severity = silent -# xUnit1048: Support for 'async void' unit tests is being removed from xUnit.net v3. To simplify upgrading, convert the test to 'async Task' instead. +# xUnit1048: Support for 'async void' unit tests is being removed +# Set as error to ensure future compatibility with xUnit v3 by requiring proper async Task signatures. dotnet_diagnostic.xUnit1048.severity = error diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15b9090..558da40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,13 @@ on: jobs: build: - uses: mycsharp/github-actions/.github/workflows/dotnet-nuget-build.yml@main + uses: mycsharp/github-actions/.github/workflows/dotnet-nuget-build-multi-sdk.yml@main with: configuration: Release + dotnet-sdks: | + 8.0.x + 9.0.x + 10.0.x + secrets: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} diff --git a/Directory.Build.props b/Directory.Build.props index 4ded6bf..0dd3328 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,55 +1,121 @@ + + MyCSharp.HttpUserAgentParser + MyCSharp.de, Benjamin Abt, Günther Foidl and Contributors + MyCSharp.de + - - MyCSharp.de, Benjamin Abt, Günther Foidl and Contributors - MyCSharp.HttpUserAgentParser - en-US - true - embedded - - - - - $(MSBuildProjectName.Contains('Test')) - $(MsBuildProjectName.Contains('Benchmark')) - - - - https://github.com/mycsharp/HttpUserAgentParser - true - HTTP User Agent Parser for .NET - 2.12 - false - UserAgent, User Agent, Parse, Browser, Client, Detector, Detection, Console, ASP, Desktop, Mobile - true - - - - net8.0;net9.0 - - - - 12.0 - enable - enable - true - - - - true - - - - true - $(MSBuildThisFileDirectory)MyCSharp.HttpUserAgentParser.snk - - 00240000048000009400000006020000002400005253413100040000010001003d5c022c088a46d41d5a5bf7591f3a3dcba30f76b0f43a312b6e45bb419d32283175cbd8bfd83134b123da6db83479e50596fb6bbe0e8c6cef50c01c64a0861c963daaf6905920f44ffe1ce44b3cfcb9c23779f34bc90c7b04e74e36a19bb58af3a69456d49b56993969dba9f8e9e935c2757844a11066d1091477f10cd923b7 - - - - - - + + true + true + true + + $(MSBuildProjectName.EndsWith('Tests')) + $(MSBuildProjectName.EndsWith('UnitTests')) + $(MSBuildProjectName.EndsWith('IntegrationTests')) + $(MsBuildProjectName.EndsWith('Benchmarks')) + + + + net8.0;net9.0;net10.0 + MyCSharp.$(MSBuildProjectName) + MyCSharp.$(MSBuildProjectName) + + + + true + $(MSBuildThisFileDirectory)MyCSharp.HttpUserAgentParser.snk + + + 00240000048000009400000006020000002400005253413100040000010001003d5c022c088a46d41d5a5bf7591f3a3dcba30f76b0f43a312b6e45bb419d32283175cbd8bfd83134b123da6db83479e50596fb6bbe0e8c6cef50c01c64a0861c963daaf6905920f44ffe1ce44b3cfcb9c23779f34bc90c7b04e74e36a19bb58af3a69456d49b56993969dba9f8e9e935c2757844a11066d1091477f10cd923b7 + + + + + preview + embedded + enable + en-US + enable + true + + + + false + true + 2.12 + true + + HTTP User Agent Parser for .NET + https://github.com/mycsharp/HttpUserAgentParser + https://github.com/mycsharp/HttpUserAgentParser + UserAgent, User Agent, Parse, Browser, Client, Detector, Detection, Console, ASP, Desktop, Mobile + + + + true + + + + + true + + + + true + all + low + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 73f2b79..4d9ff86 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,47 +2,64 @@ true - + + + - + - + + + + + + + - - - + + all + runtime; build; native; contentfiles; analyzers + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + all runtime; build; native; contentfiles; analyzers - - - + all runtime; build; native; contentfiles; analyzers - + all runtime; build; native; contentfiles; analyzers - + + + + all runtime; build; native; contentfiles; analyzers diff --git a/MyCSharp.HttpUserAgentParser.sln b/MyCSharp.HttpUserAgentParser.sln index d1dc0c3..a444be3 100644 --- a/MyCSharp.HttpUserAgentParser.sln +++ b/MyCSharp.HttpUserAgentParser.sln @@ -5,19 +5,19 @@ VisualStudioVersion = 17.4.32804.182 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{008A2BAB-78B4-42EB-A5D4-DE434438CEF0}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyCSharp.HttpUserAgentParser.AspNetCore", "src\MyCSharp.HttpUserAgentParser.AspNetCore\MyCSharp.HttpUserAgentParser.AspNetCore.csproj", "{45927CF7-1BF4-479B-BBAA-8AD9CA901AE4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpUserAgentParser.AspNetCore", "src\HttpUserAgentParser.AspNetCore\HttpUserAgentParser.AspNetCore.csproj", "{45927CF7-1BF4-479B-BBAA-8AD9CA901AE4}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyCSharp.HttpUserAgentParser", "src\MyCSharp.HttpUserAgentParser\MyCSharp.HttpUserAgentParser.csproj", "{3357BEC0-8216-409E-A539-F9A71DBACB81}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpUserAgentParser", "src\HttpUserAgentParser\HttpUserAgentParser.csproj", "{3357BEC0-8216-409E-A539-F9A71DBACB81}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyCSharp.HttpUserAgentParser.UnitTests", "tests\MyCSharp.HttpUserAgentParser.UnitTests\MyCSharp.HttpUserAgentParser.UnitTests.csproj", "{F16697F7-74B4-441D-A0C0-1A0572AC3AB0}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpUserAgentParser.UnitTests", "tests\HttpUserAgentParser.UnitTests\HttpUserAgentParser.UnitTests.csproj", "{F16697F7-74B4-441D-A0C0-1A0572AC3AB0}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests", "tests\MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests\MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests.csproj", "{75960783-8BF9-479C-9ECF-E9653B74C9A2}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpUserAgentParser.AspNetCore.UnitTests", "tests\HttpUserAgentParser.AspNetCore.UnitTests\HttpUserAgentParser.AspNetCore.UnitTests.csproj", "{75960783-8BF9-479C-9ECF-E9653B74C9A2}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F54C9296-4EF7-40F0-9F20-F23A2270ABC9}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyCSharp.HttpUserAgentParser.MemoryCache", "src\MyCSharp.HttpUserAgentParser.MemoryCache\MyCSharp.HttpUserAgentParser.MemoryCache.csproj", "{3C8CCD44-F47C-4624-8997-54C42F02E376}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpUserAgentParser.MemoryCache", "src\HttpUserAgentParser.MemoryCache\HttpUserAgentParser.MemoryCache.csproj", "{3C8CCD44-F47C-4624-8997-54C42F02E376}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests", "tests\MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests\MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests.csproj", "{39FC1EC2-2AD3-411F-A545-AB6CCB94FB7E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpUserAgentParser.MemoryCache.UnitTests", "tests\HttpUserAgentParser.MemoryCache.UnitTests\HttpUserAgentParser.MemoryCache.UnitTests.csproj", "{39FC1EC2-2AD3-411F-A545-AB6CCB94FB7E}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_", "_", "{5738CE0D-5E6E-47CB-BFF5-08F45A2C33AD}" ProjectSection(SolutionItems) = preProject @@ -35,9 +35,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_", "_", "{5738CE0D-5E6E-47 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "perf", "perf", "{FAAD18A0-E1B8-448D-B611-AFBDA8A89808}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyCSharp.HttpUserAgentParser.Benchmarks", "perf\MyCSharp.HttpUserAgentParser.Benchmarks\MyCSharp.HttpUserAgentParser.Benchmarks.csproj", "{A0D213E9-6408-46D1-AFAF-5096C2F6E027}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpUserAgentParser.Benchmarks", "perf\HttpUserAgentParser.Benchmarks\HttpUserAgentParser.Benchmarks.csproj", "{A0D213E9-6408-46D1-AFAF-5096C2F6E027}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyCSharp.HttpUserAgentParser.TestHelpers", "tests\MyCSharp.HttpUserAgentParser.TestHelpers\MyCSharp.HttpUserAgentParser.TestHelpers.csproj", "{165EE915-1A4F-4875-90CE-1A2AE1540AE7}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpUserAgentParser.TestHelpers", "tests\HttpUserAgentParser.TestHelpers\HttpUserAgentParser.TestHelpers.csproj", "{165EE915-1A4F-4875-90CE-1A2AE1540AE7}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/global.json b/global.json index 76474f0..6303b53 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { - "sdk": { - "version": "9.0.100" - } + "sdk": { + "version": "10.0.100-preview.3.25201.16" + } } diff --git a/perf/MyCSharp.HttpUserAgentParser.Benchmarks/MyCSharp.HttpUserAgentParser.Benchmarks.csproj b/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParser.Benchmarks.csproj similarity index 89% rename from perf/MyCSharp.HttpUserAgentParser.Benchmarks/MyCSharp.HttpUserAgentParser.Benchmarks.csproj rename to perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParser.Benchmarks.csproj index 7623138..e190b11 100644 --- a/perf/MyCSharp.HttpUserAgentParser.Benchmarks/MyCSharp.HttpUserAgentParser.Benchmarks.csproj +++ b/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParser.Benchmarks.csproj @@ -26,7 +26,7 @@ - + diff --git a/perf/MyCSharp.HttpUserAgentParser.Benchmarks/HttpUserAgentParserBenchmarks.cs b/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParserBenchmarks.cs similarity index 100% rename from perf/MyCSharp.HttpUserAgentParser.Benchmarks/HttpUserAgentParserBenchmarks.cs rename to perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParserBenchmarks.cs diff --git a/perf/HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs b/perf/HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs new file mode 100644 index 0000000..460d2ee --- /dev/null +++ b/perf/HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs @@ -0,0 +1,85 @@ +// Copyright © myCSharp.de - all rights reserved + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using DeviceDetectorNET; +using MyCSharp.HttpUserAgentParser.Providers; + +namespace MyCSharp.HttpUserAgentParser.Benchmarks.LibraryComparison; + +[ShortRunJob] +[MemoryDiagnoser] +[CategoriesColumn] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +public class LibraryComparisonBenchmarks +{ + public record TestData(string Label, string UserAgent) + { + public override string ToString() => Label; + } + + [ParamsSource(nameof(GetTestUserAgents))] + public TestData Data { get; set; } + + public IEnumerable GetTestUserAgents() + { + yield return new("Chrome Win10", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"); + yield return new("Google-Bot", "APIs-Google (+https://developers.google.com/webmasters/APIs-Google.html)"); + } + + [Benchmark(Baseline = true, Description = "MyCSharp")] + [BenchmarkCategory("Basic")] + public HttpUserAgentInformation MyCSharpBasic() + { + HttpUserAgentInformation info = HttpUserAgentParser.Parse(Data.UserAgent); + return info; + } + + private static readonly HttpUserAgentParserCachedProvider s_myCSharpCachedProvider = new(); + + [Benchmark(Baseline = true, Description = "MyCSharp")] + [BenchmarkCategory("Cached")] + public HttpUserAgentInformation MyCSharpCached() + { + return s_myCSharpCachedProvider.Parse(Data.UserAgent); + } + + [Benchmark(Description = "UAParser")] + [BenchmarkCategory("Basic")] + public UAParser.ClientInfo UAParserBasic() + { + UAParser.ClientInfo info = UAParser.Parser.GetDefault().Parse(Data.UserAgent); + return info; + } + + private static readonly UAParser.Parser s_uaParser = UAParser.Parser.GetDefault(new UAParser.ParserOptions { UseCompiledRegex = true }); + + [Benchmark(Description = "UAParser")] + [BenchmarkCategory("Cached")] + public UAParser.ClientInfo UAParserCached() + { + UAParser.ClientInfo info = s_uaParser.Parse(Data.UserAgent); + return info; + } + + [Benchmark(Description = "DeviceDetector.NET")] + [BenchmarkCategory("Basic")] + public object DeviceDetectorNETBasic() + { + DeviceDetector dd = new(Data.UserAgent); + dd.Parse(); + + var info = new + { + Client = dd.GetClient(), + OS = dd.GetOs(), + Device = dd.GetDeviceName(), + Brand = dd.GetBrandName(), + Model = dd.GetModel() + }; + + return info; + } +} diff --git a/perf/MyCSharp.HttpUserAgentParser.Benchmarks/Program.cs b/perf/HttpUserAgentParser.Benchmarks/Program.cs similarity index 99% rename from perf/MyCSharp.HttpUserAgentParser.Benchmarks/Program.cs rename to perf/HttpUserAgentParser.Benchmarks/Program.cs index b4186c5..5c18157 100644 --- a/perf/MyCSharp.HttpUserAgentParser.Benchmarks/Program.cs +++ b/perf/HttpUserAgentParser.Benchmarks/Program.cs @@ -9,6 +9,5 @@ ManualConfig config = ManualConfig.Create(DefaultConfig.Instance) .WithOptions(ConfigOptions.DisableOptimizationsValidator); - // dotnet run -c Release --framework net80 net90 --runtimes net90 BenchmarkSwitcher.FromAssembly(Assembly.GetExecutingAssembly()).Run(args, config); diff --git a/perf/MyCSharp.HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs b/perf/MyCSharp.HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs deleted file mode 100644 index b6e6c86..0000000 --- a/perf/MyCSharp.HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright © myCSharp.de - all rights reserved - -using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Columns; -using BenchmarkDotNet.Configs; -using BenchmarkDotNet.Diagnosers; -using DeviceDetectorNET; -using MyCSharp.HttpUserAgentParser.Providers; - -namespace MyCSharp.HttpUserAgentParser.Benchmarks.LibraryComparison -{ - [ShortRunJob] - [MemoryDiagnoser] - [CategoriesColumn] - [GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] - public class LibraryComparisonBenchmarks - { - public record TestData(string Label, string UserAgent) - { - public override string ToString() => Label; - } - - [ParamsSource(nameof(GetTestUserAgents))] - public TestData Data { get; set; } - - public IEnumerable GetTestUserAgents() - { - yield return new("Chrome Win10", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"); - yield return new("Google-Bot", "APIs-Google (+https://developers.google.com/webmasters/APIs-Google.html)"); - } - - [Benchmark(Baseline = true, Description = "MyCSharp")] - [BenchmarkCategory("Basic")] - public HttpUserAgentInformation MyCSharpBasic() - { - HttpUserAgentInformation info = HttpUserAgentParser.Parse(Data.UserAgent); - return info; - } - - private static readonly HttpUserAgentParserCachedProvider s_myCSharpCachedProvider = new(); - - [Benchmark(Baseline = true, Description = "MyCSharp")] - [BenchmarkCategory("Cached")] - public HttpUserAgentInformation MyCSharpCached() - { - return s_myCSharpCachedProvider.Parse(Data.UserAgent); - } - - [Benchmark(Description = "UAParser")] - [BenchmarkCategory("Basic")] - public UAParser.ClientInfo UAParserBasic() - { - UAParser.ClientInfo info = UAParser.Parser.GetDefault().Parse(Data.UserAgent); - return info; - } - - private static readonly UAParser.Parser s_uaParser = UAParser.Parser.GetDefault(new UAParser.ParserOptions { UseCompiledRegex = true }); - - [Benchmark(Description = "UAParser")] - [BenchmarkCategory("Cached")] - public UAParser.ClientInfo UAParserCached() - { - UAParser.ClientInfo info = s_uaParser.Parse(Data.UserAgent); - return info; - } - - [Benchmark(Description = "DeviceDetector.NET")] - [BenchmarkCategory("Basic")] - public object DeviceDetectorNETBasic() - { - DeviceDetector dd = new(Data.UserAgent); - dd.Parse(); - - var info = new - { - Client = dd.GetClient(), - OS = dd.GetOs(), - Device = dd.GetDeviceName(), - Brand = dd.GetBrandName(), - Model = dd.GetModel() - }; - - return info; - } - } -} diff --git a/src/MyCSharp.HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsExtensions.cs b/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsExtensions.cs similarity index 100% rename from src/MyCSharp.HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsExtensions.cs rename to src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsExtensions.cs diff --git a/src/MyCSharp.HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs b/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs similarity index 100% rename from src/MyCSharp.HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs rename to src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs diff --git a/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParser.AspNetCore.csproj b/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParser.AspNetCore.csproj new file mode 100644 index 0000000..fd97e4e --- /dev/null +++ b/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParser.AspNetCore.csproj @@ -0,0 +1,27 @@ + + + + HTTP User Agent Parser Extensions for ASP.NET Core + HTTP User Agent Parser Extensions for ASP.NET Core + + + + true + readme.md + LICENSE.txt + + + + + + + + + + + + + + + + diff --git a/src/MyCSharp.HttpUserAgentParser.AspNetCore/HttpUserAgentParserAccessor.cs b/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParserAccessor.cs similarity index 100% rename from src/MyCSharp.HttpUserAgentParser.AspNetCore/HttpUserAgentParserAccessor.cs rename to src/HttpUserAgentParser.AspNetCore/HttpUserAgentParserAccessor.cs diff --git a/src/MyCSharp.HttpUserAgentParser.AspNetCore/IHttpUserAgentParserAccessor.cs b/src/HttpUserAgentParser.AspNetCore/IHttpUserAgentParserAccessor.cs similarity index 100% rename from src/MyCSharp.HttpUserAgentParser.AspNetCore/IHttpUserAgentParserAccessor.cs rename to src/HttpUserAgentParser.AspNetCore/IHttpUserAgentParserAccessor.cs diff --git a/src/MyCSharp.HttpUserAgentParser.AspNetCore/LICENSE.txt b/src/HttpUserAgentParser.AspNetCore/LICENSE.txt similarity index 100% rename from src/MyCSharp.HttpUserAgentParser.AspNetCore/LICENSE.txt rename to src/HttpUserAgentParser.AspNetCore/LICENSE.txt diff --git a/src/MyCSharp.HttpUserAgentParser.AspNetCore/readme.md b/src/HttpUserAgentParser.AspNetCore/readme.md similarity index 100% rename from src/MyCSharp.HttpUserAgentParser.AspNetCore/readme.md rename to src/HttpUserAgentParser.AspNetCore/readme.md diff --git a/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensions.cs b/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensions.cs new file mode 100644 index 0000000..28f3893 --- /dev/null +++ b/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +// Copyright © myCSharp.de - all rights reserved + +using Microsoft.Extensions.DependencyInjection; +using MyCSharp.HttpUserAgentParser.DependencyInjection; +using MyCSharp.HttpUserAgentParser.Providers; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.DependencyInjection; + +/// +/// Dependency injection extensions for IMemoryCache +/// +public static class HttpUserAgentParserMemoryCacheServiceCollectionExtensions +{ + /// + /// Registers as singleton to + /// + public static HttpUserAgentParserDependencyInjectionOptions AddHttpUserAgentMemoryCachedParser( + this IServiceCollection services, Action? options = null) + { + HttpUserAgentParserMemoryCachedProviderOptions providerOptions = new(); + options?.Invoke(providerOptions); + + // register options + services.AddSingleton(providerOptions); + + // register cache provider + return services.AddHttpUserAgentParser(); + } +} diff --git a/src/MyCSharp.HttpUserAgentParser.MemoryCache/MyCSharp.HttpUserAgentParser.MemoryCache.csproj b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParser.MemoryCache.csproj similarity index 50% rename from src/MyCSharp.HttpUserAgentParser.MemoryCache/MyCSharp.HttpUserAgentParser.MemoryCache.csproj rename to src/HttpUserAgentParser.MemoryCache/HttpUserAgentParser.MemoryCache.csproj index ea9e432..31057cb 100644 --- a/src/MyCSharp.HttpUserAgentParser.MemoryCache/MyCSharp.HttpUserAgentParser.MemoryCache.csproj +++ b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParser.MemoryCache.csproj @@ -21,23 +21,8 @@ - - - all - runtime; build; native; contentfiles; analyzers - - - all - runtime; build; native; contentfiles; analyzers - - - all - runtime; build; native; contentfiles; analyzers - - - - + diff --git a/src/MyCSharp.HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs similarity index 83% rename from src/MyCSharp.HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs rename to src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs index b4d4d3b..3673257 100644 --- a/src/MyCSharp.HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs +++ b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs @@ -19,7 +19,7 @@ public class HttpUserAgentParserMemoryCachedProvider( /// public HttpUserAgentInformation Parse(string userAgent) { - CacheKey key = this.GetKey(userAgent); + CacheKey key = GetKey(userAgent); return _memoryCache.GetOrCreate(key, static entry => { @@ -50,9 +50,9 @@ private CacheKey GetKey(string userAgent) public HttpUserAgentParserMemoryCachedProviderOptions Options { get; set; } = null!; - public bool Equals(CacheKey? other) => this.UserAgent == other?.UserAgent; - public override bool Equals(object? obj) => this.Equals(obj as CacheKey); + public bool Equals(CacheKey? other) => string.Equals(UserAgent, other?.UserAgent, StringComparison.OrdinalIgnoreCase); + public override bool Equals(object? obj) => Equals(obj as CacheKey); - public override int GetHashCode() => this.UserAgent.GetHashCode(); + public override int GetHashCode() => UserAgent.GetHashCode(StringComparison.Ordinal); } } diff --git a/src/MyCSharp.HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProviderOptions.cs b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProviderOptions.cs similarity index 92% rename from src/MyCSharp.HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProviderOptions.cs rename to src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProviderOptions.cs index 9baf223..1b2115b 100644 --- a/src/MyCSharp.HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProviderOptions.cs +++ b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProviderOptions.cs @@ -41,13 +41,13 @@ public HttpUserAgentParserMemoryCachedProviderOptions(MemoryCacheEntryOptions ca public HttpUserAgentParserMemoryCachedProviderOptions( MemoryCacheOptions? cacheOptions = null, MemoryCacheEntryOptions? cacheEntryOptions = null) { - this.CacheEntryOptions = cacheEntryOptions ?? new MemoryCacheEntryOptions + CacheEntryOptions = cacheEntryOptions ?? new MemoryCacheEntryOptions { // defaults SlidingExpiration = TimeSpan.FromDays(1) }; - this.CacheOptions = cacheOptions ?? new MemoryCacheOptions + CacheOptions = cacheOptions ?? new MemoryCacheOptions { // defaults SizeLimit = 256 diff --git a/src/MyCSharp.HttpUserAgentParser.MemoryCache/LICENSE.txt b/src/HttpUserAgentParser.MemoryCache/LICENSE.txt similarity index 100% rename from src/MyCSharp.HttpUserAgentParser.MemoryCache/LICENSE.txt rename to src/HttpUserAgentParser.MemoryCache/LICENSE.txt diff --git a/src/MyCSharp.HttpUserAgentParser.MemoryCache/readme.md b/src/HttpUserAgentParser.MemoryCache/readme.md similarity index 100% rename from src/MyCSharp.HttpUserAgentParser.MemoryCache/readme.md rename to src/HttpUserAgentParser.MemoryCache/readme.md diff --git a/src/MyCSharp.HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptions.cs b/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptions.cs similarity index 100% rename from src/MyCSharp.HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptions.cs rename to src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptions.cs diff --git a/src/MyCSharp.HttpUserAgentParser/DependencyInjection/HttpUserAgentParserServiceCollectionExtensions.cs b/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserServiceCollectionExtensions.cs similarity index 100% rename from src/MyCSharp.HttpUserAgentParser/DependencyInjection/HttpUserAgentParserServiceCollectionExtensions.cs rename to src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserServiceCollectionExtensions.cs diff --git a/src/MyCSharp.HttpUserAgentParser/HttpUserAgentInformation.cs b/src/HttpUserAgentParser/HttpUserAgentInformation.cs similarity index 94% rename from src/MyCSharp.HttpUserAgentParser/HttpUserAgentInformation.cs rename to src/HttpUserAgentParser/HttpUserAgentInformation.cs index 1319154..9c23ae1 100644 --- a/src/MyCSharp.HttpUserAgentParser/HttpUserAgentInformation.cs +++ b/src/HttpUserAgentParser/HttpUserAgentInformation.cs @@ -59,7 +59,7 @@ private HttpUserAgentInformation(string userAgent, HttpUserAgentPlatformInformat /// Creates for a robot /// internal static HttpUserAgentInformation CreateForRobot(string userAgent, string robotName) - => new(userAgent, null, HttpUserAgentType.Robot, robotName, null, null); + => new(userAgent, platform: null, HttpUserAgentType.Robot, robotName, version: null, deviceName: null); /// /// Creates for a browser @@ -71,5 +71,5 @@ internal static HttpUserAgentInformation CreateForBrowser(string userAgent, Http /// Creates for an unknown agent type /// internal static HttpUserAgentInformation CreateForUnknown(string userAgent, HttpUserAgentPlatformInformation? platform, string? deviceName) - => new(userAgent, platform, HttpUserAgentType.Unknown, null, null, deviceName); + => new(userAgent, platform, HttpUserAgentType.Unknown, name: null, version: null, deviceName); } diff --git a/src/MyCSharp.HttpUserAgentParser/HttpUserAgentInformationExtensions.cs b/src/HttpUserAgentParser/HttpUserAgentInformationExtensions.cs similarity index 100% rename from src/MyCSharp.HttpUserAgentParser/HttpUserAgentInformationExtensions.cs rename to src/HttpUserAgentParser/HttpUserAgentInformationExtensions.cs diff --git a/src/MyCSharp.HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs similarity index 97% rename from src/MyCSharp.HttpUserAgentParser/HttpUserAgentParser.cs rename to src/HttpUserAgentParser/HttpUserAgentParser.cs index 04eb1ab..3e7dc18 100644 --- a/src/MyCSharp.HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -5,10 +5,13 @@ namespace MyCSharp.HttpUserAgentParser; +#pragma warning disable MA0049 // Type name should not match containing namespace + /// /// Parser logic for user agents /// public static class HttpUserAgentParser + { /// /// Parses given user agent diff --git a/src/MyCSharp.HttpUserAgentParser/MyCSharp.HttpUserAgentParser.csproj b/src/HttpUserAgentParser/HttpUserAgentParser.csproj similarity index 52% rename from src/MyCSharp.HttpUserAgentParser/MyCSharp.HttpUserAgentParser.csproj rename to src/HttpUserAgentParser/HttpUserAgentParser.csproj index 0180afe..dc30a64 100644 --- a/src/MyCSharp.HttpUserAgentParser/MyCSharp.HttpUserAgentParser.csproj +++ b/src/HttpUserAgentParser/HttpUserAgentParser.csproj @@ -20,21 +20,6 @@ - - - all - runtime; build; native; contentfiles; analyzers - - - all - runtime; build; native; contentfiles; analyzers - - - all - runtime; build; native; contentfiles; analyzers - - - diff --git a/src/MyCSharp.HttpUserAgentParser/HttpUserAgentPlatformInformation.cs b/src/HttpUserAgentParser/HttpUserAgentPlatformInformation.cs similarity index 100% rename from src/MyCSharp.HttpUserAgentParser/HttpUserAgentPlatformInformation.cs rename to src/HttpUserAgentParser/HttpUserAgentPlatformInformation.cs diff --git a/src/MyCSharp.HttpUserAgentParser/HttpUserAgentPlatformType.cs b/src/HttpUserAgentParser/HttpUserAgentPlatformType.cs similarity index 100% rename from src/MyCSharp.HttpUserAgentParser/HttpUserAgentPlatformType.cs rename to src/HttpUserAgentParser/HttpUserAgentPlatformType.cs diff --git a/src/MyCSharp.HttpUserAgentParser/HttpUserAgentStatics.cs b/src/HttpUserAgentParser/HttpUserAgentStatics.cs similarity index 97% rename from src/MyCSharp.HttpUserAgentParser/HttpUserAgentStatics.cs rename to src/HttpUserAgentParser/HttpUserAgentStatics.cs index ae4208d..7f8c203 100644 --- a/src/MyCSharp.HttpUserAgentParser/HttpUserAgentStatics.cs +++ b/src/HttpUserAgentParser/HttpUserAgentStatics.cs @@ -17,7 +17,8 @@ public static class HttpUserAgentStatics /// /// Creates default platform mapping regex /// - private static Regex CreateDefaultPlatformRegex(string key) => new(Regex.Escape($"{key}"), DefaultPlatformsRegexFlags); + private static Regex CreateDefaultPlatformRegex(string key) => new(Regex.Escape($"{key}"), + DefaultPlatformsRegexFlags, matchTimeout: TimeSpan.FromMilliseconds(1000)); /// /// Platforms @@ -77,12 +78,12 @@ public static class HttpUserAgentStatics /// Creates default browser mapping regex /// private static Regex CreateDefaultBrowserRegex(string key) - => new($@"{key}.*?([0-9\.]+)", DefaultBrowserRegexFlags); + => new($@"{key}.*?([0-9\.]+)", DefaultBrowserRegexFlags, matchTimeout: TimeSpan.FromMilliseconds(1000)); /// /// Browsers /// - public static Dictionary Browsers = new() + public static readonly Dictionary Browsers = new() { { CreateDefaultBrowserRegex("OPR"), "Opera" }, { CreateDefaultBrowserRegex("Flock"), "Flock" }, @@ -124,7 +125,7 @@ private static Regex CreateDefaultBrowserRegex(string key) /// /// Mobiles /// - public static readonly Dictionary Mobiles = new() + public static readonly Dictionary Mobiles = new(StringComparer.InvariantCultureIgnoreCase) { // Legacy { "mobileexplorer", "Mobile Explorer" }, @@ -286,7 +287,7 @@ public static readonly (string Key, string Value)[] Robots = /// /// Tools /// - public static readonly Dictionary Tools = new() + public static readonly Dictionary Tools = new(StringComparer.OrdinalIgnoreCase) { { "curl", "curl" } }; diff --git a/src/MyCSharp.HttpUserAgentParser/HttpUserAgentType.cs b/src/HttpUserAgentParser/HttpUserAgentType.cs similarity index 100% rename from src/MyCSharp.HttpUserAgentParser/HttpUserAgentType.cs rename to src/HttpUserAgentParser/HttpUserAgentType.cs diff --git a/src/MyCSharp.HttpUserAgentParser/LICENSE.txt b/src/HttpUserAgentParser/LICENSE.txt similarity index 100% rename from src/MyCSharp.HttpUserAgentParser/LICENSE.txt rename to src/HttpUserAgentParser/LICENSE.txt diff --git a/src/MyCSharp.HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs b/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs similarity index 94% rename from src/MyCSharp.HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs rename to src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs index 3f776bf..cb6ec3a 100644 --- a/src/MyCSharp.HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs +++ b/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs @@ -12,7 +12,7 @@ public class HttpUserAgentParserCachedProvider : IHttpUserAgentParserProvider /// /// internal cache /// - private readonly ConcurrentDictionary _cache = new(); + private readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); /// /// Parses the user agent or uses the internal cached information diff --git a/src/MyCSharp.HttpUserAgentParser/Providers/HttpUserAgentParserDefaultProvider.cs b/src/HttpUserAgentParser/Providers/HttpUserAgentParserDefaultProvider.cs similarity index 100% rename from src/MyCSharp.HttpUserAgentParser/Providers/HttpUserAgentParserDefaultProvider.cs rename to src/HttpUserAgentParser/Providers/HttpUserAgentParserDefaultProvider.cs diff --git a/src/MyCSharp.HttpUserAgentParser/Providers/IHttpUserAgentParserProvider.cs b/src/HttpUserAgentParser/Providers/IHttpUserAgentParserProvider.cs similarity index 100% rename from src/MyCSharp.HttpUserAgentParser/Providers/IHttpUserAgentParserProvider.cs rename to src/HttpUserAgentParser/Providers/IHttpUserAgentParserProvider.cs diff --git a/src/MyCSharp.HttpUserAgentParser/readme.md b/src/HttpUserAgentParser/readme.md similarity index 100% rename from src/MyCSharp.HttpUserAgentParser/readme.md rename to src/HttpUserAgentParser/readme.md diff --git a/src/MyCSharp.HttpUserAgentParser.AspNetCore/MyCSharp.HttpUserAgentParser.AspNetCore.csproj b/src/MyCSharp.HttpUserAgentParser.AspNetCore/MyCSharp.HttpUserAgentParser.AspNetCore.csproj deleted file mode 100644 index c51f1f3..0000000 --- a/src/MyCSharp.HttpUserAgentParser.AspNetCore/MyCSharp.HttpUserAgentParser.AspNetCore.csproj +++ /dev/null @@ -1,42 +0,0 @@ - - - - HTTP User Agent Parser Extensions for ASP.NET Core - HTTP User Agent Parser Extensions for ASP.NET Core - - - - true - readme.md - LICENSE.txt - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - all - runtime; build; native; contentfiles; analyzers - - - all - runtime; build; native; contentfiles; analyzers - - - - - - - - diff --git a/src/MyCSharp.HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensions.cs b/src/MyCSharp.HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensions.cs deleted file mode 100644 index ba4e4d6..0000000 --- a/src/MyCSharp.HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensions.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright © myCSharp.de - all rights reserved - -using Microsoft.Extensions.DependencyInjection; -using MyCSharp.HttpUserAgentParser.DependencyInjection; -using MyCSharp.HttpUserAgentParser.Providers; - -namespace MyCSharp.HttpUserAgentParser.MemoryCache.DependencyInjection -{ - /// - /// Dependency injection extensions for IMemoryCache - /// - public static class HttpUserAgentParserMemoryCacheServiceCollectionExtensions - { - /// - /// Registers as singleton to - /// - public static HttpUserAgentParserDependencyInjectionOptions AddHttpUserAgentMemoryCachedParser( - this IServiceCollection services, Action? options = null) - { - HttpUserAgentParserMemoryCachedProviderOptions providerOptions = new(); - options?.Invoke(providerOptions); - - // register options - services.AddSingleton(providerOptions); - - // register cache provider - return services.AddHttpUserAgentParser(); - } - } -} diff --git a/tests/MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs similarity index 100% rename from tests/MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs rename to tests/HttpUserAgentParser.AspNetCore.UnitTests/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs diff --git a/tests/MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests/HttpContextTestHelpers.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpContextTestHelpers.cs similarity index 100% rename from tests/MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests/HttpContextTestHelpers.cs rename to tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpContextTestHelpers.cs diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpUserAgentParser.AspNetCore.UnitTests.csproj b/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpUserAgentParser.AspNetCore.UnitTests.csproj new file mode 100644 index 0000000..269119a --- /dev/null +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpUserAgentParser.AspNetCore.UnitTests.csproj @@ -0,0 +1,16 @@ + + + + Exe + + + + + + + + + + + + diff --git a/tests/MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests/HttpUserAgentParserAccessorTests.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpUserAgentParserAccessorTests.cs similarity index 100% rename from tests/MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests/HttpUserAgentParserAccessorTests.cs rename to tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpUserAgentParserAccessorTests.cs diff --git a/tests/MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensionssTests.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensionssTests.cs similarity index 100% rename from tests/MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensionssTests.cs rename to tests/HttpUserAgentParser.MemoryCache.UnitTests/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensionssTests.cs diff --git a/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParser.MemoryCache.UnitTests.csproj b/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParser.MemoryCache.UnitTests.csproj new file mode 100644 index 0000000..b68d12f --- /dev/null +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParser.MemoryCache.UnitTests.csproj @@ -0,0 +1,16 @@ + + + + Exe + + + + + + + + + + + + diff --git a/tests/MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderOptionsTests.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderOptionsTests.cs similarity index 100% rename from tests/MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderOptionsTests.cs rename to tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderOptionsTests.cs diff --git a/tests/MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderTests.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderTests.cs similarity index 100% rename from tests/MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderTests.cs rename to tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderTests.cs diff --git a/tests/HttpUserAgentParser.TestHelpers/HttpUserAgentParser.TestHelpers.csproj b/tests/HttpUserAgentParser.TestHelpers/HttpUserAgentParser.TestHelpers.csproj new file mode 100644 index 0000000..42cceb3 --- /dev/null +++ b/tests/HttpUserAgentParser.TestHelpers/HttpUserAgentParser.TestHelpers.csproj @@ -0,0 +1,7 @@ + + + + false + + + diff --git a/tests/MyCSharp.HttpUserAgentParser.UnitTests/DependencyInjection/HttpUserAgentParserDependencyInjectionOptions.cs b/tests/HttpUserAgentParser.UnitTests/DependencyInjection/HttpUserAgentParserDependencyInjectionOptions.cs similarity index 100% rename from tests/MyCSharp.HttpUserAgentParser.UnitTests/DependencyInjection/HttpUserAgentParserDependencyInjectionOptions.cs rename to tests/HttpUserAgentParser.UnitTests/DependencyInjection/HttpUserAgentParserDependencyInjectionOptions.cs diff --git a/tests/MyCSharp.HttpUserAgentParser.UnitTests/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs b/tests/HttpUserAgentParser.UnitTests/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs similarity index 97% rename from tests/MyCSharp.HttpUserAgentParser.UnitTests/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs rename to tests/HttpUserAgentParser.UnitTests/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs index c7c97dd..3afec1f 100644 --- a/tests/MyCSharp.HttpUserAgentParser.UnitTests/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs +++ b/tests/HttpUserAgentParser.UnitTests/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs @@ -11,7 +11,7 @@ public class HttpUserAgentParserMemoryCacheServiceCollectionExtensions { public class TestHttpUserAgentParserProvider : IHttpUserAgentParserProvider { - public HttpUserAgentInformation Parse(string userAgent) => throw new System.NotImplementedException(); + public HttpUserAgentInformation Parse(string userAgent) => throw new NotSupportedException(); } [Fact] diff --git a/tests/MyCSharp.HttpUserAgentParser.UnitTests/HttpUserAgentInformationExtensionsTests.cs b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentInformationExtensionsTests.cs similarity index 100% rename from tests/MyCSharp.HttpUserAgentParser.UnitTests/HttpUserAgentInformationExtensionsTests.cs rename to tests/HttpUserAgentParser.UnitTests/HttpUserAgentInformationExtensionsTests.cs diff --git a/tests/MyCSharp.HttpUserAgentParser.UnitTests/HttpUserAgentInformationTests.cs b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentInformationTests.cs similarity index 83% rename from tests/MyCSharp.HttpUserAgentParser.UnitTests/HttpUserAgentInformationTests.cs rename to tests/HttpUserAgentParser.UnitTests/HttpUserAgentInformationTests.cs index 4022d27..13a446a 100644 --- a/tests/MyCSharp.HttpUserAgentParser.UnitTests/HttpUserAgentInformationTests.cs +++ b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentInformationTests.cs @@ -5,7 +5,7 @@ namespace MyCSharp.HttpUserAgentParser.UnitTests; -public class HttpUserAgentInformationTests +public partial class HttpUserAgentInformationTests { [Theory] [InlineData("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36 Edg/90.0.818.62")] @@ -35,8 +35,7 @@ public void CreateForRobot(string userAgent) [InlineData("Mozilla/5.0 (Linux; Android 10; HD1913) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.210 Mobile Safari/537.36 EdgA/46.3.4.5155")] public void CreateForBrowser(string userAgent) { - HttpUserAgentPlatformInformation platformInformation = - new(new Regex(""), "Android", HttpUserAgentPlatformType.Android); + HttpUserAgentPlatformInformation platformInformation = new(TextRegex(), "Android", HttpUserAgentPlatformType.Android); HttpUserAgentInformation ua = HttpUserAgentInformation.CreateForBrowser(userAgent, platformInformation, "Edge", "46.3.4.5155", "Android"); @@ -53,11 +52,10 @@ public void CreateForBrowser(string userAgent) [InlineData("Invalid user agent")] public void CreateForUnknown(string userAgent) { - HttpUserAgentPlatformInformation platformInformation = - new(new Regex(""), "Batman", HttpUserAgentPlatformType.Linux); + HttpUserAgentPlatformInformation platformInformation = new(TextRegex(), "Batman", HttpUserAgentPlatformType.Linux); HttpUserAgentInformation ua = - HttpUserAgentInformation.CreateForUnknown(userAgent, platformInformation, null); + HttpUserAgentInformation.CreateForUnknown(userAgent, platformInformation, deviceName: null); Assert.Equal(userAgent, ua.UserAgent); Assert.Equal(HttpUserAgentType.Unknown, ua.Type); @@ -66,4 +64,7 @@ public void CreateForUnknown(string userAgent) Assert.Null(ua.Version); Assert.Null(ua.MobileDeviceType); } + + [GeneratedRegex("", RegexOptions.None, matchTimeoutMilliseconds: 1000)] + private static partial Regex TextRegex(); } diff --git a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParser.UnitTests.csproj b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParser.UnitTests.csproj new file mode 100644 index 0000000..cdf4e3d --- /dev/null +++ b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParser.UnitTests.csproj @@ -0,0 +1,15 @@ + + + + Exe + + + + + + + + + + + diff --git a/tests/MyCSharp.HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs similarity index 100% rename from tests/MyCSharp.HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs rename to tests/HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs diff --git a/tests/MyCSharp.HttpUserAgentParser.UnitTests/HttpUserAgentPlatformInformationTests.cs b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentPlatformInformationTests.cs similarity index 90% rename from tests/MyCSharp.HttpUserAgentParser.UnitTests/HttpUserAgentPlatformInformationTests.cs rename to tests/HttpUserAgentParser.UnitTests/HttpUserAgentPlatformInformationTests.cs index 613369d..274e127 100644 --- a/tests/MyCSharp.HttpUserAgentParser.UnitTests/HttpUserAgentPlatformInformationTests.cs +++ b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentPlatformInformationTests.cs @@ -21,6 +21,6 @@ public void Ctor(string name, HttpUserAgentPlatformType platform) Assert.Equal(platform, info.PlatformType); } - [GeneratedRegex("")] + [GeneratedRegex("", RegexOptions.None, matchTimeoutMilliseconds: 1000)] private static partial Regex EmptyRegex(); } diff --git a/tests/MyCSharp.HttpUserAgentParser.UnitTests/HttpUserAgentPlatformTypeTests.cs b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentPlatformTypeTests.cs similarity index 100% rename from tests/MyCSharp.HttpUserAgentParser.UnitTests/HttpUserAgentPlatformTypeTests.cs rename to tests/HttpUserAgentParser.UnitTests/HttpUserAgentPlatformTypeTests.cs diff --git a/tests/MyCSharp.HttpUserAgentParser.UnitTests/HttpUserAgentTypeTests.cs b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentTypeTests.cs similarity index 100% rename from tests/MyCSharp.HttpUserAgentParser.UnitTests/HttpUserAgentTypeTests.cs rename to tests/HttpUserAgentParser.UnitTests/HttpUserAgentTypeTests.cs diff --git a/tests/MyCSharp.HttpUserAgentParser.UnitTests/Providers/HttpUserAgentParserCachedProviderTests.cs b/tests/HttpUserAgentParser.UnitTests/Providers/HttpUserAgentParserCachedProviderTests.cs similarity index 100% rename from tests/MyCSharp.HttpUserAgentParser.UnitTests/Providers/HttpUserAgentParserCachedProviderTests.cs rename to tests/HttpUserAgentParser.UnitTests/Providers/HttpUserAgentParserCachedProviderTests.cs diff --git a/tests/MyCSharp.HttpUserAgentParser.UnitTests/Providers/HttpUserAgentParserDefaultProviderTests.cs b/tests/HttpUserAgentParser.UnitTests/Providers/HttpUserAgentParserDefaultProviderTests.cs similarity index 100% rename from tests/MyCSharp.HttpUserAgentParser.UnitTests/Providers/HttpUserAgentParserDefaultProviderTests.cs rename to tests/HttpUserAgentParser.UnitTests/Providers/HttpUserAgentParserDefaultProviderTests.cs diff --git a/tests/MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests/MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests.csproj b/tests/MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests/MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests.csproj deleted file mode 100644 index 3d4c3ed..0000000 --- a/tests/MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests/MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests.csproj +++ /dev/null @@ -1,45 +0,0 @@ - - - - Exe - - - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - all - runtime; build; native; contentfiles; analyzers - - - all - runtime; build; native; contentfiles; analyzers - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - diff --git a/tests/MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests/MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests.csproj b/tests/MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests/MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests.csproj deleted file mode 100644 index 05efe7c..0000000 --- a/tests/MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests/MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests.csproj +++ /dev/null @@ -1,44 +0,0 @@ - - - - Exe - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - all - runtime; build; native; contentfiles; analyzers - - - all - runtime; build; native; contentfiles; analyzers - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - diff --git a/tests/MyCSharp.HttpUserAgentParser.TestHelpers/MyCSharp.HttpUserAgentParser.TestHelpers.csproj b/tests/MyCSharp.HttpUserAgentParser.TestHelpers/MyCSharp.HttpUserAgentParser.TestHelpers.csproj deleted file mode 100644 index dd0a1c7..0000000 --- a/tests/MyCSharp.HttpUserAgentParser.TestHelpers/MyCSharp.HttpUserAgentParser.TestHelpers.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - false - - - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - all - runtime; build; native; contentfiles; analyzers - - - all - runtime; build; native; contentfiles; analyzers - - - - diff --git a/tests/MyCSharp.HttpUserAgentParser.UnitTests/MyCSharp.HttpUserAgentParser.UnitTests.csproj b/tests/MyCSharp.HttpUserAgentParser.UnitTests/MyCSharp.HttpUserAgentParser.UnitTests.csproj deleted file mode 100644 index 24f53dd..0000000 --- a/tests/MyCSharp.HttpUserAgentParser.UnitTests/MyCSharp.HttpUserAgentParser.UnitTests.csproj +++ /dev/null @@ -1,44 +0,0 @@ - - - - Exe - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - all - runtime; build; native; contentfiles; analyzers - - - all - runtime; build; native; contentfiles; analyzers - - - all - runtime; build; native; contentfiles; analyzers - - - - - - - - diff --git a/version.json b/version.json index 8417f92..de475b0 100644 --- a/version.json +++ b/version.json @@ -1,19 +1,20 @@ { - "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "3.0", - "nugetPackageVersion": { - "semVer": 1 // optional. Set to either 1 or 2 to control how the NuGet package version string is generated. Default is 1. - }, - "publicReleaseRefSpec": [ - "^refs/heads/main$" // we release out of main - ], - "cloudBuild": { - "buildNumber": { - "enabled": true - } - }, - "release": { - "versionIncrement": "build", - "firstUnstableTag": "preview" + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", + "version": "3.0", + "nugetPackageVersion": { + "semVer": 1 // optional. Set to either 1 or 2 to control how the NuGet package version string is generated. Default is 1. + }, + "publicReleaseRefSpec": [ + "^refs/heads/main$", // we release out of main + "^refs/tags/v\\d+\\.\\d+" // we release on tags like v1.0 or v2.0.0 via GitHub Release + ], + "cloudBuild": { + "buildNumber": { + "enabled": true } + }, + "release": { + "versionIncrement": "build", + "firstUnstableTag": "preview" + } } From d8a69baeb4a5903591e752fb3003e71a6aacc906 Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Sun, 13 Apr 2025 13:20:44 +0200 Subject: [PATCH 03/27] Update copyright notices and editorconfig settings (#68) --- .editorconfig | 1 + MyCSharp.HttpUserAgentParser.sln | 10 ---------- .../HttpUserAgentParserBenchmarks.cs | 2 +- .../LibraryComparison/LibraryComparisonBenchmarks.cs | 2 +- perf/HttpUserAgentParser.Benchmarks/Program.cs | 2 +- ...rAgentParserDependencyInjectionOptionsExtensions.cs | 2 +- .../HttpContextExtensions.cs | 2 +- .../HttpUserAgentParserAccessor.cs | 2 +- .../IHttpUserAgentParserAccessor.cs | 2 +- ...gentParserMemoryCacheServiceCollectionExtensions.cs | 2 +- .../HttpUserAgentParserMemoryCachedProvider.cs | 2 +- .../HttpUserAgentParserMemoryCachedProviderOptions.cs | 2 +- .../HttpUserAgentParserDependencyInjectionOptions.cs | 2 +- .../HttpUserAgentParserServiceCollectionExtensions.cs | 2 +- src/HttpUserAgentParser/HttpUserAgentInformation.cs | 2 +- .../HttpUserAgentInformationExtensions.cs | 2 +- src/HttpUserAgentParser/HttpUserAgentParser.cs | 2 +- .../HttpUserAgentPlatformInformation.cs | 2 +- src/HttpUserAgentParser/HttpUserAgentPlatformType.cs | 2 +- src/HttpUserAgentParser/HttpUserAgentStatics.cs | 2 +- src/HttpUserAgentParser/HttpUserAgentType.cs | 2 +- .../Providers/HttpUserAgentParserCachedProvider.cs | 2 +- .../Providers/HttpUserAgentParserDefaultProvider.cs | 2 +- .../Providers/IHttpUserAgentParserProvider.cs | 2 +- ...pUserAgentParserServiceCollectionExtensionsTests.cs | 2 +- .../HttpContextTestHelpers.cs | 2 +- .../HttpUserAgentParserAccessorTests.cs | 2 +- ...rserMemoryCacheServiceCollectionExtensionssTests.cs | 2 +- ...pUserAgentParserMemoryCachedProviderOptionsTests.cs | 2 +- .../HttpUserAgentParserMemoryCachedProviderTests.cs | 2 +- .../HttpUserAgentParserDependencyInjectionOptions.cs | 2 +- ...pUserAgentParserServiceCollectionExtensionsTests.cs | 2 +- .../HttpUserAgentInformationExtensionsTests.cs | 2 +- .../HttpUserAgentInformationTests.cs | 2 +- .../HttpUserAgentParserTests.cs | 2 +- .../HttpUserAgentPlatformInformationTests.cs | 2 +- .../HttpUserAgentPlatformTypeTests.cs | 2 +- .../HttpUserAgentTypeTests.cs | 2 +- .../HttpUserAgentParserCachedProviderTests.cs | 2 +- .../HttpUserAgentParserDefaultProviderTests.cs | 2 +- 40 files changed, 39 insertions(+), 48 deletions(-) diff --git a/.editorconfig b/.editorconfig index c22c07c..af0952b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -122,6 +122,7 @@ indent_size = 2 # Code files [*.{cs,csx,vb,vbx}] indent_size = 4 +file_header_template = Copyright © https://myCSharp.de - all rights reserved # Organize usings dotnet_sort_system_directives_first = true diff --git a/MyCSharp.HttpUserAgentParser.sln b/MyCSharp.HttpUserAgentParser.sln index a444be3..958aeb0 100644 --- a/MyCSharp.HttpUserAgentParser.sln +++ b/MyCSharp.HttpUserAgentParser.sln @@ -81,16 +81,6 @@ Global GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {45927CF7-1BF4-479B-BBAA-8AD9CA901AE4} = {008A2BAB-78B4-42EB-A5D4-DE434438CEF0} - {3357BEC0-8216-409E-A539-F9A71DBACB81} = {008A2BAB-78B4-42EB-A5D4-DE434438CEF0} - {F16697F7-74B4-441D-A0C0-1A0572AC3AB0} = {F54C9296-4EF7-40F0-9F20-F23A2270ABC9} - {75960783-8BF9-479C-9ECF-E9653B74C9A2} = {F54C9296-4EF7-40F0-9F20-F23A2270ABC9} - {3C8CCD44-F47C-4624-8997-54C42F02E376} = {008A2BAB-78B4-42EB-A5D4-DE434438CEF0} - {39FC1EC2-2AD3-411F-A545-AB6CCB94FB7E} = {F54C9296-4EF7-40F0-9F20-F23A2270ABC9} - {A0D213E9-6408-46D1-AFAF-5096C2F6E027} = {FAAD18A0-E1B8-448D-B611-AFBDA8A89808} - {165EE915-1A4F-4875-90CE-1A2AE1540AE7} = {F54C9296-4EF7-40F0-9F20-F23A2270ABC9} - EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E8B0C994-0BF2-4692-9E22-E48B265B2804} EndGlobalSection diff --git a/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParserBenchmarks.cs b/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParserBenchmarks.cs index 06546d2..acde85f 100644 --- a/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParserBenchmarks.cs +++ b/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParserBenchmarks.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Jobs; diff --git a/perf/HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs b/perf/HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs index 460d2ee..94a69b6 100644 --- a/perf/HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs +++ b/perf/HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Columns; diff --git a/perf/HttpUserAgentParser.Benchmarks/Program.cs b/perf/HttpUserAgentParser.Benchmarks/Program.cs index 5c18157..00cf3e2 100644 --- a/perf/HttpUserAgentParser.Benchmarks/Program.cs +++ b/perf/HttpUserAgentParser.Benchmarks/Program.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using System.Reflection; using BenchmarkDotNet.Configs; diff --git a/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsExtensions.cs b/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsExtensions.cs index 4209560..b73b694 100644 --- a/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsExtensions.cs +++ b/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsExtensions.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Microsoft.Extensions.DependencyInjection; using MyCSharp.HttpUserAgentParser.DependencyInjection; diff --git a/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs b/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs index a8c50fb..9da3b29 100644 --- a/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs +++ b/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; diff --git a/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParserAccessor.cs b/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParserAccessor.cs index 30e7f86..c5f87ed 100644 --- a/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParserAccessor.cs +++ b/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParserAccessor.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Microsoft.AspNetCore.Http; using MyCSharp.HttpUserAgentParser.Providers; diff --git a/src/HttpUserAgentParser.AspNetCore/IHttpUserAgentParserAccessor.cs b/src/HttpUserAgentParser.AspNetCore/IHttpUserAgentParserAccessor.cs index f0f737c..f33d5b4 100644 --- a/src/HttpUserAgentParser.AspNetCore/IHttpUserAgentParserAccessor.cs +++ b/src/HttpUserAgentParser.AspNetCore/IHttpUserAgentParserAccessor.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Microsoft.AspNetCore.Http; diff --git a/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensions.cs b/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensions.cs index 28f3893..cbe8534 100644 --- a/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensions.cs +++ b/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Microsoft.Extensions.DependencyInjection; using MyCSharp.HttpUserAgentParser.DependencyInjection; diff --git a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs index 3673257..c8ecb91 100644 --- a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs +++ b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Microsoft.Extensions.Caching.Memory; using MyCSharp.HttpUserAgentParser.Providers; diff --git a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProviderOptions.cs b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProviderOptions.cs index 1b2115b..d9afb4a 100644 --- a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProviderOptions.cs +++ b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProviderOptions.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Microsoft.Extensions.Caching.Memory; diff --git a/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptions.cs b/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptions.cs index 8fb66d3..5804145 100644 --- a/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptions.cs +++ b/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptions.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Microsoft.Extensions.DependencyInjection; diff --git a/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserServiceCollectionExtensions.cs b/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserServiceCollectionExtensions.cs index 29da355..23b96bc 100644 --- a/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserServiceCollectionExtensions.cs +++ b/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Microsoft.Extensions.DependencyInjection; using MyCSharp.HttpUserAgentParser.Providers; diff --git a/src/HttpUserAgentParser/HttpUserAgentInformation.cs b/src/HttpUserAgentParser/HttpUserAgentInformation.cs index 9c23ae1..fc54e9b 100644 --- a/src/HttpUserAgentParser/HttpUserAgentInformation.cs +++ b/src/HttpUserAgentParser/HttpUserAgentInformation.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved namespace MyCSharp.HttpUserAgentParser; diff --git a/src/HttpUserAgentParser/HttpUserAgentInformationExtensions.cs b/src/HttpUserAgentParser/HttpUserAgentInformationExtensions.cs index 1edc608..409bafd 100644 --- a/src/HttpUserAgentParser/HttpUserAgentInformationExtensions.cs +++ b/src/HttpUserAgentParser/HttpUserAgentInformationExtensions.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved namespace MyCSharp.HttpUserAgentParser; diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index 3e7dc18..e32d5d8 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; diff --git a/src/HttpUserAgentParser/HttpUserAgentPlatformInformation.cs b/src/HttpUserAgentParser/HttpUserAgentPlatformInformation.cs index fef830a..68563e4 100644 --- a/src/HttpUserAgentParser/HttpUserAgentPlatformInformation.cs +++ b/src/HttpUserAgentParser/HttpUserAgentPlatformInformation.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using System.Text.RegularExpressions; diff --git a/src/HttpUserAgentParser/HttpUserAgentPlatformType.cs b/src/HttpUserAgentParser/HttpUserAgentPlatformType.cs index de57827..c6a125e 100644 --- a/src/HttpUserAgentParser/HttpUserAgentPlatformType.cs +++ b/src/HttpUserAgentParser/HttpUserAgentPlatformType.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved namespace MyCSharp.HttpUserAgentParser; diff --git a/src/HttpUserAgentParser/HttpUserAgentStatics.cs b/src/HttpUserAgentParser/HttpUserAgentStatics.cs index 7f8c203..32d4580 100644 --- a/src/HttpUserAgentParser/HttpUserAgentStatics.cs +++ b/src/HttpUserAgentParser/HttpUserAgentStatics.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using System.Text.RegularExpressions; diff --git a/src/HttpUserAgentParser/HttpUserAgentType.cs b/src/HttpUserAgentParser/HttpUserAgentType.cs index 6bddba2..3ad5278 100644 --- a/src/HttpUserAgentParser/HttpUserAgentType.cs +++ b/src/HttpUserAgentParser/HttpUserAgentType.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved namespace MyCSharp.HttpUserAgentParser; diff --git a/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs b/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs index cb6ec3a..381fd5b 100644 --- a/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs +++ b/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using System.Collections.Concurrent; diff --git a/src/HttpUserAgentParser/Providers/HttpUserAgentParserDefaultProvider.cs b/src/HttpUserAgentParser/Providers/HttpUserAgentParserDefaultProvider.cs index 7c0421b..f43e3c4 100644 --- a/src/HttpUserAgentParser/Providers/HttpUserAgentParserDefaultProvider.cs +++ b/src/HttpUserAgentParser/Providers/HttpUserAgentParserDefaultProvider.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved namespace MyCSharp.HttpUserAgentParser.Providers; diff --git a/src/HttpUserAgentParser/Providers/IHttpUserAgentParserProvider.cs b/src/HttpUserAgentParser/Providers/IHttpUserAgentParserProvider.cs index e745240..dc127ba 100644 --- a/src/HttpUserAgentParser/Providers/IHttpUserAgentParserProvider.cs +++ b/src/HttpUserAgentParser/Providers/IHttpUserAgentParserProvider.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved namespace MyCSharp.HttpUserAgentParser.Providers; diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs index 85bea8b..c5268e6 100644 --- a/tests/HttpUserAgentParser.AspNetCore.UnitTests/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Microsoft.Extensions.DependencyInjection; using MyCSharp.HttpUserAgentParser.AspNetCore.DependencyInjection; diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpContextTestHelpers.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpContextTestHelpers.cs index e8615d2..c053911 100644 --- a/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpContextTestHelpers.cs +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpContextTestHelpers.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Microsoft.AspNetCore.Http; diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpUserAgentParserAccessorTests.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpUserAgentParserAccessorTests.cs index 29b3c1d..65a2ce5 100644 --- a/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpUserAgentParserAccessorTests.cs +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpUserAgentParserAccessorTests.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Microsoft.AspNetCore.Http; using MyCSharp.HttpUserAgentParser.Providers; diff --git a/tests/HttpUserAgentParser.MemoryCache.UnitTests/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensionssTests.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensionssTests.cs index e53e1a5..01e13fe 100644 --- a/tests/HttpUserAgentParser.MemoryCache.UnitTests/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensionssTests.cs +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/DependencyInjection/HttpUserAgentParserMemoryCacheServiceCollectionExtensionssTests.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Microsoft.Extensions.DependencyInjection; using MyCSharp.HttpUserAgentParser.MemoryCache.DependencyInjection; diff --git a/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderOptionsTests.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderOptionsTests.cs index 44f093b..4bf1ac6 100644 --- a/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderOptionsTests.cs +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderOptionsTests.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Microsoft.Extensions.Caching.Memory; using Xunit; diff --git a/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderTests.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderTests.cs index efcabe2..f0073cc 100644 --- a/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderTests.cs +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderTests.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Xunit; diff --git a/tests/HttpUserAgentParser.UnitTests/DependencyInjection/HttpUserAgentParserDependencyInjectionOptions.cs b/tests/HttpUserAgentParser.UnitTests/DependencyInjection/HttpUserAgentParserDependencyInjectionOptions.cs index 6981a98..4bb1b34 100644 --- a/tests/HttpUserAgentParser.UnitTests/DependencyInjection/HttpUserAgentParserDependencyInjectionOptions.cs +++ b/tests/HttpUserAgentParser.UnitTests/DependencyInjection/HttpUserAgentParserDependencyInjectionOptions.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Microsoft.Extensions.DependencyInjection; using MyCSharp.HttpUserAgentParser.DependencyInjection; diff --git a/tests/HttpUserAgentParser.UnitTests/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs b/tests/HttpUserAgentParser.UnitTests/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs index 3afec1f..0461545 100644 --- a/tests/HttpUserAgentParser.UnitTests/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs +++ b/tests/HttpUserAgentParser.UnitTests/DependencyInjection/HttpUserAgentParserServiceCollectionExtensionsTests.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Microsoft.Extensions.DependencyInjection; using MyCSharp.HttpUserAgentParser.DependencyInjection; diff --git a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentInformationExtensionsTests.cs b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentInformationExtensionsTests.cs index 02301ef..e3b3e8a 100644 --- a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentInformationExtensionsTests.cs +++ b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentInformationExtensionsTests.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Xunit; diff --git a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentInformationTests.cs b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentInformationTests.cs index 13a446a..1200a7f 100644 --- a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentInformationTests.cs +++ b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentInformationTests.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using System.Text.RegularExpressions; using Xunit; diff --git a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs index a8b475a..1d4a92c 100644 --- a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs +++ b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Xunit; diff --git a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentPlatformInformationTests.cs b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentPlatformInformationTests.cs index 274e127..0099677 100644 --- a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentPlatformInformationTests.cs +++ b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentPlatformInformationTests.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using System.Text.RegularExpressions; using Xunit; diff --git a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentPlatformTypeTests.cs b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentPlatformTypeTests.cs index 7efc396..08cb3cc 100644 --- a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentPlatformTypeTests.cs +++ b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentPlatformTypeTests.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Xunit; diff --git a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentTypeTests.cs b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentTypeTests.cs index c1c5cb7..b58b990 100644 --- a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentTypeTests.cs +++ b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentTypeTests.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using Xunit; diff --git a/tests/HttpUserAgentParser.UnitTests/Providers/HttpUserAgentParserCachedProviderTests.cs b/tests/HttpUserAgentParser.UnitTests/Providers/HttpUserAgentParserCachedProviderTests.cs index db04bff..fb79528 100644 --- a/tests/HttpUserAgentParser.UnitTests/Providers/HttpUserAgentParserCachedProviderTests.cs +++ b/tests/HttpUserAgentParser.UnitTests/Providers/HttpUserAgentParserCachedProviderTests.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using MyCSharp.HttpUserAgentParser.Providers; using Xunit; diff --git a/tests/HttpUserAgentParser.UnitTests/Providers/HttpUserAgentParserDefaultProviderTests.cs b/tests/HttpUserAgentParser.UnitTests/Providers/HttpUserAgentParserDefaultProviderTests.cs index 4b9f740..a566217 100644 --- a/tests/HttpUserAgentParser.UnitTests/Providers/HttpUserAgentParserDefaultProviderTests.cs +++ b/tests/HttpUserAgentParser.UnitTests/Providers/HttpUserAgentParserDefaultProviderTests.cs @@ -1,4 +1,4 @@ -// Copyright © myCSharp.de - all rights reserved +// Copyright © https://myCSharp.de - all rights reserved using MyCSharp.HttpUserAgentParser.Providers; using Xunit; From 879243b1fa633e3293353ae7dbf4e294344b7ced Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Sat, 26 Apr 2025 19:47:44 +0200 Subject: [PATCH 04/27] Fixes Analyzer warning because of missing PrivateAssets (#70) --- Directory.Build.props | 15 ++++++++++++--- Directory.Packages.props | 15 ++++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 0dd3328..749af9f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -101,9 +101,18 @@ all runtime; build; native; contentfiles; analyzers - - - + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers + all runtime; build; native; contentfiles; analyzers diff --git a/Directory.Packages.props b/Directory.Packages.props index 4d9ff86..f7ce539 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -56,9 +56,18 @@ all runtime; build; native; contentfiles; analyzers - - - + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers + all runtime; build; native; contentfiles; analyzers From f6d5f63db6611ccf9d3d9cb26b6efc6998eb1294 Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Sat, 23 Aug 2025 19:51:15 +0200 Subject: [PATCH 05/27] add performance enhancements (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Günther Foidl --- .vscode/settings.json | 11 ++ .vscode/tasks.json | 13 ++ Directory.Packages.props | 6 +- Justfile | 88 ++++++++++++++ LICENSE | 2 +- MyCSharp.HttpUserAgentParser.sln | 11 ++ README.md | 2 +- .../HttpUserAgentParser.Benchmarks.csproj | 6 + .../HttpUserAgentParserBenchmarks.cs | 5 +- .../LibraryComparisonBenchmarks.cs | 5 +- .../LICENSE.txt | 2 +- .../LICENSE.txt | 2 +- .../HttpUserAgentParser.cs | 106 +++++++++++++++-- .../HttpUserAgentStatics.cs | 112 +++++++++++++++++- src/HttpUserAgentParser/LICENSE.txt | 2 +- .../HttpUserAgentParserDefaultProvider.cs | 2 +- 16 files changed, 350 insertions(+), 25 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 Justfile diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2ef0a8a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + // github copilot commit message instructions (preview) + "github.copilot.chat.commitMessageGeneration.instructions": [ + { "text": "Use conventional commit format: type(scope): description" }, + { "text": "Use imperative mood: 'Add feature' not 'Added feature'" }, + { "text": "Keep subject line under 50 characters" }, + { "text": "Use types: feat, fix, docs, style, refactor, perf, test, chore, ci" }, + { "text": "Include scope when relevant (e.g., api, ui, auth)" }, + { "text": "Reference issue numbers with # prefix" } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..5ddca9a --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,13 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "test", + "type": "shell", + "command": "dotnet test --nologo", + "args": [], + "problemMatcher": [ + "$msCompile" + ], + "group": "build" + } diff --git a/Directory.Packages.props b/Directory.Packages.props index f7ce539..eaa97d7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,12 +3,16 @@ true + + + + - + diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..4b447ed --- /dev/null +++ b/Justfile @@ -0,0 +1,88 @@ +# Justfile .NET - Benjamin Abt 2025 - https://benjamin-abt.com +# https://github.com/BenjaminAbt/templates/blob/main/justfile/dotnet + +set shell := ["pwsh", "-c"] + +# ===== Configurable defaults ===== +CONFIG := "Debug" +TFM := "net10.0" +BENCH_PRJ := "perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParser.Benchmarks.csproj" + +# ===== Default / Help ===== +default: help + +help: + # Overview: + just --list + # Usage: + # just build + # just test + # just bench + +# ===== Basic .NET Workflows ===== +restore: + dotnet restore + +build *ARGS: + dotnet build --configuration "{{CONFIG}}" --nologo --verbosity minimal {{ARGS}} + +rebuild *ARGS: + dotnet build --configuration "{{CONFIG}}" --nologo --verbosity minimal --no-incremental {{ARGS}} + +clean: + dotnet clean --configuration "{{CONFIG}}" --nologo + +run *ARGS: + dotnet run --project --framework "{{TFM}}" --configuration "{{CONFIG}}" --no-launch-profile {{ARGS}} + +# ===== Quality / Tests ===== +format: + dotnet format --verbosity minimal + +format-check: + dotnet format --verify-no-changes --verbosity minimal + +test *ARGS: + dotnet test --configuration "{{CONFIG}}" --framework "{{TFM}}" --nologo --verbosity minimal {{ARGS}} + +test-cov: + dotnet test --configuration "{{CONFIG}}" --framework "{{TFM}}" --nologo --verbosity minimal /p:CollectCoverage=true /p:CoverletOutputFormat="cobertura,lcov,opencover" /p:CoverletOutput="./TestResults/coverage/coverage" + + +test-filter QUERY: + dotnet test --configuration "{{CONFIG}}" --framework "{{TFM}}" --nologo --verbosity minimal --filter "{{QUERY}}" + +# ===== Packaging / Release ===== +pack *ARGS: + dotnet pack --configuration "{{CONFIG}}" --nologo --verbosity minimal -o "./artifacts/packages" {{ARGS}} + +publish *ARGS: + dotnet publish --configuration "{{CONFIG}}" --framework "{{TFM}}" --nologo --verbosity minimal -o "./artifacts/publish/{{TFM}}" {{ARGS}} + +publish-sc RID *ARGS: + dotnet publish --configuration "{{CONFIG}}" --framework "{{TFM}}" --runtime "{{RID}}" --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=false --nologo --verbosity minimal -o "./artifacts/publish/{{TFM}}-{{RID}}" {{ARGS}} + +# ===== Benchmarks ===== +bench *ARGS: + dotnet run --configuration Release --project "{{BENCH_PRJ}}" --framework "{{TFM}}" {{ARGS}} + +# ===== Housekeeping ===== +clean-artifacts: + if (Test-Path "./artifacts") { Remove-Item "./artifacts" -Recurse -Force } + +clean-all: + just clean + just clean-artifacts + # Optionally: git clean -xdf + +# ===== Combined Flows ===== +fmt-build: + just format + just build + +ci: + just clean + just restore + just format-check + just build + just test-cov diff --git a/LICENSE b/LICENSE index 11152f9..023aed7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2023 MyCSharp +Copyright (c) 2021-2025 MyCSharp Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MyCSharp.HttpUserAgentParser.sln b/MyCSharp.HttpUserAgentParser.sln index 958aeb0..7596310 100644 --- a/MyCSharp.HttpUserAgentParser.sln +++ b/MyCSharp.HttpUserAgentParser.sln @@ -27,6 +27,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_", "_", "{5738CE0D-5E6E-47 Directory.Build.props = Directory.Build.props Directory.Packages.props = Directory.Packages.props global.json = global.json + Justfile = Justfile LICENSE = LICENSE NuGet.config = NuGet.config README.md = README.md @@ -81,6 +82,16 @@ Global GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {45927CF7-1BF4-479B-BBAA-8AD9CA901AE4} = {008A2BAB-78B4-42EB-A5D4-DE434438CEF0} + {3357BEC0-8216-409E-A539-F9A71DBACB81} = {008A2BAB-78B4-42EB-A5D4-DE434438CEF0} + {F16697F7-74B4-441D-A0C0-1A0572AC3AB0} = {F54C9296-4EF7-40F0-9F20-F23A2270ABC9} + {75960783-8BF9-479C-9ECF-E9653B74C9A2} = {F54C9296-4EF7-40F0-9F20-F23A2270ABC9} + {3C8CCD44-F47C-4624-8997-54C42F02E376} = {008A2BAB-78B4-42EB-A5D4-DE434438CEF0} + {39FC1EC2-2AD3-411F-A545-AB6CCB94FB7E} = {F54C9296-4EF7-40F0-9F20-F23A2270ABC9} + {A0D213E9-6408-46D1-AFAF-5096C2F6E027} = {FAAD18A0-E1B8-448D-B611-AFBDA8A89808} + {165EE915-1A4F-4875-90CE-1A2AE1540AE7} = {F54C9296-4EF7-40F0-9F20-F23A2270ABC9} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E8B0C994-0BF2-4692-9E22-E48B265B2804} EndGlobalSection diff --git a/README.md b/README.md index 6ce902b..5d07d44 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ by [@BenjaminAbt](https://github.com/BenjaminAbt) and [@gfoidl](https://github.c MIT License -Copyright (c) 2021-2023 MyCSharp +Copyright (c) 2021-2025 MyCSharp Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParser.Benchmarks.csproj b/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParser.Benchmarks.csproj index e190b11..eb6a92c 100644 --- a/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParser.Benchmarks.csproj +++ b/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParser.Benchmarks.csproj @@ -5,6 +5,12 @@ disable + + + $(MSBuildProjectName) + $(MSBuildProjectName) + + $(DefineConstants);OS_WIN diff --git a/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParserBenchmarks.cs b/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParserBenchmarks.cs index acde85f..8b0b706 100644 --- a/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParserBenchmarks.cs +++ b/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParserBenchmarks.cs @@ -2,12 +2,13 @@ using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Jobs; +using MyCSharp.HttpUserAgentParser; #if OS_WIN using BenchmarkDotNet.Diagnostics.Windows.Configs; #endif -namespace MyCSharp.HttpUserAgentParser.Benchmarks; +namespace HttpUserAgentParser.Benchmarks; [MemoryDiagnoser] [SimpleJob(RuntimeMoniker.Net80)] @@ -43,7 +44,7 @@ public void Parse() for (int i = 0; i < testUserAgentMix.Length; ++i) { - results[i] = HttpUserAgentParser.Parse(testUserAgentMix[i]); + results[i] = MyCSharp.HttpUserAgentParser.HttpUserAgentParser.Parse(testUserAgentMix[i]); } } } diff --git a/perf/HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs b/perf/HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs index 94a69b6..4d7bcb0 100644 --- a/perf/HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs +++ b/perf/HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs @@ -5,9 +5,10 @@ using BenchmarkDotNet.Configs; using BenchmarkDotNet.Diagnosers; using DeviceDetectorNET; +using MyCSharp.HttpUserAgentParser; using MyCSharp.HttpUserAgentParser.Providers; -namespace MyCSharp.HttpUserAgentParser.Benchmarks.LibraryComparison; +namespace HttpUserAgentParser.Benchmarks.LibraryComparison; [ShortRunJob] [MemoryDiagnoser] @@ -33,7 +34,7 @@ public IEnumerable GetTestUserAgents() [BenchmarkCategory("Basic")] public HttpUserAgentInformation MyCSharpBasic() { - HttpUserAgentInformation info = HttpUserAgentParser.Parse(Data.UserAgent); + HttpUserAgentInformation info = MyCSharp.HttpUserAgentParser.HttpUserAgentParser.Parse(Data.UserAgent); return info; } diff --git a/src/HttpUserAgentParser.AspNetCore/LICENSE.txt b/src/HttpUserAgentParser.AspNetCore/LICENSE.txt index 11152f9..023aed7 100644 --- a/src/HttpUserAgentParser.AspNetCore/LICENSE.txt +++ b/src/HttpUserAgentParser.AspNetCore/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2023 MyCSharp +Copyright (c) 2021-2025 MyCSharp Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/HttpUserAgentParser.MemoryCache/LICENSE.txt b/src/HttpUserAgentParser.MemoryCache/LICENSE.txt index 11152f9..023aed7 100644 --- a/src/HttpUserAgentParser.MemoryCache/LICENSE.txt +++ b/src/HttpUserAgentParser.MemoryCache/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2023 MyCSharp +Copyright (c) 2021-2025 MyCSharp Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index e32d5d8..5e788fd 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -1,7 +1,7 @@ // Copyright © https://myCSharp.de - all rights reserved using System.Diagnostics.CodeAnalysis; -using System.Text.RegularExpressions; +using System.Runtime.CompilerServices; namespace MyCSharp.HttpUserAgentParser; @@ -48,11 +48,15 @@ public static HttpUserAgentInformation Parse(string userAgent) /// public static HttpUserAgentPlatformInformation? GetPlatform(string userAgent) { - foreach (HttpUserAgentPlatformInformation item in HttpUserAgentStatics.Platforms) + // Fast, allocation-free token scan (keeps public statics untouched) + ReadOnlySpan ua = userAgent.AsSpan(); + foreach ((string Token, string Name, HttpUserAgentPlatformType PlatformType) platform in HttpUserAgentStatics.s_platformRules) { - if (item.Regex.IsMatch(userAgent)) + if (ContainsIgnoreCase(ua, platform.Token)) { - return item; + return new HttpUserAgentPlatformInformation( + HttpUserAgentStatics.GetPlatformRegexForToken(platform.Token), + platform.Name, platform.PlatformType); } } @@ -73,13 +77,41 @@ public static bool TryGetPlatform(string userAgent, [NotNullWhen(true)] out Http /// public static (string Name, string? Version)? GetBrowser(string userAgent) { - foreach ((Regex key, string? value) in HttpUserAgentStatics.Browsers) + ReadOnlySpan ua = userAgent.AsSpan(); + foreach ((string Name, string DetectToken, string? VersionToken) browserRule in HttpUserAgentStatics.s_browserRules) { - Match match = key.Match(userAgent); - if (match.Success) + if (!TryIndexOf(ua, browserRule.DetectToken, out int detectIndex)) { - return (value, match.Groups[1].Value); + continue; } + + // Version token may differ (e.g., Safari uses "Version/") + int versionSearchStart = detectIndex; + if (!string.IsNullOrEmpty(browserRule.VersionToken)) + { + if (TryIndexOf(ua, browserRule.VersionToken!, out int vtIndex)) + { + versionSearchStart = vtIndex + browserRule.VersionToken!.Length; + } + else + { + // If specific version token wasn't found, fall back to detect token area + versionSearchStart = detectIndex + browserRule.DetectToken.Length; + } + } + else + { + versionSearchStart = detectIndex + browserRule.DetectToken.Length; + } + + string? version = null; + ua = ua.Slice(versionSearchStart); + if (TryExtractVersion(ua, out Range range)) + { + version = ua[range].ToString(); + } + + return (browserRule.Name, version); } return null; @@ -143,4 +175,62 @@ public static bool TryGetMobileDevice(string userAgent, [NotNullWhen(true)] out device = GetMobileDevice(userAgent); return device is not null; } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool ContainsIgnoreCase(ReadOnlySpan haystack, ReadOnlySpan needle) + => TryIndexOf(haystack, needle, out _); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool TryIndexOf(ReadOnlySpan haystack, ReadOnlySpan needle, out int index) + { + index = haystack.IndexOf(needle, StringComparison.OrdinalIgnoreCase); + return index >= 0; + } + + /// + /// Extracts a dotted numeric version. + /// Accepts digits and dots; skips common separators ('/', ' ', ':', '=') until first digit. + /// Returns false if no version-like token is found. + /// + private static bool TryExtractVersion(ReadOnlySpan haystack, out Range range) + { + range = default; + + // Limit search window to avoid scanning entire UA string unnecessarily + const int Window = 128; + if (haystack.Length >= Window) + { + haystack = haystack.Slice(0, Window); + } + + int i = 0; + for (; i < haystack.Length; ++i) + { + char c = haystack[i]; + if (char.IsBetween(c, '0', '9')) + { + break; + } + } + + int s = i; + haystack = haystack.Slice(i + 1); + for (i = 0; i < haystack.Length; ++i) + { + char c = haystack[i]; + if (!(char.IsBetween(c, '0', '9') || c == '.')) + { + break; + } + } + i += s + 1; // shift back the previous domain + + if (i == s) + { + return false; + } + + range = new Range(s, i); + return true; + } } diff --git a/src/HttpUserAgentParser/HttpUserAgentStatics.cs b/src/HttpUserAgentParser/HttpUserAgentStatics.cs index 32d4580..3eca3f0 100644 --- a/src/HttpUserAgentParser/HttpUserAgentStatics.cs +++ b/src/HttpUserAgentParser/HttpUserAgentStatics.cs @@ -1,5 +1,6 @@ // Copyright © https://myCSharp.de - all rights reserved +using System.Collections.Frozen; using System.Text.RegularExpressions; namespace MyCSharp.HttpUserAgentParser; @@ -70,6 +71,62 @@ public static class HttpUserAgentStatics new(CreateDefaultPlatformRegex("symbian"), "Symbian OS", HttpUserAgentPlatformType.Symbian), ]; + /// + /// Fast-path platform token rules for zero-allocation Contains checks + /// + internal static readonly (string Token, string Name, HttpUserAgentPlatformType PlatformType)[] s_platformRules = + [ + ("windows nt 10.0", "Windows 10", HttpUserAgentPlatformType.Windows), + ("windows nt 6.3", "Windows 8.1", HttpUserAgentPlatformType.Windows), + ("windows nt 6.2", "Windows 8", HttpUserAgentPlatformType.Windows), + ("windows nt 6.1", "Windows 7", HttpUserAgentPlatformType.Windows), + ("windows nt 6.0", "Windows Vista", HttpUserAgentPlatformType.Windows), + ("windows nt 5.2", "Windows 2003", HttpUserAgentPlatformType.Windows), + ("windows nt 5.1", "Windows XP", HttpUserAgentPlatformType.Windows), + ("windows nt 5.0", "Windows 2000", HttpUserAgentPlatformType.Windows), + ("windows nt 4.0", "Windows NT 4.0", HttpUserAgentPlatformType.Windows), + ("winnt4.0", "Windows NT 4.0", HttpUserAgentPlatformType.Windows), + ("winnt 4.0", "Windows NT", HttpUserAgentPlatformType.Windows), + ("winnt", "Windows NT", HttpUserAgentPlatformType.Windows), + ("windows 98", "Windows 98", HttpUserAgentPlatformType.Windows), + ("win98", "Windows 98", HttpUserAgentPlatformType.Windows), + ("windows 95", "Windows 95", HttpUserAgentPlatformType.Windows), + ("win95", "Windows 95", HttpUserAgentPlatformType.Windows), + ("windows phone", "Windows Phone", HttpUserAgentPlatformType.Windows), + ("windows", "Unknown Windows OS", HttpUserAgentPlatformType.Windows), + ("android", "Android", HttpUserAgentPlatformType.Android), + ("blackberry", "BlackBerry", HttpUserAgentPlatformType.BlackBerry), + ("iphone", "iOS", HttpUserAgentPlatformType.IOS), + ("ipad", "iOS", HttpUserAgentPlatformType.IOS), + ("ipod", "iOS", HttpUserAgentPlatformType.IOS), + ("cros", "ChromeOS", HttpUserAgentPlatformType.ChromeOS), + ("os x", "Mac OS X", HttpUserAgentPlatformType.MacOS), + ("ppc mac", "Power PC Mac", HttpUserAgentPlatformType.MacOS), + ("freebsd", "FreeBSD", HttpUserAgentPlatformType.Linux), + ("ppc", "Macintosh", HttpUserAgentPlatformType.Linux), + ("linux", "Linux", HttpUserAgentPlatformType.Linux), + ("debian", "Debian", HttpUserAgentPlatformType.Linux), + ("sunos", "Sun Solaris", HttpUserAgentPlatformType.Generic), + ("beos", "BeOS", HttpUserAgentPlatformType.Generic), + ("apachebench", "ApacheBench", HttpUserAgentPlatformType.Generic), + ("aix", "AIX", HttpUserAgentPlatformType.Generic), + ("irix", "Irix", HttpUserAgentPlatformType.Generic), + ("osf", "DEC OSF", HttpUserAgentPlatformType.Generic), + ("hp-ux", "HP-UX", HttpUserAgentPlatformType.Windows), + ("netbsd", "NetBSD", HttpUserAgentPlatformType.Generic), + ("bsdi", "BSDi", HttpUserAgentPlatformType.Generic), + ("openbsd", "OpenBSD", HttpUserAgentPlatformType.Unix), + ("gnu", "GNU/Linux", HttpUserAgentPlatformType.Linux), + ("unix", "Unknown Unix OS", HttpUserAgentPlatformType.Unix), + ("symbian", "Symbian OS", HttpUserAgentPlatformType.Symbian), + ]; + + // Precompiled platform regex map to attach to PlatformInformation without per-call allocations + private static readonly FrozenDictionary s_platformRegexMap = s_platformRules + .ToFrozenDictionary(p => p.Token, p => CreateDefaultPlatformRegex(p.Token), StringComparer.OrdinalIgnoreCase); + + internal static Regex GetPlatformRegexForToken(string token) => s_platformRegexMap[token]; + /// /// Regex defauls for browser mappings /// @@ -83,7 +140,7 @@ private static Regex CreateDefaultBrowserRegex(string key) /// /// Browsers /// - public static readonly Dictionary Browsers = new() + public static readonly FrozenDictionary Browsers = new Dictionary() { { CreateDefaultBrowserRegex("OPR"), "Opera" }, { CreateDefaultBrowserRegex("Flock"), "Flock" }, @@ -120,12 +177,54 @@ private static Regex CreateDefaultBrowserRegex(string key) { CreateDefaultBrowserRegex("Maxthon"), "Maxthon" }, { CreateDefaultBrowserRegex("ipod touch"), "Apple iPod" }, { CreateDefaultBrowserRegex("Ubuntu"), "Ubuntu Web Browser" }, - }; + }.ToFrozenDictionary(); + + /// + /// Fast-path browser token rules. If these fail to extract a version, code will fall back to regex rules. + /// + internal static readonly (string Name, string DetectToken, string? VersionToken)[] s_browserRules = + [ + ("Opera", "OPR", null), + ("Flock", "Flock", null), + ("Edge", "Edge", null), + ("Edge", "EdgA", null), + ("Edge", "Edg", null), + ("Vivaldi", "Vivaldi", null), + ("Brave", "Brave Chrome", null), + ("Chrome", "Chrome", null), + ("Chrome", "CriOS", null), + ("Opera", "Opera", "Version/"), + ("Opera", "Opera", null), + ("Internet Explorer", "MSIE", "MSIE "), + ("Internet Explorer", "Internet Explorer", null), + ("Internet Explorer", "Trident", "rv:"), + ("Shiira", "Shiira", null), + ("Firefox", "Firefox", null), + ("Firefox", "FxiOS", null), + ("Chimera", "Chimera", null), + ("Phoenix", "Phoenix", null), + ("Firebird", "Firebird", null), + ("Camino", "Camino", null), + ("Netscape", "Netscape", null), + ("OmniWeb", "OmniWeb", null), + ("Safari", "Version/", "Version/"), + ("Mozilla", "Mozilla", null), + ("Konqueror", "Konqueror", null), + ("iCab", "icab", null), + ("Lynx", "Lynx", null), + ("Links", "Links", null), + ("HotJava", "hotjava", null), + ("Amaya", "amaya", null), + ("IBrowse", "IBrowse", null), + ("Maxthon", "Maxthon", null), + ("Apple iPod", "ipod touch", null), + ("Ubuntu Web Browser", "Ubuntu", null), + ]; /// /// Mobiles /// - public static readonly Dictionary Mobiles = new(StringComparer.InvariantCultureIgnoreCase) + public static readonly FrozenDictionary Mobiles = new Dictionary(StringComparer.InvariantCultureIgnoreCase) { // Legacy { "mobileexplorer", "Mobile Explorer" }, @@ -208,7 +307,7 @@ private static Regex CreateDefaultBrowserRegex(string key) { "up.browser", "Generic Mobile" }, { "smartphone", "Generic Mobile" }, { "cellphone", "Generic Mobile" }, - }; + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); /// /// Robots @@ -287,8 +386,9 @@ public static readonly (string Key, string Value)[] Robots = /// /// Tools /// - public static readonly Dictionary Tools = new(StringComparer.OrdinalIgnoreCase) + public static readonly FrozenDictionary Tools = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "curl", "curl" } - }; + } + .ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); } diff --git a/src/HttpUserAgentParser/LICENSE.txt b/src/HttpUserAgentParser/LICENSE.txt index 11152f9..023aed7 100644 --- a/src/HttpUserAgentParser/LICENSE.txt +++ b/src/HttpUserAgentParser/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2023 MyCSharp +Copyright (c) 2021-2025 MyCSharp Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/HttpUserAgentParser/Providers/HttpUserAgentParserDefaultProvider.cs b/src/HttpUserAgentParser/Providers/HttpUserAgentParserDefaultProvider.cs index f43e3c4..7b3bd51 100644 --- a/src/HttpUserAgentParser/Providers/HttpUserAgentParserDefaultProvider.cs +++ b/src/HttpUserAgentParser/Providers/HttpUserAgentParserDefaultProvider.cs @@ -8,7 +8,7 @@ namespace MyCSharp.HttpUserAgentParser.Providers; public class HttpUserAgentParserDefaultProvider : IHttpUserAgentParserProvider { /// - /// returns the result of + /// returns the result of /// public HttpUserAgentInformation Parse(string userAgent) => HttpUserAgentParser.Parse(userAgent); From ce923766fa657d415c356aa2e20139885249d5a5 Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Sat, 23 Aug 2025 20:30:56 +0200 Subject: [PATCH 06/27] docs(readme): update benchmark results and format (#74) --- README.md | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5d07d44..c43e521 100644 --- a/README.md +++ b/README.md @@ -112,22 +112,33 @@ public void MyMethod(IHttpUserAgentParserAccessor parserAccessor) ## Benchmark -```sh -BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042 -AMD Ryzen 9 5950X, 1 CPU, 32 logical and 16 physical cores -.NET Core SDK=5.0.300-preview.21228.15 - [Host] : .NET Core 5.0.5 (CoreCLR 5.0.521.16609, CoreFX 5.0.521.16609), X64 RyuJIT - DefaultJob : .NET Core 5.0.5 (CoreCLR 5.0.521.16609, CoreFX 5.0.521.16609), X64 RyuJIT +```shell +BenchmarkDotNet v0.14.0, Windows 10 (10.0.19045.6216/22H2/2022Update) +AMD Ryzen 9 9950X, 1 CPU, 32 logical and 16 physical cores +.NET SDK 10.0.100-preview.7.25380.108 + [Host] : .NET 10.0.0 (10.0.25.38108), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + ShortRun : .NET 10.0.0 (10.0.25.38108), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + +Job=ShortRun IterationCount=3 LaunchCount=1 +WarmupCount=3 + +| Method | Categories | Data | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio | +|------------------- |----------- |------------- |----------------:|-----------------:|---------------:|----------:|--------:|---------:|---------:|---------:|-----------:|------------:| +| MyCSharp | Basic | Chrome Win10 | 936.44 ns | 131.253 ns | 7.194 ns | 1.00 | 0.01 | 0.0029 | - | - | 48 B | 1.00 | +| UAParser | Basic | Chrome Win10 | 9,512,347.40 ns | 3,961,045.109 ns | 217,118.249 ns | 10,158.42 | 211.89 | 656.2500 | 546.8750 | 109.3750 | 11523315 B | 240,069.06 | +| DeviceDetector.NET | Basic | Chrome Win10 | 5,428,530.73 ns | 5,276,988.556 ns | 289,249.550 ns | 5,797.23 | 270.29 | 296.8750 | 125.0000 | 31.2500 | 5002239 B | 104,213.31 | +| | | | | | | | | | | | | | +| MyCSharp | Basic | Google-Bot | 165.66 ns | 21.926 ns | 1.202 ns | 1.00 | 0.01 | - | - | - | - | NA | +| UAParser | Basic | Google-Bot | 9,737,403.12 ns | 2,336,698.462 ns | 128,082.328 ns | 58,781.92 | 764.74 | 671.8750 | 656.2500 | 109.3750 | 11877003 B | NA | +| DeviceDetector.NET | Basic | Google-Bot | 6,331,960.42 ns | 1,602,716.199 ns | 87,850.283 ns | 38,224.23 | 518.30 | 500.0000 | 62.5000 | - | 8817013 B | NA | +| | | | | | | | | | | | | | +| MyCSharp | Cached | Chrome Win10 | 26.75 ns | 3.749 ns | 0.205 ns | 1.00 | 0.01 | - | - | - | - | NA | +| UAParser | Cached | Chrome Win10 | 250,039.55 ns | 6,502.182 ns | 356.407 ns | 9,346.54 | 63.39 | 2.1973 | - | - | 37488 B | NA | +| | | | | | | | | | | | | | +| MyCSharp | Cached | Google-Bot | 19.66 ns | 4.312 ns | 0.236 ns | 1.00 | 0.01 | - | - | - | - | NA | +| UAParser | Cached | Google-Bot | 184,991.85 ns | 46,235.986 ns | 2,534.350 ns | 9,408.77 | 148.82 | 2.6855 | - | - | 45857 B | NA | ``` -| Method | Mean | Error | StdDev | Gen 0 | Gen 1 | Gen 2 | Allocated | -|-------------------- |------------:|----------:|----------:|--------:|-------:|------:|----------:| -| 'UA Parser' | 3,238.59 us | 27.435 us | 25.663 us | 7.8125 | - | - | 168225 B | -| UserAgentService | 391.11 us | 5.126 us | 4.795 us | 35.1563 | 3.4180 | - | 589664 B | -| HttpUserAgentParser | 67.07 us | 0.740 us | 0.693 us | - | - | - | 848 B | - -More benchmark results can be found [in this comment](https://github.com/mycsharp/HttpUserAgentParser/issues/2#issuecomment-842188532). - ## Disclaimer This library is inspired by [UserAgentService by DannyBoyNg](https://github.com/DannyBoyNg/UserAgentService) and contains optimizations for our requirements on [myCSharp.de](https://mycsharp.de). From 30066004f430c962cc0414841769c6c38fb4e25b Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Mon, 25 Aug 2025 12:59:53 +0200 Subject: [PATCH 07/27] =?UTF-8?q?feat(parser):=20enhance=20user=20agent=20?= =?UTF-8?q?parsing=20logic=20and=20add=20tests=20for=20inva=E2=80=A6=20(#7?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 30 ++++----- .../HttpUserAgentParser.cs | 62 ++++++++++++------- .../HttpUserAgentStatics.cs | 2 +- .../HttpUserAgentParserTests.cs | 50 +++++++++++++++ 4 files changed, 104 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index c43e521..fe6a97b 100644 --- a/README.md +++ b/README.md @@ -122,21 +122,21 @@ AMD Ryzen 9 9950X, 1 CPU, 32 logical and 16 physical cores Job=ShortRun IterationCount=3 LaunchCount=1 WarmupCount=3 -| Method | Categories | Data | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio | -|------------------- |----------- |------------- |----------------:|-----------------:|---------------:|----------:|--------:|---------:|---------:|---------:|-----------:|------------:| -| MyCSharp | Basic | Chrome Win10 | 936.44 ns | 131.253 ns | 7.194 ns | 1.00 | 0.01 | 0.0029 | - | - | 48 B | 1.00 | -| UAParser | Basic | Chrome Win10 | 9,512,347.40 ns | 3,961,045.109 ns | 217,118.249 ns | 10,158.42 | 211.89 | 656.2500 | 546.8750 | 109.3750 | 11523315 B | 240,069.06 | -| DeviceDetector.NET | Basic | Chrome Win10 | 5,428,530.73 ns | 5,276,988.556 ns | 289,249.550 ns | 5,797.23 | 270.29 | 296.8750 | 125.0000 | 31.2500 | 5002239 B | 104,213.31 | -| | | | | | | | | | | | | | -| MyCSharp | Basic | Google-Bot | 165.66 ns | 21.926 ns | 1.202 ns | 1.00 | 0.01 | - | - | - | - | NA | -| UAParser | Basic | Google-Bot | 9,737,403.12 ns | 2,336,698.462 ns | 128,082.328 ns | 58,781.92 | 764.74 | 671.8750 | 656.2500 | 109.3750 | 11877003 B | NA | -| DeviceDetector.NET | Basic | Google-Bot | 6,331,960.42 ns | 1,602,716.199 ns | 87,850.283 ns | 38,224.23 | 518.30 | 500.0000 | 62.5000 | - | 8817013 B | NA | -| | | | | | | | | | | | | | -| MyCSharp | Cached | Chrome Win10 | 26.75 ns | 3.749 ns | 0.205 ns | 1.00 | 0.01 | - | - | - | - | NA | -| UAParser | Cached | Chrome Win10 | 250,039.55 ns | 6,502.182 ns | 356.407 ns | 9,346.54 | 63.39 | 2.1973 | - | - | 37488 B | NA | -| | | | | | | | | | | | | | -| MyCSharp | Cached | Google-Bot | 19.66 ns | 4.312 ns | 0.236 ns | 1.00 | 0.01 | - | - | - | - | NA | -| UAParser | Cached | Google-Bot | 184,991.85 ns | 46,235.986 ns | 2,534.350 ns | 9,408.77 | 148.82 | 2.6855 | - | - | 45857 B | NA | +| Method | Categories | Data | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio | +|------------------- |----------- |------------- |----------------:|-----------------:|---------------:|----------:|---------:|---------:|---------:|---------:|-----------:|------------:| +| MyCSharp | Basic | Chrome Win10 | 871.85 ns | 132.008 ns | 7.236 ns | 1.00 | 0.01 | 0.0029 | - | - | 48 B | 1.00 | +| UAParser | Basic | Chrome Win10 | 8,901,909.90 ns | 3,411,259.484 ns | 186,982.644 ns | 10,210.80 | 199.60 | 656.2500 | 578.1250 | 109.3750 | 11523310 B | 240,068.96 | +| DeviceDetector.NET | Basic | Chrome Win10 | 5,391,412.50 ns | 8,253,446.769 ns | 452,399.269 ns | 6,184.14 | 451.58 | 296.8750 | 125.0000 | 31.2500 | 5002239 B | 104,213.31 | +| | | | | | | | | | | | | | +| MyCSharp | Basic | Google-Bot | 158.80 ns | 19.584 ns | 1.073 ns | 1.00 | 0.01 | - | - | - | - | NA | +| UAParser | Basic | Google-Bot | 9,666,739.32 ns | 7,566,085.041 ns | 414,722.653 ns | 60,873.62 | 2,289.43 | 671.8750 | 656.2500 | 109.3750 | 11876998 B | NA | +| DeviceDetector.NET | Basic | Google-Bot | 6,106,666.41 ns | 593,634.990 ns | 32,539.137 ns | 38,455.05 | 285.97 | 539.0625 | 117.1875 | 23.4375 | 8817078 B | NA | +| | | | | | | | | | | | | | +| MyCSharp | Cached | Chrome Win10 | 26.43 ns | 0.132 ns | 0.007 ns | 1.00 | 0.00 | - | - | - | - | NA | +| UAParser | Cached | Chrome Win10 | 177,417.99 ns | 24,390.139 ns | 1,336.906 ns | 6,713.66 | 43.84 | 2.1973 | - | - | 37488 B | NA | +| | | | | | | | | | | | | | +| MyCSharp | Cached | Google-Bot | 17.03 ns | 1.835 ns | 0.101 ns | 1.00 | 0.01 | - | - | - | - | NA | +| UAParser | Cached | Google-Bot | 129,445.13 ns | 21,319.059 ns | 1,168.570 ns | 7,599.76 | 70.93 | 2.6855 | - | - | 45857 B | NA | ``` ## Disclaimer diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index 5e788fd..3e6cf6b 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -11,7 +11,6 @@ namespace MyCSharp.HttpUserAgentParser; /// Parser logic for user agents /// public static class HttpUserAgentParser - { /// /// Parses given user agent @@ -48,7 +47,6 @@ public static HttpUserAgentInformation Parse(string userAgent) /// public static HttpUserAgentPlatformInformation? GetPlatform(string userAgent) { - // Fast, allocation-free token scan (keeps public statics untouched) ReadOnlySpan ua = userAgent.AsSpan(); foreach ((string Token, string Name, HttpUserAgentPlatformType PlatformType) platform in HttpUserAgentStatics.s_platformRules) { @@ -78,6 +76,7 @@ public static bool TryGetPlatform(string userAgent, [NotNullWhen(true)] out Http public static (string Name, string? Version)? GetBrowser(string userAgent) { ReadOnlySpan ua = userAgent.AsSpan(); + foreach ((string Name, string DetectToken, string? VersionToken) browserRule in HttpUserAgentStatics.s_browserRules) { if (!TryIndexOf(ua, browserRule.DetectToken, out int detectIndex)) @@ -86,7 +85,18 @@ public static (string Name, string? Version)? GetBrowser(string userAgent) } // Version token may differ (e.g., Safari uses "Version/") - int versionSearchStart = detectIndex; + + int versionSearchStart; + // For rules without a specific version token, ensure pattern Token/ + if (string.IsNullOrEmpty(browserRule.VersionToken)) + { + int afterDetect = detectIndex + browserRule.DetectToken.Length; + if (afterDetect >= ua.Length || ua[afterDetect] != '/') + { + // Likely a misspelling or partial token (e.g., Edgg, Oprea, Chromee) + continue; + } + } if (!string.IsNullOrEmpty(browserRule.VersionToken)) { if (TryIndexOf(ua, browserRule.VersionToken!, out int vtIndex)) @@ -104,14 +114,14 @@ public static (string Name, string? Version)? GetBrowser(string userAgent) versionSearchStart = detectIndex + browserRule.DetectToken.Length; } - string? version = null; - ua = ua.Slice(versionSearchStart); - if (TryExtractVersion(ua, out Range range)) + ReadOnlySpan search = ua.Slice(versionSearchStart); + if (TryExtractVersion(search, out Range range)) { - version = ua[range].ToString(); + string? version = search[range].ToString(); + return (browserRule.Name, version); } - return (browserRule.Name, version); + // If we didn't find a version for this rule, try next rule } return null; @@ -198,39 +208,43 @@ private static bool TryExtractVersion(ReadOnlySpan haystack, out Range ran // Limit search window to avoid scanning entire UA string unnecessarily const int Window = 128; - if (haystack.Length >= Window) + if (haystack.Length > Window) { haystack = haystack.Slice(0, Window); } - int i = 0; - for (; i < haystack.Length; ++i) + // Find first digit + int start = -1; + for (int i = 0; i < haystack.Length; i++) { char c = haystack[i]; - if (char.IsBetween(c, '0', '9')) + if (c >= '0' && c <= '9') { + start = i; break; } } - int s = i; - haystack = haystack.Slice(i + 1); - for (i = 0; i < haystack.Length; ++i) + if (start < 0) { - char c = haystack[i]; - if (!(char.IsBetween(c, '0', '9') || c == '.')) - { - break; - } + // No digit found => no version + return false; } - i += s + 1; // shift back the previous domain - if (i == s) + // Consume digits and dots after first digit + int end = start + 1; + while (end < haystack.Length) { - return false; + char c = haystack[end]; + if (!((c >= '0' && c <= '9') || c == '.')) + { + break; + } + end++; } - range = new Range(s, i); + // Create exclusive end range + range = new Range(start, end); return true; } } diff --git a/src/HttpUserAgentParser/HttpUserAgentStatics.cs b/src/HttpUserAgentParser/HttpUserAgentStatics.cs index 3eca3f0..996abab 100644 --- a/src/HttpUserAgentParser/HttpUserAgentStatics.cs +++ b/src/HttpUserAgentParser/HttpUserAgentStatics.cs @@ -187,6 +187,7 @@ internal static readonly (string Name, string DetectToken, string? VersionToken) ("Opera", "OPR", null), ("Flock", "Flock", null), ("Edge", "Edge", null), + ("Edge", "EdgiOS", null), ("Edge", "EdgA", null), ("Edge", "Edg", null), ("Vivaldi", "Vivaldi", null), @@ -208,7 +209,6 @@ internal static readonly (string Name, string DetectToken, string? VersionToken) ("Netscape", "Netscape", null), ("OmniWeb", "OmniWeb", null), ("Safari", "Version/", "Version/"), - ("Mozilla", "Mozilla", null), ("Konqueror", "Konqueror", null), ("iCab", "icab", null), ("Lynx", "Lynx", null), diff --git a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs index 1d4a92c..b778535 100644 --- a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs +++ b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs @@ -173,4 +173,54 @@ public void BotTests(string ua, string name) Assert.False(uaInfo.IsMobile()); Assert.True(uaInfo.IsRobot()); } + + [Theory] + [InlineData("")] + [InlineData("???")] + [InlineData("NotAUserAgent")] + [InlineData("Mozilla")] + [InlineData("Mozilla/")] + [InlineData("()")] + [InlineData("UserAgent/")] + [InlineData("Bot/123 (")] + [InlineData("123456")] + [InlineData("curl")] + [InlineData("invalid/useragent")] + [InlineData("Mozilla (Windows)")] + [InlineData("Chrome/ABC")] + [InlineData(";;!!##")] + [InlineData("Safari/ ")] + [InlineData("Opera( )")] + [InlineData("Mozilla/5.0 (X11; ) Gecko")] + [InlineData("FakeUA/1.0 (Test)???")] + [InlineData("Mozilla/ (iPhone; U; CPU iPhone OS like Mac OS X) AppleWebKit/ (KHTML, like Gecko) Version/ Mobile/ Safari/")] + [InlineData("Mozzila/5.0 (Windows NT 10.0; Win64; x64)")] + [InlineData("Chorme/91.0.4472.124 (Windows NT 10.0; Win64; x64)")] + [InlineData("FireFoxx/89.0 (Macintosh; Intel Mac OS X 10_15_7)")] + [InlineData("Safarii/14.1 (iPhone; CPU iPhone OS 14_6 like Mac OS X)")] + [InlineData("InternetExploder/11.0 (Windows NT 6.1; WOW64)")] + [InlineData("Bravee/1.25.72 (Windows NT 10.0; Win64; x64)")] + [InlineData("Mozzila/5.0 (X11; Ubuntu; Linux x86_64; rv:89.0)")] + [InlineData("Chromee/99.0.4758.102 (X11; Linux x86_64)")] + [InlineData("FirreFox/100.0 (Windows NT 10.0; rv:100.0)")] + [InlineData("Saffari/605.1.15 (iPad; CPU OS 14_6 like Mac OS X)")] + [InlineData("Edgg/103.0.1264.37 (Macintosh; Intel Mac OS X 11_5_2)")] + [InlineData("Chorome/91.0.4472.124 (Linux; Android 10; SM-G973F)")] + [InlineData("Edgee/18.18363 (Windows 10 1909; Win64; x64)")] + public void InvalidUserAgent(string userAgent) + { + HttpUserAgentInformation info = HttpUserAgentInformation.Parse(userAgent); + + // Invalid or malformed UAs must be classified as Unknown + Assert.Equal(HttpUserAgentType.Unknown, info.Type); + Assert.Null(info.Name); + Assert.Null(info.Version); + + // Parser trims input via Cleanup, so compare to trimmed UA + Assert.Equal(userAgent.Trim(), info.UserAgent); + + // Should not be considered a browser or a robot + Assert.False(info.IsBrowser()); + Assert.False(info.IsRobot()); + } } From b1b98ccbe0bbeea8dfa8f3a94a5d519549490ad3 Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Mon, 25 Aug 2025 13:28:55 +0200 Subject: [PATCH 08/27] feat(tests): add coverage settings and new unit tests (#77) --- Directory.Build.props | 26 +++++++ Directory.Packages.props | 36 ++++----- .../HttpContextExtensionsTests.cs | 37 +++++++++ ...serAgentParser.AspNetCore.UnitTests.csproj | 7 ++ ...erAgentParser.MemoryCache.UnitTests.csproj | 7 ++ ...rserMemoryCachedProviderAdditionalTests.cs | 31 ++++++++ .../HttpUserAgentParser.UnitTests.csproj | 7 ++ .../HttpUserAgentParserTests.cs | 75 +++++++++++++++++++ 8 files changed, 205 insertions(+), 21 deletions(-) create mode 100644 tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpContextExtensionsTests.cs create mode 100644 tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderAdditionalTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 749af9f..66c247a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -68,6 +68,7 @@ + @@ -88,6 +89,31 @@ + + + true + lcov,opencover,cobertura + $(MSBuildThisFileDirectory)TestResults/coverage/$(MSBuildProjectName). + GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute + **/*Program.cs;**/*Startup.cs;**/*GlobalUsings.cs + true + + 100 + line + total + + + + + [MyCSharp.HttpUserAgentParser]* + + + [MyCSharp.HttpUserAgentParser.MemoryCache]* + + + [MyCSharp.HttpUserAgentParser.AspNetCore]* + + all diff --git a/Directory.Packages.props b/Directory.Packages.props index eaa97d7..ef3e22e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,77 +2,71 @@ true - - - - - - - + + - - + all runtime; build; native; contentfiles; analyzers - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + all runtime; build; native; contentfiles; analyzers - + all runtime; build; native; contentfiles; analyzers - + all runtime; build; native; contentfiles; analyzers - + all runtime; build; native; contentfiles; analyzers - + all runtime; build; native; contentfiles; analyzers - + all runtime; build; native; contentfiles; analyzers - + all runtime; build; native; contentfiles; analyzers diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpContextExtensionsTests.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpContextExtensionsTests.cs new file mode 100644 index 0000000..065cfb7 --- /dev/null +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpContextExtensionsTests.cs @@ -0,0 +1,37 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using Microsoft.AspNetCore.Http; +using MyCSharp.HttpUserAgentParser.AspNetCore; +using MyCSharp.HttpUserAgentParser.Providers; +using NSubstitute; +using Xunit; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests; + +public class HttpContextExtensionsTests +{ + [Fact] + public void GetUserAgentString_Returns_Value_When_Present() + { + HttpContext ctx = HttpContextTestHelpers.GetHttpContext("UA"); + Assert.Equal("UA", ctx.GetUserAgentString()); + } + + [Fact] + public void GetUserAgentString_Returns_Null_When_Absent() + { + DefaultHttpContext ctx = new(); + Assert.Null(ctx.GetUserAgentString()); + } + + [Fact] + public void Accessor_Get_Returns_Null_When_Header_Missing() + { + var provider = Substitute.For(); + HttpUserAgentParserAccessor accessor = new(provider); + DefaultHttpContext ctx = new(); + + Assert.Null(accessor.Get(ctx)); + provider.DidNotReceiveWithAnyArgs().Parse(default!); + } +} diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpUserAgentParser.AspNetCore.UnitTests.csproj b/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpUserAgentParser.AspNetCore.UnitTests.csproj index 269119a..f24713d 100644 --- a/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpUserAgentParser.AspNetCore.UnitTests.csproj +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/HttpUserAgentParser.AspNetCore.UnitTests.csproj @@ -13,4 +13,11 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParser.MemoryCache.UnitTests.csproj b/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParser.MemoryCache.UnitTests.csproj index b68d12f..4c330fe 100644 --- a/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParser.MemoryCache.UnitTests.csproj +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParser.MemoryCache.UnitTests.csproj @@ -13,4 +13,11 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderAdditionalTests.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderAdditionalTests.cs new file mode 100644 index 0000000..45e9753 --- /dev/null +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/HttpUserAgentParserMemoryCachedProviderAdditionalTests.cs @@ -0,0 +1,31 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using Microsoft.Extensions.Caching.Memory; +using Xunit; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests; + +public class HttpUserAgentParserMemoryCachedProviderAdditionalTests +{ + [Fact] + public void Options_Defaults_Are_Set() + { + HttpUserAgentParserMemoryCachedProviderOptions options = new(); + Assert.NotNull(options.CacheOptions); + Assert.NotNull(options.CacheEntryOptions); + Assert.True(options.CacheOptions.SizeLimit is null || options.CacheOptions.SizeLimit >= 0); + Assert.NotEqual(default, options.CacheEntryOptions.SlidingExpiration); + } + + [Fact] + public void Provider_Caches_Entries_And_Resolves_Twice() + { + HttpUserAgentParserMemoryCachedProvider provider = new(new HttpUserAgentParserMemoryCachedProviderOptions(new MemoryCacheOptions { SizeLimit = 10 })); + string ua = "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36"; + HttpUserAgentInformation a = provider.Parse(ua); + HttpUserAgentInformation b = provider.Parse(ua); + + Assert.Equal(a.Name, b.Name); + Assert.Equal(a.Version, b.Version); + } +} diff --git a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParser.UnitTests.csproj b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParser.UnitTests.csproj index cdf4e3d..7366e0c 100644 --- a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParser.UnitTests.csproj +++ b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParser.UnitTests.csproj @@ -12,4 +12,11 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs index b778535..972f42a 100644 --- a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs +++ b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs @@ -223,4 +223,79 @@ public void InvalidUserAgent(string userAgent) Assert.False(info.IsBrowser()); Assert.False(info.IsRobot()); } + + [Fact] + public void Cleanup_Trims_Input() + { + string input = " Mozilla/5.0 "; + Assert.Equal("Mozilla/5.0", HttpUserAgentParser.Cleanup(input)); + } + + [Fact] + public void TryGetPlatform_True_And_False() + { + bool ok = HttpUserAgentParser.TryGetPlatform("Mozilla/5.0 (Windows NT 10.0)", out HttpUserAgentPlatformInformation? platform); + Assert.True(ok); + Assert.NotNull(platform); + Assert.Equal(HttpUserAgentPlatformType.Windows, platform!.Value.PlatformType); + + ok = HttpUserAgentParser.TryGetPlatform("UnknownAgent", out platform); + Assert.False(ok); + Assert.Null(platform); + } + + [Fact] + public void TryGetRobot_True_And_False() + { + bool ok = HttpUserAgentParser.TryGetRobot("Googlebot/2.1 (+http://www.google.com/bot.html)", out string? robot); + Assert.True(ok); + Assert.Equal("Googlebot", robot); + + ok = HttpUserAgentParser.TryGetRobot("NoBotHere", out robot); + Assert.False(ok); + Assert.Null(robot); + } + + [Fact] + public void TryGetMobileDevice_True_And_False() + { + bool ok = HttpUserAgentParser.TryGetMobileDevice("(iPhone; CPU iPhone OS)", out string? device); + Assert.True(ok); + Assert.Equal("Apple iPhone", device); + + ok = HttpUserAgentParser.TryGetMobileDevice("Desktop Machine", out device); + Assert.False(ok); + Assert.Null(device); + } + + [Fact] + public void TryGetBrowser_False_When_Token_Without_Slash() + { + // Contains DetectToken (Edg) but not followed by '/', should be ignored by fast-path and no regex fallback here + (string Name, string? Version)? browser; + bool ok = HttpUserAgentParser.TryGetBrowser("Mozilla Edg 123 something", out browser); + Assert.False(ok); + Assert.Null(browser); + } + + [Fact] + public void GetBrowser_Trident_Without_RV_Falls_Back_To_Detect_Token() + { + // Trident present but no rv:, fallback should extract version after DetectToken (Trident/7.0) + (string Name, string? Version)? browser = HttpUserAgentParser.GetBrowser("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Trident/7.0 like Gecko"); + Assert.NotNull(browser); + Assert.Equal("Internet Explorer", browser!.Value.Name); + Assert.Equal("7.0", browser.Value.Version); + } + + [Fact] + public void GetBrowser_LongToken_NoDigits_Within_Window_Does_Not_Parse_Version() + { + // Build UA: Detect token present (Chrome), but after '/' there are no digits within first 200 chars + string longJunk = new('a', 200); + string ua = $"Mozilla/5.0 Chrome/{longJunk} versionafterwindow1.2"; + + (string Name, string? Version)? browser = HttpUserAgentParser.GetBrowser(ua); + Assert.Null(browser); // Should fail to extract version and continue, ending with no browser match + } } From 4a82130c94a2b8373c6fc3e2ff7a4e06b9e8a317 Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Thu, 28 Aug 2025 18:44:16 +0200 Subject: [PATCH 09/27] fix(project): update authors list for consistency (#78) --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 66c247a..9acd0dd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ MyCSharp.HttpUserAgentParser - MyCSharp.de, Benjamin Abt, Günther Foidl and Contributors + MyCSharp, BenjaminAbt, gfoidl MyCSharp.de From 5ad9c0053f0193bb344b22c81c2cc3810300b670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=BCnther=20Foidl?= Date: Sun, 5 Oct 2025 12:54:23 +0200 Subject: [PATCH 10/27] Vectorized HttpUserAgentParser.TryExtractVersion (#79) * Vectorized HttpUserAgentParser.TryExtractVersion * Added a comment --- .../HttpUserAgentParser.cs | 147 ++++++++++++++---- src/HttpUserAgentParser/VectorExtensions.cs | 78 ++++++++++ 2 files changed, 197 insertions(+), 28 deletions(-) create mode 100644 src/HttpUserAgentParser/VectorExtensions.cs diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index 3e6cf6b..7b1e419 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -1,7 +1,10 @@ // Copyright © https://myCSharp.de - all rights reserved +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; namespace MyCSharp.HttpUserAgentParser; @@ -206,45 +209,133 @@ private static bool TryExtractVersion(ReadOnlySpan haystack, out Range ran { range = default; - // Limit search window to avoid scanning entire UA string unnecessarily - const int Window = 128; - if (haystack.Length > Window) - { - haystack = haystack.Slice(0, Window); - } + // Vectorization is used in a optimistic way and specialized to common (trimmed down) user agents. + // When the first two char-vectors don't yield any success, we fall back to the scalar path. + // This penalized not found versions, but has an advantage for found versions. + // Vector512 is left out, because there are no common inputs with length 128 or more. + // + // Two short (same size as char) vectors are read, then packed to byte vectors on which the + // operation is done. For short / chart the higher byte is not of interest and zero or outside + // the target characters, thus with bytes we can process twice as much elements at once. - // Find first digit - int start = -1; - for (int i = 0; i < haystack.Length; i++) + if (Vector256.IsHardwareAccelerated && haystack.Length >= 2 * Vector256.Count) { - char c = haystack[i]; - if (c >= '0' && c <= '9') + ref char ptr = ref MemoryMarshal.GetReference(haystack); + + Vector256 vec = ptr.ReadVector256AsBytes(0); + Vector256 between0and9 = Vector256.LessThan(vec - Vector256.Create((byte)'0'), Vector256.Create((byte)('9' - '0' + 1))); + + if (between0and9 == Vector256.Zero) { - start = i; - break; + goto Scalar; } - } - if (start < 0) + uint bitMask = between0and9.ExtractMostSignificantBits(); + int idx = (int)uint.TrailingZeroCount(bitMask); + Debug.Assert(idx is >= 0 and <= 32); + int start = idx; + + Vector256 byteMask = between0and9 | Vector256.Equals(vec, Vector256.Create((byte)'.')); + byteMask = ~byteMask; + + if (byteMask == Vector256.Zero) + { + goto Scalar; + } + + bitMask = byteMask.ExtractMostSignificantBits(); + bitMask >>= start; + + idx = start + (int)uint.TrailingZeroCount(bitMask); + Debug.Assert(idx is >= 0 and <= 32); + int end = idx; + + range = new Range(start, end); + return true; + } + else if (Vector128.IsHardwareAccelerated && haystack.Length >= 2 * Vector128.Count) { - // No digit found => no version - return false; + ref char ptr = ref MemoryMarshal.GetReference(haystack); + + Vector128 vec = ptr.ReadVector128AsBytes(0); + Vector128 between0and9 = Vector128.LessThan(vec - Vector128.Create((byte)'0'), Vector128.Create((byte)('9' - '0' + 1))); + + if (between0and9 == Vector128.Zero) + { + goto Scalar; + } + + uint bitMask = between0and9.ExtractMostSignificantBits(); + int idx = (int)uint.TrailingZeroCount(bitMask); + Debug.Assert(idx is >= 0 and <= 16); + int start = idx; + + Vector128 byteMask = between0and9 | Vector128.Equals(vec, Vector128.Create((byte)'.')); + byteMask = ~byteMask; + + if (byteMask == Vector128.Zero) + { + goto Scalar; + } + + bitMask = byteMask.ExtractMostSignificantBits(); + bitMask >>= start; + + idx = start + (int)uint.TrailingZeroCount(bitMask); + Debug.Assert(idx is >= 0 and <= 16); + int end = idx; + + range = new Range(start, end); + return true; } - // Consume digits and dots after first digit - int end = start + 1; - while (end < haystack.Length) + Scalar: { - char c = haystack[end]; - if (!((c >= '0' && c <= '9') || c == '.')) + // Limit search window to avoid scanning entire UA string unnecessarily + const int Windows = 128; + if (haystack.Length > Windows) { - break; + haystack = haystack.Slice(0, Windows); + } + + int start = -1; + int i = 0; + + for (; i < haystack.Length; ++i) + { + char c = haystack[i]; + if (char.IsBetween(c, '0', '9')) + { + start = i; + break; + } + } + + if (start < 0) + { + // No digit found => no version + return false; + } + + haystack = haystack.Slice(i + 1); + for (i = 0; i < haystack.Length; ++i) + { + char c = haystack[i]; + if (!(char.IsBetween(c, '0', '9') || c == '.')) + { + break; + } } - end++; - } - // Create exclusive end range - range = new Range(start, end); - return true; + i += start + 1; // shift back the previous domain + + if (i == start) + { + return false; + } + + range = new Range(start, i); + return true; + } } } diff --git a/src/HttpUserAgentParser/VectorExtensions.cs b/src/HttpUserAgentParser/VectorExtensions.cs new file mode 100644 index 0000000..c8547d0 --- /dev/null +++ b/src/HttpUserAgentParser/VectorExtensions.cs @@ -0,0 +1,78 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Runtime.CompilerServices; +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.Arm; +using System.Runtime.Intrinsics.X86; + +namespace MyCSharp.HttpUserAgentParser; + +internal static class VectorExtensions +{ + extension(ref char c) + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Vector128 ReadVector128AsBytes(int offset) + { + ref short ptr = ref Unsafe.As(ref c); + +#if NET10_0_OR_GREATER + return Vector128.NarrowWithSaturation( + Vector128.LoadUnsafe(ref ptr, (uint)offset), + Vector128.LoadUnsafe(ref ptr, (uint)(offset + Vector128.Count)) + ).AsByte(); +#else + if (Sse2.IsSupported) + { + return Sse2.PackUnsignedSaturate( + Vector128.LoadUnsafe(ref ptr, (uint)offset), + Vector128.LoadUnsafe(ref ptr, (uint)(offset + Vector128.Count))); + } + else if (AdvSimd.Arm64.IsSupported) + { + return AdvSimd.Arm64.UnzipEven( + Vector128.LoadUnsafe(ref ptr, (uint)offset).AsByte(), + Vector128.LoadUnsafe(ref ptr, (uint)(offset + Vector128.Count)).AsByte()); + } + else + { + return Vector128.Narrow( + Vector128.LoadUnsafe(ref ptr, (uint)offset), + Vector128.LoadUnsafe(ref ptr, (uint)(offset + Vector128.Count)) + ).AsByte(); + } +#endif + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Vector256 ReadVector256AsBytes(int offset) + { + ref short ptr = ref Unsafe.As(ref c); + +#if NET10_0_OR_GREATER + return Vector256.NarrowWithSaturation( + Vector256.LoadUnsafe(ref ptr, (uint)offset), + Vector256.LoadUnsafe(ref ptr, (uint)offset + (uint)Vector256.Count) + ).AsByte(); +#else + if (Avx2.IsSupported) + { + Vector256 tmp = Avx2.PackUnsignedSaturate( + Vector256.LoadUnsafe(ref ptr, (uint)offset), + Vector256.LoadUnsafe(ref ptr, (uint)offset + (uint)Vector256.Count)); + + Vector256 tmp1 = Avx2.Permute4x64(tmp.AsInt64(), 0b_11_01_10_00); + + return tmp1.AsByte(); + } + else + { + return Vector256.Narrow( + Vector256.LoadUnsafe(ref ptr, (uint)offset), + Vector256.LoadUnsafe(ref ptr, (uint)offset + (uint)Vector256.Count) + ).AsByte(); + } +#endif + } + } +} From 64d6088e9300bf00014ff3d7592c62a44e1e56c9 Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Wed, 24 Dec 2025 13:38:19 +0100 Subject: [PATCH 11/27] feat(ci): add main build, PR validation, and release workflows (#81) --- .github/workflows/ci.yml | 24 ----- .github/workflows/main-build.yml | 127 ++++++++++++++++++++++++++ .github/workflows/pr-validation.yml | 46 ++++++++++ .github/workflows/release-publish.yml | 75 +++++++++++++++ 4 files changed, 248 insertions(+), 24 deletions(-) delete mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/main-build.yml create mode 100644 .github/workflows/pr-validation.yml create mode 100644 .github/workflows/release-publish.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 558da40..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: NET - -on: - push: - branches: - - main - pull_request: - branches: - - main - release: - types: [published] - -jobs: - build: - uses: mycsharp/github-actions/.github/workflows/dotnet-nuget-build-multi-sdk.yml@main - with: - configuration: Release - dotnet-sdks: | - 8.0.x - 9.0.x - 10.0.x - - secrets: - NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} diff --git a/.github/workflows/main-build.yml b/.github/workflows/main-build.yml new file mode 100644 index 0000000..749a9c2 --- /dev/null +++ b/.github/workflows/main-build.yml @@ -0,0 +1,127 @@ +name: Main Build + +on: + push: + branches: + - main + +permissions: + contents: write + packages: write + +jobs: + build-and-pack: + name: Build, Pack and Create Draft Release + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Required for GitVersion + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --configuration Release --no-build --verbosity normal + + - name: Pack NuGet packages + run: dotnet pack --configuration Release --no-build --output ./artifacts + + - name: Get version from packages + id: get-version + run: | + # Extract version from the first package + VERSION=$(ls ./artifacts/*.nupkg | head -1 | sed -n 's/.*\.MyCSharp\.HttpUserAgentParser\.\([0-9]\+\.[0-9]\+\.[0-9]\+.*\)\.nupkg/\1/p') + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Version: $VERSION" + + - name: Check for existing draft release + id: check-draft + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + DRAFT_RELEASE=$(gh release list --limit 100 --json isDraft,name,tagName | jq -r '.[] | select(.isDraft == true) | .tagName' | head -1) + if [ -n "$DRAFT_RELEASE" ]; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "tag=$DRAFT_RELEASE" >> $GITHUB_OUTPUT + echo "Found existing draft release: $DRAFT_RELEASE" + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "No existing draft release found" + fi + + - name: Delete existing draft release + if: steps.check-draft.outputs.exists == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "Deleting existing draft release: ${{ steps.check-draft.outputs.tag }}" + gh release delete ${{ steps.check-draft.outputs.tag }} --yes --cleanup-tag || true + + - name: Create draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ steps.get-version.outputs.version }}" + TAG="v${VERSION}" + + # Create release notes + cat > release-notes.md << 'EOF' + ## What's Changed + + This is an automated draft release created from the main branch. + + ### Packages + + The following NuGet packages are included in this release: + + EOF + + # List all packages + for file in ./artifacts/*.nupkg; do + filename=$(basename "$file") + echo "- \`$filename\`" >> release-notes.md + done + + cat >> release-notes.md << 'EOF' + + ### Installation + + ```bash + dotnet add package MyCSharp.HttpUserAgentParser + dotnet add package MyCSharp.HttpUserAgentParser.AspNetCore + dotnet add package MyCSharp.HttpUserAgentParser.MemoryCache + ``` + + **Full Changelog**: https://github.com/${{ github.repository }}/commits/${{ github.sha }} + EOF + + # Create draft release + gh release create "$TAG" \ + ./artifacts/*.nupkg \ + --draft \ + --title "Release $VERSION" \ + --notes-file release-notes.md \ + --target ${{ github.sha }} + + echo "Created draft release: $TAG" + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: ./artifacts/*.nupkg + retention-days: 30 diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000..bf1f32f --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,46 @@ +name: PR Validation + +on: + pull_request: + branches: + - main + +permissions: + contents: read + pull-requests: read + +jobs: + validate: + name: Build and Test + runs-on: ubuntu-latest + + strategy: + matrix: + dotnet-version: ['8.0.x', '9.0.x', '10.0.x'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Required for GitVersion + + - name: Setup .NET ${{ matrix.dotnet-version }} + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ matrix.dotnet-version }} + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.dotnet-version }} + path: '**/TestResults/**/*.trx' diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 0000000..b6dc4a3 --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,75 @@ +name: Publish Release + +on: + release: + types: [published] + +permissions: + contents: read + packages: write + +jobs: + publish-nuget: + name: Publish to NuGet.org + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --configuration Release --no-build --verbosity normal + + - name: Pack NuGet packages + run: dotnet pack --configuration Release --no-build --output ./artifacts + + - name: Verify packages + run: | + echo "Packages to be published:" + ls -la ./artifacts/*.nupkg + + # Verify package count + PACKAGE_COUNT=$(ls ./artifacts/*.nupkg | wc -l) + if [ "$PACKAGE_COUNT" -eq 0 ]; then + echo "Error: No packages found!" + exit 1 + fi + + echo "Found $PACKAGE_COUNT package(s) to publish" + + - name: Publish to NuGet.org + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + for package in ./artifacts/*.nupkg; do + echo "Publishing $package to NuGet.org..." + dotnet nuget push "$package" \ + --api-key "$NUGET_API_KEY" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate + done + + echo "All packages published successfully!" + + - name: Upload published packages as artifacts + uses: actions/upload-artifact@v4 + with: + name: published-nuget-packages + path: ./artifacts/*.nupkg + retention-days: 90 From fd0094ce8164cd5dc6d804cd0cfa33e33664b323 Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Wed, 24 Dec 2025 14:34:03 +0100 Subject: [PATCH 12/27] feat(parser): enhance user agent parsing performance (#80) --- Directory.Build.props | 11 ++- Directory.Packages.props | 34 ++++----- README.md | 40 +++++----- global.json | 2 +- .../HttpUserAgentParser.cs | 6 +- .../HttpUserAgentStatics.cs | 75 +++++++++++-------- 6 files changed, 96 insertions(+), 72 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 9acd0dd..5df9af5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -40,6 +40,12 @@ true + + + true + true + + false true @@ -97,8 +103,9 @@ GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute **/*Program.cs;**/*Startup.cs;**/*GlobalUsings.cs true - - 100 + + + 96 line total diff --git a/Directory.Packages.props b/Directory.Packages.props index ef3e22e..61d389f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,22 +6,22 @@ - + - + - - + + - + @@ -29,46 +29,46 @@ runtime; build; native; contentfiles; analyzers - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers - + all runtime; build; native; contentfiles; analyzers - + all runtime; build; native; contentfiles; analyzers - + all runtime; build; native; contentfiles; analyzers - + all runtime; build; native; contentfiles; analyzers - + all runtime; build; native; contentfiles; analyzers - + all runtime; build; native; contentfiles; analyzers - + \ No newline at end of file diff --git a/README.md b/README.md index fe6a97b..599a556 100644 --- a/README.md +++ b/README.md @@ -113,30 +113,30 @@ public void MyMethod(IHttpUserAgentParserAccessor parserAccessor) ## Benchmark ```shell -BenchmarkDotNet v0.14.0, Windows 10 (10.0.19045.6216/22H2/2022Update) -AMD Ryzen 9 9950X, 1 CPU, 32 logical and 16 physical cores -.NET SDK 10.0.100-preview.7.25380.108 - [Host] : .NET 10.0.0 (10.0.25.38108), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI - ShortRun : .NET 10.0.0 (10.0.25.38108), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI +BenchmarkDotNet v0.15.8, Windows 10 (10.0.19045.6691/22H2/2022Update) +AMD Ryzen 9 9950X 4.30GHz, 1 CPU, 32 logical and 16 physical cores +.NET SDK 10.0.101 + [Host] : .NET 10.0.1 (10.0.1, 10.0.125.57005), X64 RyuJIT x86-64-v4 + ShortRun : .NET 10.0.1 (10.0.1, 10.0.125.57005), X64 RyuJIT x86-64-v4 Job=ShortRun IterationCount=3 LaunchCount=1 WarmupCount=3 -| Method | Categories | Data | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio | -|------------------- |----------- |------------- |----------------:|-----------------:|---------------:|----------:|---------:|---------:|---------:|---------:|-----------:|------------:| -| MyCSharp | Basic | Chrome Win10 | 871.85 ns | 132.008 ns | 7.236 ns | 1.00 | 0.01 | 0.0029 | - | - | 48 B | 1.00 | -| UAParser | Basic | Chrome Win10 | 8,901,909.90 ns | 3,411,259.484 ns | 186,982.644 ns | 10,210.80 | 199.60 | 656.2500 | 578.1250 | 109.3750 | 11523310 B | 240,068.96 | -| DeviceDetector.NET | Basic | Chrome Win10 | 5,391,412.50 ns | 8,253,446.769 ns | 452,399.269 ns | 6,184.14 | 451.58 | 296.8750 | 125.0000 | 31.2500 | 5002239 B | 104,213.31 | -| | | | | | | | | | | | | | -| MyCSharp | Basic | Google-Bot | 158.80 ns | 19.584 ns | 1.073 ns | 1.00 | 0.01 | - | - | - | - | NA | -| UAParser | Basic | Google-Bot | 9,666,739.32 ns | 7,566,085.041 ns | 414,722.653 ns | 60,873.62 | 2,289.43 | 671.8750 | 656.2500 | 109.3750 | 11876998 B | NA | -| DeviceDetector.NET | Basic | Google-Bot | 6,106,666.41 ns | 593,634.990 ns | 32,539.137 ns | 38,455.05 | 285.97 | 539.0625 | 117.1875 | 23.4375 | 8817078 B | NA | -| | | | | | | | | | | | | | -| MyCSharp | Cached | Chrome Win10 | 26.43 ns | 0.132 ns | 0.007 ns | 1.00 | 0.00 | - | - | - | - | NA | -| UAParser | Cached | Chrome Win10 | 177,417.99 ns | 24,390.139 ns | 1,336.906 ns | 6,713.66 | 43.84 | 2.1973 | - | - | 37488 B | NA | -| | | | | | | | | | | | | | -| MyCSharp | Cached | Google-Bot | 17.03 ns | 1.835 ns | 0.101 ns | 1.00 | 0.01 | - | - | - | - | NA | -| UAParser | Cached | Google-Bot | 129,445.13 ns | 21,319.059 ns | 1,168.570 ns | 7,599.76 | 70.93 | 2.6855 | - | - | 45857 B | NA | +| Method | Categories | Data | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio | +|------------------- |----------- |------------- |----------------:|-----------------:|---------------:|----------:|--------:|---------:|---------:|---------:|-----------:|------------:| +| MyCSharp | Basic | Chrome Win10 | 939.54 ns | 113.807 ns | 6.238 ns | 1.00 | 0.01 | 0.0019 | - | - | 48 B | 1.00 | +| UAParser | Basic | Chrome Win10 | 9,120,055.21 ns | 2,108,412.449 ns | 115,569.201 ns | 9,707.23 | 120.28 | 671.8750 | 609.3750 | 109.3750 | 11659008 B | 242,896.00 | +| DeviceDetector.NET | Basic | Chrome Win10 | 5,099,680.21 ns | 5,313,448.322 ns | 291,248.033 ns | 5,428.01 | 270.28 | 296.8750 | 140.6250 | 31.2500 | 5034130 B | 104,877.71 | +| | | | | | | | | | | | | | +| MyCSharp | Basic | Google-Bot | 226.47 ns | 20.818 ns | 1.141 ns | 1.00 | 0.01 | - | - | - | - | NA | +| UAParser | Basic | Google-Bot | 9,007,285.42 ns | 491,694.016 ns | 26,951.408 ns | 39,772.36 | 202.28 | 687.5000 | 640.6250 | 125.0000 | 12015474 B | NA | +| DeviceDetector.NET | Basic | Google-Bot | 6,056,996.61 ns | 567,479.924 ns | 31,105.490 ns | 26,745.13 | 166.88 | 546.8750 | 132.8125 | 23.4375 | 8862491 B | NA | +| | | | | | | | | | | | | | +| MyCSharp | Cached | Chrome Win10 | 24.59 ns | 2.222 ns | 0.122 ns | 1.00 | 0.01 | - | - | - | - | NA | +| UAParser | Cached | Chrome Win10 | 162,917.93 ns | 36,544.250 ns | 2,003.114 ns | 6,625.90 | 76.03 | 2.1973 | - | - | 37488 B | NA | +| | | | | | | | | | | | | | +| MyCSharp | Cached | Google-Bot | 17.42 ns | 1.077 ns | 0.059 ns | 1.00 | 0.00 | - | - | - | - | NA | +| UAParser | Cached | Google-Bot | 126,321.45 ns | 3,171.908 ns | 173.863 ns | 7,253.51 | 23.01 | 2.6855 | - | - | 45856 B | NA | ``` ## Disclaimer diff --git a/global.json b/global.json index 6303b53..936a420 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "10.0.100-preview.3.25201.16" + "version": "10.0.101" } } diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index 7b1e419..410637a 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -144,9 +144,10 @@ public static bool TryGetBrowser(string userAgent, [NotNullWhen(true)] out (stri /// public static string? GetRobot(string userAgent) { + ReadOnlySpan ua = userAgent.AsSpan(); foreach ((string key, string value) in HttpUserAgentStatics.Robots) { - if (userAgent.Contains(key, StringComparison.OrdinalIgnoreCase)) + if (ContainsIgnoreCase(ua, key)) { return value; } @@ -169,9 +170,10 @@ public static bool TryGetRobot(string userAgent, [NotNullWhen(true)] out string? /// public static string? GetMobileDevice(string userAgent) { + ReadOnlySpan ua = userAgent.AsSpan(); foreach ((string key, string value) in HttpUserAgentStatics.Mobiles) { - if (userAgent.Contains(key, StringComparison.OrdinalIgnoreCase)) + if (ContainsIgnoreCase(ua, key)) { return value; } diff --git a/src/HttpUserAgentParser/HttpUserAgentStatics.cs b/src/HttpUserAgentParser/HttpUserAgentStatics.cs index 996abab..eead441 100644 --- a/src/HttpUserAgentParser/HttpUserAgentStatics.cs +++ b/src/HttpUserAgentParser/HttpUserAgentStatics.cs @@ -73,14 +73,30 @@ public static class HttpUserAgentStatics /// /// Fast-path platform token rules for zero-allocation Contains checks + /// Sorted by frequency for better performance (most common platforms first) /// internal static readonly (string Token, string Name, HttpUserAgentPlatformType PlatformType)[] s_platformRules = [ + // Most common: Windows (specific versions before generic) ("windows nt 10.0", "Windows 10", HttpUserAgentPlatformType.Windows), + ("windows nt 6.1", "Windows 7", HttpUserAgentPlatformType.Windows), ("windows nt 6.3", "Windows 8.1", HttpUserAgentPlatformType.Windows), ("windows nt 6.2", "Windows 8", HttpUserAgentPlatformType.Windows), - ("windows nt 6.1", "Windows 7", HttpUserAgentPlatformType.Windows), ("windows nt 6.0", "Windows Vista", HttpUserAgentPlatformType.Windows), + // Android (very common on mobile) + ("android", "Android", HttpUserAgentPlatformType.Android), + // iOS devices (very common) + ("iphone", "iOS", HttpUserAgentPlatformType.IOS), + ("ipad", "iOS", HttpUserAgentPlatformType.IOS), + ("ipod", "iOS", HttpUserAgentPlatformType.IOS), + // ChromeOS (must be before "os x" to avoid false match with "CrOS") + ("cros", "ChromeOS", HttpUserAgentPlatformType.ChromeOS), + // Mac OS (common) + ("os x", "Mac OS X", HttpUserAgentPlatformType.MacOS), + // Linux (common) + ("linux", "Linux", HttpUserAgentPlatformType.Linux), + // Other Windows versions + ("windows phone", "Windows Phone", HttpUserAgentPlatformType.Windows), ("windows nt 5.2", "Windows 2003", HttpUserAgentPlatformType.Windows), ("windows nt 5.1", "Windows XP", HttpUserAgentPlatformType.Windows), ("windows nt 5.0", "Windows 2000", HttpUserAgentPlatformType.Windows), @@ -92,20 +108,17 @@ internal static readonly (string Token, string Name, HttpUserAgentPlatformType P ("win98", "Windows 98", HttpUserAgentPlatformType.Windows), ("windows 95", "Windows 95", HttpUserAgentPlatformType.Windows), ("win95", "Windows 95", HttpUserAgentPlatformType.Windows), - ("windows phone", "Windows Phone", HttpUserAgentPlatformType.Windows), ("windows", "Unknown Windows OS", HttpUserAgentPlatformType.Windows), - ("android", "Android", HttpUserAgentPlatformType.Android), + // Less common platforms ("blackberry", "BlackBerry", HttpUserAgentPlatformType.BlackBerry), - ("iphone", "iOS", HttpUserAgentPlatformType.IOS), - ("ipad", "iOS", HttpUserAgentPlatformType.IOS), - ("ipod", "iOS", HttpUserAgentPlatformType.IOS), - ("cros", "ChromeOS", HttpUserAgentPlatformType.ChromeOS), - ("os x", "Mac OS X", HttpUserAgentPlatformType.MacOS), ("ppc mac", "Power PC Mac", HttpUserAgentPlatformType.MacOS), + ("debian", "Debian", HttpUserAgentPlatformType.Linux), ("freebsd", "FreeBSD", HttpUserAgentPlatformType.Linux), ("ppc", "Macintosh", HttpUserAgentPlatformType.Linux), - ("linux", "Linux", HttpUserAgentPlatformType.Linux), - ("debian", "Debian", HttpUserAgentPlatformType.Linux), + ("gnu", "GNU/Linux", HttpUserAgentPlatformType.Linux), + ("unix", "Unknown Unix OS", HttpUserAgentPlatformType.Unix), + ("openbsd", "OpenBSD", HttpUserAgentPlatformType.Unix), + ("symbian", "Symbian OS", HttpUserAgentPlatformType.Symbian), ("sunos", "Sun Solaris", HttpUserAgentPlatformType.Generic), ("beos", "BeOS", HttpUserAgentPlatformType.Generic), ("apachebench", "ApacheBench", HttpUserAgentPlatformType.Generic), @@ -115,10 +128,6 @@ internal static readonly (string Token, string Name, HttpUserAgentPlatformType P ("hp-ux", "HP-UX", HttpUserAgentPlatformType.Windows), ("netbsd", "NetBSD", HttpUserAgentPlatformType.Generic), ("bsdi", "BSDi", HttpUserAgentPlatformType.Generic), - ("openbsd", "OpenBSD", HttpUserAgentPlatformType.Unix), - ("gnu", "GNU/Linux", HttpUserAgentPlatformType.Linux), - ("unix", "Unknown Unix OS", HttpUserAgentPlatformType.Unix), - ("symbian", "Symbian OS", HttpUserAgentPlatformType.Symbian), ]; // Precompiled platform regex map to attach to PlatformInformation without per-call allocations @@ -181,42 +190,48 @@ private static Regex CreateDefaultBrowserRegex(string key) /// /// Fast-path browser token rules. If these fail to extract a version, code will fall back to regex rules. + /// Sorted by specificity first, then frequency - more specific tokens must come before generic ones + /// (e.g., Edge/Opera before Chrome, since Edge/Opera UAs contain "Chrome") /// internal static readonly (string Name, string DetectToken, string? VersionToken)[] s_browserRules = [ + // Most specific browsers first (contain Chrome/Mozilla in their UA) ("Opera", "OPR", null), - ("Flock", "Flock", null), + ("Opera", "Opera", "Version/"), + ("Opera", "Opera", null), + ("Edge", "Edg", null), ("Edge", "Edge", null), - ("Edge", "EdgiOS", null), ("Edge", "EdgA", null), - ("Edge", "Edg", null), - ("Vivaldi", "Vivaldi", null), + ("Edge", "EdgiOS", null), ("Brave", "Brave Chrome", null), + ("Vivaldi", "Vivaldi", null), + ("Flock", "Flock", null), + // Common browsers ("Chrome", "Chrome", null), ("Chrome", "CriOS", null), - ("Opera", "Opera", "Version/"), - ("Opera", "Opera", null), + ("Safari", "Version/", "Version/"), + ("Firefox", "Firefox", null), + ("Firefox", "FxiOS", null), + // Internet Explorer (legacy but still in use - MSIE before Trident to avoid false matches) ("Internet Explorer", "MSIE", "MSIE "), - ("Internet Explorer", "Internet Explorer", null), ("Internet Explorer", "Trident", "rv:"), + ("Internet Explorer", "Internet Explorer", null), + // Less common browsers + ("Maxthon", "Maxthon", null), + ("Netscape", "Netscape", null), + ("Konqueror", "Konqueror", null), + ("OmniWeb", "OmniWeb", null), ("Shiira", "Shiira", null), - ("Firefox", "Firefox", null), - ("Firefox", "FxiOS", null), ("Chimera", "Chimera", null), - ("Phoenix", "Phoenix", null), - ("Firebird", "Firebird", null), ("Camino", "Camino", null), - ("Netscape", "Netscape", null), - ("OmniWeb", "OmniWeb", null), - ("Safari", "Version/", "Version/"), - ("Konqueror", "Konqueror", null), + ("Firebird", "Firebird", null), + ("Phoenix", "Phoenix", null), ("iCab", "icab", null), ("Lynx", "Lynx", null), ("Links", "Links", null), ("HotJava", "hotjava", null), ("Amaya", "amaya", null), ("IBrowse", "IBrowse", null), - ("Maxthon", "Maxthon", null), ("Apple iPod", "ipod touch", null), ("Ubuntu Web Browser", "Ubuntu", null), ]; From cac961645b9cd93bb2394a99650be3dd03be49a8 Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Wed, 24 Dec 2025 14:49:04 +0100 Subject: [PATCH 13/27] feat(ci): update workflows for build and release process (#82) --- .github/release-drafter.yml | 110 +++++++++++++++++++ .github/workflows/build-and-test.yml | 89 ++++++++++++++++ .github/workflows/main-build.yml | 152 ++++++++++----------------- .github/workflows/pr-validation.yml | 54 ++++------ 4 files changed, 277 insertions(+), 128 deletions(-) create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/build-and-test.yml diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..6d2420b --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,110 @@ +# Release Drafter Configuration +# Documentation: https://github.com/release-drafter/release-drafter + +name-template: 'Version $RESOLVED_VERSION' +tag-template: 'v$RESOLVED_VERSION' + +# Categories for organizing release notes +categories: + - title: '🚀 Features' + labels: + - 'feature' + - 'enhancement' + - title: '🐛 Bug Fixes' + labels: + - 'bug' + - 'fix' + - title: '🔧 Maintenance' + labels: + - 'maintenance' + - 'chore' + - 'refactor' + - 'dependencies' + - title: '📚 Documentation' + labels: + - 'documentation' + - 'docs' + - title: '⚡ Performance' + labels: + - 'performance' + - title: '🔒 Security' + labels: + - 'security' + +# Exclude certain labels from release notes +exclude-labels: + - 'skip-changelog' + - 'wip' + +# Change template (how each PR is listed) +change-template: '- $TITLE @$AUTHOR (#$NUMBER)' +change-title-escapes: '\<*_&' # Escape special markdown characters + +# Template for the release body +template: | + ## What's Changed + + $CHANGES + + ## 📦 NuGet Packages + + The following packages are included in this release: + + - `MyCSharp.HttpUserAgentParser` + - `MyCSharp.HttpUserAgentParser.AspNetCore` + - `MyCSharp.HttpUserAgentParser.MemoryCache` + + ### Installation + + ```bash + dotnet add package MyCSharp.HttpUserAgentParser + dotnet add package MyCSharp.HttpUserAgentParser.AspNetCore + dotnet add package MyCSharp.HttpUserAgentParser.MemoryCache + ``` + + ## Contributors + + $CONTRIBUTORS + + --- + + **Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION + +# Automatically label PRs based on modified files +autolabeler: + - label: 'documentation' + files: + - '*.md' + - 'docs/**/*' + - label: 'bug' + branch: + - '/fix\/.+/' + title: + - '/fix/i' + - label: 'feature' + branch: + - '/feature\/.+/' + title: + - '/feature/i' + - label: 'dependencies' + files: + - '**/packages.lock.json' + - '**/*.csproj' + - 'Directory.Packages.props' + - 'Directory.Build.props' + - label: 'github-actions' + files: + - '.github/workflows/**/*' + - label: 'tests' + files: + - 'tests/**/*' + - '**/*Tests.cs' + - '**/*Test.cs' + - label: 'performance' + files: + - 'perf/**/*' + - '**/*Benchmark*.cs' + +# Version resolver (uses version from workflow input) +version-resolver: + default: patch diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..0e567c6 --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,89 @@ +name: Build and Test (Reusable) + +on: + workflow_call: + inputs: + dotnet-version: + description: '.NET version to use (can be multi-line for multiple versions)' + required: false + type: string + default: | + 8.0.x + 9.0.x + 10.0.x + configuration: + description: 'Build configuration' + required: false + type: string + default: 'Release' + upload-test-results: + description: 'Whether to upload test results as artifacts' + required: false + type: boolean + default: false + create-pack: + description: 'Whether to pack NuGet packages' + required: false + type: boolean + default: false + outputs: + version: + description: 'The calculated version from NBGV' + value: ${{ jobs.build.outputs.version }} + +jobs: + build: + name: Build and Test + runs-on: ubuntu-latest + outputs: + version: ${{ steps.nbgv.outputs.version }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Required for GitVersion + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ inputs.dotnet-version }} + + - name: Install Nerdbank.GitVersioning + run: dotnet tool install -g nbgv + + - name: Set version with NBGV + id: nbgv + run: | + nbgv get-version --format json > version.json + VERSION=$(nbgv get-version -v NuGetPackageVersion) + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Calculated version: $VERSION" + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration ${{ inputs.configuration }} --no-restore + + - name: Test + run: dotnet test --configuration ${{ inputs.configuration }} --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx" + + - name: Upload test results + if: always() && inputs.upload-test-results + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ inputs.dotnet-version }} + path: '**/TestResults/**/*.trx' + + - name: Pack NuGet packages + if: inputs.create-pack + run: dotnet pack --configuration ${{ inputs.configuration }} --no-build --output ./artifacts + + - name: Upload NuGet packages + if: inputs.create-pack + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: ./artifacts/*.nupkg + retention-days: 30 diff --git a/.github/workflows/main-build.yml b/.github/workflows/main-build.yml index 749a9c2..c0c71ec 100644 --- a/.github/workflows/main-build.yml +++ b/.github/workflows/main-build.yml @@ -10,118 +10,82 @@ permissions: packages: write jobs: - build-and-pack: - name: Build, Pack and Create Draft Release + build-and-test: + name: Build, Test and Pack + uses: ./.github/workflows/build-and-test.yml + with: + create-pack: true + + create-draft-release: + name: Create Draft Release + needs: build-and-test runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout code uses: actions/checkout@v4 - with: - fetch-depth: 0 # Required for GitVersion - - name: Setup .NET - uses: actions/setup-dotnet@v4 + - name: Download NuGet packages + uses: actions/download-artifact@v4 with: - dotnet-version: | - 8.0.x - 9.0.x - 10.0.x - - - name: Restore dependencies - run: dotnet restore - - - name: Build - run: dotnet build --configuration Release --no-restore - - - name: Test - run: dotnet test --configuration Release --no-build --verbosity normal - - - name: Pack NuGet packages - run: dotnet pack --configuration Release --no-build --output ./artifacts + name: nuget-packages + path: ./artifacts - - name: Get version from packages - id: get-version + - name: Check if tag exists + id: check-tag run: | - # Extract version from the first package - VERSION=$(ls ./artifacts/*.nupkg | head -1 | sed -n 's/.*\.MyCSharp\.HttpUserAgentParser\.\([0-9]\+\.[0-9]\+\.[0-9]\+.*\)\.nupkg/\1/p') - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Version: $VERSION" - - - name: Check for existing draft release - id: check-draft - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - DRAFT_RELEASE=$(gh release list --limit 100 --json isDraft,name,tagName | jq -r '.[] | select(.isDraft == true) | .tagName' | head -1) - if [ -n "$DRAFT_RELEASE" ]; then + TAG="v${{ needs.build-and-test.outputs.version }}" + if git rev-parse "$TAG" >/dev/null 2>&1; then echo "exists=true" >> $GITHUB_OUTPUT - echo "tag=$DRAFT_RELEASE" >> $GITHUB_OUTPUT - echo "Found existing draft release: $DRAFT_RELEASE" + echo "⚠️ Tag $TAG already exists" else echo "exists=false" >> $GITHUB_OUTPUT - echo "No existing draft release found" + echo "✅ Tag $TAG does not exist yet" fi - - name: Delete existing draft release - if: steps.check-draft.outputs.exists == 'true' + - name: Create/Update Draft Release + if: steps.check-tag.outputs.exists == 'false' + uses: release-drafter/release-drafter@v6 env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - echo "Deleting existing draft release: ${{ steps.check-draft.outputs.tag }}" - gh release delete ${{ steps.check-draft.outputs.tag }} --yes --cleanup-tag || true - - - name: Create draft release + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + config-name: release-drafter.yml + version: v${{ needs.build-and-test.outputs.version }} + tag: v${{ needs.build-and-test.outputs.version }} + name: Version ${{ needs.build-and-test.outputs.version }} + publish: false + prerelease: false + + - name: Upload packages to draft release + if: steps.check-tag.outputs.exists == 'false' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - VERSION="${{ steps.get-version.outputs.version }}" - TAG="v${VERSION}" - - # Create release notes - cat > release-notes.md << 'EOF' - ## What's Changed - - This is an automated draft release created from the main branch. - - ### Packages - - The following NuGet packages are included in this release: - - EOF - - # List all packages + TAG="v${{ needs.build-and-test.outputs.version }}" + # Wait a moment for the release to be created + sleep 2 + # Upload artifacts to the draft release for file in ./artifacts/*.nupkg; do - filename=$(basename "$file") - echo "- \`$filename\`" >> release-notes.md + gh release upload "$TAG" "$file" --clobber done + echo "✅ Uploaded NuGet packages to draft release" - cat >> release-notes.md << 'EOF' - - ### Installation - - ```bash - dotnet add package MyCSharp.HttpUserAgentParser - dotnet add package MyCSharp.HttpUserAgentParser.AspNetCore - dotnet add package MyCSharp.HttpUserAgentParser.MemoryCache - ``` - - **Full Changelog**: https://github.com/${{ github.repository }}/commits/${{ github.sha }} - EOF - - # Create draft release - gh release create "$TAG" \ - ./artifacts/*.nupkg \ - --draft \ - --title "Release $VERSION" \ - --notes-file release-notes.md \ - --target ${{ github.sha }} - - echo "Created draft release: $TAG" - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: nuget-packages - path: ./artifacts/*.nupkg - retention-days: 30 + - name: Summary + if: steps.check-tag.outputs.exists == 'false' + run: | + echo "✅ Draft release created/updated" >> $GITHUB_STEP_SUMMARY + echo "Version: v${{ needs.build-and-test.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Next steps:" >> $GITHUB_STEP_SUMMARY + echo "1. Go to [Releases](../../releases)" >> $GITHUB_STEP_SUMMARY + echo "2. Review the draft release" >> $GITHUB_STEP_SUMMARY + echo "3. Edit release notes if needed" >> $GITHUB_STEP_SUMMARY + echo "4. Publish release to trigger production deployment" >> $GITHUB_STEP_SUMMARY + + - name: Release already exists + if: steps.check-tag.outputs.exists == 'true' + run: | + echo "ℹ️ Release v${{ needs.build-and-test.outputs.version }} already exists" >> $GITHUB_STEP_SUMMARY + echo "No action taken" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index bf1f32f..dea70ab 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -10,37 +10,23 @@ permissions: pull-requests: read jobs: - validate: - name: Build and Test - runs-on: ubuntu-latest - - strategy: - matrix: - dotnet-version: ['8.0.x', '9.0.x', '10.0.x'] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Required for GitVersion - - - name: Setup .NET ${{ matrix.dotnet-version }} - uses: actions/setup-dotnet@v4 - with: - dotnet-version: ${{ matrix.dotnet-version }} - - - name: Restore dependencies - run: dotnet restore - - - name: Build - run: dotnet build --configuration Release --no-restore - - - name: Test - run: dotnet test --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx" - - - name: Upload test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-results-${{ matrix.dotnet-version }} - path: '**/TestResults/**/*.trx' + validate-dotnet-8: + name: Test on .NET 8.0 + uses: ./.github/workflows/build-and-test.yml + with: + dotnet-version: '8.0.x' + upload-test-results: true + + validate-dotnet-9: + name: Test on .NET 9.0 + uses: ./.github/workflows/build-and-test.yml + with: + dotnet-version: '9.0.x' + upload-test-results: true + + validate-dotnet-10: + name: Test on .NET 10.0 + uses: ./.github/workflows/build-and-test.yml + with: + dotnet-version: '10.0.x' + upload-test-results: true From b090c43f50df512d2884428d5170d971378ce63c Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Wed, 24 Dec 2025 14:58:11 +0100 Subject: [PATCH 14/27] fix(ci): correct version output in build workflow (#83) --- .github/workflows/build-and-test.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 0e567c6..54a3e7b 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -36,7 +36,7 @@ jobs: name: Build and Test runs-on: ubuntu-latest outputs: - version: ${{ steps.nbgv.outputs.version }} + version: ${{ steps.nbgv.outputs.SemVer2 }} steps: - name: Checkout code @@ -48,23 +48,23 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ inputs.dotnet-version }} + global-json-file: ./global.json - - name: Install Nerdbank.GitVersioning - run: dotnet tool install -g nbgv - - - name: Set version with NBGV + - name: Calculate Version with NBGV + uses: dotnet/nbgv@master id: nbgv + with: + setAllVars: true + + - name: Version Info run: | - nbgv get-version --format json > version.json - VERSION=$(nbgv get-version -v NuGetPackageVersion) - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Calculated version: $VERSION" + echo "Calculated version: ${{ steps.nbgv.outputs.SemVer2 }}" - name: Restore dependencies run: dotnet restore - name: Build - run: dotnet build --configuration ${{ inputs.configuration }} --no-restore + run: dotnet build --configuration ${{ inputs.configuration }} --no-restore /p:Version=${{ steps.nbgv.outputs.SemVer2 }} - name: Test run: dotnet test --configuration ${{ inputs.configuration }} --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx" @@ -78,7 +78,7 @@ jobs: - name: Pack NuGet packages if: inputs.create-pack - run: dotnet pack --configuration ${{ inputs.configuration }} --no-build --output ./artifacts + run: dotnet pack --configuration ${{ inputs.configuration }} --no-build --output ./artifacts /p:PackageVersion=${{ steps.nbgv.outputs.SemVer2 }} - name: Upload NuGet packages if: inputs.create-pack From 92f1d97213824603de056f9e8511095d2a9e7540 Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Wed, 24 Dec 2025 15:06:05 +0100 Subject: [PATCH 15/27] feat(release): remove old NuGet packages before upload (#84) --- .github/workflows/main-build.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main-build.yml b/.github/workflows/main-build.yml index c0c71ec..b416dfc 100644 --- a/.github/workflows/main-build.yml +++ b/.github/workflows/main-build.yml @@ -66,7 +66,16 @@ jobs: TAG="v${{ needs.build-and-test.outputs.version }}" # Wait a moment for the release to be created sleep 2 - # Upload artifacts to the draft release + + # Remove old NuGet packages from the draft release + echo "🗑️ Removing old NuGet packages..." + gh release view "$TAG" --json assets --jq '.assets[].name' | grep '\.nupkg$' | while read -r asset; do + echo "Deleting old package: $asset" + gh api --method DELETE "/repos/${{ github.repository }}/releases/assets/$(gh release view "$TAG" --json assets --jq ".assets[] | select(.name == \"$asset\") | .id")" || true + done + + # Upload new artifacts to the draft release + echo "📦 Uploading new NuGet packages..." for file in ./artifacts/*.nupkg; do gh release upload "$TAG" "$file" --clobber done From 18e5056f00f03b11b68212c19ca5a713f1e0a70c Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Wed, 24 Dec 2025 15:11:30 +0100 Subject: [PATCH 16/27] fix(release): improve deletion of old NuGet packages (#85) --- .github/workflows/main-build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main-build.yml b/.github/workflows/main-build.yml index b416dfc..5e93997 100644 --- a/.github/workflows/main-build.yml +++ b/.github/workflows/main-build.yml @@ -69,9 +69,9 @@ jobs: # Remove old NuGet packages from the draft release echo "🗑️ Removing old NuGet packages..." - gh release view "$TAG" --json assets --jq '.assets[].name' | grep '\.nupkg$' | while read -r asset; do - echo "Deleting old package: $asset" - gh api --method DELETE "/repos/${{ github.repository }}/releases/assets/$(gh release view "$TAG" --json assets --jq ".assets[] | select(.name == \"$asset\") | .id")" || true + gh release view "$TAG" --json assets -q '.assets[] | select(.name | endswith(".nupkg")) | "\(.id) \(.name)"' | while read -r asset_id asset_name; do + echo "Deleting old package: $asset_name (ID: $asset_id)" + gh api --method DELETE "/repos/${{ github.repository }}/releases/assets/$asset_id" || echo "Failed to delete $asset_name" done # Upload new artifacts to the draft release From 95d9a54b501ab0e23e72623ad392b8c40d858925 Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Wed, 24 Dec 2025 15:17:15 +0100 Subject: [PATCH 17/27] feat(ci): update build workflow for draft release process (#87) --- .github/workflows/main-build.yml | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/.github/workflows/main-build.yml b/.github/workflows/main-build.yml index 5e93997..63f3a00 100644 --- a/.github/workflows/main-build.yml +++ b/.github/workflows/main-build.yml @@ -45,6 +45,23 @@ jobs: echo "✅ Tag $TAG does not exist yet" fi + - name: Remove old packages from existing draft + if: steps.check-tag.outputs.exists == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="v${{ needs.build-and-test.outputs.version }}" + # Check if draft release already exists + if gh release view "$TAG" &>/dev/null; then + echo "🗑️ Removing old NuGet packages from existing draft..." + gh release view "$TAG" --json assets -q '.assets[] | select(.name | endswith(".nupkg")) | "\(.id) \(.name)"' | while read -r asset_id asset_name; do + echo "Deleting old package: $asset_name (ID: $asset_id)" + gh api --method DELETE "/repos/${{ github.repository }}/releases/assets/$asset_id" || echo "Failed to delete $asset_name" + done + else + echo "No existing draft release found" + fi + - name: Create/Update Draft Release if: steps.check-tag.outputs.exists == 'false' uses: release-drafter/release-drafter@v6 @@ -64,16 +81,9 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="v${{ needs.build-and-test.outputs.version }}" - # Wait a moment for the release to be created + # Wait a moment for the release to be created/updated sleep 2 - # Remove old NuGet packages from the draft release - echo "🗑️ Removing old NuGet packages..." - gh release view "$TAG" --json assets -q '.assets[] | select(.name | endswith(".nupkg")) | "\(.id) \(.name)"' | while read -r asset_id asset_name; do - echo "Deleting old package: $asset_name (ID: $asset_id)" - gh api --method DELETE "/repos/${{ github.repository }}/releases/assets/$asset_id" || echo "Failed to delete $asset_name" - done - # Upload new artifacts to the draft release echo "📦 Uploading new NuGet packages..." for file in ./artifacts/*.nupkg; do From 5e3841fdfd01495737712ffb1d88ba33300391cc Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Wed, 24 Dec 2025 15:32:21 +0100 Subject: [PATCH 18/27] fix(release): improve deletion of existing draft releases (#89) --- .github/workflows/main-build.yml | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/.github/workflows/main-build.yml b/.github/workflows/main-build.yml index 63f3a00..6e3a096 100644 --- a/.github/workflows/main-build.yml +++ b/.github/workflows/main-build.yml @@ -45,24 +45,18 @@ jobs: echo "✅ Tag $TAG does not exist yet" fi - - name: Remove old packages from existing draft + - name: Delete existing draft releases if: steps.check-tag.outputs.exists == 'false' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - TAG="v${{ needs.build-and-test.outputs.version }}" - # Check if draft release already exists - if gh release view "$TAG" &>/dev/null; then - echo "🗑️ Removing old NuGet packages from existing draft..." - gh release view "$TAG" --json assets -q '.assets[] | select(.name | endswith(".nupkg")) | "\(.id) \(.name)"' | while read -r asset_id asset_name; do - echo "Deleting old package: $asset_name (ID: $asset_id)" - gh api --method DELETE "/repos/${{ github.repository }}/releases/assets/$asset_id" || echo "Failed to delete $asset_name" - done - else - echo "No existing draft release found" - fi + echo "Checking for existing draft releases..." + gh release list --json isDraft,tagName --jq '.[] | select(.isDraft) | .tagName' | while read -r tag_name; do + echo "Deleting existing draft release: $tag_name" + gh release delete "$tag_name" --yes --cleanup-tag || echo "Failed to delete release $tag_name" + done - - name: Create/Update Draft Release + - name: Create Draft Release if: steps.check-tag.outputs.exists == 'false' uses: release-drafter/release-drafter@v6 env: @@ -81,7 +75,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="v${{ needs.build-and-test.outputs.version }}" - # Wait a moment for the release to be created/updated + # Wait a moment for the release to be created sleep 2 # Upload new artifacts to the draft release From f3b8402eb4103112d1a27ebb85057b153c2e1387 Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Thu, 15 Jan 2026 18:08:08 +0100 Subject: [PATCH 19/27] feat(docs): add GitHub Copilot instructions (#91) --- .github/copilot-instructions.md | 129 ++++++++++++++++++++++++++++++++ .vscode/tasks.json | 56 +++++++++++--- 2 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..62ffd87 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,129 @@ +# GitHub Copilot Instructions + +## 1. Repository Context & Mission + +**Repository:** `HttpUserAgentParser` (mycsharp) + +**Primary Goal:** +Provide a **high-performance, stable, and broadly compatible .NET library** for parsing HTTP User-Agent strings, including integrations for ASP.NET Core and MemoryCache. + +**Core Design Principles:** +- API stability over convenience +- Predictable performance characteristics +- Minimal allocations in hot paths +- Full test coverage for all observable behavior + +--- + +## 2. Repository Structure (Authoritative) + +Copilot must understand and respect the architectural boundaries: + +- `src/HttpUserAgentParser` + → Core parsing logic and public APIs + +- `src/HttpUserAgentParser.AspNetCore` + → ASP.NET Core integration (middleware, extensions) + +- `src/HttpUserAgentParser.MemoryCache` + → Caching extensions and cache-aware abstractions + +- `tests/*` + → Unit tests for **all** shipped packages + → Tests define expected behavior and are the source of truth + +- `perf/*` + → Benchmarks for performance-sensitive code paths + +--- + +## 3. Standard .NET CLI Commands + +Use these commands consistently: + +- Clean: + `dotnet clean --nologo` + +- Restore: + `dotnet restore` + +- Build: + `dotnet build --nologo` + +- Test (all): + `dotnet test --nologo` + +- Test (single project): + `dotnet test --nologo` + +--- + +## 4. Autonomous Execution Rules (Critical) + +Copilot is expected to work **independently and end-to-end** without human intervention. + +### Mandatory Quality Gates (Never Skip) +- The solution **must compile** after every change +- **All tests must pass** +- New behavior **must include unit tests** +- Public APIs **must not break** existing users unless explicitly intended +- Changes must be **minimal, focused, and intentional** + +If any gate fails: +1. Diagnose the root cause +2. Fix the issue +3. Re-run the full validation cycle + +--- + +## 5. Change Strategy & Scope Control + +When solving a task, Copilot should: + +1. **Analyze existing code first** + - Prefer extension over modification + - Reuse established patterns and helpers +2. **Avoid architectural rewrites** + - No refactors unless explicitly required +3. **Preserve backward compatibility** + - No breaking changes to public APIs + - No silent behavioral changes + +If multiple solutions are possible: +- Prefer the **simplest**, **most explicit**, and **least invasive** option + +--- + +## 6. Testing Requirements + +Every functional change must be fully tested: + +- Unit tests are mandatory for: + - New features + - Bug fixes + - Edge cases and regressions +- Prefer existing utilities from: + `tests/HttpUserAgentParser.TestHelpers` + +Tests define correctness. If behavior is unclear, tests take precedence over assumptions. + +--- + +## 7. Performance Guidelines + +- Treat parsing logic as performance-critical +- Avoid unnecessary allocations and LINQ in hot paths +- Prefer spans, pooling, and cached results where appropriate +- Update or add benchmarks in `perf/*` for performance-relevant changes + +--- + +## 8. Output Expectations + +Copilot should deliver: +- Compilable, production-ready code +- Complete test coverage for new behavior +- Clear, intentional commits without unrelated changes + +**Do not stop early.** +A task is only complete when **all quality gates pass** and the solution is fully validated. diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 5ddca9a..9b69370 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,13 +1,45 @@ { - "version": "2.0.0", - "tasks": [ - { - "label": "test", - "type": "shell", - "command": "dotnet test --nologo", - "args": [], - "problemMatcher": [ - "$msCompile" - ], - "group": "build" - } + "version": "2.0.0", + "tasks": [ + { + "label": "clean", + "type": "shell", + "command": "dotnet clean", + "problemMatcher": "$msCompile" + }, + { + "label": "restore", + "type": "shell", + "command": "dotnet restore", + "problemMatcher": "$msCompile" + }, + { + "label": "build", + "type": "shell", + "command": "dotnet build --nologo", + "problemMatcher": "$msCompile", + "group": "build" + }, + { + "label": "test", + "type": "shell", + "command": "dotnet test --nologo", + "problemMatcher": "$msCompile", + "group": "test" + }, + { + "label": "ci:validate", + "dependsOn": [ + "clean", + "restore", + "build", + "test" + ], + "dependsOrder": "sequence", + "group": { + "kind": "build", + "isDefault": true + } + } + ] +} From aa9ad4f1ae62f9a99b11c47ce46f30d52e739255 Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Thu, 19 Feb 2026 17:14:05 +0100 Subject: [PATCH 20/27] feat(telemetry): add telemetry support for user agent parsing (#90) * feat(telemetry): add telemetry support for user agent parsing * feat(telemetry): add telemetry support and documentation * feat(telemetry): add native metrics support for user agent parser * feat(telemetry): enhance metrics and telemetry documentation * feat(telemetry): enhance telemetry documentation and add meters support * refactor(telemetry): simplify method signatures and improve readability * refactor(telemetry): remove DEBUG conditional compilation * refactor(telemetry): update metric naming conventions * refactor(telemetry): update duration metrics to seconds * refactor(telemetry): standardize meter names and descriptions * refactor(meters): simplify initialization logic in Enable method * refactor(tests): standardize meter names in telemetry tests * feat(telemetry): add fluent API for meter telemetry configuration * feat(tests): enhance telemetry tests with EventCounter listener * feat(telemetry): ensure EventCounter logging is initialized * feat(telemetry): initialize EventSource in Enable method * feat(telemetry): ensure deterministic EventSource construction * feat(telemetry): refactor meter name generation logic * docs(license): update copyright year to 2026 --- .vscode/tasks.json | 125 ++++++--- Directory.Packages.props | 4 +- LICENSE | 2 +- README.md | 265 +++++++++++++++++- ...encyInjectionOptionsTelemetryExtensions.cs | 49 ++++ .../HttpContextExtensions.cs | 13 + .../HttpUserAgentParser.AspNetCore.csproj | 4 + .../LICENSE.txt | 2 +- ...ttpUserAgentParserAspNetCoreEventSource.cs | 96 +++++++ .../HttpUserAgentParserAspNetCoreMeters.cs | 124 ++++++++ .../HttpUserAgentParserAspNetCoreTelemetry.cs | 143 ++++++++++ src/HttpUserAgentParser.AspNetCore/readme.md | 112 +++++++- ...encyInjectionOptionsTelemetryExtensions.cs | 49 ++++ .../HttpUserAgentParser.MemoryCache.csproj | 4 + ...HttpUserAgentParserMemoryCachedProvider.cs | 102 ++++++- .../LICENSE.txt | 2 +- ...tpUserAgentParserMemoryCacheEventSource.cs | 87 ++++++ .../HttpUserAgentParserMemoryCacheMeters.cs | 82 ++++++ ...HttpUserAgentParserMemoryCacheTelemetry.cs | 139 +++++++++ ...serAgentParserMemoryCacheTelemetryState.cs | 47 ++++ src/HttpUserAgentParser.MemoryCache/readme.md | 143 +++++++++- ...encyInjectionOptionsTelemetryExtensions.cs | 50 ++++ .../HttpUserAgentParser.cs | 55 +++- .../HttpUserAgentParser.csproj | 2 + src/HttpUserAgentParser/LICENSE.txt | 2 +- .../HttpUserAgentParserCachedProvider.cs | 36 ++- .../HttpUserAgentParserEventSource.cs | 167 +++++++++++ .../HttpUserAgentParserMeterNameHelper.cs | 70 +++++ .../Telemetry/HttpUserAgentParserMeters.cs | 138 +++++++++ .../Telemetry/HttpUserAgentParserTelemetry.cs | 170 +++++++++++ .../HttpUserAgentParserTelemetryState.cs | 38 +++ src/HttpUserAgentParser/readme.md | 171 ++++++++++- .../Telemetry/EventCounterTestListener.cs | 61 ++++ ...entParserAspNetCoreMetersTelemetryTests.cs | 93 ++++++ ...UserAgentParserAspNetCoreTelemetryTests.cs | 38 +++ .../Telemetry/MeterTestListener.cs | 39 +++ .../Telemetry/EventCounterTestListener.cs | 61 ++++ ...ntParserMemoryCacheMetersTelemetryTests.cs | 99 +++++++ ...serAgentParserMemoryCacheTelemetryTests.cs | 36 +++ .../Telemetry/MeterTestListener.cs | 41 +++ .../Telemetry/EventCounterTestListener.cs | 48 ++++ ...HttpUserAgentParserMeterNameHelperTests.cs | 62 ++++ ...HttpUserAgentParserMetersTelemetryTests.cs | 114 ++++++++ .../HttpUserAgentParserTelemetryTests.cs | 34 +++ .../Telemetry/MeterTestListener.cs | 48 ++++ 45 files changed, 3194 insertions(+), 73 deletions(-) create mode 100644 src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs create mode 100644 src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs create mode 100644 src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreMeters.cs create mode 100644 src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs create mode 100644 src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs create mode 100644 src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs create mode 100644 src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheMeters.cs create mode 100644 src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs create mode 100644 src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetryState.cs create mode 100644 src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs create mode 100644 src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs create mode 100644 src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeterNameHelper.cs create mode 100644 src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs create mode 100644 src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs create mode 100644 src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs create mode 100644 tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/EventCounterTestListener.cs create mode 100644 tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreMetersTelemetryTests.cs create mode 100644 tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreTelemetryTests.cs create mode 100644 tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/MeterTestListener.cs create mode 100644 tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/EventCounterTestListener.cs create mode 100644 tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheMetersTelemetryTests.cs create mode 100644 tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheTelemetryTests.cs create mode 100644 tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/MeterTestListener.cs create mode 100644 tests/HttpUserAgentParser.UnitTests/Telemetry/EventCounterTestListener.cs create mode 100644 tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMeterNameHelperTests.cs create mode 100644 tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMetersTelemetryTests.cs create mode 100644 tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserTelemetryTests.cs create mode 100644 tests/HttpUserAgentParser.UnitTests/Telemetry/MeterTestListener.cs diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9b69370..dcefe9b 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,45 +1,84 @@ { - "version": "2.0.0", - "tasks": [ - { - "label": "clean", - "type": "shell", - "command": "dotnet clean", - "problemMatcher": "$msCompile" - }, - { - "label": "restore", - "type": "shell", - "command": "dotnet restore", - "problemMatcher": "$msCompile" - }, - { - "label": "build", - "type": "shell", - "command": "dotnet build --nologo", - "problemMatcher": "$msCompile", - "group": "build" - }, - { - "label": "test", - "type": "shell", - "command": "dotnet test --nologo", - "problemMatcher": "$msCompile", - "group": "test" - }, - { - "label": "ci:validate", - "dependsOn": [ - "clean", - "restore", - "build", - "test" - ], - "dependsOrder": "sequence", - "group": { - "kind": "build", - "isDefault": true - } - } - ] + "version": "2.0.0", + "tasks": [ + { + "label": "dotnet: restore", + "type": "shell", + "command": "dotnet", + "args": [ + "restore" + ], + "problemMatcher": "$msCompile", + "presentation": { + "reveal": "silent", + "panel": "dedicated", + "close": true, + "showReuseMessage": false + } + }, + { + "label": "dotnet: build", + "type": "shell", + "command": "dotnet", + "args": [ + "build", + "--no-restore" + ], + "problemMatcher": "$msCompile", + "dependsOn": "dotnet: restore", + "presentation": { + "reveal": "always", + "panel": "dedicated", + "close": true, + "showReuseMessage": false + }, + "group": "build" + }, + { + "label": "dotnet: test", + "type": "shell", + "command": "dotnet", + "args": [ + "test", + "--no-build", + "--nologo" + ], + "problemMatcher": "$msCompile", + "dependsOn": "dotnet: build", + "presentation": { + "reveal": "always", + "panel": "dedicated", + "close": true, + "showReuseMessage": false + } + }, + { + "label": "test", + "type": "shell", + "command": "dotnet test --nologo", + "args": [], + "isBackground": false + }, + { + "label": "test", + "type": "shell", + "command": "dotnet test --nologo", + "args": [], + "isBackground": false + }, + { + "label": "test", + "type": "shell", + "command": "dotnet test --nologo", + "args": [], + "isBackground": false + }, + { + "label": "test", + "type": "shell", + "command": "dotnet test --nologo", + "args": [], + "isBackground": false + } + ] } diff --git a/Directory.Packages.props b/Directory.Packages.props index 61d389f..edb2c82 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,7 +6,7 @@ - + @@ -71,4 +71,4 @@ runtime; build; native; contentfiles; analyzers - \ No newline at end of file + diff --git a/LICENSE b/LICENSE index 023aed7..df1bf06 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2025 MyCSharp +Copyright (c) 2021-2026 myCSharp.de Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 599a556..18d3f13 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Parsing HTTP User Agents with .NET | NuGet | |-| | [![MyCSharp.HttpUserAgentParser](https://img.shields.io/nuget/v/MyCSharp.HttpUserAgentParser.svg?logo=nuget&label=MyCSharp.HttpUserAgentParser)](https://www.nuget.org/packages/MyCSharp.HttpUserAgentParser) | -| [![MyCSharp.HttpUserAgentParser](https://img.shields.io/nuget/v/MyCSharp.HttpUserAgentParser.MemoryCache.svg?logo=nuget&label=MyCSharp.HttpUserAgentParser.MemoryCache)](https://www.nuget.org/packages/MyCSharp.HttpUserAgentParser.MemoryCache)| `dotnet add package MyCSharp.HttpUserAgentParser.MemoryCach.MemoryCache` | +| [![MyCSharp.HttpUserAgentParser](https://img.shields.io/nuget/v/MyCSharp.HttpUserAgentParser.MemoryCache.svg?logo=nuget&label=MyCSharp.HttpUserAgentParser.MemoryCache)](https://www.nuget.org/packages/MyCSharp.HttpUserAgentParser.MemoryCache)| `dotnet add package MyCSharp.HttpUserAgentParser.MemoryCache` | | [![MyCSharp.HttpUserAgentParser.AspNetCore](https://img.shields.io/nuget/v/MyCSharp.HttpUserAgentParser.AspNetCore.svg?logo=nuget&label=MyCSharp.HttpUserAgentParser.AspNetCore)](https://www.nuget.org/packages/MyCSharp.HttpUserAgentParser.AspNetCore) | `dotnet add package MyCSharp.HttpUserAgentParser.AspNetCore` | @@ -110,6 +110,269 @@ public void MyMethod(IHttpUserAgentParserAccessor parserAccessor) } ``` +## Telemetry (EventCounters) + +Telemetry is **opt-in** and **modular per package**. + +- Opt-in: no telemetry overhead unless you explicitly enable it. +- Modular: each package has its own `EventSource` name, so you can monitor only what you use. + +### Enable telemetry (Fluent API) + +Core parser telemetry: + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services + .AddHttpUserAgentParser() + .WithTelemetry(); +} +``` + +MemoryCache telemetry (in addition to core, optional): + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services + .AddHttpUserAgentMemoryCachedParser() + .WithTelemetry() // core counters (optional) + .WithMemoryCacheTelemetry(); +} +``` + +ASP.NET Core telemetry (header present/missing): + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services + .AddHttpUserAgentMemoryCachedParser() + .AddHttpUserAgentParserAccessor() + .WithAspNetCoreTelemetry(); +} +``` + +### EventSource names and counters + +Core (`MyCSharp.HttpUserAgentParser`) + +- `parse-requests` +- `parse-duration` (s) +- `cache-concurrentdictionary-hit` +- `cache-concurrentdictionary-miss` +- `cache-concurrentdictionary-size` + +MemoryCache (`MyCSharp.HttpUserAgentParser.MemoryCache`) + +- `cache-hit` +- `cache-miss` +- `cache-size` + +ASP.NET Core (`MyCSharp.HttpUserAgentParser.AspNetCore`) + +- `useragent-present` +- `useragent-missing` + +### Monitor counters + +Using `dotnet-counters`: + +```bash +dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser +dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser.MemoryCache +dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser.AspNetCore +``` + +## Telemetry (Meters) + +Native `System.Diagnostics.Metrics` instruments are **opt-in** per package. + +### Enable meters (Fluent API) + +Core parser meters: + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services + .AddHttpUserAgentParser() + .WithMeterTelemetry(); +} +``` + +MemoryCache meters: + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services + .AddHttpUserAgentMemoryCachedParser() + .WithMeterTelemetry() // core meters (optional) + .WithMemoryCacheMeterTelemetry(); +} +``` + +ASP.NET Core meters: + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services + .AddHttpUserAgentMemoryCachedParser() + .AddHttpUserAgentParserAccessor() + .WithAspNetCoreMeterTelemetry(); +} +``` + +### Meter names and instruments + +Core meter (default: `mycsharp.http_user_agent_parser`) + +- `parse.requests` (counter, `{call}`) +- `parse.duration` (histogram, `s`) +- `cache.hit` (counter, `{call}`) +- `cache.miss` (counter, `{call}`) +- `cache.size` (observable gauge, `{entry}`) + +MemoryCache meter (default: `mycsharp.http_user_agent_parser.memorycache`) + +- `cache.hit` (counter, `{call}`) +- `cache.miss` (counter, `{call}`) +- `cache.size` (observable gauge, `{entry}`) + +ASP.NET Core meter (default: `mycsharp.http_user_agent_parser.aspnetcore`) + +- `user_agent.present` (counter, `{call}`) +- `user_agent.missing` (counter, `{call}`) + +### Meter prefix configuration + +The default prefix is `mycsharp.`. The prefix can be configured via DI: + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services + .AddHttpUserAgentParser() + .WithMeterTelemetryPrefix("acme."); +} +``` + +Rules: + +- `""` (empty) is allowed and removes the prefix. +- Otherwise the prefix must be **alphanumeric** and **end with `.`**. + +Example results: + +- Prefix `"mycsharp."` -> `mycsharp.http_user_agent_parser` +- Prefix `""` -> `http_user_agent_parser` +- Prefix `"acme."` -> `acme.http_user_agent_parser` + +### Export to OpenTelemetry + +You can collect these EventCounters via OpenTelemetry metrics. + +Packages you typically need: + +- `OpenTelemetry` +- `OpenTelemetry.Instrumentation.EventCounters` +- an exporter (e.g. `OpenTelemetry.Exporter.OpenTelemetryProtocol`) + +Example (minimal): + +```csharp +using OpenTelemetry.Metrics; +using MyCSharp.HttpUserAgentParser.Telemetry; +using MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; +using MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; + +builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .AddEventCountersInstrumentation(options => + { + options.AddEventSources( + HttpUserAgentParserEventSource.EventSourceName, + HttpUserAgentParserMemoryCacheEventSource.EventSourceName, + HttpUserAgentParserAspNetCoreEventSource.EventSourceName); + }) + .AddOtlpExporter(); + }); +``` + +### Export to Application Insights + +Two common approaches: + +1) OpenTelemetry → Application Insights (recommended) + - Collect counters with OpenTelemetry (see above) + - Export using an Azure Monitor / Application Insights exporter (API varies by package/version) + +2) Custom `EventListener` → `TelemetryClient` + - Attach an `EventListener` + - Parse the `EventCounters` payload + - Forward values as custom metrics + +### OpenTelemetry listener (recommended) + +You can collect EventCounters as OpenTelemetry metrics. + +Typical packages: + +- `OpenTelemetry` +- `OpenTelemetry.Instrumentation.EventCounters` +- An exporter, e.g. `OpenTelemetry.Exporter.OpenTelemetryProtocol` + +Example: + +```csharp +using OpenTelemetry.Metrics; + +builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .AddEventCountersInstrumentation(options => + { + options.AddEventSources( + HttpUserAgentParserEventSource.EventSourceName, + HttpUserAgentParserMemoryCacheEventSource.EventSourceName, + HttpUserAgentParserAspNetCoreEventSource.EventSourceName); + }) + .AddOtlpExporter(); + }); +``` + +From there you can route metrics to: + +- OpenTelemetry Collector +- Prometheus +- Azure Monitor / Application Insights (via an Azure Monitor exporter) + +### Application Insights listener (custom) + +If you want a direct listener, you can attach an `EventListener` and forward counter values into Application Insights custom metrics. + +High-level steps: + +1) Enable telemetry via `.WithTelemetry()` / `.WithMemoryCacheTelemetry()` / `.WithAspNetCoreTelemetry()` +2) Register an `EventListener` that enables the corresponding EventSources +3) On `EventCounters` payload, forward values to `TelemetryClient.GetMetric(...).TrackValue(...)` + +Notes: + +- This is best-effort telemetry. +- Prefer longer polling intervals (e.g. 10s) to reduce noise. + +> Notes +> +> - Counters are only emitted when telemetry is enabled and a listener is attached. +> - Values are best-effort and may include cache races. + ## Benchmark ```shell diff --git a/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs b/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs new file mode 100644 index 0000000..9e8b54a --- /dev/null +++ b/src/HttpUserAgentParser.AspNetCore/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs @@ -0,0 +1,49 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.Metrics; +using MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; +using MyCSharp.HttpUserAgentParser.DependencyInjection; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.DependencyInjection; + +/// +/// Fluent extensions to enable telemetry for the AspNetCore package. +/// +public static class HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions +{ + /// + /// Enables EventCounter telemetry for the AspNetCore package. + /// + public static HttpUserAgentParserDependencyInjectionOptions WithAspNetCoreTelemetry( + this HttpUserAgentParserDependencyInjectionOptions options) + { + HttpUserAgentParserAspNetCoreTelemetry.Enable(); + return options; + } + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the AspNetCore package. + /// + public static HttpUserAgentParserDependencyInjectionOptions WithAspNetCoreMeterTelemetry( + this HttpUserAgentParserDependencyInjectionOptions options, + Meter? meter = null) + { + HttpUserAgentParserAspNetCoreTelemetry.EnableMeters(meter); + return options; + } + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the AspNetCore package using a custom meter prefix. + /// + /// The options container. + /// The prefix to use for the meter name. + /// Thrown when the prefix is not empty and does not match the required format. + public static HttpUserAgentParserDependencyInjectionOptions WithAspNetCoreMeterTelemetryPrefix( + this HttpUserAgentParserDependencyInjectionOptions options, + string meterPrefix) + { + Meter meter = new(HttpUserAgentParserAspNetCoreMeters.GetMeterName(meterPrefix)); + HttpUserAgentParserAspNetCoreTelemetry.EnableMeters(meter); + return options; + } +} diff --git a/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs b/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs index 9da3b29..d6f2462 100644 --- a/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs +++ b/src/HttpUserAgentParser.AspNetCore/HttpContextExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; +using MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; namespace MyCSharp.HttpUserAgentParser.AspNetCore; @@ -16,7 +17,19 @@ public static class HttpContextExtensions public static string? GetUserAgentString(this HttpContext httpContext) { if (httpContext.Request.Headers.TryGetValue("User-Agent", out StringValues value)) + { + if (HttpUserAgentParserAspNetCoreTelemetry.IsEnabled) + { + HttpUserAgentParserAspNetCoreTelemetry.UserAgentPresent(); + } + return value; + } + + if (HttpUserAgentParserAspNetCoreTelemetry.IsEnabled) + { + HttpUserAgentParserAspNetCoreTelemetry.UserAgentMissing(); + } return null; } diff --git a/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParser.AspNetCore.csproj b/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParser.AspNetCore.csproj index fd97e4e..19d4fa6 100644 --- a/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParser.AspNetCore.csproj +++ b/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParser.AspNetCore.csproj @@ -24,4 +24,8 @@ + + + + diff --git a/src/HttpUserAgentParser.AspNetCore/LICENSE.txt b/src/HttpUserAgentParser.AspNetCore/LICENSE.txt index 023aed7..df1bf06 100644 --- a/src/HttpUserAgentParser.AspNetCore/LICENSE.txt +++ b/src/HttpUserAgentParser.AspNetCore/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2025 MyCSharp +Copyright (c) 2021-2026 myCSharp.de Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs new file mode 100644 index 0000000..7a1fcab --- /dev/null +++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreEventSource.cs @@ -0,0 +1,96 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; + +/// +/// EventSource for EventCounters emitted by MyCSharp.HttpUserAgentParser.AspNetCore. +/// +/// +/// Provides EventCounter-based telemetry for User-Agent presence detection. +/// Counters are incremented only when the EventSource is enabled to minimize +/// overhead on hot paths. +/// +[EventSource(Name = EventSourceName)] +[ExcludeFromCodeCoverage] +public sealed class HttpUserAgentParserAspNetCoreEventSource : EventSource +{ + /// + /// The EventSource name used for EventCounters. + /// + public const string EventSourceName = "MyCSharp.HttpUserAgentParser.AspNetCore"; + + /// + /// Singleton instance of the EventSource. + /// + internal static HttpUserAgentParserAspNetCoreEventSource Log { get; } = new(); + + private readonly IncrementingEventCounter _userAgentPresent; + private readonly IncrementingEventCounter _userAgentMissing; + + /// + /// Initializes the EventCounters used by this EventSource. + /// + private HttpUserAgentParserAspNetCoreEventSource() + { + _userAgentPresent = new IncrementingEventCounter("useragent-present", this) + { + DisplayName = "User-Agent header present", + DisplayUnits = "calls", + }; + + _userAgentMissing = new IncrementingEventCounter("useragent-missing", this) + { + DisplayName = "User-Agent header missing", + DisplayUnits = "calls", + }; + } + + /// + /// Increments the EventCounter for requests with a present User-Agent header. + /// + [NonEvent] + internal void UserAgentPresent() + { + if (!IsEnabled()) + { + return; + } + + _userAgentPresent?.Increment(); + } + + /// + /// Increments the EventCounter for requests with a missing User-Agent header. + /// + [NonEvent] + internal void UserAgentMissing() + { + if (!IsEnabled()) + { + return; + } + + _userAgentMissing?.Increment(); + } + + /// + /// Releases all EventCounter resources used by this EventSource. + /// + /// + /// when called from Dispose; + /// when called from a finalizer. + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _userAgentPresent?.Dispose(); + _userAgentMissing?.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreMeters.cs b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreMeters.cs new file mode 100644 index 0000000..dba87f3 --- /dev/null +++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreMeters.cs @@ -0,0 +1,124 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; +using MyCSharp.HttpUserAgentParser.Telemetry; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; + +/// +/// System.Diagnostics.Metrics instruments emitted by MyCSharp.HttpUserAgentParser.AspNetCore. +/// +/// +/// Provides meter-based telemetry for User-Agent parsing and presence detection. +/// Instrument creation is performed once and guarded by a lock-free initialization +/// check to minimize overhead on hot paths. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserAspNetCoreMeters +{ + /// + /// Name of the meter used to publish AspNetCore User-Agent metrics. + /// + private const string MeterNameSuffix = "http_user_agent_parser.aspnetcore"; + + /// + /// Name of the meter used to publish AspNetCore User-Agent metrics. + /// + public const string MeterName = "mycsharp." + MeterNameSuffix; + + /// + /// Builds a meter name from a custom prefix. + /// + /// + /// The prefix to use. When null, the default prefix is used. When empty, + /// no prefix is applied. Otherwise, the prefix must be alphanumeric and end with '.'. + /// + /// The full meter name. + /// Thrown when the prefix is not empty and does not match the required format. + public static string GetMeterName(string? meterPrefix) + => HttpUserAgentParserMeterNameHelper.GetMeterName(meterPrefix, MeterNameSuffix); + + /// + /// Indicates whether the meter and its instruments have been initialized. + /// + private static int s_initialized; + + private static Meter? s_meter; + private static Counter? s_userAgentPresent; + private static Counter? s_userAgentMissing; + + /// + /// Gets a value indicating whether meter-based telemetry is enabled. + /// + /// + /// Returns once the meter and counters have been initialized. + /// + public static bool IsEnabled + => Volatile.Read(ref s_initialized) != 0; + + /// + /// Enables meter-based telemetry and initializes all metric instruments. + /// + /// + /// Optional externally managed instance. If not provided, + /// a new meter is created using . + /// + /// + /// Initialization is performed at most once. Subsequent calls are ignored. + /// + public static void Enable(Meter? meter = null) + { + if (Interlocked.Exchange(ref s_initialized, 1) == 1) + { + return; + } + + s_meter = meter ?? new Meter(MeterName); + + s_userAgentPresent = s_meter.CreateCounter( + name: "user_agent.present", + unit: "{call}", + description: "User-Agent header present"); + + s_userAgentMissing = s_meter.CreateCounter( + name: "user_agent.missing", + unit: "{call}", + description: "User-Agent header missing"); + } + + /// + /// Records a metric indicating that a User-Agent header was present. + /// + /// + /// This method is optimized for hot paths and performs no work + /// if the counter has not been initialized. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void UserAgentPresent() + => s_userAgentPresent?.Add(1); + + /// + /// Records a metric indicating that a User-Agent header was missing. + /// + /// + /// This method is optimized for hot paths and performs no work + /// if the counter has not been initialized. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void UserAgentMissing() + => s_userAgentMissing?.Add(1); + + /// + /// Resets static state to support isolated unit tests. + /// + public static void ResetForTests() + { + Volatile.Write(ref s_initialized, 0); + + s_meter = null; + s_userAgentPresent = null; + s_userAgentMissing = null; + } +} diff --git a/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs new file mode 100644 index 0000000..ca5d976 --- /dev/null +++ b/src/HttpUserAgentParser.AspNetCore/Telemetry/HttpUserAgentParserAspNetCoreTelemetry.cs @@ -0,0 +1,143 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; + +/// +/// Opt-in switch for AspNetCore package telemetry. +/// +/// +/// Controls whether telemetry is emitted via event counters and/or meters. +/// The state is evaluated using lock-free, thread-safe reads and is intended +/// to be checked on hot paths. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserAspNetCoreTelemetry +{ + /// + /// Flag indicating that event counter–based telemetry is enabled. + /// + private const int EventCountersFlag = 1; + + /// + /// Flag indicating that meter-based telemetry is enabled. + /// + private const int MetersFlag = 2; + + /// + /// Bit field storing the currently enabled telemetry backends. + /// + /// + /// Accessed using volatile reads to ensure cross-thread visibility + /// without requiring synchronization. + /// + private static int s_enabledFlags; + + /// + /// Gets a value indicating whether any telemetry backend is enabled. + /// + /// + /// Returns if at least one telemetry backend + /// has been enabled. + /// + public static bool IsEnabled + => Volatile.Read(ref s_enabledFlags) != 0; + + /// + /// Gets a value indicating whether event counter telemetry is enabled. + /// + /// + /// Returns only if the event counter flag is set + /// and the underlying event source is enabled. + /// + public static bool AreCountersEnabled + => (Volatile.Read(ref s_enabledFlags) & EventCountersFlag) != 0 + && HttpUserAgentParserAspNetCoreEventSource.Log.IsEnabled(); + + /// + /// Gets a value indicating whether meter-based telemetry is enabled. + /// + /// + /// Returns only if the meter flag is set + /// and the meter provider is enabled. + /// + public static bool AreMetersEnabled + => (Volatile.Read(ref s_enabledFlags) & MetersFlag) != 0 + && HttpUserAgentParserAspNetCoreMeters.IsEnabled; + + /// + /// Enables EventCounter telemetry for the AspNetCore package. + /// + public static void Enable() + { + // Force EventSource construction at enable-time so listeners can subscribe deterministically. + // This avoids CI-only timing races where first telemetry events happen before listener attachment. + _ = HttpUserAgentParserAspNetCoreEventSource.Log; + Interlocked.Or(ref s_enabledFlags, EventCountersFlag); + } + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the AspNetCore package. + /// + public static void EnableMeters(Meter? meter = null) + { + HttpUserAgentParserAspNetCoreMeters.Enable(meter); + Interlocked.Or(ref s_enabledFlags, MetersFlag); + } + + /// + /// Records telemetry indicating that a User-Agent header was present. + /// + /// + /// Emits telemetry only for the enabled backends (event counters and/or meters). + /// The method is optimized for hot paths and performs a single volatile flag read. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void UserAgentPresent() + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserAspNetCoreEventSource.Log.UserAgentPresent(); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserAspNetCoreMeters.UserAgentPresent(); + } + } + + /// + /// Records telemetry indicating that a User-Agent header was missing. + /// + /// + /// Emits telemetry only for the enabled backends (event counters and/or meters). + /// The method is optimized for hot paths and performs a single volatile flag read. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void UserAgentMissing() + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserAspNetCoreEventSource.Log.UserAgentMissing(); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserAspNetCoreMeters.UserAgentMissing(); + } + } + + /// + /// Resets telemetry state for unit tests. + /// + public static void ResetForTests() + { + Volatile.Write(ref s_enabledFlags, 0); + HttpUserAgentParserAspNetCoreMeters.ResetForTests(); + } +} diff --git a/src/HttpUserAgentParser.AspNetCore/readme.md b/src/HttpUserAgentParser.AspNetCore/readme.md index 2cf587b..277a74d 100644 --- a/src/HttpUserAgentParser.AspNetCore/readme.md +++ b/src/HttpUserAgentParser.AspNetCore/readme.md @@ -1,5 +1,113 @@ -# MyCSharp.HttpUserAgentParser +# MyCSharp.HttpUserAgentParser.AspNetCore -Parsing HTTP User Agents with .NET +ASP.NET Core integration for MyCSharp.HttpUserAgentParser. +Repository: https://github.com/mycsharp/HttpUserAgentParser + +## Install + +```bash +dotnet add package MyCSharp.HttpUserAgentParser.AspNetCore +``` + +## Quick start + +Register a provider (any of the available ones) and then add the accessor: + +The accessor pattern reads the `User-Agent` header from the current `HttpContext` and parses it using the registered provider. + +```csharp +services + .AddHttpUserAgentMemoryCachedParser() // or: AddHttpUserAgentParser / AddHttpUserAgentCachedParser + .AddHttpUserAgentParserAccessor(); +``` + +Usage: + +```csharp +public sealed class MyController(IHttpUserAgentParserAccessor accessor) +{ + public HttpUserAgentInformation Get() => accessor.Get(); +} +``` + +### Just read the header + +If you only want the raw User-Agent string: + +```csharp +string? ua = HttpContext.GetUserAgentString(); +``` + +## Telemetry (EventCounters) + +Telemetry is **modular** and **opt-in**. + +### Enable (Fluent API) + +```csharp +services + .AddHttpUserAgentParserAccessor() + .WithAspNetCoreTelemetry(); +``` + +> The accessor registration returns the same options object, so you can chain this after any parser registration. + +### EventSource + counters + +EventSource: `MyCSharp.HttpUserAgentParser.AspNetCore` (constant: `HttpUserAgentParserAspNetCoreEventSource.EventSourceName`) + +- `user_agent.present` (incrementing) +- `user_agent.missing` (incrementing) + +### Monitor with dotnet-counters + +```bash +dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser.AspNetCore +``` + +## Telemetry (native Meters) + +This package can also emit native `System.Diagnostics.Metrics` instruments. + +### Enable meters (Fluent API) + +```csharp +services + .AddHttpUserAgentParserAccessor() + .WithAspNetCoreMeterTelemetry(); +``` + +### Meter + instruments + +Meter: `MyCSharp.HttpUserAgentParser.AspNetCore` (constant: `HttpUserAgentParserAspNetCoreMeters.MeterName`) + +- `user_agent.present` (counter) +- `user_agent.missing` (counter) + +## Export to OpenTelemetry / Application Insights + +Collect via OpenTelemetry EventCounters instrumentation: + +```csharp +using OpenTelemetry.Metrics; + +metrics.AddEventCountersInstrumentation(options => +{ + options.AddEventSources(HttpUserAgentParserAspNetCoreEventSource.EventSourceName); +}); +``` + +Then export using your preferred exporter (OTLP, Prometheus, Azure Monitor / Application Insights, …). + +### Export native meters to OpenTelemetry + +If you enabled **native meters** (see above), collect them via `AddMeter(...)`: + +```csharp +using OpenTelemetry.Metrics; +using MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; + +metrics.AddMeter(HttpUserAgentParserAspNetCoreMeters.MeterName); +``` diff --git a/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs b/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs new file mode 100644 index 0000000..6b31060 --- /dev/null +++ b/src/HttpUserAgentParser.MemoryCache/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs @@ -0,0 +1,49 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.Metrics; +using MyCSharp.HttpUserAgentParser.DependencyInjection; +using MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.DependencyInjection; + +/// +/// Fluent extensions to enable telemetry for the MemoryCache package. +/// +public static class HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions +{ + /// + /// Enables EventCounter telemetry for the MemoryCache provider. + /// + public static HttpUserAgentParserDependencyInjectionOptions WithMemoryCacheTelemetry( + this HttpUserAgentParserDependencyInjectionOptions options) + { + HttpUserAgentParserMemoryCacheTelemetry.Enable(); + return options; + } + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the MemoryCache provider. + /// + public static HttpUserAgentParserDependencyInjectionOptions WithMemoryCacheMeterTelemetry( + this HttpUserAgentParserDependencyInjectionOptions options, + Meter? meter = null) + { + HttpUserAgentParserMemoryCacheTelemetry.EnableMeters(meter); + return options; + } + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the MemoryCache provider using a custom meter prefix. + /// + /// The options container. + /// The prefix to use for the meter name. + /// Thrown when the prefix is not empty and does not match the required format. + public static HttpUserAgentParserDependencyInjectionOptions WithMemoryCacheMeterTelemetryPrefix( + this HttpUserAgentParserDependencyInjectionOptions options, + string meterPrefix) + { + Meter meter = new(HttpUserAgentParserMemoryCacheMeters.GetMeterName(meterPrefix)); + HttpUserAgentParserMemoryCacheTelemetry.EnableMeters(meter); + return options; + } +} diff --git a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParser.MemoryCache.csproj b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParser.MemoryCache.csproj index 31057cb..9a97676 100644 --- a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParser.MemoryCache.csproj +++ b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParser.MemoryCache.csproj @@ -25,4 +25,8 @@ + + + + diff --git a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs index c8ecb91..d059628 100644 --- a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs +++ b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParserMemoryCachedProvider.cs @@ -1,6 +1,8 @@ // Copyright © https://myCSharp.de - all rights reserved +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Caching.Memory; +using MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; using MyCSharp.HttpUserAgentParser.Providers; namespace MyCSharp.HttpUserAgentParser.MemoryCache; @@ -13,17 +15,71 @@ namespace MyCSharp.HttpUserAgentParser.MemoryCache; public class HttpUserAgentParserMemoryCachedProvider( HttpUserAgentParserMemoryCachedProviderOptions options) : IHttpUserAgentParserProvider { + /// + /// The name of the Meter used for metrics. + /// + public const string MeterName = "mycsharp.http_user_agent_parser.memorycache"; + private readonly Microsoft.Extensions.Caching.Memory.MemoryCache _memoryCache = new(options.CacheOptions); private readonly HttpUserAgentParserMemoryCachedProviderOptions _options = options; /// + /// + /// This method includes performance optimizations for telemetry: + /// + /// Telemetry checks use a volatile flag to ensure zero overhead when disabled. + /// Cache size tracking (via and ) is skipped entirely if the size metric is not enabled to avoid allocations. + /// + /// public HttpUserAgentInformation Parse(string userAgent) { CacheKey key = GetKey(userAgent); + if (!HttpUserAgentParserMemoryCacheTelemetry.IsEnabled) + { + return ParseWithoutTelemetry(key); + } + + if (_memoryCache.TryGetValue(key, out HttpUserAgentInformation cached)) + { + HttpUserAgentParserMemoryCacheTelemetry.CacheHit(); + return cached; + } + + return _memoryCache.GetOrCreate(key, static entry => + { + CacheKey key = (entry.Key as CacheKey)!; + entry.SlidingExpiration = key.Options.CacheEntryOptions.SlidingExpiration; + entry.SetSize(1); + + // Miss path. Note: Like other cache implementations, races can happen; counters are best-effort. + HttpUserAgentParserMemoryCacheTelemetry.CacheMiss(); + + if (HttpUserAgentParserMemoryCacheTelemetry.IsCacheSizeEnabled) + { + // Optimization: Avoid Interlocked overhead and delegate allocation if telemetry is disabled. + HttpUserAgentParserMemoryCacheTelemetry.CacheSizeIncrement(); + entry.RegisterPostEvictionCallback(static (_, _, _, _) => HttpUserAgentParserMemoryCacheTelemetry.CacheSizeDecrement()); + } + + return HttpUserAgentParser.Parse(key.UserAgent); + }); + } + + /// + /// Parses the user agent string using the memory cache without emitting telemetry. + /// + /// + /// This method is excluded from code coverage as it mainly wires together + /// cache access and parsing logic without additional behavior. + /// + [ExcludeFromCodeCoverage] + private HttpUserAgentInformation ParseWithoutTelemetry(CacheKey key) + { return _memoryCache.GetOrCreate(key, static entry => { CacheKey key = (entry.Key as CacheKey)!; + entry.SlidingExpiration = key.Options.CacheEntryOptions.SlidingExpiration; entry.SetSize(1); @@ -31,9 +87,22 @@ public HttpUserAgentInformation Parse(string userAgent) }); } + /// + /// Thread-local reusable cache key instance to avoid per-call allocations. + /// + /// + /// Marked as to ensure thread safety without locking. + /// [ThreadStatic] private static CacheKey? s_tKey; + /// + /// Gets a cache key instance initialized for the specified user agent. + /// + /// + /// Reuses a thread-local instance to minimize allocations. The returned instance + /// must not be stored or shared across threads. + /// private CacheKey GetKey(string userAgent) { CacheKey key = s_tKey ??= new CacheKey(); @@ -44,15 +113,42 @@ private CacheKey GetKey(string userAgent) return key; } + /// + /// Cache key used for memory-cached HTTP User-Agent parsing. + /// + /// + /// Implements as required by IMemoryCache + /// to ensure correct key comparison semantics. + /// private class CacheKey : IEquatable // required for IMemoryCache { + /// + /// Gets or sets the raw User-Agent string. + /// public string UserAgent { get; set; } = null!; + /// + /// Gets or sets the cache configuration options associated with this key. + /// public HttpUserAgentParserMemoryCachedProviderOptions Options { get; set; } = null!; - public bool Equals(CacheKey? other) => string.Equals(UserAgent, other?.UserAgent, StringComparison.OrdinalIgnoreCase); - public override bool Equals(object? obj) => Equals(obj as CacheKey); + /// + /// Determines equality based on the User-Agent string, ignoring case. + /// + public bool Equals(CacheKey? other) + => string.Equals(UserAgent, other?.UserAgent, StringComparison.OrdinalIgnoreCase); + + /// + public override bool Equals(object? obj) + => Equals(obj as CacheKey); - public override int GetHashCode() => UserAgent.GetHashCode(StringComparison.Ordinal); + /// + /// Returns a hash code based on the User-Agent string. + /// + /// + /// Uses ordinal comparison for performance and consistency with the cache. + /// + public override int GetHashCode() + => UserAgent.GetHashCode(StringComparison.Ordinal); } } diff --git a/src/HttpUserAgentParser.MemoryCache/LICENSE.txt b/src/HttpUserAgentParser.MemoryCache/LICENSE.txt index 023aed7..df1bf06 100644 --- a/src/HttpUserAgentParser.MemoryCache/LICENSE.txt +++ b/src/HttpUserAgentParser.MemoryCache/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2025 MyCSharp +Copyright (c) 2021-2026 myCSharp.de Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs new file mode 100644 index 0000000..da71f14 --- /dev/null +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheEventSource.cs @@ -0,0 +1,87 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; + +/// +/// EventSource for EventCounters emitted by MyCSharp.HttpUserAgentParser.MemoryCache. +/// +[EventSource(Name = EventSourceName)] +[ExcludeFromCodeCoverage] +public sealed class HttpUserAgentParserMemoryCacheEventSource : EventSource +{ + /// + /// The EventSource name used for EventCounters. + /// + public const string EventSourceName = "MyCSharp.HttpUserAgentParser.MemoryCache"; + + internal static HttpUserAgentParserMemoryCacheEventSource Log { get; } = new(); + + private readonly IncrementingEventCounter? _cacheHit; + private readonly IncrementingEventCounter _cacheMiss; + private readonly PollingCounter _cacheSize; + + private HttpUserAgentParserMemoryCacheEventSource() + { + _cacheHit = new IncrementingEventCounter("cache-hit", this) + { + DisplayName = "MemoryCache cache hit", + DisplayUnits = "calls", + }; + + _cacheMiss = new IncrementingEventCounter("cache-miss", this) + { + DisplayName = "MemoryCache cache miss", + DisplayUnits = "calls", + }; + + _cacheSize = new PollingCounter("cache-size", this, static () => HttpUserAgentParserMemoryCacheTelemetryState.CacheSize) + { + DisplayName = "MemoryCache cache size", + DisplayUnits = "entries", + }; + } + + [NonEvent] + internal void CacheHit() + { + if (!IsEnabled()) + { + return; + } + + _cacheHit?.Increment(); + } + + [NonEvent] + internal void CacheMiss() + { + if (!IsEnabled()) + { + return; + } + + _cacheMiss?.Increment(); + } + + [NonEvent] + internal static void CacheSizeIncrement() => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeIncrement(); + + [NonEvent] + internal static void CacheSizeDecrement() => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeDecrement(); + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _cacheHit?.Dispose(); + _cacheMiss?.Dispose(); + _cacheSize?.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheMeters.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheMeters.cs new file mode 100644 index 0000000..561f3ba --- /dev/null +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheMeters.cs @@ -0,0 +1,82 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; +using MyCSharp.HttpUserAgentParser.Telemetry; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; + +/// +/// System.Diagnostics.Metrics instruments emitted by MyCSharp.HttpUserAgentParser.MemoryCache. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserMemoryCacheMeters +{ + private const string MeterNameSuffix = "http_user_agent_parser.memorycache"; + + public const string MeterName = "mycsharp." + MeterNameSuffix; + + /// + /// Builds a meter name from a custom prefix. + /// + /// + /// The prefix to use. When null, the default prefix is used. When empty, + /// no prefix is applied. Otherwise, the prefix must be alphanumeric and end with '.'. + /// + /// The full meter name. + /// Thrown when the prefix is not empty and does not match the required format. + public static string GetMeterName(string? meterPrefix) + => HttpUserAgentParserMeterNameHelper.GetMeterName(meterPrefix, MeterNameSuffix); + + private static int s_initialized; + + private static Meter? s_meter; + private static Counter? s_cacheHit; + private static Counter? s_cacheMiss; + private static ObservableGauge? s_cacheSize; + + public static bool IsEnabled => Volatile.Read(ref s_initialized) != 0; + + public static void Enable(Meter? meter = null) + { + if (Interlocked.Exchange(ref s_initialized, 1) == 1) + { + return; + } + + s_meter = meter ?? new Meter(MeterName); + + s_cacheHit = s_meter.CreateCounter( + name: "cache.hit", + unit: "{call}", + description: "Cache hit"); + + s_cacheMiss = s_meter.CreateCounter( + name: "cache.miss", + unit: "{call}", + description: "Cache miss"); + + s_cacheSize = s_meter.CreateObservableGauge( + name: "cache.size", + observeValue: static () => HttpUserAgentParserMemoryCacheTelemetryState.CacheSize, + unit: "{entry}", + description: "Cache size"); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CacheHit() => s_cacheHit?.Add(1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CacheMiss() => s_cacheMiss?.Add(1); + + public static void ResetForTests() + { + Volatile.Write(ref s_initialized, 0); + + s_meter = null; + s_cacheHit = null; + s_cacheMiss = null; + s_cacheSize = null; + } +} diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs new file mode 100644 index 0000000..448de31 --- /dev/null +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetry.cs @@ -0,0 +1,139 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; + +/// +/// Opt-in switch for MemoryCache package telemetry. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserMemoryCacheTelemetry +{ + // Bit flags to track which telemetry systems are enabled. + // This allows us to support both EventCounters and Meters simultaneously with a single check. + private const int EventCountersFlag = 1; + private const int MetersFlag = 2; + + // Volatile integer used as a bitmask. + // Volatile.Read is used to ensure we get the latest value without the overhead of a full lock, + // making the "is telemetry enabled?" check extremely cheap on the hot path. + private static int s_enabledFlags; + + /// + /// Fast check if ANY telemetry is enabled. + /// Used to guard the entire telemetry block to minimize overhead when not in use. + /// + public static bool IsEnabled => Volatile.Read(ref s_enabledFlags) != 0; + + /// + /// Checks if EventCounters are specifically enabled. + /// + public static bool AreCountersEnabled + => (Volatile.Read(ref s_enabledFlags) & EventCountersFlag) != 0 + && HttpUserAgentParserMemoryCacheEventSource.Log.IsEnabled(); + + /// + /// Checks if Meters are specifically enabled. + /// + public static bool AreMetersEnabled + => (Volatile.Read(ref s_enabledFlags) & MetersFlag) != 0 + && HttpUserAgentParserMemoryCacheMeters.IsEnabled; + + /// + /// Checks if cache size tracking is enabled for either system. + /// This is used to guard expensive operations like .Count or Interlocked updates. + /// + public static bool IsCacheSizeEnabled + => AreCountersEnabled || AreMetersEnabled; + + /// + /// Enables EventCounter telemetry for the MemoryCache provider. + /// + public static void Enable() + { + // Force EventSource construction at enable-time so listeners can subscribe deterministically. + // This avoids CI-only timing races where first telemetry events happen before listener attachment. + _ = HttpUserAgentParserMemoryCacheEventSource.Log; + Interlocked.Or(ref s_enabledFlags, EventCountersFlag); + } + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the MemoryCache provider. + /// + public static void EnableMeters(Meter? meter = null) + { + HttpUserAgentParserMemoryCacheMeters.Enable(meter); + Interlocked.Or(ref s_enabledFlags, MetersFlag); + } + + /// + /// Records a cache hit. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CacheHit() + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserMemoryCacheEventSource.Log.CacheHit(); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserMemoryCacheMeters.CacheHit(); + } + } + + /// + /// Records a cache miss. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CacheMiss() + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserMemoryCacheEventSource.Log.CacheMiss(); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserMemoryCacheMeters.CacheMiss(); + } + } + + /// + /// Increments the cache size counter. + /// + /// + /// The operation is forwarded to the internal telemetry state and is safe + /// to call concurrently. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CacheSizeIncrement() + => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeIncrement(); + + /// + /// Decrements the cache size counter. + /// + /// + /// The operation is forwarded to the internal telemetry state and is safe + /// to call concurrently. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CacheSizeDecrement() + => HttpUserAgentParserMemoryCacheTelemetryState.CacheSizeDecrement(); + + /// + /// Resets telemetry state for unit tests. + /// + public static void ResetForTests() + { + Volatile.Write(ref s_enabledFlags, 0); + HttpUserAgentParserMemoryCacheTelemetryState.ResetForTests(); + HttpUserAgentParserMemoryCacheMeters.ResetForTests(); + } +} diff --git a/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetryState.cs b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetryState.cs new file mode 100644 index 0000000..7dc525f --- /dev/null +++ b/src/HttpUserAgentParser.MemoryCache/Telemetry/HttpUserAgentParserMemoryCacheTelemetryState.cs @@ -0,0 +1,47 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; + +/// +/// Holds telemetry state for tracking the size of the HTTP User-Agent parser memory cache. +/// +/// +/// This class is excluded from code coverage as it contains only simple +/// thread-safe state management logic. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserMemoryCacheTelemetryState +{ + private static long s_cacheSize; + + /// + /// Gets the current number of entries in the memory cache. + /// + /// + /// The value is read atomically to ensure thread safety. + /// + public static long CacheSize => Volatile.Read(ref s_cacheSize); + + /// + /// Increments the cached entry counter by one. + /// + /// + /// Uses an atomic operation to remain safe in concurrent scenarios. + /// + public static void CacheSizeIncrement() => Interlocked.Increment(ref s_cacheSize); + + /// + /// Decrements the cached entry counter by one. + /// + /// + /// Uses an atomic operation to remain safe in concurrent scenarios. + /// + public static void CacheSizeDecrement() => Interlocked.Decrement(ref s_cacheSize); + + /// + /// Resets the cache size for unit tests. + /// + public static void ResetForTests() => Volatile.Write(ref s_cacheSize, 0); +} diff --git a/src/HttpUserAgentParser.MemoryCache/readme.md b/src/HttpUserAgentParser.MemoryCache/readme.md index 2cf587b..fcdaaba 100644 --- a/src/HttpUserAgentParser.MemoryCache/readme.md +++ b/src/HttpUserAgentParser.MemoryCache/readme.md @@ -1,5 +1,144 @@ -# MyCSharp.HttpUserAgentParser +# MyCSharp.HttpUserAgentParser.MemoryCache -Parsing HTTP User Agents with .NET +IMemoryCache-based caching provider for MyCSharp.HttpUserAgentParser. +Repository: https://github.com/mycsharp/HttpUserAgentParser + +## Install + +```bash +dotnet add package MyCSharp.HttpUserAgentParser.MemoryCache +``` + +## Quick start + +Register the provider: + +```csharp +services.AddHttpUserAgentMemoryCachedParser(); +``` + +Then inject `IHttpUserAgentParserProvider`: + +```csharp +public sealed class MyService(IHttpUserAgentParserProvider parser) +{ + public HttpUserAgentInformation Parse(string userAgent) => parser.Parse(userAgent); +``` + +### Configure cache + +```csharp +services.AddHttpUserAgentMemoryCachedParser(options => +{ + options.CacheEntryOptions.SlidingExpiration = TimeSpan.FromMinutes(60); // default is 1 day + options.CacheOptions.SizeLimit = 1024; // default is null (= no limit) +}); +``` + +Notes: + +- Each unique user-agent string counts as one cache entry. +- The provider is registered as singleton and owns its internal `MemoryCache` instance. +- Like any cache, concurrent requests for a new key can race; counters are best-effort. + +## Telemetry (EventCounters) + +Telemetry is **modular** and **opt-in**. + +### Enable (Fluent API) + +```csharp +services + .AddHttpUserAgentMemoryCachedParser() + .WithMemoryCacheTelemetry(); +``` + +Optionally enable core counters too: + +```csharp +services + .AddHttpUserAgentMemoryCachedParser() + .WithTelemetry() + .WithMemoryCacheTelemetry(); +``` + +### EventSource + counters + +EventSource: `MyCSharp.HttpUserAgentParser.MemoryCache` (constant: `HttpUserAgentParserMemoryCacheEventSource.EventSourceName`) + +- `user_agent_parser.cache.hit` (incrementing) +- `user_agent_parser.cache.miss` (incrementing) +- `user_agent_parser.cache.size` (polling) + +### Monitor with dotnet-counters + +```bash +dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser.MemoryCache +``` + +## Telemetry (native Meters) + +This package can also emit native `System.Diagnostics.Metrics` instruments. + +### Enable meters (Fluent API) + +```csharp +services + .AddHttpUserAgentMemoryCachedParser() + .WithMemoryCacheMeterTelemetry(); +``` + +Optionally enable core meters too: + +```csharp +services + .AddHttpUserAgentMemoryCachedParser() + .WithMeterTelemetry() + .WithMemoryCacheMeterTelemetry(); +``` + +### Meter + instruments + +Meter: `MyCSharp.HttpUserAgentParser.MemoryCache` (constant: `HttpUserAgentParserMemoryCacheMeters.MeterName`) + +- `user_agent_parser.cache.hit` (counter) +- `user_agent_parser.cache.miss` (counter) +- `user_agent_parser.cache.size` (observable gauge) + +## Export to OpenTelemetry / Application Insights + +You can collect these counters with OpenTelemetry’s EventCounters instrumentation. + +Add the EventSource name: + +```csharp +using OpenTelemetry.Metrics; + +metrics.AddEventCountersInstrumentation(options => +{ + options.AddEventSources(HttpUserAgentParserMemoryCacheEventSource.EventSourceName); +}); +``` + +From there you can export to: + +- OTLP (Collector) +- Prometheus +- Azure Monitor / Application Insights (via an Azure Monitor exporter) + +### Export native meters to OpenTelemetry + +If you enabled **native meters** (see above), collect them via `AddMeter(...)`: + +```csharp +using OpenTelemetry.Metrics; +using MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; + +metrics.AddMeter(HttpUserAgentParserMemoryCacheMeters.MeterName); +``` + +### Application Insights listener registration + +If you prefer a direct listener instead of OpenTelemetry, you can attach an `EventListener` and forward values into Application Insights. diff --git a/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs b/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs new file mode 100644 index 0000000..a5e6ee8 --- /dev/null +++ b/src/HttpUserAgentParser/DependencyInjection/HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions.cs @@ -0,0 +1,50 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.Metrics; +using MyCSharp.HttpUserAgentParser.Telemetry; + +namespace MyCSharp.HttpUserAgentParser.DependencyInjection; + +/// +/// Fluent extensions to enable telemetry. +/// +public static class HttpUserAgentParserDependencyInjectionOptionsTelemetryExtensions +{ + /// + /// Enables core EventCounter telemetry for the parser. + /// This is opt-in to keep the default path free of telemetry overhead. + /// + public static HttpUserAgentParserDependencyInjectionOptions WithTelemetry( + this HttpUserAgentParserDependencyInjectionOptions options) + { + HttpUserAgentParserTelemetry.Enable(); + return options; + } + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the parser. + /// This is opt-in to keep the default path free of telemetry overhead. + /// + public static HttpUserAgentParserDependencyInjectionOptions WithMeterTelemetry( + this HttpUserAgentParserDependencyInjectionOptions options, + Meter? meter = null) + { + HttpUserAgentParserTelemetry.EnableMeters(meter); + return options; + } + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the parser using a custom meter prefix. + /// + /// The options container. + /// The prefix to use for the meter name. + /// Thrown when the prefix is not empty and does not match the required format. + public static HttpUserAgentParserDependencyInjectionOptions WithMeterTelemetryPrefix( + this HttpUserAgentParserDependencyInjectionOptions options, + string meterPrefix) + { + Meter meter = new(HttpUserAgentParserMeters.GetMeterName(meterPrefix)); + HttpUserAgentParserTelemetry.EnableMeters(meter); + return options; + } +} diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index 410637a..e0bc8f7 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -5,6 +5,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Intrinsics; +using MyCSharp.HttpUserAgentParser.Telemetry; namespace MyCSharp.HttpUserAgentParser; @@ -18,7 +19,33 @@ public static class HttpUserAgentParser /// /// Parses given user agent /// + /// + /// If telemetry is enabled, this method will emit metrics for parse requests and duration. + /// The telemetry check is designed to be zero-overhead when disabled (using a volatile boolean check). + /// public static HttpUserAgentInformation Parse(string userAgent) + { + if (!HttpUserAgentParserTelemetry.IsEnabled) + { + return ParseInternal(userAgent); + } + + bool measureDuration = HttpUserAgentParserTelemetry.ShouldMeasureParseDuration; + long startTimestamp = measureDuration ? Stopwatch.GetTimestamp() : 0; + + HttpUserAgentParserTelemetry.ParseRequest(); + + HttpUserAgentInformation result = ParseInternal(userAgent); + + if (measureDuration) + { + HttpUserAgentParserTelemetry.ParseDuration(Stopwatch.GetElapsedTime(startTimestamp)); + } + + return result; + } + + private static HttpUserAgentInformation ParseInternal(string userAgent) { // prepare userAgent = Cleanup(userAgent); @@ -51,13 +78,13 @@ public static HttpUserAgentInformation Parse(string userAgent) public static HttpUserAgentPlatformInformation? GetPlatform(string userAgent) { ReadOnlySpan ua = userAgent.AsSpan(); - foreach ((string Token, string Name, HttpUserAgentPlatformType PlatformType) platform in HttpUserAgentStatics.s_platformRules) + foreach ((string Token, string Name, HttpUserAgentPlatformType PlatformType) in HttpUserAgentStatics.s_platformRules) { - if (ContainsIgnoreCase(ua, platform.Token)) + if (ContainsIgnoreCase(ua, Token)) { return new HttpUserAgentPlatformInformation( - HttpUserAgentStatics.GetPlatformRegexForToken(platform.Token), - platform.Name, platform.PlatformType); + HttpUserAgentStatics.GetPlatformRegexForToken(Token), + Name, PlatformType); } } @@ -80,9 +107,9 @@ public static (string Name, string? Version)? GetBrowser(string userAgent) { ReadOnlySpan ua = userAgent.AsSpan(); - foreach ((string Name, string DetectToken, string? VersionToken) browserRule in HttpUserAgentStatics.s_browserRules) + foreach ((string Name, string DetectToken, string? VersionToken) in HttpUserAgentStatics.s_browserRules) { - if (!TryIndexOf(ua, browserRule.DetectToken, out int detectIndex)) + if (!TryIndexOf(ua, DetectToken, out int detectIndex)) { continue; } @@ -91,37 +118,37 @@ public static (string Name, string? Version)? GetBrowser(string userAgent) int versionSearchStart; // For rules without a specific version token, ensure pattern Token/ - if (string.IsNullOrEmpty(browserRule.VersionToken)) + if (string.IsNullOrEmpty(VersionToken)) { - int afterDetect = detectIndex + browserRule.DetectToken.Length; + int afterDetect = detectIndex + DetectToken.Length; if (afterDetect >= ua.Length || ua[afterDetect] != '/') { // Likely a misspelling or partial token (e.g., Edgg, Oprea, Chromee) continue; } } - if (!string.IsNullOrEmpty(browserRule.VersionToken)) + if (!string.IsNullOrEmpty(VersionToken)) { - if (TryIndexOf(ua, browserRule.VersionToken!, out int vtIndex)) + if (TryIndexOf(ua, VersionToken!, out int vtIndex)) { - versionSearchStart = vtIndex + browserRule.VersionToken!.Length; + versionSearchStart = vtIndex + VersionToken!.Length; } else { // If specific version token wasn't found, fall back to detect token area - versionSearchStart = detectIndex + browserRule.DetectToken.Length; + versionSearchStart = detectIndex + DetectToken.Length; } } else { - versionSearchStart = detectIndex + browserRule.DetectToken.Length; + versionSearchStart = detectIndex + DetectToken.Length; } ReadOnlySpan search = ua.Slice(versionSearchStart); if (TryExtractVersion(search, out Range range)) { string? version = search[range].ToString(); - return (browserRule.Name, version); + return (Name, version); } // If we didn't find a version for this rule, try next rule diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.csproj b/src/HttpUserAgentParser/HttpUserAgentParser.csproj index dc30a64..b007a8d 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.csproj +++ b/src/HttpUserAgentParser/HttpUserAgentParser.csproj @@ -22,6 +22,8 @@ + + diff --git a/src/HttpUserAgentParser/LICENSE.txt b/src/HttpUserAgentParser/LICENSE.txt index 023aed7..df1bf06 100644 --- a/src/HttpUserAgentParser/LICENSE.txt +++ b/src/HttpUserAgentParser/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2025 MyCSharp +Copyright (c) 2021-2026 myCSharp.de Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs b/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs index 381fd5b..a3f792b 100644 --- a/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs +++ b/src/HttpUserAgentParser/Providers/HttpUserAgentParserCachedProvider.cs @@ -1,6 +1,7 @@ // Copyright © https://myCSharp.de - all rights reserved using System.Collections.Concurrent; +using MyCSharp.HttpUserAgentParser.Telemetry; namespace MyCSharp.HttpUserAgentParser.Providers; @@ -17,8 +18,41 @@ public class HttpUserAgentParserCachedProvider : IHttpUserAgentParserProvider /// /// Parses the user agent or uses the internal cached information /// + /// + /// This method includes performance optimizations for telemetry: + /// + /// Telemetry checks use a volatile flag to ensure zero overhead when disabled. + /// Cache size reporting (which requires an expensive lock) is only executed if the specific metric is enabled. + /// + /// public HttpUserAgentInformation Parse(string userAgent) - => _cache.GetOrAdd(userAgent, static ua => HttpUserAgentParser.Parse(ua)); + { + if (!HttpUserAgentParserTelemetry.IsEnabled) + { + return _cache.GetOrAdd(userAgent, static ua => HttpUserAgentParser.Parse(ua)); + } + + if (_cache.TryGetValue(userAgent, out HttpUserAgentInformation cached)) + { + HttpUserAgentParserTelemetry.ConcurrentCacheHit(); + return cached; + } + + // Note: ConcurrentDictionary can invoke the factory multiple times in races; counters are best-effort. + HttpUserAgentInformation result = _cache.GetOrAdd(userAgent, static ua => + { + HttpUserAgentParserTelemetry.ConcurrentCacheMiss(); + return HttpUserAgentParser.Parse(ua); + }); + + if (HttpUserAgentParserTelemetry.IsCacheSizeEnabled) + { + // Optimization: Avoid expensive .Count property access (locks all buckets) if telemetry is disabled. + HttpUserAgentParserTelemetry.ConcurrentCacheSizeSet(_cache.Count); + } + + return result; + } /// /// Total count of entries in cache diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs new file mode 100644 index 0000000..0934b26 --- /dev/null +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserEventSource.cs @@ -0,0 +1,167 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; + +namespace MyCSharp.HttpUserAgentParser.Telemetry; + +/// +/// EventSource for EventCounters emitted by MyCSharp.HttpUserAgentParser. +/// +/// +/// The implementation is designed to keep overhead negligible unless a listener +/// is enabled. All counters are updated using lightweight, non-blocking operations +/// suitable for hot paths. +/// +[EventSource(Name = EventSourceName)] +[ExcludeFromCodeCoverage] +public sealed class HttpUserAgentParserEventSource : EventSource +{ + /// + /// The EventSource name used for EventCounters. + /// + public const string EventSourceName = "MyCSharp.HttpUserAgentParser"; + + /// + /// Singleton instance of the EventSource. + /// + internal static HttpUserAgentParserEventSource Log { get; } = new(); + + private readonly IncrementingEventCounter _parseRequests; + private readonly EventCounter? _parseDurationSeconds; + + private readonly IncrementingEventCounter _cacheHit; + private readonly IncrementingEventCounter _cacheMiss; + private readonly PollingCounter _cacheSize; + + /// + /// Initializes all EventCounters and polling counters used by this EventSource. + /// + private HttpUserAgentParserEventSource() + { + // Parser + _parseRequests = new IncrementingEventCounter("parse-requests", this) + { + DisplayName = "User-Agent parse requests", + DisplayUnits = "calls", + }; + + _parseDurationSeconds = new EventCounter("parse-duration", this) + { + DisplayName = "Parse duration", + DisplayUnits = "s", + }; + + // Providers (cache) + _cacheHit = new IncrementingEventCounter("cache-hit", this) + { + DisplayName = "Cache hit", + DisplayUnits = "calls", + }; + + _cacheMiss = new IncrementingEventCounter("cache-miss", this) + { + DisplayName = "Cache miss", + DisplayUnits = "calls", + }; + + _cacheSize = new PollingCounter( + "cache-size", + this, + static () => HttpUserAgentParserTelemetryState.ConcurrentCacheSize) + { + DisplayName = "Cache size", + DisplayUnits = "entries", + }; + } + + /// + /// Records a User-Agent parse request. + /// + [NonEvent] + internal void ParseRequest() + { + if (!IsEnabled()) + { + return; + } + + _parseRequests?.Increment(); + } + + /// + /// Records the duration of a User-Agent parse operation. + /// + /// Elapsed parse time in seconds. + [NonEvent] + internal void ParseDuration(double seconds) + { + if (!IsEnabled()) + { + return; + } + + _parseDurationSeconds?.WriteMetric(seconds); + } + + /// + /// Records a cache hit. + /// + [NonEvent] + internal void CacheHit() + { + if (!IsEnabled()) + { + return; + } + + _cacheHit?.Increment(); + } + + /// + /// Records a cache miss. + /// + [NonEvent] + internal void CacheMiss() + { + if (!IsEnabled()) + { + return; + } + + _cacheMiss?.Increment(); + } + + /// + /// Updates the size used by the polling counter. + /// + /// Current number of entries in the cache. + /// + /// The size is updated even when telemetry is disabled so that the polling + /// counter reports a correct value once a listener attaches. + /// + [NonEvent] + internal static void CacheSizeSet(int size) => HttpUserAgentParserTelemetryState.SetConcurrentCacheSize(size); + + /// + /// Releases all EventCounter and PollingCounter resources used by this EventSource. + /// + /// + /// when called from ; + /// when called from a finalizer. + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _parseRequests?.Dispose(); + _parseDurationSeconds?.Dispose(); + + _cacheHit?.Dispose(); + _cacheMiss?.Dispose(); + _cacheSize?.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeterNameHelper.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeterNameHelper.cs new file mode 100644 index 0000000..df0e094 --- /dev/null +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeterNameHelper.cs @@ -0,0 +1,70 @@ +// Copyright © https://myCSharp.de - all rights reserved + +namespace MyCSharp.HttpUserAgentParser.Telemetry; + +/// +/// Provides shared logic for building meter names from a customizable prefix and a per-component suffix. +/// +/// +/// All MyCSharp.HttpUserAgentParser meter classes follow the naming convention +/// mycsharp.<suffix>. This helper centralises the prefix-validation and +/// name-composition rules so they do not need to be duplicated across every package. +/// +internal static class HttpUserAgentParserMeterNameHelper +{ + /// + /// The default organisation prefix applied when the meterPrefix argument is . + /// + private const string DefaultPrefix = "mycsharp."; + + /// + /// Builds a meter name from an optional custom prefix and a fixed component suffix. + /// + /// + /// Controls the prefix of the returned meter name: + /// + /// — uses the default prefix mycsharp.. + /// Empty (or whitespace-only) string — no prefix is applied; the suffix is returned as-is. + /// Any other value — must consist solely of ASCII letters or digits and must end with .. + /// The prefix is then prepended to . + /// + /// + /// + /// The component-specific part of the meter name (e.g. http_user_agent_parser.aspnetcore). + /// Must not be . + /// + /// The fully composed meter name. + /// + /// Thrown when is non-empty but either does not end with . + /// or contains non-alphanumeric characters (excluding the trailing dot). + /// + public static string GetMeterName(string? meterPrefix, string meterNameSuffix) + { + if (meterPrefix is null) + { + return DefaultPrefix + meterNameSuffix; + } + + meterPrefix = meterPrefix.Trim(); + if (meterPrefix.Length == 0) + { + return meterNameSuffix; + } + + if (!meterPrefix.EndsWith('.')) + { + throw new ArgumentException("Meter prefix must end with '.'.", nameof(meterPrefix)); + } + + for (int i = 0; i < meterPrefix.Length - 1; i++) + { + char c = meterPrefix[i]; + if (!char.IsLetterOrDigit(c)) + { + throw new ArgumentException("Meter prefix must be alphanumeric.", nameof(meterPrefix)); + } + } + + return meterPrefix + meterNameSuffix; + } +} diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs new file mode 100644 index 0000000..cb06c26 --- /dev/null +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserMeters.cs @@ -0,0 +1,138 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; + +namespace MyCSharp.HttpUserAgentParser.Telemetry; + +/// +/// System.Diagnostics.Metrics instruments emitted by MyCSharp.HttpUserAgentParser. +/// This is opt-in and designed to keep overhead negligible unless a listener is enabled. +/// +/// +/// Instruments are created once on first enablement and emit no data unless observed +/// by an active listener. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserMeters +{ + /// + /// The meter name used for all instruments. + /// + private const string MeterNameSuffix = "http_user_agent_parser"; + + /// + /// The meter name used for all instruments. + /// + public const string MeterName = "mycsharp." + MeterNameSuffix; + + /// + /// Builds a meter name from a custom prefix. + /// + /// + /// The prefix to use. When null, the default prefix is used. When empty, + /// no prefix is applied. Otherwise, the prefix must be alphanumeric and end with '.'. + /// + /// The full meter name. + /// Thrown when the prefix is not empty and does not match the required format. + public static string GetMeterName(string? meterPrefix) + => HttpUserAgentParserMeterNameHelper.GetMeterName(meterPrefix, MeterNameSuffix); + + private static int s_initialized; + + private static Meter? s_meter; + + private static Counter? s_parseRequests; + private static Histogram? s_parseDuration; + + private static Counter? s_concurrentCacheHit; + private static Counter? s_concurrentCacheMiss; + private static ObservableGauge? s_concurrentCacheSize; + + /// + /// Gets whether meters have been initialized. + /// + public static bool IsEnabled => Volatile.Read(ref s_initialized) != 0; + + /// + /// Gets whether the parse duration histogram is currently enabled by a listener. + /// + public static bool IsParseDurationEnabled => s_parseDuration?.Enabled ?? false; + + /// + /// Initializes the meter and creates all metric instruments. + /// + /// + /// Initialization is performed at most once. Subsequent calls are ignored. + /// + public static void Enable(Meter? meter = null) + { + s_meter = meter ?? new Meter(MeterName); + + s_parseRequests = s_meter.CreateCounter( + name: "parse.requests", + unit: "{call}", + description: "User-Agent parse requests"); + + s_parseDuration = s_meter.CreateHistogram( + name: "parse.duration", + unit: "s", + description: "Parse duration"); + + s_concurrentCacheHit = s_meter.CreateCounter( + name: "cache.hit", + unit: "{call}", + description: "Cache hit"); + + s_concurrentCacheMiss = s_meter.CreateCounter( + name: "cache.miss", + unit: "{call}", + description: "Cache miss"); + + s_concurrentCacheSize = s_meter.CreateObservableGauge( + name: "cache.size", + observeValue: static () => HttpUserAgentParserTelemetryState.ConcurrentCacheSize, + unit: "{entry}", + description: "Cache size"); + } + + /// + /// Emits a counter increment for a parse request. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ParseRequest() => s_parseRequests?.Add(1); + + /// + /// Records the parse duration in seconds. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ParseDuration(double seconds) => s_parseDuration?.Record(seconds); + + /// + /// Emits a counter increment for a cache hit. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CacheHit() => s_concurrentCacheHit?.Add(1); + + /// + /// Emits a counter increment for a cache miss. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void CacheMiss() => s_concurrentCacheMiss?.Add(1); + + /// + /// Resets static state to support isolated unit tests. + /// + public static void ResetForTests() + { + Volatile.Write(ref s_initialized, 0); + + s_meter = null; + s_parseRequests = null; + s_parseDuration = null; + s_concurrentCacheHit = null; + s_concurrentCacheMiss = null; + s_concurrentCacheSize = null; + } +} diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs new file mode 100644 index 0000000..defb596 --- /dev/null +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetry.cs @@ -0,0 +1,170 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; + +namespace MyCSharp.HttpUserAgentParser.Telemetry; + +/// +/// Opt-in switch for core telemetry. +/// Telemetry is disabled by default to ensure zero overhead unless explicitly enabled. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserTelemetry +{ + // Bit flags to track which telemetry systems are enabled. + // This allows us to support both EventCounters and Meters simultaneously with a single check. + private const int EventCountersFlag = 1; + private const int MetersFlag = 2; + + // Volatile integer used as a bitmask. + // Volatile.Read is used to ensure we get the latest value without the overhead of a full lock, + // making the "is telemetry enabled?" check extremely cheap on the hot path. + private static int s_enabledFlags; + + /// + /// Fast check if ANY telemetry is enabled. + /// Used to guard the entire telemetry block to minimize overhead when not in use. + /// + public static bool IsEnabled => Volatile.Read(ref s_enabledFlags) != 0; + + /// + /// Checks if EventCounters are specifically enabled. + /// + public static bool AreCountersEnabled + => (Volatile.Read(ref s_enabledFlags) & EventCountersFlag) != 0 + && HttpUserAgentParserEventSource.Log.IsEnabled(); + + /// + /// Checks if Meters are specifically enabled. + /// + public static bool AreMetersEnabled + => (Volatile.Read(ref s_enabledFlags) & MetersFlag) != 0 + && HttpUserAgentParserMeters.IsEnabled; + + /// + /// Checks if parse duration should be measured. + /// This is true if either EventCounters are enabled OR if the specific Meter instrument for duration is enabled. + /// + public static bool ShouldMeasureParseDuration + => AreCountersEnabled || HttpUserAgentParserMeters.IsParseDurationEnabled; + + /// + /// Checks if cache size tracking is enabled for either system. + /// This is used to guard expensive operations like .Count or Interlocked updates. + /// + public static bool IsCacheSizeEnabled + => AreCountersEnabled || AreMetersEnabled; + + /// + /// Enables core EventCounter telemetry for the parser. + /// + public static void Enable() + { + // Force EventSource construction at enable-time so listeners can subscribe deterministically. + // This avoids CI-only timing races where first telemetry events happen before listener attachment. + _ = HttpUserAgentParserEventSource.Log; + Interlocked.Or(ref s_enabledFlags, EventCountersFlag); + } + + /// + /// Enables native System.Diagnostics.Metrics telemetry for the parser. + /// + public static void EnableMeters(Meter? meter = null) + { + HttpUserAgentParserMeters.Enable(meter); + Interlocked.Or(ref s_enabledFlags, MetersFlag); + } + + /// + /// Records a parse request event. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ParseRequest() + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserEventSource.Log.ParseRequest(); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserMeters.ParseRequest(); + } + } + + /// + /// Records the duration of a parse request. + /// + /// The elapsed duration. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ParseDuration(TimeSpan duration) + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserEventSource.Log.ParseDuration(duration.TotalSeconds); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserMeters.ParseDuration(duration.TotalSeconds); + } + } + + /// + /// Records a cache hit in the concurrent dictionary. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ConcurrentCacheHit() + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserEventSource.Log.CacheHit(); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserMeters.CacheHit(); + } + } + + /// + /// Records a cache miss in the concurrent dictionary. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ConcurrentCacheMiss() + { + int flags = Volatile.Read(ref s_enabledFlags); + if ((flags & EventCountersFlag) != 0) + { + HttpUserAgentParserEventSource.Log.CacheMiss(); + } + + if ((flags & MetersFlag) != 0) + { + HttpUserAgentParserMeters.CacheMiss(); + } + } + + /// + /// Updates the concurrent cache size. + /// + /// The current size of the cache. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ConcurrentCacheSizeSet(int size) + => HttpUserAgentParserTelemetryState.SetConcurrentCacheSize(size); + + /// + /// Resets telemetry state for unit testing. + /// + public static void ResetForTests() + { + Volatile.Write(ref s_enabledFlags, 0); + HttpUserAgentParserTelemetryState.ResetForTests(); + HttpUserAgentParserMeters.ResetForTests(); + } +} diff --git a/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs new file mode 100644 index 0000000..7bd68b9 --- /dev/null +++ b/src/HttpUserAgentParser/Telemetry/HttpUserAgentParserTelemetryState.cs @@ -0,0 +1,38 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.CodeAnalysis; + +namespace MyCSharp.HttpUserAgentParser.Telemetry; + +/// +/// Holds shared telemetry state for the concurrent dictionary cache. +/// +/// +/// The state is updated independently of whether telemetry is currently enabled +/// so that polling-based instruments can report correct values once a listener +/// attaches. +/// +[ExcludeFromCodeCoverage] +internal static class HttpUserAgentParserTelemetryState +{ + private static long s_concurrentCacheSize; + + /// + /// Gets the current size of the concurrent dictionary cache. + /// + public static long ConcurrentCacheSize + => Volatile.Read(ref s_concurrentCacheSize); + + /// + /// Updates the current size of the concurrent dictionary cache. + /// + /// Current number of entries in the cache. + public static void SetConcurrentCacheSize(int size) + => Volatile.Write(ref s_concurrentCacheSize, size); + + /// + /// Resets the telemetry state for unit tests. + /// + public static void ResetForTests() + => Volatile.Write(ref s_concurrentCacheSize, 0); +} diff --git a/src/HttpUserAgentParser/readme.md b/src/HttpUserAgentParser/readme.md index 2cf587b..1d3c653 100644 --- a/src/HttpUserAgentParser/readme.md +++ b/src/HttpUserAgentParser/readme.md @@ -1,5 +1,174 @@ # MyCSharp.HttpUserAgentParser -Parsing HTTP User Agents with .NET +Fast HTTP User-Agent parsing for .NET. +Repository: https://github.com/mycsharp/HttpUserAgentParser + +## Install + +```bash +dotnet add package MyCSharp.HttpUserAgentParser +``` + +## Quick start (no DI) + +```csharp +string userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; +HttpUserAgentInformation info = HttpUserAgentParser.Parse(userAgent); +// or: HttpUserAgentInformation.Parse(userAgent) +``` + +## Dependency injection + +If you want to inject a parser (e.g., in ASP.NET Core), use `IHttpUserAgentParserProvider`. + +### No cache + +```csharp +services + .AddHttpUserAgentParser(); +``` + +### ConcurrentDictionary cache + +```csharp +services + .AddHttpUserAgentCachedParser(); +// or: .AddHttpUserAgentParser(); +``` + +## Telemetry (EventCounters) + +Telemetry is: + +- **Opt-in**: disabled by default (keeps hot path overhead-free) +- **Low overhead**: counters are only written when a listener is attached + +### Enable telemetry (Fluent API) + +```csharp +services + .AddHttpUserAgentParser() + .WithTelemetry(); +``` + +### EventSource + counters + +EventSource: `MyCSharp.HttpUserAgentParser` (constant: `HttpUserAgentParserEventSource.EventSourceName`) + +- `parse.requests` (incrementing) +- `parse.duration` (ms, event counter) +- `cache.hit` (incrementing) +- `cache.miss` (incrementing) +- `cache.size` (polling) + +### Monitor with dotnet-counters + +```bash +dotnet-counters monitor --process-id MyCSharp.HttpUserAgentParser +``` + +## Telemetry (native Meters) + +In addition to EventCounters, this package can emit **native** `System.Diagnostics.Metrics` instruments. + +Telemetry is: + +- **Opt-in**: disabled by default (keeps hot path overhead-free) +- **Low overhead**: measurements are only recorded when enabled + +### Enable meters (Fluent API) + +```csharp +services + .AddHttpUserAgentParser() + .WithMeterTelemetry(); +``` + +### Meter + instruments + +Meter: `MyCSharp.HttpUserAgentParser` (constant: `HttpUserAgentParserMeters.MeterName`) + +- `parse.requests` (counter) +- `parse.duration` (histogram, ms) +- `cache.hit` (counter) +- `cache.miss` (counter) +- `cache.size` (observable gauge) + +## Export to OpenTelemetry + +You can collect these EventCounters via OpenTelemetry metrics and export them (OTLP, Prometheus, Azure Monitor, …). + +Packages you typically need: + +- `OpenTelemetry` +- `OpenTelemetry.Exporter.OpenTelemetryProtocol` (or another exporter) +- `OpenTelemetry.Instrumentation.EventCounters` + +Example (minimal): + +```csharp +using OpenTelemetry.Metrics; +using MyCSharp.HttpUserAgentParser.Telemetry; + +builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .AddEventCountersInstrumentation(options => + { + options.AddEventSources(HttpUserAgentParserEventSource.EventSourceName); + }) + .AddOtlpExporter(); + }); +``` + +> If you also use the MemoryCache/AspNetCore packages, add their EventSource names too. + +### Export native meters to OpenTelemetry + +If you enabled **native meters** (see above), collect them via `AddMeter(...)`: + +```csharp +using OpenTelemetry.Metrics; +using MyCSharp.HttpUserAgentParser.Telemetry; + +builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .AddMeter(HttpUserAgentParserMeters.MeterName) + .AddOtlpExporter(); + }); +``` + +## Export to Application Insights + +There are two common approaches: + +### 1) Recommended: OpenTelemetry → Application Insights + +Collect with OpenTelemetry (see above) and export to Azure Monitor / Application Insights using an Azure Monitor exporter. +This keeps your pipeline consistent and avoids custom listeners. + +Typical packages (names may differ by version): + +- `OpenTelemetry` +- `OpenTelemetry.Instrumentation.EventCounters` +- `Azure.Monitor.OpenTelemetry.Exporter` + +### 2) Custom EventListener → TelemetryClient + +If you prefer a direct listener, you can attach an `EventListener` and forward values as custom metrics. + +High-level idea: + +- Enable the EventSource +- Parse the `EventCounters` payload +- Track as Application Insights metrics + +Notes: + +- This is best-effort telemetry (caches can race) +- Keep aggregation intervals reasonable (e.g. 10s) diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/EventCounterTestListener.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/EventCounterTestListener.cs new file mode 100644 index 0000000..b405d3b --- /dev/null +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/EventCounterTestListener.cs @@ -0,0 +1,61 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.Tracing; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests.Telemetry; + +internal sealed class EventCounterTestListener(string eventSourceName) : EventListener +{ + private readonly string _eventSourceName = eventSourceName; + private volatile bool _sawEventCounters; + private volatile bool _enabled; + + protected override void OnEventSourceCreated(EventSource eventSource) + { + if (!string.Equals(eventSource.Name, _eventSourceName, StringComparison.Ordinal)) + { + return; + } + + EnableEvents( + eventSource, + EventLevel.LogAlways, + EventKeywords.All, + new Dictionary(StringComparer.Ordinal) + { + ["EventCounterIntervalSec"] = "0.1" + }); + + _enabled = true; + } + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + if (string.Equals(eventData.EventName, "EventCounters", StringComparison.Ordinal)) + { + _sawEventCounters = true; + } + } + + public bool WaitForCounters(TimeSpan timeout) + { + DateTimeOffset start = DateTimeOffset.UtcNow; + while (!_sawEventCounters && DateTimeOffset.UtcNow - start < timeout) + { + Thread.Sleep(10); + } + + return _sawEventCounters; + } + + public bool WaitUntilEnabled(TimeSpan timeout) + { + DateTimeOffset start = DateTimeOffset.UtcNow; + while (!_enabled && DateTimeOffset.UtcNow - start < timeout) + { + Thread.Sleep(10); + } + + return _enabled; + } +} diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreMetersTelemetryTests.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreMetersTelemetryTests.cs new file mode 100644 index 0000000..de66438 --- /dev/null +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreMetersTelemetryTests.cs @@ -0,0 +1,93 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using MyCSharp.HttpUserAgentParser.AspNetCore.DependencyInjection; +using MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; +using MyCSharp.HttpUserAgentParser.DependencyInjection; +using Xunit; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests.Telemetry; + +public class HttpUserAgentParserAspNetCoreMetersTelemetryTests +{ + [Fact] + public void Meters_Emit_WhenEnabled() + { + HttpUserAgentParserAspNetCoreTelemetry.ResetForTests(); + + using MeterTestListener listener = new("mycsharp.http_user_agent_parser.aspnetcore"); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithAspNetCoreMeterTelemetry(); + + DefaultHttpContext ctx = new(); + + // present + ctx.Request.Headers.UserAgent = "UA"; + Assert.NotNull(ctx.GetUserAgentString()); + + // missing + ctx.Request.Headers.Remove("User-Agent"); + Assert.Null(ctx.GetUserAgentString()); + + Assert.Contains("user_agent.present", listener.InstrumentNames); + Assert.Contains("user_agent.missing", listener.InstrumentNames); + } + + [Fact] + public void Meters_Emit_WhenEnabled_WithCustomPrefix() + { + HttpUserAgentParserAspNetCoreTelemetry.ResetForTests(); + + const string prefix = "acme."; + using MeterTestListener listener = new("acme.http_user_agent_parser.aspnetcore"); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithAspNetCoreMeterTelemetryPrefix(prefix); + + DefaultHttpContext ctx = new(); + + // present + ctx.Request.Headers.UserAgent = "UA"; + Assert.NotNull(ctx.GetUserAgentString()); + + // missing + ctx.Request.Headers.Remove("User-Agent"); + Assert.Null(ctx.GetUserAgentString()); + + Assert.Contains("user_agent.present", listener.InstrumentNames); + Assert.Contains("user_agent.missing", listener.InstrumentNames); + } + + [Fact] + public void Meters_Emit_WhenEnabled_WithEmptyPrefix() + { + HttpUserAgentParserAspNetCoreTelemetry.ResetForTests(); + + using MeterTestListener listener = new("http_user_agent_parser.aspnetcore"); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithAspNetCoreMeterTelemetryPrefix(string.Empty); + + DefaultHttpContext ctx = new(); + + // present + ctx.Request.Headers.UserAgent = "UA"; + Assert.NotNull(ctx.GetUserAgentString()); + + Assert.Contains("user_agent.present", listener.InstrumentNames); + } + + [Fact] + public void WithAspNetCoreMeterTelemetryPrefix_Throws_WhenInvalid() + { + HttpUserAgentParserAspNetCoreTelemetry.ResetForTests(); + + HttpUserAgentParserDependencyInjectionOptions options = new(new ServiceCollection()); + + Assert.Throws(() => options.WithAspNetCoreMeterTelemetryPrefix("acme")); + Assert.Throws(() => options.WithAspNetCoreMeterTelemetryPrefix("acme-")); + Assert.Throws(() => options.WithAspNetCoreMeterTelemetryPrefix("acme..")); + } +} diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreTelemetryTests.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreTelemetryTests.cs new file mode 100644 index 0000000..bb6d3a0 --- /dev/null +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/HttpUserAgentParserAspNetCoreTelemetryTests.cs @@ -0,0 +1,38 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using MyCSharp.HttpUserAgentParser.AspNetCore.DependencyInjection; +using MyCSharp.HttpUserAgentParser.AspNetCore.Telemetry; +using MyCSharp.HttpUserAgentParser.DependencyInjection; +using Xunit; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests.Telemetry; + +public class HttpUserAgentParserAspNetCoreTelemetryTests +{ + [Fact] + public void EventCounters_DoNotThrow_WhenEnabled() + { + using EventCounterTestListener listener = new(HttpUserAgentParserAspNetCoreEventSource.EventSourceName); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithAspNetCoreTelemetry(); + + DefaultHttpContext ctx = new(); + + // First call ensures the EventSource gets created (listener enables right after creation). + ctx.Request.Headers.UserAgent = "UA"; + Assert.NotNull(ctx.GetUserAgentString()); + Assert.True(listener.WaitUntilEnabled(TimeSpan.FromSeconds(2))); + + // Now exercise telemetry-enabled paths. + ctx.Request.Headers.UserAgent = "UA"; + Assert.NotNull(ctx.GetUserAgentString()); + + ctx.Request.Headers.Remove("User-Agent"); + Assert.Null(ctx.GetUserAgentString()); + + Assert.True(listener.WaitForCounters(TimeSpan.FromSeconds(2))); + } +} diff --git a/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/MeterTestListener.cs b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/MeterTestListener.cs new file mode 100644 index 0000000..dd5a09b --- /dev/null +++ b/tests/HttpUserAgentParser.AspNetCore.UnitTests/Telemetry/MeterTestListener.cs @@ -0,0 +1,39 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Collections.Concurrent; +using System.Diagnostics.Metrics; + +namespace MyCSharp.HttpUserAgentParser.AspNetCore.UnitTests.Telemetry; + +internal sealed class MeterTestListener : IDisposable +{ + private readonly string _meterName; + private readonly MeterListener _listener; + + public ConcurrentBag InstrumentNames { get; } = []; + + public MeterTestListener(string meterName) + { + _meterName = meterName; + _listener = new MeterListener(); + + _listener.InstrumentPublished = (instrument, listener) => + { + if (!string.Equals(instrument.Meter.Name, _meterName, StringComparison.Ordinal)) + { + return; + } + + listener.EnableMeasurementEvents(instrument); + }; + + _listener.SetMeasurementEventCallback((instrument, _, _, _) => + { + InstrumentNames.Add(instrument.Name); + }); + + _listener.Start(); + } + + public void Dispose() => _listener.Dispose(); +} diff --git a/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/EventCounterTestListener.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/EventCounterTestListener.cs new file mode 100644 index 0000000..760c7c6 --- /dev/null +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/EventCounterTestListener.cs @@ -0,0 +1,61 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.Tracing; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests.Telemetry; + +internal sealed class EventCounterTestListener(string eventSourceName) : EventListener +{ + private readonly string _eventSourceName = eventSourceName; + private volatile bool _sawEventCounters; + private volatile bool _enabled; + + protected override void OnEventSourceCreated(EventSource eventSource) + { + if (!string.Equals(eventSource.Name, _eventSourceName, StringComparison.Ordinal)) + { + return; + } + + EnableEvents( + eventSource, + EventLevel.LogAlways, + EventKeywords.All, + new Dictionary(StringComparer.Ordinal) + { + ["EventCounterIntervalSec"] = "0.1" + }); + + _enabled = true; + } + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + if (string.Equals(eventData.EventName, "EventCounters", StringComparison.Ordinal)) + { + _sawEventCounters = true; + } + } + + public bool WaitForCounters(TimeSpan timeout) + { + DateTimeOffset start = DateTimeOffset.UtcNow; + while (!_sawEventCounters && DateTimeOffset.UtcNow - start < timeout) + { + Thread.Sleep(10); + } + + return _sawEventCounters; + } + + public bool WaitUntilEnabled(TimeSpan timeout) + { + DateTimeOffset start = DateTimeOffset.UtcNow; + while (!_enabled && DateTimeOffset.UtcNow - start < timeout) + { + Thread.Sleep(10); + } + + return _enabled; + } +} diff --git a/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheMetersTelemetryTests.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheMetersTelemetryTests.cs new file mode 100644 index 0000000..06f6801 --- /dev/null +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheMetersTelemetryTests.cs @@ -0,0 +1,99 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using Microsoft.Extensions.DependencyInjection; +using MyCSharp.HttpUserAgentParser.DependencyInjection; +using MyCSharp.HttpUserAgentParser.MemoryCache.DependencyInjection; +using MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; +using Xunit; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests.Telemetry; + +public class HttpUserAgentParserMemoryCacheMetersTelemetryTests +{ + [Fact] + public void Meters_Emit_WhenEnabled() + { + HttpUserAgentParserMemoryCacheTelemetry.ResetForTests(); + + using MeterTestListener listener = new("mycsharp.http_user_agent_parser.memorycache"); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithMemoryCacheMeterTelemetry(); + + HttpUserAgentParserMemoryCachedProvider provider = new(new HttpUserAgentParserMemoryCachedProviderOptions()); + + const string ua1 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; + const string ua2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/88.0"; + + _ = provider.Parse(ua1); // miss + _ = provider.Parse(ua1); // hit + _ = provider.Parse(ua2); // miss + + listener.RecordObservableInstruments(); + + Assert.Contains("cache.hit", listener.InstrumentNames); + Assert.Contains("cache.miss", listener.InstrumentNames); + Assert.Contains("cache.size", listener.InstrumentNames); + } + + [Fact] + public void Meters_Emit_WhenEnabled_WithCustomPrefix() + { + HttpUserAgentParserMemoryCacheTelemetry.ResetForTests(); + + const string prefix = "acme."; + using MeterTestListener listener = new("acme.http_user_agent_parser.memorycache"); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithMemoryCacheMeterTelemetryPrefix(prefix); + + HttpUserAgentParserMemoryCachedProvider provider = new(new HttpUserAgentParserMemoryCachedProviderOptions()); + + const string ua1 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; + const string ua2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/88.0"; + + _ = provider.Parse(ua1); // miss + _ = provider.Parse(ua1); // hit + _ = provider.Parse(ua2); // miss + + listener.RecordObservableInstruments(); + + Assert.Contains("cache.hit", listener.InstrumentNames); + Assert.Contains("cache.miss", listener.InstrumentNames); + Assert.Contains("cache.size", listener.InstrumentNames); + } + + [Fact] + public void Meters_Emit_WhenEnabled_WithEmptyPrefix() + { + HttpUserAgentParserMemoryCacheTelemetry.ResetForTests(); + + using MeterTestListener listener = new("http_user_agent_parser.memorycache"); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithMemoryCacheMeterTelemetryPrefix(string.Empty); + + HttpUserAgentParserMemoryCachedProvider provider = new(new HttpUserAgentParserMemoryCachedProviderOptions()); + + const string ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; + + _ = provider.Parse(ua); // miss + + listener.RecordObservableInstruments(); + + Assert.Contains("cache.miss", listener.InstrumentNames); + Assert.Contains("cache.size", listener.InstrumentNames); + } + + [Fact] + public void WithMemoryCacheMeterTelemetryPrefix_Throws_WhenInvalid() + { + HttpUserAgentParserMemoryCacheTelemetry.ResetForTests(); + + HttpUserAgentParserDependencyInjectionOptions options = new(new ServiceCollection()); + + Assert.Throws(() => options.WithMemoryCacheMeterTelemetryPrefix("acme")); + Assert.Throws(() => options.WithMemoryCacheMeterTelemetryPrefix("acme-")); + Assert.Throws(() => options.WithMemoryCacheMeterTelemetryPrefix("acme..")); + } +} diff --git a/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheTelemetryTests.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheTelemetryTests.cs new file mode 100644 index 0000000..1b8950b --- /dev/null +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/HttpUserAgentParserMemoryCacheTelemetryTests.cs @@ -0,0 +1,36 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using Xunit; +using Microsoft.Extensions.DependencyInjection; +using MyCSharp.HttpUserAgentParser.DependencyInjection; +using MyCSharp.HttpUserAgentParser.MemoryCache.DependencyInjection; +using MyCSharp.HttpUserAgentParser.MemoryCache.Telemetry; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests.Telemetry; + +public class HttpUserAgentParserMemoryCacheTelemetryTests +{ + [Fact] + public void EventCounters_DoNotThrow_WhenEnabled() + { + using EventCounterTestListener listener = new(HttpUserAgentParserMemoryCacheEventSource.EventSourceName); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithMemoryCacheTelemetry(); + + HttpUserAgentParserMemoryCachedProvider provider = new(new HttpUserAgentParserMemoryCachedProviderOptions()); + + const string ua1 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; + const string ua2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/88.0"; + + // First call ensures the EventSource gets created (listener enables right after creation). + _ = provider.Parse(ua1); + Assert.True(listener.WaitUntilEnabled(TimeSpan.FromSeconds(2))); + + // Now exercise telemetry-enabled paths: miss (ua2), hit (ua1) + _ = provider.Parse(ua2); // miss under enabled + _ = provider.Parse(ua1); // hit under enabled + + Assert.True(listener.WaitForCounters(TimeSpan.FromSeconds(2))); + } +} diff --git a/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/MeterTestListener.cs b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/MeterTestListener.cs new file mode 100644 index 0000000..47a07d1 --- /dev/null +++ b/tests/HttpUserAgentParser.MemoryCache.UnitTests/Telemetry/MeterTestListener.cs @@ -0,0 +1,41 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Collections.Concurrent; +using System.Diagnostics.Metrics; + +namespace MyCSharp.HttpUserAgentParser.MemoryCache.UnitTests.Telemetry; + +internal sealed class MeterTestListener : IDisposable +{ + private readonly string _meterName; + private readonly MeterListener _listener; + + public ConcurrentBag InstrumentNames { get; } = []; + + public MeterTestListener(string meterName) + { + _meterName = meterName; + _listener = new MeterListener(); + + _listener.InstrumentPublished = (instrument, listener) => + { + if (!string.Equals(instrument.Meter.Name, _meterName, StringComparison.Ordinal)) + { + return; + } + + listener.EnableMeasurementEvents(instrument); + }; + + _listener.SetMeasurementEventCallback((instrument, _, _, _) => + { + InstrumentNames.Add(instrument.Name); + }); + + _listener.Start(); + } + + public void RecordObservableInstruments() => _listener.RecordObservableInstruments(); + + public void Dispose() => _listener.Dispose(); +} diff --git a/tests/HttpUserAgentParser.UnitTests/Telemetry/EventCounterTestListener.cs b/tests/HttpUserAgentParser.UnitTests/Telemetry/EventCounterTestListener.cs new file mode 100644 index 0000000..91f0e9b --- /dev/null +++ b/tests/HttpUserAgentParser.UnitTests/Telemetry/EventCounterTestListener.cs @@ -0,0 +1,48 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Diagnostics.Tracing; + +namespace MyCSharp.HttpUserAgentParser.UnitTests.Telemetry; + +internal sealed class EventCounterTestListener(string eventSourceName) : EventListener +{ + private readonly string _eventSourceName = eventSourceName; + private volatile bool _sawEventCounters; + + protected override void OnEventSourceCreated(EventSource eventSource) + { + if (!string.Equals(eventSource.Name, _eventSourceName, StringComparison.Ordinal)) + { + return; + } + + EnableEvents( + eventSource, + EventLevel.LogAlways, + EventKeywords.All, + new Dictionary(StringComparer.Ordinal) + { + // Make the test responsive while keeping runtime short. + ["EventCounterIntervalSec"] = "0.1" + }); + } + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + if (string.Equals(eventData.EventName, "EventCounters", StringComparison.Ordinal)) + { + _sawEventCounters = true; + } + } + + public bool WaitForCounters(TimeSpan timeout) + { + DateTimeOffset start = DateTimeOffset.UtcNow; + while (!_sawEventCounters && DateTimeOffset.UtcNow - start < timeout) + { + Thread.Sleep(10); + } + + return _sawEventCounters; + } +} diff --git a/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMeterNameHelperTests.cs b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMeterNameHelperTests.cs new file mode 100644 index 0000000..7426f97 --- /dev/null +++ b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMeterNameHelperTests.cs @@ -0,0 +1,62 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using MyCSharp.HttpUserAgentParser.Telemetry; +using Xunit; + +namespace MyCSharp.HttpUserAgentParser.UnitTests.Telemetry; + +public class HttpUserAgentParserMeterNameHelperTests +{ + private const string Suffix = "http_user_agent_parser.test"; + + [Fact] + public void GetMeterName_NullPrefix_ReturnsDefaultPrefixedName() + { + string result = HttpUserAgentParserMeterNameHelper.GetMeterName(null, Suffix); + + Assert.Equal("mycsharp." + Suffix, result); + } + + [Fact] + public void GetMeterName_EmptyPrefix_ReturnsSuffixOnly() + { + string result = HttpUserAgentParserMeterNameHelper.GetMeterName(string.Empty, Suffix); + + Assert.Equal(Suffix, result); + } + + [Fact] + public void GetMeterName_WhitespaceOnlyPrefix_ReturnsSuffixOnly() + { + string result = HttpUserAgentParserMeterNameHelper.GetMeterName(" ", Suffix); + + Assert.Equal(Suffix, result); + } + + [Fact] + public void GetMeterName_ValidAlphanumericPrefix_ReturnsPrefixedName() + { + string result = HttpUserAgentParserMeterNameHelper.GetMeterName("acme.", Suffix); + + Assert.Equal("acme." + Suffix, result); + } + + [Fact] + public void GetMeterName_ValidNumericPrefix_ReturnsPrefixedName() + { + string result = HttpUserAgentParserMeterNameHelper.GetMeterName("org123.", Suffix); + + Assert.Equal("org123." + Suffix, result); + } + + [Theory] + [InlineData("acme")] // missing trailing dot + [InlineData("acme-")] // hyphen is not alphanumeric + [InlineData("acme..")] // two trailing dots (second is not valid in prefix-1 range check) + [InlineData("ac me.")] // space is not alphanumeric + public void GetMeterName_InvalidPrefix_ThrowsArgumentException(string invalidPrefix) + { + Assert.Throws(() => + HttpUserAgentParserMeterNameHelper.GetMeterName(invalidPrefix, Suffix)); + } +} diff --git a/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMetersTelemetryTests.cs b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMetersTelemetryTests.cs new file mode 100644 index 0000000..0d7715a --- /dev/null +++ b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserMetersTelemetryTests.cs @@ -0,0 +1,114 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using Microsoft.Extensions.DependencyInjection; +using MyCSharp.HttpUserAgentParser.DependencyInjection; +using MyCSharp.HttpUserAgentParser.Providers; +using MyCSharp.HttpUserAgentParser.Telemetry; +using Xunit; + +namespace MyCSharp.HttpUserAgentParser.UnitTests.Telemetry; + +public class HttpUserAgentParserMetersTelemetryTests +{ + [Fact] + public void Meters_DoNotEmit_WhenDisabled() + { + HttpUserAgentParserTelemetry.ResetForTests(); + + using MeterTestListener listener = new(MyCSharp.HttpUserAgentParser.Telemetry.HttpUserAgentParserMeters.MeterName); + + const string ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; + _ = HttpUserAgentInformation.Parse(ua); + + Assert.Empty(listener.InstrumentNames); + } + + [Fact] + public void Meters_Emit_WhenEnabled() + { + HttpUserAgentParserTelemetry.ResetForTests(); + + using MeterTestListener listener = new("mycsharp.http_user_agent_parser"); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithMeterTelemetry(); + + const string ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; + + _ = HttpUserAgentInformation.Parse(ua); + + HttpUserAgentParserCachedProvider provider = new(); + _ = provider.Parse(ua); // miss + _ = provider.Parse(ua); // hit + + listener.RecordObservableInstruments(); + + Assert.Contains("parse.requests", listener.InstrumentNames); + Assert.Contains("parse.duration", listener.InstrumentNames); + Assert.Contains("cache.hit", listener.InstrumentNames); + Assert.Contains("cache.miss", listener.InstrumentNames); + Assert.Contains("cache.size", listener.InstrumentNames); + + Assert.Equal("s", listener.InstrumentUnits["parse.duration"]); + } + + [Fact] + public void Meters_Emit_WhenEnabled_WithCustomPrefix() + { + HttpUserAgentParserTelemetry.ResetForTests(); + + const string prefix = "acme."; + using MeterTestListener listener = new("acme.http_user_agent_parser"); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithMeterTelemetryPrefix(prefix); + + const string ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; + + _ = HttpUserAgentInformation.Parse(ua); + + HttpUserAgentParserCachedProvider provider = new(); + _ = provider.Parse(ua); // miss + _ = provider.Parse(ua); // hit + + listener.RecordObservableInstruments(); + + Assert.Contains("parse.requests", listener.InstrumentNames); + Assert.Contains("parse.duration", listener.InstrumentNames); + Assert.Contains("cache.hit", listener.InstrumentNames); + Assert.Contains("cache.miss", listener.InstrumentNames); + Assert.Contains("cache.size", listener.InstrumentNames); + } + + [Fact] + public void Meters_Emit_WhenEnabled_WithEmptyPrefix() + { + HttpUserAgentParserTelemetry.ResetForTests(); + + using MeterTestListener listener = new("http_user_agent_parser"); + + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithMeterTelemetryPrefix(string.Empty); + + const string ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; + + _ = HttpUserAgentInformation.Parse(ua); + + listener.RecordObservableInstruments(); + + Assert.Contains("parse.requests", listener.InstrumentNames); + Assert.Contains("parse.duration", listener.InstrumentNames); + } + + [Fact] + public void WithMeterTelemetryPrefix_Throws_WhenInvalid() + { + HttpUserAgentParserTelemetry.ResetForTests(); + + HttpUserAgentParserDependencyInjectionOptions options = new(new ServiceCollection()); + + Assert.Throws(() => options.WithMeterTelemetryPrefix("acme")); + Assert.Throws(() => options.WithMeterTelemetryPrefix("acme-")); + Assert.Throws(() => options.WithMeterTelemetryPrefix("acme..")); + } +} diff --git a/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserTelemetryTests.cs b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserTelemetryTests.cs new file mode 100644 index 0000000..a5e2682 --- /dev/null +++ b/tests/HttpUserAgentParser.UnitTests/Telemetry/HttpUserAgentParserTelemetryTests.cs @@ -0,0 +1,34 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using Microsoft.Extensions.DependencyInjection; +using MyCSharp.HttpUserAgentParser.DependencyInjection; +using MyCSharp.HttpUserAgentParser.Providers; +using MyCSharp.HttpUserAgentParser.Telemetry; +using Xunit; + +namespace MyCSharp.HttpUserAgentParser.UnitTests.Telemetry; + +public class HttpUserAgentParserTelemetryTests +{ + [Fact] + public void EventCounters_DoNotThrow_WhenEnabled() + { + using EventCounterTestListener listener = new(HttpUserAgentParserEventSource.EventSourceName); + + // Opt-in telemetry so production default stays overhead-free. + new HttpUserAgentParserDependencyInjectionOptions(new ServiceCollection()) + .WithTelemetry(); + + const string ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36"; + + // Core parser + _ = HttpUserAgentInformation.Parse(ua); + + // ConcurrentDictionary cached provider + HttpUserAgentParserCachedProvider provider = new(); + _ = provider.Parse(ua); // miss + _ = provider.Parse(ua); // hit + + Assert.True(listener.WaitForCounters(TimeSpan.FromSeconds(2))); + } +} diff --git a/tests/HttpUserAgentParser.UnitTests/Telemetry/MeterTestListener.cs b/tests/HttpUserAgentParser.UnitTests/Telemetry/MeterTestListener.cs new file mode 100644 index 0000000..666e9b5 --- /dev/null +++ b/tests/HttpUserAgentParser.UnitTests/Telemetry/MeterTestListener.cs @@ -0,0 +1,48 @@ +// Copyright © https://myCSharp.de - all rights reserved + +using System.Collections.Concurrent; +using System.Diagnostics.Metrics; + +namespace MyCSharp.HttpUserAgentParser.UnitTests.Telemetry; + +internal sealed class MeterTestListener : IDisposable +{ + private readonly string _meterName; + private readonly MeterListener _listener; + + public ConcurrentBag InstrumentNames { get; } = []; + public ConcurrentDictionary InstrumentUnits { get; } = new(StringComparer.Ordinal); + + public MeterTestListener(string meterName) + { + _meterName = meterName; + _listener = new MeterListener(); + + _listener.InstrumentPublished = (instrument, listener) => + { + if (!string.Equals(instrument.Meter.Name, _meterName, StringComparison.Ordinal)) + { + return; + } + + InstrumentUnits[instrument.Name] = instrument.Unit; + listener.EnableMeasurementEvents(instrument); + }; + + _listener.SetMeasurementEventCallback((instrument, _, _, _) => + { + InstrumentNames.Add(instrument.Name); + }); + + _listener.SetMeasurementEventCallback((instrument, _, _, _) => + { + InstrumentNames.Add(instrument.Name); + }); + + _listener.Start(); + } + + public void RecordObservableInstruments() => _listener.RecordObservableInstruments(); + + public void Dispose() => _listener.Dispose(); +} From eb5fa7c104a5ca7a7fdeb7fcfc67ec6f735fd837 Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Thu, 19 Feb 2026 17:25:42 +0100 Subject: [PATCH 21/27] chore(version): bump version to 3.1 (#93) --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index de475b0..72f7505 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "3.0", + "version": "3.1", "nugetPackageVersion": { "semVer": 1 // optional. Set to either 1 or 2 to control how the NuGet package version string is generated. Default is 1. }, From f2aa59e26c6955fe5dba58a6d8b8218f3951759a Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Thu, 19 Feb 2026 18:07:33 +0100 Subject: [PATCH 22/27] add release fix (#94) * chore(project): update company name to myCSharp.de * feat(coverage): add global coverage settings for tests * chore(ci): streamline release workflow by removing unnecessary steps * fix(assembly): consolidate public key into a single line * fix(project): add PublicKey to UnitTests visibility --- .github/workflows/release-publish.yml | 24 +++++++------------ Directory.Build.props | 18 +++++++++++++- README.md | 2 +- .../HttpUserAgentParser.csproj | 2 +- 4 files changed, 27 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index b6dc4a3..ae0c522 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -14,10 +14,14 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - name: Download release assets + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download "${{ github.event.release.tag_name }}" \ + --pattern "*.nupkg" \ + --dir ./artifacts \ + --repo "${{ github.repository }}" - name: Setup .NET uses: actions/setup-dotnet@v4 @@ -27,18 +31,6 @@ jobs: 9.0.x 10.0.x - - name: Restore dependencies - run: dotnet restore - - - name: Build - run: dotnet build --configuration Release --no-restore - - - name: Test - run: dotnet test --configuration Release --no-build --verbosity normal - - - name: Pack NuGet packages - run: dotnet pack --configuration Release --no-build --output ./artifacts - - name: Verify packages run: | echo "Packages to be published:" diff --git a/Directory.Build.props b/Directory.Build.props index 5df9af5..3705a16 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,8 @@ MyCSharp.HttpUserAgentParser MyCSharp, BenjaminAbt, gfoidl - MyCSharp.de + myCSharp.de + myCSharp.de @@ -73,6 +74,21 @@ low + + + true + lcov,opencover,cobertura + $(MSBuildThisFileDirectory)TestResults/coverage/$(MSBuildProjectName). + GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute + **/*Program.cs;**/*Startup.cs;**/*GlobalUsings.cs + true + + + 96 + line + total + + diff --git a/README.md b/README.md index 18d3f13..10f827b 100644 --- a/README.md +++ b/README.md @@ -415,7 +415,7 @@ by [@BenjaminAbt](https://github.com/BenjaminAbt) and [@gfoidl](https://github.c MIT License -Copyright (c) 2021-2025 MyCSharp +Copyright (c) 2021-2026 myCSharp.de Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.csproj b/src/HttpUserAgentParser/HttpUserAgentParser.csproj index b007a8d..0a2f2df 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.csproj +++ b/src/HttpUserAgentParser/HttpUserAgentParser.csproj @@ -21,7 +21,7 @@ - + From 5c04ac16e40ab6c3da099065c3a6b2426f79c5d3 Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Thu, 5 Mar 2026 23:07:03 +0100 Subject: [PATCH 23/27] test(tests): add user agent test cases for Opera on Android (#96) --- .../HttpUserAgentParser.cs | 96 +++++++++++-------- .../HttpUserAgentParserTests.cs | 4 + 2 files changed, 58 insertions(+), 42 deletions(-) diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index e0bc8f7..4711e46 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -236,8 +236,6 @@ private static bool TryIndexOf(ReadOnlySpan haystack, ReadOnlySpan n /// private static bool TryExtractVersion(ReadOnlySpan haystack, out Range range) { - range = default; - // Vectorization is used in a optimistic way and specialized to common (trimmed down) user agents. // When the first two char-vectors don't yield any success, we fall back to the scalar path. // This penalized not found versions, but has an advantage for found versions. @@ -256,7 +254,7 @@ private static bool TryExtractVersion(ReadOnlySpan haystack, out Range ran if (between0and9 == Vector256.Zero) { - goto Scalar; + return TryExtractVersionScalar(haystack, out range); } uint bitMask = between0and9.ExtractMostSignificantBits(); @@ -269,12 +267,17 @@ private static bool TryExtractVersion(ReadOnlySpan haystack, out Range ran if (byteMask == Vector256.Zero) { - goto Scalar; + return TryExtractVersionScalar(haystack, out range); } bitMask = byteMask.ExtractMostSignificantBits(); bitMask >>= start; + if (bitMask == 0) + { + return TryExtractVersionScalar(haystack, out range); + } + idx = start + (int)uint.TrailingZeroCount(bitMask); Debug.Assert(idx is >= 0 and <= 32); int end = idx; @@ -291,7 +294,7 @@ private static bool TryExtractVersion(ReadOnlySpan haystack, out Range ran if (between0and9 == Vector128.Zero) { - goto Scalar; + return TryExtractVersionScalar(haystack, out range); } uint bitMask = between0and9.ExtractMostSignificantBits(); @@ -304,12 +307,17 @@ private static bool TryExtractVersion(ReadOnlySpan haystack, out Range ran if (byteMask == Vector128.Zero) { - goto Scalar; + return TryExtractVersionScalar(haystack, out range); } bitMask = byteMask.ExtractMostSignificantBits(); bitMask >>= start; + if (bitMask == 0) + { + return TryExtractVersionScalar(haystack, out range); + } + idx = start + (int)uint.TrailingZeroCount(bitMask); Debug.Assert(idx is >= 0 and <= 16); int end = idx; @@ -318,53 +326,57 @@ private static bool TryExtractVersion(ReadOnlySpan haystack, out Range ran return true; } - Scalar: - { - // Limit search window to avoid scanning entire UA string unnecessarily - const int Windows = 128; - if (haystack.Length > Windows) - { - haystack = haystack.Slice(0, Windows); - } + return TryExtractVersionScalar(haystack, out range); + } - int start = -1; - int i = 0; + private static bool TryExtractVersionScalar(ReadOnlySpan haystack, out Range range) + { + range = default; - for (; i < haystack.Length; ++i) - { - char c = haystack[i]; - if (char.IsBetween(c, '0', '9')) - { - start = i; - break; - } - } + // Limit search window to avoid scanning entire UA string unnecessarily + const int Windows = 128; + if (haystack.Length > Windows) + { + haystack = haystack.Slice(0, Windows); + } - if (start < 0) - { - // No digit found => no version - return false; - } + int start = -1; + int i = 0; - haystack = haystack.Slice(i + 1); - for (i = 0; i < haystack.Length; ++i) + for (; i < haystack.Length; ++i) + { + char c = haystack[i]; + if (char.IsBetween(c, '0', '9')) { - char c = haystack[i]; - if (!(char.IsBetween(c, '0', '9') || c == '.')) - { - break; - } + start = i; + break; } + } - i += start + 1; // shift back the previous domain + if (start < 0) + { + // No digit found => no version + return false; + } - if (i == start) + haystack = haystack.Slice(i + 1); + for (i = 0; i < haystack.Length; ++i) + { + char c = haystack[i]; + if (!(char.IsBetween(c, '0', '9') || c == '.')) { - return false; + break; } + } - range = new Range(start, i); - return true; + i += start + 1; // shift back the previous domain + + if (i == start) + { + return false; } + + range = new Range(start, i); + return true; } } diff --git a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs index 972f42a..608e9f6 100644 --- a/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs +++ b/tests/HttpUserAgentParser.UnitTests/HttpUserAgentParserTests.cs @@ -54,6 +54,10 @@ public class HttpUserAgentParserTests [InlineData("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36 OPR/76.0.4017.107", "Opera", "76.0.4017.107", "Windows 10", HttpUserAgentPlatformType.Windows, null)] [InlineData("Mozilla/5.0 (Macintosh; Intel Mac OS X 11_3_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36 OPR/76.0.4017.107", "Opera", "76.0.4017.107", "Mac OS X", HttpUserAgentPlatformType.MacOS, null)] [InlineData("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36 OPR/76.0.4017.107", "Opera", "76.0.4017.107", "Linux", HttpUserAgentPlatformType.Linux, null)] + [InlineData("Mozilla/5.0 (Linux; U; Android 13; Hisense U53 Build/TP1A.220624.014; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/145.0.7632.79 Mobile Safari/537.36 OPR/98.0.2254.81553", "Opera", "98.0.2254.81553", "Android", HttpUserAgentPlatformType.Android, "Android")] + [InlineData("Mozilla/5.0 (Linux; Android 10; MED-LX9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.5481.192 Mobile Safari/537.36 OPR/74.0.3922.71152", "Opera", "74.0.3922.71152", "Android", HttpUserAgentPlatformType.Android, "Android")] + [InlineData("Mozilla/5.0 (Linux; U; Android 13; Infinix X6526 Build/TP1A.220624.014; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/145.0.7632.79 Mobile Safari/537.36 OPR/97.1.2254.80849", "Opera", "97.1.2254.80849", "Android", HttpUserAgentPlatformType.Android, "Android")] + [InlineData("Mozilla/5.0 (Linux; U; Android 13; Infinix X6836 Build/TP1A.220624.014; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/145.0.7632.120 Mobile Safari/537.36 OPR/98.0.2254.81553", "Opera", "98.0.2254.81553", "Android", HttpUserAgentPlatformType.Android, "Android")] public void BrowserTests(string ua, string name, string version, string platformName, HttpUserAgentPlatformType platformType, string? mobileDeviceType) { HttpUserAgentInformation uaInfo = HttpUserAgentInformation.Parse(ua); From d3231059727f52fa43a8ef452f75ad25a02c2db0 Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Wed, 1 Apr 2026 20:49:01 +0200 Subject: [PATCH 24/27] chore(package): update license handling and add MIT license expression (#97) --- Directory.Build.props | 16 +------------- .../HttpUserAgentParser.AspNetCore.csproj | 2 -- .../LICENSE.txt | 21 ------------------- .../HttpUserAgentParser.MemoryCache.csproj | 2 -- .../LICENSE.txt | 21 ------------------- .../HttpUserAgentParser.csproj | 2 -- src/HttpUserAgentParser/LICENSE.txt | 21 ------------------- 7 files changed, 1 insertion(+), 84 deletions(-) delete mode 100644 src/HttpUserAgentParser.AspNetCore/LICENSE.txt delete mode 100644 src/HttpUserAgentParser.MemoryCache/LICENSE.txt delete mode 100644 src/HttpUserAgentParser/LICENSE.txt diff --git a/Directory.Build.props b/Directory.Build.props index 3705a16..cdc4524 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -56,6 +56,7 @@ HTTP User Agent Parser for .NET https://github.com/mycsharp/HttpUserAgentParser https://github.com/mycsharp/HttpUserAgentParser + MIT UserAgent, User Agent, Parse, Browser, Client, Detector, Detection, Console, ASP, Desktop, Mobile @@ -111,21 +112,6 @@ - - - true - lcov,opencover,cobertura - $(MSBuildThisFileDirectory)TestResults/coverage/$(MSBuildProjectName). - GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute - **/*Program.cs;**/*Startup.cs;**/*GlobalUsings.cs - true - - - 96 - line - total - - [MyCSharp.HttpUserAgentParser]* diff --git a/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParser.AspNetCore.csproj b/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParser.AspNetCore.csproj index 19d4fa6..ba7a8f0 100644 --- a/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParser.AspNetCore.csproj +++ b/src/HttpUserAgentParser.AspNetCore/HttpUserAgentParser.AspNetCore.csproj @@ -8,12 +8,10 @@ true readme.md - LICENSE.txt - diff --git a/src/HttpUserAgentParser.AspNetCore/LICENSE.txt b/src/HttpUserAgentParser.AspNetCore/LICENSE.txt deleted file mode 100644 index df1bf06..0000000 --- a/src/HttpUserAgentParser.AspNetCore/LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2021-2026 myCSharp.de - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParser.MemoryCache.csproj b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParser.MemoryCache.csproj index 9a97676..163172a 100644 --- a/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParser.MemoryCache.csproj +++ b/src/HttpUserAgentParser.MemoryCache/HttpUserAgentParser.MemoryCache.csproj @@ -8,12 +8,10 @@ true readme.md - LICENSE.txt - diff --git a/src/HttpUserAgentParser.MemoryCache/LICENSE.txt b/src/HttpUserAgentParser.MemoryCache/LICENSE.txt deleted file mode 100644 index df1bf06..0000000 --- a/src/HttpUserAgentParser.MemoryCache/LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2021-2026 myCSharp.de - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.csproj b/src/HttpUserAgentParser/HttpUserAgentParser.csproj index 0a2f2df..b783b72 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.csproj +++ b/src/HttpUserAgentParser/HttpUserAgentParser.csproj @@ -8,12 +8,10 @@ true readme.md - LICENSE.txt - diff --git a/src/HttpUserAgentParser/LICENSE.txt b/src/HttpUserAgentParser/LICENSE.txt deleted file mode 100644 index df1bf06..0000000 --- a/src/HttpUserAgentParser/LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2021-2026 myCSharp.de - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. From 4a4d4b563818216926d26a90d43380a073b0d130 Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Wed, 1 Apr 2026 20:56:30 +0200 Subject: [PATCH 25/27] chore: add nuget logo (#98) --- Directory.Build.props | 11 +++++++++++ res/mycsharpde-logo-nuget.png | Bin 0 -> 102068 bytes 2 files changed, 11 insertions(+) create mode 100644 res/mycsharpde-logo-nuget.png diff --git a/Directory.Build.props b/Directory.Build.props index cdc4524..91f65ac 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -58,6 +58,17 @@ https://github.com/mycsharp/HttpUserAgentParser MIT UserAgent, User Agent, Parse, Browser, Client, Detector, Detection, Console, ASP, Desktop, Mobile + + unio.png + + + + + + + + + false diff --git a/res/mycsharpde-logo-nuget.png b/res/mycsharpde-logo-nuget.png new file mode 100644 index 0000000000000000000000000000000000000000..8657cbb2764e38ff4f82d0036df99f98393d5296 GIT binary patch literal 102068 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 Date: Wed, 1 Apr 2026 21:03:17 +0200 Subject: [PATCH 26/27] chore(package): update package icon to mycsharpde-logo-nuget.png (#99) --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 91f65ac..5f33e4a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -59,7 +59,7 @@ MIT UserAgent, User Agent, Parse, Browser, Client, Detector, Detection, Console, ASP, Desktop, Mobile - unio.png + mycsharpde-logo-nuget.png From 01fe87d30f2d9ad25f75239432af9667151d6631 Mon Sep 17 00:00:00 2001 From: BEN ABT Date: Wed, 1 Apr 2026 21:19:28 +0200 Subject: [PATCH 27/27] ci: update GitHub Actions to use latest versions (#101) --- .github/workflows/build-and-test.yml | 8 ++++---- .github/workflows/main-build.yml | 2 +- .github/workflows/release-publish.yml | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 54a3e7b..02615ec 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -40,12 +40,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 # Required for GitVersion - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: ${{ inputs.dotnet-version }} global-json-file: ./global.json @@ -71,7 +71,7 @@ jobs: - name: Upload test results if: always() && inputs.upload-test-results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: test-results-${{ inputs.dotnet-version }} path: '**/TestResults/**/*.trx' @@ -82,7 +82,7 @@ jobs: - name: Upload NuGet packages if: inputs.create-pack - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: nuget-packages path: ./artifacts/*.nupkg diff --git a/.github/workflows/main-build.yml b/.github/workflows/main-build.yml index 6e3a096..4c810f0 100644 --- a/.github/workflows/main-build.yml +++ b/.github/workflows/main-build.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Download NuGet packages uses: actions/download-artifact@v4 diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index ae0c522..874c3df 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -24,7 +24,7 @@ jobs: --repo "${{ github.repository }}" - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: | 8.0.x @@ -60,7 +60,7 @@ jobs: echo "All packages published successfully!" - name: Upload published packages as artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: published-nuget-packages path: ./artifacts/*.nupkg