From c6e5d33ecc9cefc2f0e76c844195fc31084bdab4 Mon Sep 17 00:00:00 2001 From: Ivan Jones Date: Sun, 16 Nov 2025 18:32:14 -0800 Subject: [PATCH 1/3] Refactor tests and modernize codebase - Removed deprecated test files, including `CliInputUtilitiesTests.cs` and `MenuHelperTests.cs`. - Updated tests to use C# 12 collection literals (`[...]`) for arrays and lists. - Renamed `FrameworkOptionsTests` to `OptionsTests` and `DivideByZeroExtensionsTests` to `MathExtensionsTests` to reflect class renaming. - Simplified object initialization with `new()` and improved null-checking. - Removed `version.json` and added new NuGet dependencies in `VisionaryCoder.Framework.Tests.csproj`. - Refactored `StorageServiceTests` and `QueryFilterExtensionsTests` for clarity and modern syntax. - General cleanup and alignment with modern C# practices. --- .github/copilot-instructions.md | 7 +- .github/csharp.instructions.md | 16 +- .nuget/NuGet/NuGet.config | 6 +- Directory.Build.props | 10 +- Directory.Build.targets | 12 - Directory.Packages.props | 67 +- README.md | 20 +- VisionaryCoder.Framework.README.md | 270 ----- VisionaryCoder.Framework.sln | 9 +- global.json | 8 - .../Authentication/SOLID_USAGE_EXAMPLES.md | 214 ---- .../Configuration/AppConfigurationHelper.cs | 50 - .../AzureAppConfigurationProviderOptions.cs | 74 -- .../IAppConfigurationProvider.cs | 41 - .../LocalAppConfigurationProviderOptions.cs | 66 -- ...ions.cs => DataConfigurationExtensions.cs} | 8 +- .../Extensions/DictionaryExtensions.cs | 405 +++---- .../Extensions/EnumerableExtensions.cs | 2 +- ...eByZeroExtensions.cs => MathExtensions.cs} | 2 +- .../Extensions/ServiceCollectionExtensions.cs | 50 - .../Extensions/TypeExtension.cs | 1031 +++++++++-------- .../Abstractions/IFilterExecutionStrategy.cs | 2 + .../EFCore/EfFilterExecutionStrategy.cs | 16 +- .../EFCore/EfFilterExpressionBuilder.cs | 170 +++ .../Filtering/ExpressionToFilterNode.cs | 252 +++- .../Filtering/Filter.cs | 2 + .../Filtering/FilterBuilder.cs | 8 +- .../Filtering/FilterCombination.cs | 2 + .../Filtering/FilterCondition.cs | 2 + .../Filtering/FilterGroup.cs | 2 + .../Filtering/FilterNode.cs | 2 + .../Filtering/FilterOperator.cs | 2 + .../Poco/PocoFilterExecutionStrategy.cs | 15 +- .../Poco/PocoFilterExpressionBuilder.cs | 166 +++ .../Serialization/ExpressionToFilterNode.cs | 391 ------- .../Logging/LogHelper.cs | 4 +- ...tionExtensions.cs => LoggingExtensions.cs} | 39 +- .../Logging/LoggingOptions.cs | 27 + src/VisionaryCoder.Framework/Models/Month.cs | 4 +- .../{FrameworkOptions.cs => Options.cs} | 2 +- .../Pagination/PageExtensions.cs | 4 +- .../Abstractions/EndpointResolution.cs | 3 + .../Pipeline/Abstractions/ICache.cs | 7 + .../Abstractions/IEndpointResolver.cs | 7 + .../Pipeline/Abstractions/IInterceptor.cs | 6 + .../Pipeline/Abstractions/IInvoker.cs | 7 + .../Pipeline/Abstractions/ILocalDispatcher.cs | 7 + .../Abstractions/IRemoteDispatcher.cs | 7 + .../Pipeline/Abstractions/IRequest.cs | 3 + .../Pipeline/Abstractions/IRequestHandler.cs | 7 + .../Pipeline/Abstractions/IServiceRegistry.cs | 8 + .../Pipeline/Abstractions/ISpan.cs | 7 + .../Pipeline/Correlation.cs | 12 + .../Abstractions/GenericInvoker.proto | 14 + .../Dispatch/Abstractions/ISerializer.cs | 7 + .../Pipeline/Dispatch/GenericGrpcClient.cs | 25 + .../Pipeline/Dispatch/GrpcRemoteDispatcher.cs | 33 + .../Pipeline/Dispatch/HttpRemoteDispatcher.cs | 37 + .../Pipeline/Dispatch/LocalDispatcher.cs | 16 + .../Dispatch/SystemTextJsonSerializer.cs | 10 + .../Abstractions/IAuthorizationService.cs | 6 + .../Pipeline/Interceptors/AuthInterceptor.cs | 15 + .../Interceptors/CachingInterceptor.cs | 24 + .../Interceptors/LoggingInterceptor.cs | 34 + .../Interceptors/MetricsInterceptor.cs | 36 + .../Interceptors/ResilienceInterceptor.cs | 20 + .../Interceptors/TracingInterceptor.cs | 37 + .../Observibility/Abstractions/IMetrics.cs | 7 + .../Observibility/Abstractions/ITracer.cs | 8 + .../Observibility/OpenTelemetryMetrics.cs | 26 + .../Observibility/OpenTelemetryTracer.cs | 23 + .../Pipeline/PipelineInvoker.cs | 34 + .../Routing/InMemoryServiceRegistry.cs | 19 + .../Pipeline/Routing/KubernetesDnsRegistry.cs | 13 + .../Pipeline/Routing/RegistryBasedResolver.cs | 44 + .../Pipeline/Routing/ServiceEntry.cs | 8 + .../EFCore/EntityIdModelBuilderExtensions.cs | 11 +- .../Data/EFCore/EntityIdValueConverter.cs | 2 + .../Web/AspNetCore/EntityIdModelBinder.cs | 1 + .../AspNetCore/EntityIdModelBinderProvider.cs | 9 +- .../Proxy/Caching/CachePolicy.cs | 19 - .../Proxy/Caching/CachingOptions.cs | 23 - .../Proxy/Caching/IProxyCache.cs | 24 - .../Proxy/Caching/MemoryProxyCache.cs | 35 - .../{ProxyExceptions.cs => ProxyException.cs} | 0 .../Auditing/AuditingInterceptor.cs | 4 +- .../Interceptors/Auditing/LoggingAuditSink.cs | 2 + .../AuthenticationExtensions.cs} | 23 +- .../JwtAuthenticationInterceptor.cs | 12 +- .../Interceptors/KeyVaultJwtInterceptor.cs | 101 +- .../Interceptors/KeyVaultJwtOptions.cs | 96 ++ .../Authentication/Jwt/ITokenProvider.cs | 2 +- .../Authentication/Jwt/JwtOptions.cs | 6 +- .../Authentication/Jwt/TokenRequest.cs | 10 +- .../Authentication/Jwt/TokenResult.cs | 4 +- .../Providers/DefaultTenantContextProvider.cs | 75 +- .../Providers/DefaultTokenProvider.cs | 45 +- .../Providers/DefaultUserContextProvider.cs | 71 +- .../Providers/ITenantContextProvider.cs | 16 +- .../Providers/IUserContextProvider.cs | 16 +- .../Providers/NullTenantContextProvider.cs | 4 +- .../Providers/NullTokenProvider.cs | 16 +- .../Providers/NullUserContextProvider.cs | 4 +- .../Authentication/TenantContext.cs | 14 +- .../Authentication/UserContext.cs | 2 +- .../Authorization/AuthorizationExtensions.cs} | 10 +- .../Policies/IAuthorizationPolicy.cs | 16 +- .../Policies/NullAuthorizationPolicy.cs | 4 +- .../Policies/RoleBasedAuthorizationPolicy.cs | 17 +- .../Results/AuthorizationResult.cs | 4 +- .../Interceptors}/Caching/CachePolicy.cs | 4 +- .../Caching/CachingExtensions.cs} | 24 +- .../Caching/CachingInterceptor.cs | 9 +- ...ons.cs => CachingInterceptorExtensions.cs} | 7 +- .../Interceptors}/Caching/CachingOptions.cs | 4 +- .../Caching/DefaultCacheKeyProvider.cs | 2 - .../Caching/DefaultCachePolicyProvider.cs | 2 - .../Caching/ICacheKeyProvider.cs | 2 +- .../Caching/ICachePolicyProvider.cs | 2 +- .../Caching/ICachingInterceptor.cs | 4 +- .../Interceptors}/Caching/IProxyCache.cs | 6 +- .../Interceptors/CachingInterceptor.cs | 32 +- .../Interceptors}/Caching/MemoryProxyCache.cs | 10 +- .../Caching/NullCachingInterceptor.cs | 2 +- .../Providers/DefaultCacheKeyProvider.cs | 7 +- .../Providers/DefaultCachePolicyProvider.cs | 10 +- .../Caching/Providers/ICacheKeyProvider.cs | 6 +- .../Caching/Providers/ICachePolicyProvider.cs | 4 +- .../Caching/Providers/NullCacheKeyProvider.cs | 4 +- .../Providers/NullCachePolicyProvider.cs | 4 +- .../Caching/Providers/NullProxyCache.cs | 4 +- .../Azure/AzureConfigurationProvider.cs} | 186 +-- .../AzureConfigurationProviderOptions.cs | 41 + ...eConfigurationProviderOptionsExtensions.cs | 33 + .../Configuration/ConfigurationExtensions.cs} | 43 +- .../Configuration/ConfigurationHelper.cs | 35 + .../Configuration/ConfigurationOptions.cs} | 6 +- .../Configuration/ConfigurationProvider.cs | 180 +++ .../ConfigurationProviderOptions.cs | 25 + .../ConfigurationProviderOptionsExtensions.cs | 16 + .../Configuration/IConfigurationProvider.cs | 27 + .../Local/LocalConfigurationProvider.cs} | 357 ++---- .../LocalConfigurationProviderOptions.cs | 37 + ...lConfigurationProviderOptionsExtensions.cs | 23 + .../Correlation/CorrelationInterceptor.cs | 2 + .../Interceptors/ILoggingInterceptor.cs | 4 +- .../Logging/LoggingInterceptor.cs | 1 + ...ons.cs => LoggingInterceptorExtensions.cs} | 4 +- .../Interceptors/Logging/TimingInterceptor.cs | 1 + .../Interceptors/LoggingInterceptor.cs | 19 +- .../Interceptors/NullLoggingInterceptor.cs | 6 +- ...sions.cs => ProxyInterceptorExtensions.cs} | 8 +- .../QueryFiltering/QueryFilterInterceptor.cs | 6 +- .../Resilience/RateLimitingInterceptor.cs | 2 +- .../Resilience/ResilienceInterceptor.cs | 3 + .../Retries/CircuitBreakerInterceptor.cs | 1 + .../Interceptors/Retries/RetryInterceptor.cs | 6 +- .../Security/SecurityInterceptor.cs | 1 + ...ns.cs => SecurityInterceptorExtensions.cs} | 4 +- .../Security/Web/JwtBearerEnricher.cs | 2 + .../Security/Web/JwtBearerInterceptor.cs | 1 + .../Security/Web/KeyVaultJwtInterceptor.cs | 1 + .../Interceptors/Security/Web/TokenRequest.cs | 2 +- .../Security/Web/WebJwtInterceptor.cs | 2 + .../Security/Web/WebJwtOptions.cs | 2 +- .../Telemetry/TelemetryInterceptor.cs | 2 + .../Interceptors/TimingInterceptor.cs | 21 +- ...ectionExtensions.cs => ProxyExtensions.cs} | 28 +- .../Querying/QueryFilterExtensions.cs | 8 +- .../Querying/QueryFilterSchemaValidator.cs | 127 ++ .../Querying/QueryFilterValidator.cs | 40 - .../Serialization/QueryFilterRehydrator.cs | 28 +- ...ionExtensions.cs => KeyVaultExtensions.cs} | 8 +- .../Azure/KeyVault/KeyVaultSecretProvider.cs | 135 ++- .../Secrets/Local/LocalSecretProvider.cs | 1 + ...cretProviderServiceCollectionExtensions.cs | 3 - src/VisionaryCoder.Framework/ServiceBase.cs | 2 + src/VisionaryCoder.Framework/ServiceResult.cs | 122 +- .../ServiceResultOfType.cs | 85 -- .../Azure/Blob/AzureBlobStorageOptions.cs | 10 +- .../Azure/Blob/AzureBlobStorageProvider.cs | 11 +- .../Azure/Queue/AzureQueueStorageOptions.cs | 8 +- .../Azure/Queue/AzureQueueStorageProvider.cs | 7 +- .../Azure/Table/AzureTableStorageOptions.cs | 8 +- .../Azure/Table/AzureTableStorageProvider.cs | 11 +- .../Storage/Ftp/FtpStorageProvider.cs | 2 + .../Storage/Local/LocalStorageProvider.cs | 2 + ...tionExtensions.cs => StorageExtensions.cs} | 8 +- .../Storage/StorageRegistrationBuilder.cs | 2 + .../Storage/StorageService.cs | 20 +- .../VisionaryCoder.Framework.csproj | 134 ++- .../AssemblyInfo.cs | 5 + ...icationServiceCollectionExtensionsTests.cs | 43 +- .../Authentication/TenantContextTests.cs | 10 +- .../Authentication/UserContextTests.cs | 8 +- ...izationServiceCollectionExtensionsTests.cs | 19 +- .../Results/AuthorizationResultTests.cs | 8 +- .../BasicTests.cs | 2 + ...CachingServiceCollectionExtensionsTests.cs | 51 +- .../Providers/DefaultCacheKeyProviderTests.cs | 74 +- .../Providers/NullCacheKeyProviderTests.cs | 54 +- .../AppConfigurationOptionsTests.cs.skip | 601 ---------- .../CorrelationIdProviderTests.cs | 6 +- .../Extensions/CliInputUtilitiesTests.cs | 567 --------- .../Extensions/CollectionExtensionsTests.cs | 12 +- .../Extensions/DictionaryExtensionsTests.cs | 2 +- .../Extensions/EnumerableExtensionsTests.cs | 4 +- .../Extensions/HashSetExtensionsTests.cs | 20 +- ...ensionsTests.cs => MathExtensionsTests.cs} | 62 +- .../Extensions/MenuHelperTests.cs | 375 ------ .../Extensions/ReflectionExtensionsTests.cs | 15 +- .../Extensions/TestClass.cs | 29 - .../Extensions/TypeExtensionTests.cs | 4 +- .../FrameworkInfoProviderTests.cs | 1 - .../FrameworkResultTests.cs | 6 +- .../IFrameworkInfoProviderTests.cs | 2 +- .../IRequestIdProviderTests.cs | 2 +- .../Logging/LogDelegatesTests.cs | 2 +- .../Logging/LogHelperTests.cs | 2 + ...ameworkOptionsTests.cs => OptionsTests.cs} | 34 +- .../Pagination/PageExtensionsTests.cs | 3 +- .../Pagination/PageTests.cs | 16 +- .../EntityIdJsonConverterFactoryTests.cs | 6 +- .../Primitives/EntityIdTests.cs | 2 +- .../Providers/CorrelationIdProviderTests.cs | 2 +- .../Providers/FrameworkInfoProviderTests.cs | 2 +- .../Providers/RequestIdProviderTests.cs | 2 +- .../Proxy/AuditRecordTests.cs | 2 +- .../Proxy/DefaultProxyPipelineTests.cs | 1 + .../Proxy/ExceptionTests.cs | 1 - .../Auditing/AuditingInterceptorTests.cs | 8 +- .../Interceptors/Caching/CachePolicyTests.cs | 3 +- .../Caching/CachingInterceptorTests.cs | 4 +- .../Caching/CachingOptionsTests.cs | 3 +- .../Caching/DefaultCacheKeyProviderTests.cs | 2 +- .../Caching/NullCachingInterceptorTests.cs | 6 +- .../CorrelationInterceptorTests.cs | 2 + .../Logging/LoggingInterceptorTests.cs | 2 + .../Logging/TimingInterceptorTests.cs | 2 + .../OrderedProxyInterceptorTests.cs | 1 + .../QueryFilterPipelineTests.cs.bak | 91 -- .../Retries/RetryInterceptorTests.cs | 3 + .../Security/AuthorizationResultTests.cs | 2 +- .../Security/TenantContextTests.cs | 2 +- .../Proxy/ProxyResponseTests.cs | 2 +- .../Proxy/ProxyTestsPlaceholder.cs | 2 + .../Querying/QueryFilterExtensionsTests.cs | 30 +- .../Querying/Serialization/Class1.cs | 1 + .../RequestIdProviderTests.cs | 6 +- .../Secrets/LocalSecretProviderTests.cs | 3 + .../ServiceBaseTests.cs | 15 +- .../SimpleTest.cs | 4 +- .../Storage/StorageFactoryOptionsTests.cs | 76 +- .../Storage/StorageServiceTests.cs | 100 +- .../VisionaryCoder.Framework.Tests/Usings.cs | 2 + .../VisionaryCoder.Framework.Tests.csproj | 26 +- version.json | 24 - 257 files changed, 3946 insertions(+), 5357 deletions(-) delete mode 100644 VisionaryCoder.Framework.README.md delete mode 100644 global.json delete mode 100644 src/VisionaryCoder.Framework/Authentication/SOLID_USAGE_EXAMPLES.md delete mode 100644 src/VisionaryCoder.Framework/Configuration/AppConfigurationHelper.cs delete mode 100644 src/VisionaryCoder.Framework/Configuration/Azure/AzureAppConfigurationProviderOptions.cs delete mode 100644 src/VisionaryCoder.Framework/Configuration/IAppConfigurationProvider.cs delete mode 100644 src/VisionaryCoder.Framework/Configuration/Local/LocalAppConfigurationProviderOptions.cs rename src/VisionaryCoder.Framework/Extensions/{DataConfigurationServiceCollectionExtensions.cs => DataConfigurationExtensions.cs} (93%) rename src/VisionaryCoder.Framework/Extensions/{DivideByZeroExtensions.cs => MathExtensions.cs} (98%) delete mode 100644 src/VisionaryCoder.Framework/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/VisionaryCoder.Framework/Filtering/EFCore/EfFilterExpressionBuilder.cs create mode 100644 src/VisionaryCoder.Framework/Filtering/Poco/PocoFilterExpressionBuilder.cs delete mode 100644 src/VisionaryCoder.Framework/Filtering/Serialization/ExpressionToFilterNode.cs rename src/VisionaryCoder.Framework/Logging/{LoggingServiceCollectionExtensions.cs => LoggingExtensions.cs} (86%) create mode 100644 src/VisionaryCoder.Framework/Logging/LoggingOptions.cs rename src/VisionaryCoder.Framework/{FrameworkOptions.cs => Options.cs} (95%) create mode 100644 src/VisionaryCoder.Framework/Pipeline/Abstractions/EndpointResolution.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Abstractions/ICache.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Abstractions/IEndpointResolver.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Abstractions/IInterceptor.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Abstractions/IInvoker.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Abstractions/ILocalDispatcher.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Abstractions/IRemoteDispatcher.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Abstractions/IRequest.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Abstractions/IRequestHandler.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Abstractions/IServiceRegistry.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Abstractions/ISpan.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Correlation.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Dispatch/Abstractions/GenericInvoker.proto create mode 100644 src/VisionaryCoder.Framework/Pipeline/Dispatch/Abstractions/ISerializer.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Dispatch/GenericGrpcClient.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Dispatch/GrpcRemoteDispatcher.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Dispatch/HttpRemoteDispatcher.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Dispatch/LocalDispatcher.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Dispatch/SystemTextJsonSerializer.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Interceptors/Abstractions/IAuthorizationService.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Interceptors/AuthInterceptor.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Interceptors/CachingInterceptor.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Interceptors/LoggingInterceptor.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Interceptors/MetricsInterceptor.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Interceptors/ResilienceInterceptor.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Interceptors/TracingInterceptor.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Observibility/Abstractions/IMetrics.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Observibility/Abstractions/ITracer.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Observibility/OpenTelemetryMetrics.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Observibility/OpenTelemetryTracer.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/PipelineInvoker.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Routing/InMemoryServiceRegistry.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Routing/KubernetesDnsRegistry.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Routing/RegistryBasedResolver.cs create mode 100644 src/VisionaryCoder.Framework/Pipeline/Routing/ServiceEntry.cs delete mode 100644 src/VisionaryCoder.Framework/Proxy/Caching/CachePolicy.cs delete mode 100644 src/VisionaryCoder.Framework/Proxy/Caching/CachingOptions.cs delete mode 100644 src/VisionaryCoder.Framework/Proxy/Caching/IProxyCache.cs delete mode 100644 src/VisionaryCoder.Framework/Proxy/Caching/MemoryProxyCache.cs rename src/VisionaryCoder.Framework/Proxy/Exceptions/{ProxyExceptions.cs => ProxyException.cs} (100%) rename src/VisionaryCoder.Framework/{Authentication/AuthenticationServiceCollectionExtensions.cs => Proxy/Interceptors/Authentication/AuthenticationExtensions.cs} (96%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authentication/Interceptors/JwtAuthenticationInterceptor.cs (96%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authentication/Interceptors/KeyVaultJwtInterceptor.cs (71%) create mode 100644 src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Interceptors/KeyVaultJwtOptions.cs rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authentication/Jwt/ITokenProvider.cs (97%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authentication/Jwt/JwtOptions.cs (98%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authentication/Jwt/TokenRequest.cs (96%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authentication/Jwt/TokenResult.cs (98%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authentication/Providers/DefaultTenantContextProvider.cs (87%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authentication/Providers/DefaultTokenProvider.cs (89%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authentication/Providers/DefaultUserContextProvider.cs (84%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authentication/Providers/ITenantContextProvider.cs (78%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authentication/Providers/IUserContextProvider.cs (72%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authentication/Providers/NullTenantContextProvider.cs (94%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authentication/Providers/NullTokenProvider.cs (85%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authentication/Providers/NullUserContextProvider.cs (93%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authentication/TenantContext.cs (87%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authentication/UserContext.cs (98%) rename src/VisionaryCoder.Framework/{Authorization/AuthorizationServiceCollectionExtensions.cs => Proxy/Interceptors/Authorization/AuthorizationExtensions.cs} (94%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authorization/Policies/IAuthorizationPolicy.cs (72%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authorization/Policies/NullAuthorizationPolicy.cs (93%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authorization/Policies/RoleBasedAuthorizationPolicy.cs (94%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Authorization/Results/AuthorizationResult.cs (97%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Caching/CachePolicy.cs (94%) rename src/VisionaryCoder.Framework/{Caching/CachingServiceCollectionExtensions.cs => Proxy/Interceptors/Caching/CachingExtensions.cs} (90%) rename src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/{CachingInterceptorServiceCollectionExtensions.cs => CachingInterceptorExtensions.cs} (90%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Caching/CachingOptions.cs (96%) rename src/VisionaryCoder.Framework/Proxy/{ => Interceptors}/Caching/ICacheKeyProvider.cs (85%) rename src/VisionaryCoder.Framework/Proxy/{ => Interceptors}/Caching/ICachePolicyProvider.cs (90%) rename src/VisionaryCoder.Framework/Proxy/{ => Interceptors}/Caching/ICachingInterceptor.cs (84%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Caching/IProxyCache.cs (92%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Caching/Interceptors/CachingInterceptor.cs (83%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Caching/MemoryProxyCache.cs (94%) rename src/VisionaryCoder.Framework/Proxy/{ => Interceptors}/Caching/NullCachingInterceptor.cs (90%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Caching/Providers/DefaultCacheKeyProvider.cs (94%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Caching/Providers/DefaultCachePolicyProvider.cs (94%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Caching/Providers/ICacheKeyProvider.cs (77%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Caching/Providers/ICachePolicyProvider.cs (93%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Caching/Providers/NullCacheKeyProvider.cs (94%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Caching/Providers/NullCachePolicyProvider.cs (95%) rename src/VisionaryCoder.Framework/{ => Proxy/Interceptors}/Caching/Providers/NullProxyCache.cs (97%) rename src/VisionaryCoder.Framework/{Configuration/Azure/AzureAppConfigurationProvider.cs => Proxy/Interceptors/Configuration/Azure/AzureConfigurationProvider.cs} (51%) create mode 100644 src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Azure/AzureConfigurationProviderOptions.cs create mode 100644 src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Azure/AzureConfigurationProviderOptionsExtensions.cs rename src/VisionaryCoder.Framework/{Configuration/AppConfigurationServiceCollectionExtensions.cs => Proxy/Interceptors/Configuration/ConfigurationExtensions.cs} (66%) create mode 100644 src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationHelper.cs rename src/VisionaryCoder.Framework/{Configuration/AppConfigurationOptions.cs => Proxy/Interceptors/Configuration/ConfigurationOptions.cs} (90%) create mode 100644 src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationProvider.cs create mode 100644 src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationProviderOptions.cs create mode 100644 src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationProviderOptionsExtensions.cs create mode 100644 src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/IConfigurationProvider.cs rename src/VisionaryCoder.Framework/{Configuration/Local/LocalAppConfigurationProvider.cs => Proxy/Interceptors/Configuration/Local/LocalConfigurationProvider.cs} (57%) create mode 100644 src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Local/LocalConfigurationProviderOptions.cs create mode 100644 src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Local/LocalConfigurationProviderOptionsExtensions.cs rename src/VisionaryCoder.Framework/{Logging => Proxy}/Interceptors/ILoggingInterceptor.cs (90%) rename src/VisionaryCoder.Framework/Proxy/Interceptors/Logging/{LoggingInterceptorServiceCollectionExtensions.cs => LoggingInterceptorExtensions.cs} (86%) rename src/VisionaryCoder.Framework/{Logging => Proxy}/Interceptors/LoggingInterceptor.cs (87%) rename src/VisionaryCoder.Framework/{Logging => Proxy}/Interceptors/NullLoggingInterceptor.cs (87%) rename src/VisionaryCoder.Framework/Proxy/Interceptors/{ProxyInterceptorServiceCollectionExtensions.cs => ProxyInterceptorExtensions.cs} (95%) rename src/VisionaryCoder.Framework/Proxy/Interceptors/Security/{SecurityInterceptorServiceCollectionExtensions.cs => SecurityInterceptorExtensions.cs} (95%) rename src/VisionaryCoder.Framework/{Logging => Proxy}/Interceptors/TimingInterceptor.cs (87%) rename src/VisionaryCoder.Framework/Proxy/{ProxyServiceCollectionExtensions.cs => ProxyExtensions.cs} (69%) create mode 100644 src/VisionaryCoder.Framework/Querying/QueryFilterSchemaValidator.cs delete mode 100644 src/VisionaryCoder.Framework/Querying/QueryFilterValidator.cs rename src/VisionaryCoder.Framework/Secrets/Azure/KeyVault/{KeyVaultServiceCollectionExtensions.cs => KeyVaultExtensions.cs} (93%) delete mode 100644 src/VisionaryCoder.Framework/Secrets/SecretProviderServiceCollectionExtensions.cs delete mode 100644 src/VisionaryCoder.Framework/ServiceResultOfType.cs rename src/VisionaryCoder.Framework/Storage/{StorageServiceCollectionExtensions.cs => StorageExtensions.cs} (91%) create mode 100644 tests/VisionaryCoder.Framework.Tests/AssemblyInfo.cs delete mode 100644 tests/VisionaryCoder.Framework.Tests/Configuration/AppConfigurationOptionsTests.cs.skip delete mode 100644 tests/VisionaryCoder.Framework.Tests/Extensions/CliInputUtilitiesTests.cs rename tests/VisionaryCoder.Framework.Tests/Extensions/{DivideByZeroExtensionsTests.cs => MathExtensionsTests.cs} (81%) delete mode 100644 tests/VisionaryCoder.Framework.Tests/Extensions/MenuHelperTests.cs delete mode 100644 tests/VisionaryCoder.Framework.Tests/Extensions/TestClass.cs rename tests/VisionaryCoder.Framework.Tests/{FrameworkOptionsTests.cs => OptionsTests.cs} (90%) delete mode 100644 tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/QueryFiltering/QueryFilterPipelineTests.cs.bak create mode 100644 tests/VisionaryCoder.Framework.Tests/Usings.cs delete mode 100644 version.json diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0747a7a..c76a51d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -418,8 +418,8 @@ applyTo: '**/*' - **Communication Rules:** - All communication must go through Contract interfaces and proxy implementations - Use fastest appropriate protocol: in-process DI β†’ gRPC β†’ HTTP/2 β†’ message queues - - Prevent direct sibling communication; use message bus or mediator patterns - - Implement circuit breakers and retry policies in all external communications + - Prevent direct sibling communication; Use parent component; If manager use message bus; + - Implement circuit breakers and retry policies in all communications - **Composition over Inheritance:** Compose behaviors to keep components focused and testable ### Cross-Cutting Concerns @@ -462,7 +462,8 @@ applyTo: '**/*' - **Production:** Live production environment - **Configuration Management:** Use environment-specific `AppSettings.json` files: - `AppSettings.json` (base configuration) - - `AppSettings.Development.json` (development overrides) + - `AppSettings.Local.json` (local environment) + - `AppSettings.Development.json` (development environment) - `AppSettings.Testing.json` (testing environment) - `AppSettings.Staging.json` (staging environment) - `AppSettings.Production.json` (production environment) diff --git a/.github/csharp.instructions.md b/.github/csharp.instructions.md index 2f9d3cf..84cb592 100644 --- a/.github/csharp.instructions.md +++ b/.github/csharp.instructions.md @@ -1,4 +1,4 @@ -ο»Ώ--- +--- # πŸ”§ Copilot Instruction Metadata version: 1.0.0 schema: 1 @@ -36,10 +36,10 @@ Applies to `.cs`, `.razor`, `.csproj`, and `.sln` files. - Address nullable reference warnings (CS8602) with proper null checks. ## Project Structure -- **Central Package Management**: Currently disabled (`ManagePackageVersionsCentrally=false`). +- **Central Package Management**: Currently enabled (`ManagePackageVersionsCentrally=true`). - **Solution structure**: Standard .sln file excludes WinUI packaging project (.wapproj). - **Dependencies**: `Directory.Build.props` defines common properties and version variables. -- **Package versions**: Orleans 9.2.1, Aspire 9.5.2, MSTest 4.0.1, FluentAssertions 8.8.0. +- **Package versions**: Orleans (latest), Aspire (latest), MSTest (latest), FluentAssertions (latest). ## Extension Methods Pattern - Create static extension classes in dedicated `Model.Extensions/` project. @@ -49,9 +49,9 @@ Applies to `.cs`, `.razor`, `.csproj`, and `.sln` files. - Extension methods enable testability without modifying core models. ## Testing -- Use MSTest 4.0.1 for all unit and integration tests. +- Use MSTest (latest) for all unit and integration tests. - Use `[TestMethod]` with `[DataRow(...)]` for data-driven tests. -- Use FluentAssertions 8.8.0 for readable assertions. +- Use FluentAssertions (latest) for readable assertions. - Use `[TestInitialize]` and `[TestCleanup]` for setup/teardown. - Follow Arrange-Act-Assert structure. - Organize tests with `#region` blocks for logical grouping. @@ -59,4 +59,8 @@ Applies to `.cs`, `.razor`, `.csproj`, and `.sln` files. ## πŸ“ Changelog ### 1.0.0 (2025-10-03) -- Added metadata header (initial versioning schema). \ No newline at end of file +- Added metadata header (initial versioning schema). + +### 1.1.0 (2025-10-10) +- Established C#/.NET coding standards and best practices. +- Improved project structure and organization. diff --git a/.nuget/NuGet/NuGet.config b/.nuget/NuGet/NuGet.config index 7fc174c..6690f5a 100644 --- a/.nuget/NuGet/NuGet.config +++ b/.nuget/NuGet/NuGet.config @@ -5,13 +5,13 @@ - + - + diff --git a/Directory.Build.props b/Directory.Build.props index 44b1ec2..fa50d6a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - + net8.0 Library latest enable @@ -13,8 +13,8 @@ true - Ivan Jones - Visionary Coder LLC + Visionary Coder + Visionary Coder Copyright Β© $([System.DateTime]::Now.Year) MIT true @@ -60,8 +60,4 @@ false - - - - diff --git a/Directory.Build.targets b/Directory.Build.targets index 84854bf..3a24f7e 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -8,16 +8,4 @@ - - - - - - - - - - - - diff --git a/Directory.Packages.props b/Directory.Packages.props index bffcca4..1d1ec48 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,40 +4,43 @@ true - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + - - + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + - \ No newline at end of file + diff --git a/README.md b/README.md index 692acdc..dc6f2c2 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A modular, enterprise-grade framework starting with a single foundational librar ```bash # Clone -git clone https://github.com/visionarycoder/vc.git +git clone https://github.com/visionarycoder/Framework.git cd vc # Restore @@ -27,21 +27,21 @@ dotnet test VisionaryCoder.Framework.sln --configuration Release ## πŸ“¦ Current Solution Contents -| Project | Type | Description | -|---------|------|-------------| -| `VisionaryCoder.Framework` | Library | Core framework primitives (configuration, results, options, providers, proxy abstractions). | -| `VisionaryCoder.Framework.Tests` | Test Project | Unit tests validating core behaviors (results, request/correlation IDs, options). | +| Project | Type | Description | +|----------------------------------|--------------|---------------------------------------------------------------------------------------------| +| `VisionaryCoder.Framework` | Library | Core framework primitives (configuration, results, options, providers, proxy abstractions). | +| `VisionaryCoder.Framework.Tests` | Test Project | Unit tests validating core behaviors (results, request/correlation IDs, options). | Planned future packages (tracked via ADRs) will be introduced gradually rather than pre-listed. See ADR index for roadmap context. ## πŸ—ƒοΈ Repository Structure (High-Level) ```text -/.copilot # Modular AI assistant instruction set (base + C# + patterns + standards) -/docs # Documentation (ADRs, best-practices capsules, diagrams, reviews, onboarding) -/src/VisionaryCoder.Framework # Core library source -/tests/VisionaryCoder.Framework.Tests # Unit tests -/.github # Global Copilot instructions & workflows +/.copilot # Modular AI assistant instruction set (base + C# + patterns + standards) +/docs # Documentation (ADRs, best-practices capsules, diagrams, reviews, onboarding) +/src/VisionaryCoder.Framework # Core library source +/tests/VisionaryCoder.Framework.Tests # Unit tests +/.github # Global Copilot instructions & workflows ``` --- diff --git a/VisionaryCoder.Framework.README.md b/VisionaryCoder.Framework.README.md deleted file mode 100644 index 965e1cf..0000000 --- a/VisionaryCoder.Framework.README.md +++ /dev/null @@ -1,270 +0,0 @@ -# VisionaryCoder.Framework - Production-Ready .NET Framework - -## Overview - -VisionaryCoder.Framework is a comprehensive, production-ready .NET framework that follows Microsoft best practices and enterprise architecture patterns. Built with .NET 8 and C# 12, it provides strongly-typed abstractions, service patterns, and data access layers for building scalable applications. - -## Framework Architecture - -The framework follows a modular architecture organized by functional concerns: - -### Core Projects - -#### πŸ—οΈ VisionaryCoder.Framework.Abstractions - -Foundation layer providing core base classes and abstractions - -- **ServiceBase<T>** - Base class for services with integrated logging and dependency injection patterns -- **EntityBase** - Base entity class with audit fields, soft delete support, and optimistic concurrency -- **StronglyTypedId<TValue, TId>** - Generic strongly-typed identifier pattern to prevent primitive obsession - -```csharp -// Example: Strongly-typed ID -public sealed record UserId : StronglyTypedId -{ - public UserId(Guid value) : base(value) { } -} - -// Example: Entity with audit fields -public class User : EntityBase -{ - public UserId Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - // Inherits: CreatedAt, ModifiedAt, CreatedBy, ModifiedBy, IsDeleted, RowVersion -} -``` - -#### πŸ”„ VisionaryCoder.Framework.Abstractions.Services - -##### Service contract definitions following Microsoft dependency injection patterns - -- **IFileSystem** - Unified interface for all file and directory operations with async support -- Clean, testable interface that consolidates file system operations in one place -- Follows Microsoft System.IO.Abstractions patterns for better testability - -```csharp -// Example: File system service usage -public class DocumentProcessor : ServiceBase -{ - private readonly IFileSystem _fileSystem; - - public DocumentProcessor(IFileSystem fileSystem, ILogger logger) - : base(logger) - { - _fileSystem = fileSystem; - } - - public async Task ProcessAsync(string filePath, CancellationToken cancellationToken = default) - { - var content = await _fileSystem.ReadAllTextAsync(filePath, cancellationToken); - // Process content... - } -} -``` - -#### πŸ’Ύ VisionaryCoder.Framework.Data.Abstractions - -##### Repository and Unit of Work patterns for data access - -- **IRepository<TEntity, TKey>** - Generic repository with expression-based querying -- **IUnitOfWork** - Transaction management and coordinated persistence -- **IQueryBuilder<T>** - Fluent query construction with LINQ expressions - -```csharp -// Example: Repository pattern -public class UserService : ServiceBase -{ - private readonly IRepository _userRepository; - private readonly IUnitOfWork _unitOfWork; - - public async Task GetUserAsync(UserId id) - { - return await _userRepository.GetByIdAsync(id); - } - - public async Task CreateUserAsync(User user) - { - await _userRepository.AddAsync(user); - await _unitOfWork.CommitAsync(); - } -} -``` - -#### πŸ“ VisionaryCoder.Framework.Services.FileSystem - -##### Production-ready file system service implementations - -- **FileSystemService** - Unified implementation of IFileSystem with comprehensive logging and error handling -- Async-first operations with proper cancellation token support -- Structured logging with correlation IDs for tracking operations -- Microsoft I/O patterns and System.IO.Abstractions compatibility - -## Key Features - -### ✨ **Microsoft Best Practices** - -- PascalCase naming conventions throughout -- **NO underscore prefixes** - follows Microsoft guidelines strictly -- Async/await patterns for all I/O operations -- Proper dependency injection with IServiceCollection integration -- Comprehensive XML documentation - -### πŸ›‘οΈ **Type Safety** - -- Strongly-typed identifiers prevent primitive obsession -- Generic repository patterns with type constraints -- Nullable reference types enabled throughout -- Expression-based querying for compile-time safety - -### πŸ“Š **Enterprise Patterns** - -- Repository and Unit of Work for data access -- Service layer abstractions for business logic -- Base classes for common functionality -- Audit fields and soft delete support built-in - -### πŸš€ **Performance & Scalability** - -- Async/await throughout for non-blocking operations -- Cancellation token support for responsive applications -- Optimistic concurrency with row versioning -- Minimal allocations with record types and spans - -### πŸ” **Observability** - -- Structured logging with Microsoft.Extensions.Logging -- ServiceBase<T> provides built-in logging capabilities -- Correlation ID support for request tracking -- Performance monitoring hooks - -## Getting Started - -### Installation - -Add the framework projects to your solution: - -```bash -dotnet sln add src/VisionaryCoder.Framework.Abstractions/VisionaryCoder.Framework.Abstractions.csproj -dotnet sln add src/VisionaryCoder.Framework.Services.Abstractions/VisionaryCoder.Framework.Services.Abstractions.csproj -dotnet sln add src/VisionaryCoder.Framework.Data.Abstractions/VisionaryCoder.Framework.Data.Abstractions.csproj -dotnet sln add src/VisionaryCoder.Framework.Services.FileSystem/VisionaryCoder.Framework.Services.FileSystem.csproj -``` - -### Basic Usage - -```csharp -using Microsoft.Extensions.DependencyInjection; -using VisionaryCoder.Framework.Abstractions; -using VisionaryCoder.Framework.Abstractions.Services; -using VisionaryCoder.Framework.Services.FileSystem; - -// Configure dependency injection -services.AddFileSystemServices(); -services.AddScoped(); - -// Define strongly-typed entities -public sealed record DocumentId : StronglyTypedId -{ - public DocumentId(Guid value) : base(value) { } -} - -public class Document : EntityBase -{ - public DocumentId Id { get; set; } - public string Title { get; set; } = string.Empty; - public string Content { get; set; } = string.Empty; -} - -// Implement services using framework patterns -public class DocumentService : ServiceBase -{ - private readonly IFileSystem _fileSystem; - - public DocumentService(IFileSystem fileSystem, ILogger logger) - : base(logger) - { - _fileSystem = fileSystem; - } - - public async Task LoadDocumentAsync(string filePath) - { - Logger.LogInformation("Loading document from {FilePath}", filePath); - - if (!_fileService.Exists(filePath)) - { - Logger.LogWarning("Document not found at {FilePath}", filePath); - return null; - } - - var content = await _fileService.ReadAllTextAsync(filePath); - - return new Document - { - Id = new DocumentId(Guid.NewGuid()), - Title = Path.GetFileNameWithoutExtension(filePath), - Content = content - }; - } -} -``` - -## Framework Validation - -All framework projects build successfully and demonstrate proper Microsoft patterns: - -```bash -βœ“ VisionaryCoder.Framework.Abstractions - Build succeeded -βœ“ VisionaryCoder.Framework.Services.Abstractions - Build succeeded -βœ“ VisionaryCoder.Framework.Data.Abstractions - Build succeeded -βœ“ VisionaryCoder.Framework.Services.FileSystem - Build succeeded -βœ“ VisionaryCoder.Framework.Example - Build succeeded and runs correctly -``` - -## Project Structure - -```text -src/ -β”œβ”€β”€ VisionaryCoder.Framework.Abstractions/ # Core abstractions and base classes -β”‚ β”œβ”€β”€ ServiceBase.cs # Base service with logging -β”‚ β”œβ”€β”€ EntityBase.cs # Base entity with audit fields -β”‚ └── StronglyTypedId.cs # Strongly-typed identifier pattern -β”œβ”€β”€ VisionaryCoder.Framework.Abstractions.Services/ # Service contracts -β”‚ └── IFileSystem.cs # Unified file system operations -β”œβ”€β”€ VisionaryCoder.Framework.Data.Abstractions/ # Data access patterns -β”‚ β”œβ”€β”€ IRepository.cs # Generic repository pattern -β”‚ β”œβ”€β”€ IUnitOfWork.cs # Transaction coordination -β”‚ └── IQueryBuilder.cs # Fluent query construction -β”œβ”€β”€ VisionaryCoder.Framework.Services.FileSystem/ # File system implementations -β”‚ └── FileService.cs # Production-ready file service -└── VisionaryCoder.Framework.Example/ # Working demonstration - └── Program.cs # Framework usage example -``` - -## Standards Compliance - -- βœ… **C# 12** - Uses latest language features (records, pattern matching, required members) -- βœ… **.NET 8** - Targets modern .NET for best performance and features -- βœ… **Microsoft Naming** - Strict adherence to Microsoft naming conventions -- βœ… **Async/Await** - Async patterns throughout for scalable applications -- βœ… **Nullable References** - Full nullable reference type support -- βœ… **XML Documentation** - Comprehensive API documentation -- βœ… **Enterprise Patterns** - Repository, Unit of Work, Service Layer patterns -- βœ… **Production Ready** - Error handling, logging, cancellation support - -## Next Steps - -1. **Add Entity Framework Integration** - Create EF Core implementations of data abstractions -2. **Add Caching Layer** - Implement distributed caching abstractions and Redis integration -3. **Add Validation Framework** - FluentValidation integration with framework patterns -4. **Add Testing Utilities** - Test helpers and mocking utilities for framework consumers -5. **Add Configuration Management** - Strongly-typed configuration patterns -6. **Add Health Checks** - ASP.NET Core health check integration -7. **Add OpenTelemetry Integration** - Distributed tracing and metrics - ---- - -**Version:** 1.0.0 -**Target Framework:** .NET 8 -**Language:** C# 12 -**Status:** βœ… Production Ready diff --git a/VisionaryCoder.Framework.sln b/VisionaryCoder.Framework.sln index 177d894..df1acdd 100644 --- a/VisionaryCoder.Framework.sln +++ b/VisionaryCoder.Framework.sln @@ -1,7 +1,7 @@ ο»Ώ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 18 -VisualStudioVersion = 18.0.11205.157 +# Visual Studio Version 17 +VisualStudioVersion = 17 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{CF68B68C-8A91-4020-AA05-C6862858DAB7}" ProjectSection(SolutionItems) = preProject @@ -11,11 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Directory.Build.props = Directory.Build.props Directory.Build.targets = Directory.Build.targets Directory.Packages.props = Directory.Packages.props - global.json = global.json LICENSE = LICENSE README.md = README.md - version.json = version.json - VisionaryCoder.Framework.README.md = VisionaryCoder.Framework.README.md EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{94FEF38A-DA45-4CF1-A0DD-EA337586A1AF}" @@ -120,7 +117,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "best-practices", "best-prac docs\best-practices\radar.md = docs\best-practices\radar.md EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "architecture-decision-records", "architecture-decision-records", "{D179AF1B-D641-4436-BF3F-5E394D3955D0}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "adr", "adr", "{D179AF1B-D641-4436-BF3F-5E394D3955D0}" ProjectSection(SolutionItems) = preProject docs\adr\adr-0001.md = docs\adr\adr-0001.md docs\adr\adr-0002.md = docs\adr\adr-0002.md diff --git a/global.json b/global.json deleted file mode 100644 index 762ec68..0000000 --- a/global.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "sdk": { - "version": "9.0.301" - }, - "projects": [ - "src", "tests", "tools" - ] -} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Authentication/SOLID_USAGE_EXAMPLES.md b/src/VisionaryCoder.Framework/Authentication/SOLID_USAGE_EXAMPLES.md deleted file mode 100644 index f23bcd8..0000000 --- a/src/VisionaryCoder.Framework/Authentication/SOLID_USAGE_EXAMPLES.md +++ /dev/null @@ -1,214 +0,0 @@ -# SOLID Principles Authentication Usage Examples - -## Overview - -The VisionaryCoder.Framework.Authentication namespace now follows SOLID principles, specifically the **Dependency Inversion Principle** and **Null Object Pattern**. This ensures explicit intent in provider registration and safe fallback behavior. - -## Core SOLID Principle Applied - -### Dependency Inversion Principle (DIP) - -- **High-level modules should not depend on low-level modules; both should depend on abstractions** -- **Abstractions should not depend on details; details should depend on abstractions** - -### Implementation Strategy - -1. **Null Object Pattern**: Safe fallbacks without implicit defaults -2. **Explicit Registration**: No automatic provider assumptions -3. **Interface-Based Design**: All dependencies through contracts - -## Basic JWT Authentication Setup - -```csharp -public void ConfigureServices(IServiceCollection services) -{ - // Step 1: Register JWT authentication with null object fallbacks - services.AddJwtAuthentication(options => - { - options.Authority = "https://your-identity-provider"; - options.Audience = "your-api-audience"; - options.ClientId = "your-client-id"; - }); - - // At this point, all providers are NULL OBJECTS that provide safe fallback behavior - // No implicit defaults are registered - this follows SOLID DIP principles -} -``` - -## Explicit Provider Registration (Recommended) - -```csharp -public void ConfigureServices(IServiceCollection services) -{ - // Step 1: Register JWT authentication infrastructure - services.AddJwtAuthentication(options => - { - options.Authority = "https://your-identity-provider"; - options.Audience = "your-api-audience"; - options.ClientId = "your-client-id"; - }); - - // Step 2: EXPLICITLY register your providers (SOLID principle) - services.ReplaceUserContextProvider(); - services.ReplaceTenantContextProvider(); - services.ReplaceTokenProvider(); - - // OR use convenience method for defaults: - // services.UseDefaultAuthenticationProviders(); -} -``` - -## Custom Provider Implementation - -```csharp -// Step 1: Implement your custom provider -public class CustomUserContextProvider : IUserContextProvider -{ - public Task GetCurrentUserAsync() - { - // Your custom implementation - return Task.FromResult(new UserContext - { - UserId = "custom-user-id", - UserName = "custom-user" - }); - } -} - -// Step 2: Register your custom provider -public void ConfigureServices(IServiceCollection services) -{ - services.AddJwtAuthentication(options => { /* ... */ }); - - // Replace null object with your custom implementation - services.ReplaceUserContextProvider(); -} -``` - -## Null Object Pattern Benefits - -### Safe Fallback Behavior - -```csharp -// If no explicit provider is registered, null objects provide safe behavior: - -// NullUserContextProvider returns null (no exceptions) -var userContext = await userContextProvider.GetCurrentUserAsync(); // returns null - -// NullTenantContextProvider returns null (no exceptions) -var tenantContext = await tenantContextProvider.GetCurrentTenantAsync(); // returns null - -// NullTokenProvider returns failed results or throws meaningful exceptions -var tokenResult = await tokenProvider.GetTokenAsync(request); // returns failed TokenResult -``` - -### SOLID Principle Compliance - -1. **Single Responsibility**: Each provider has one clear purpose -2. **Open/Closed**: Easily extend with new providers without modifying existing code -3. **Liskov Substitution**: All implementations are substitutable through interfaces -4. **Interface Segregation**: Focused interfaces with specific responsibilities -5. **Dependency Inversion**: Depend on abstractions, not concrete implementations - -## Anti-Patterns to Avoid - -### ❌ Don't rely on implicit defaults - -```csharp -// BAD: Assuming providers are automatically registered -services.AddJwtAuthentication(options => { /* ... */ }); -// User expects DefaultUserContextProvider but gets NullUserContextProvider -``` - -### ❌ Don't register concrete dependencies directly - -```csharp -// BAD: Bypassing the framework's registration methods -services.AddScoped(); -// This doesn't replace the null object and violates explicit intent -``` - -## βœ… Best Practices - -### Explicit Intent Required - -```csharp -// GOOD: Clear, explicit provider registration -services.AddJwtAuthentication(options => { /* ... */ }); -services.UseDefaultAuthenticationProviders(); // Explicit intent to use defaults -``` - -### Custom Implementation with Validation - -```csharp -public class ValidatedUserContextProvider : IUserContextProvider -{ - private readonly ILogger logger; - - public ValidatedUserContextProvider(ILogger logger) - { - this.logger = logger; - } - - public async Task GetCurrentUserAsync() - { - try - { - // Your validation logic - var context = await GetUserFromToken(); - logger.LogInformation("User context retrieved: {UserId}", context?.UserId); - return context; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to retrieve user context"); - return null; // Safe fallback - } - } -} -``` - -## Testing with SOLID Principles - -```csharp -[TestMethod] -public async Task ShouldUseNullObjectWhenNoProviderRegistered() -{ - // Arrange - var services = new ServiceCollection(); - services.AddJwtAuthentication(options => { /* valid options */ }); - var provider = services.BuildServiceProvider(); - - // Act - var userContextProvider = provider.GetRequiredService(); - var userContext = await userContextProvider.GetCurrentUserAsync(); - - // Assert - Assert.IsNull(userContext); // Null object returns null safely - Assert.IsInstanceOfType(userContextProvider, typeof(NullUserContextProvider)); -} - -[TestMethod] -public async Task ShouldUseExplicitProviderWhenRegistered() -{ - // Arrange - var services = new ServiceCollection(); - services.AddJwtAuthentication(options => { /* valid options */ }); - services.ReplaceUserContextProvider(); - var provider = services.BuildServiceProvider(); - - // Act - var userContextProvider = provider.GetRequiredService(); - - // Assert - Assert.IsInstanceOfType(userContextProvider, typeof(DefaultUserContextProvider)); -} -``` - -This approach ensures that: - -1. **No implicit behavior** - everything must be explicitly registered -2. **Safe fallbacks** - null objects prevent runtime errors -3. **Clear intent** - developers must explicitly choose their providers -4. **Testable design** - easy to mock and verify behavior -5. **SOLID compliance** - follows dependency inversion and interface segregation principles diff --git a/src/VisionaryCoder.Framework/Configuration/AppConfigurationHelper.cs b/src/VisionaryCoder.Framework/Configuration/AppConfigurationHelper.cs deleted file mode 100644 index 13aff31..0000000 --- a/src/VisionaryCoder.Framework/Configuration/AppConfigurationHelper.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Text.Json; - -namespace VisionaryCoder.Framework.AppConfiguration; - -internal static class AppConfigurationHelper -{ - - public static T ConvertValue(string stringValue, T defaultValue) - { - try - { - if (typeof(T) == typeof(string)) - return (T)(object)stringValue; - - if (typeof(T) == typeof(int)) - return (T)(object)int.Parse(stringValue); - - if (typeof(T) == typeof(long)) - return (T)(object)long.Parse(stringValue); - - if (typeof(T) == typeof(double)) - return (T)(object)double.Parse(stringValue); - - if (typeof(T) == typeof(decimal)) - return (T)(object)decimal.Parse(stringValue); - - if (typeof(T) == typeof(bool)) - return (T)(object)bool.Parse(stringValue); - - if (typeof(T) == typeof(DateTime)) - return (T)(object)DateTime.Parse(stringValue); - - if (typeof(T) == typeof(DateTimeOffset)) - return (T)(object)DateTimeOffset.Parse(stringValue); - - if (typeof(T) == typeof(TimeSpan)) - return (T)(object)TimeSpan.Parse(stringValue); - - if (typeof(T) == typeof(Guid)) - return (T)(object)Guid.Parse(stringValue); - - // For complex types, try JSON deserialization - return JsonSerializer.Deserialize(stringValue) ?? defaultValue; - } - catch - { - return defaultValue; - } - } -} diff --git a/src/VisionaryCoder.Framework/Configuration/Azure/AzureAppConfigurationProviderOptions.cs b/src/VisionaryCoder.Framework/Configuration/Azure/AzureAppConfigurationProviderOptions.cs deleted file mode 100644 index a5b3ed0..0000000 --- a/src/VisionaryCoder.Framework/Configuration/Azure/AzureAppConfigurationProviderOptions.cs +++ /dev/null @@ -1,74 +0,0 @@ -namespace VisionaryCoder.Framework.AppConfiguration.Azure; - -/// -/// Configuration options for Azure App Configuration provider. -/// -public sealed class AzureAppConfigurationProviderOptions -{ - /// - /// The endpoint URI for the Azure App Configuration service. - /// - /// https://your-config.azconfig.io - public Uri? Endpoint { get; init; } - - /// - /// The label to use for environment-specific configuration (e.g., "Development", "Testing", "Staging", "Production"). - /// - public string Label { get; init; } = "Production"; - - /// - /// The sentinel key used to trigger configuration refresh. - /// - public string SentinelKey { get; init; } = "App:Sentinel"; - - /// - /// The cache expiration time for configuration values. - /// - public TimeSpan CacheExpiration { get; init; } = TimeSpan.FromSeconds(30); - - /// - /// Whether to use connection string authentication instead of managed identity. - /// - public bool UseConnectionString { get; init; } = false; - - /// - /// The connection string for Azure App Configuration (when UseConnectionString is true). - /// - public string? ConnectionString { get; init; } - - /// - /// Whether to enable automatic refresh of configuration values. - /// - public bool EnableRefresh { get; init; } = true; - - /// - /// The prefix to filter configuration keys (optional). - /// - public string? KeyPrefix { get; init; } - - /// - /// Validates the configuration options. - /// - internal void Validate() - { - if (UseConnectionString) - { - if (string.IsNullOrWhiteSpace(ConnectionString)) - throw new InvalidOperationException("ConnectionString must be provided when UseConnectionString is true."); - } - else - { - if (Endpoint is null) - throw new InvalidOperationException("Endpoint must be provided when not using connection string authentication."); - } - - if (string.IsNullOrWhiteSpace(Label)) - throw new InvalidOperationException("Label cannot be null or empty."); - - if (string.IsNullOrWhiteSpace(SentinelKey)) - throw new InvalidOperationException("SentinelKey cannot be null or empty."); - - if (CacheExpiration <= TimeSpan.Zero) - throw new InvalidOperationException("CacheExpiration must be greater than zero."); - } -} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Configuration/IAppConfigurationProvider.cs b/src/VisionaryCoder.Framework/Configuration/IAppConfigurationProvider.cs deleted file mode 100644 index 4844568..0000000 --- a/src/VisionaryCoder.Framework/Configuration/IAppConfigurationProvider.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace VisionaryCoder.Framework.AppConfiguration; - -/// -/// Defines the contract for application configuration providers. -/// Supports async operations, caching, refresh capabilities, and type-safe configuration access. -/// -public interface IAppConfigurationProvider -{ - /// - /// Retrieves a strongly-typed configuration value by key. - /// - /// The type to deserialize the configuration value to - /// The configuration key - /// The default value to return if the key is not found - /// Cancellation token - /// The configuration value or default value if not found - Task GetValueAsync(string key, T defaultValue = default!, CancellationToken cancellationToken = default); - - /// - /// Retrieves a strongly-typed configuration section by name. - /// - /// The type to deserialize the configuration section to - /// The name of the configuration section - /// Cancellation token - /// The deserialized configuration section - Task GetSectionAsync(string sectionName, CancellationToken cancellationToken = default) where T : class, new(); - - /// - /// Retrieves all configuration values as a dictionary. - /// - /// Cancellation token - /// Dictionary containing all configuration key-value pairs - Task> GetAllValuesAsync(CancellationToken cancellationToken = default); - - /// - /// Forces a refresh of the configuration data from the underlying source. - /// - /// Cancellation token - /// True if refresh was successful, false otherwise - Task RefreshAsync(CancellationToken cancellationToken = default); -} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Configuration/Local/LocalAppConfigurationProviderOptions.cs b/src/VisionaryCoder.Framework/Configuration/Local/LocalAppConfigurationProviderOptions.cs deleted file mode 100644 index f13f89c..0000000 --- a/src/VisionaryCoder.Framework/Configuration/Local/LocalAppConfigurationProviderOptions.cs +++ /dev/null @@ -1,66 +0,0 @@ -namespace VisionaryCoder.Framework.AppConfiguration.Local; - -/// -/// Configuration options for Local (file-based) App Configuration provider. -/// -public sealed class LocalAppConfigurationProviderOptions -{ - /// - /// The file path for the configuration file. - /// - public string FilePath { get; init; } = "appsettings.json"; - - /// - /// Whether to watch the file for changes and automatically reload. - /// - public bool ReloadOnChange { get; init; } = true; - - /// - /// Whether the configuration file is optional. - /// - public bool Optional { get; init; } = false; - - /// - /// Additional configuration files to include (e.g., environment-specific files). - /// - public IEnumerable AdditionalFiles { get; init; } = Array.Empty(); - - /// - /// The base path for configuration files. - /// - public string? BasePath { get; init; } - - /// - /// The prefix to filter configuration keys (optional). - /// - public string? KeyPrefix { get; init; } - - /// - /// Whether to enable caching of configuration values. - /// - public bool EnableCaching { get; init; } = true; - - /// - /// The cache expiration time for configuration values. - /// - public TimeSpan CacheExpiration { get; init; } = TimeSpan.FromMinutes(5); - - /// - /// Validates the configuration options. - /// - internal void Validate() - { - if (string.IsNullOrWhiteSpace(FilePath)) - throw new InvalidOperationException("FilePath cannot be null or empty."); - - if (CacheExpiration <= TimeSpan.Zero) - throw new InvalidOperationException("CacheExpiration must be greater than zero."); - - // Validate additional files - foreach (string file in AdditionalFiles) - { - if (string.IsNullOrWhiteSpace(file)) - throw new InvalidOperationException("Additional file paths cannot be null or empty."); - } - } -} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Extensions/DataConfigurationServiceCollectionExtensions.cs b/src/VisionaryCoder.Framework/Extensions/DataConfigurationExtensions.cs similarity index 93% rename from src/VisionaryCoder.Framework/Extensions/DataConfigurationServiceCollectionExtensions.cs rename to src/VisionaryCoder.Framework/Extensions/DataConfigurationExtensions.cs index 7258c0e..faa260f 100644 --- a/src/VisionaryCoder.Framework/Extensions/DataConfigurationServiceCollectionExtensions.cs +++ b/src/VisionaryCoder.Framework/Extensions/DataConfigurationExtensions.cs @@ -1,10 +1,12 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using VisionaryCoder.Framework.Secrets; namespace VisionaryCoder.Framework.Extensions; /// /// Extension methods for configuring database connections and connection strings. /// -public static class DataConfigurationServiceCollectionExtensions +public static class DataConfigurationExtensions { /// @@ -44,8 +46,8 @@ public static IServiceCollection AddNamedConnectionString( { throw new InvalidOperationException($"Connection string '{connectionName}' is not configured."); } - services.AddKeyedSingleton(serviceName, connectionStringValue); - return services; + services.AddKeyedSingleton(serviceName, connectionStringValue); + return services; } /// Adds a connection string from a secret provider to the service collection. /// The service collection to add the connection string to. diff --git a/src/VisionaryCoder.Framework/Extensions/DictionaryExtensions.cs b/src/VisionaryCoder.Framework/Extensions/DictionaryExtensions.cs index 04bf4b5..fdd948d 100644 --- a/src/VisionaryCoder.Framework/Extensions/DictionaryExtensions.cs +++ b/src/VisionaryCoder.Framework/Extensions/DictionaryExtensions.cs @@ -4,246 +4,255 @@ using System.Reflection; namespace VisionaryCoder.Framework.Extensions; + public static class DictionaryExtensions { - /// - /// Gets a value from a dictionary or returns a default value if the key doesn't exist. - /// + + /// The dictionary to search. /// The type of the keys in the dictionary. /// The type of the values in the dictionary. - /// The dictionary to search. - /// The key to find. - /// The default value to return if the key is not found. - /// The value associated with the key or the default value. - public static TValue GetValueOrDefault(this IDictionary dictionary, TKey key, TValue defaultValue = default!) + extension(IDictionary dictionary) + { + /// + /// Gets a value from a dictionary or returns a default value if the key doesn't exist. + /// + /// The key to find. + /// The default value to return if the key is not found. + /// The value associated with the key or the default value. + public TValue GetValueOrDefault(TKey key, TValue defaultValue = default!) { return dictionary.TryGetValue(key, out TValue? value) ? value : defaultValue; } + /// /// Gets a value from a dictionary or computes it if the key doesn't exist. /// - /// The type of the keys in the dictionary. - /// The type of the values in the dictionary. - /// The dictionary to search. /// The key to find. /// A function that computes the value if the key is not found. /// The value associated with the key or the computed value. - public static TValue GetOrAdd(this IDictionary dictionary, TKey key, Func valueFactory) + public TValue GetOrAdd(TKey key, Func valueFactory) { - ArgumentNullException.ThrowIfNull(dictionary, nameof(dictionary)); - ArgumentNullException.ThrowIfNull(valueFactory, nameof(valueFactory)); + ArgumentNullException.ThrowIfNull(dictionary); + ArgumentNullException.ThrowIfNull(valueFactory); if (dictionary.TryGetValue(key, out TValue? value)) { return value; } + value = valueFactory(key); dictionary[key] = value; return value; } + /// /// Adds or updates a value in the dictionary. /// - /// The type of the keys in the dictionary. - /// The type of the values in the dictionary. - /// The dictionary to modify. /// The key to add or update. /// The value to add if the key doesn't exist. /// A function to generate an updated value based on the key and existing value. /// The new value in the dictionary. - public static TValue AddOrUpdate(this IDictionary dictionary, TKey key, TValue addValue, Func updateValueFactory) + public TValue AddOrUpdate(TKey key, TValue addValue, Func updateValueFactory) { - ArgumentNullException.ThrowIfNull(updateValueFactory, nameof(updateValueFactory)); + ArgumentNullException.ThrowIfNull(updateValueFactory); if (dictionary.TryGetValue(key, out TValue? existingValue)) { TValue newValue = updateValueFactory(key, existingValue); dictionary[key] = newValue; return newValue; } + dictionary[key] = addValue; return addValue; } + /// /// Adds or updates a value in the dictionary using a value factory. /// - /// The type of the keys in the dictionary. - /// The type of the values in the dictionary. - /// The dictionary to modify. /// The key to add or update. /// A function to generate a value to add if the key doesn't exist. /// A function to generate an updated value based on the key and existing value. /// The new value in the dictionary. - public static TValue AddOrUpdate(this IDictionary dictionary, TKey key, Func addValueFactory, Func updateValueFactory) + public TValue AddOrUpdate(TKey key, Func addValueFactory, + Func updateValueFactory) { - ArgumentNullException.ThrowIfNull(addValueFactory, nameof(addValueFactory)); + ArgumentNullException.ThrowIfNull(addValueFactory); TValue addValue = addValueFactory(key); return AddOrUpdate(dictionary, key, addValue, updateValueFactory); } - /// Converts a dictionary to an immutable dictionary. - /// The dictionary to convert - /// An immutable version of the dictionary - public static IImmutableDictionary ToImmutableDictionary(this IDictionary dictionary) where TKey : notnull - { - return dictionary.ToImmutableDictionary(kvp => kvp.Key, kvp => kvp.Value); - } - /// Converts a dictionary to a read-only dictionary. - /// A read-only version of the dictionary - public static ReadOnlyDictionary ToReadOnlyDictionary(this IDictionary dictionary) where TKey : notnull +} + + +/// The dictionary to convert +/// The type of the keys in the dictionaries +/// The type of the values in the dictionaries +extension(IDictionary dictionary) where TKey : notnull { - return new ReadOnlyDictionary(dictionary); - } - /// Merges two dictionaries into a new dictionary. - /// The type of the keys in the dictionaries - /// The type of the values in the dictionaries - /// The first dictionary - /// The second dictionary - /// Optional function to resolve conflicts when keys exist in both dictionaries - /// A new dictionary containing all keys and values from both input dictionaries - public static Dictionary Merge(this IDictionary first, IDictionary second, Func? conflictResolver = null) where TKey : notnull + /// Converts a dictionary to an immutable dictionary. + /// An immutable version of the dictionary + public IImmutableDictionary ToImmutableDictionary() +{ + return dictionary.ToImmutableDictionary(kvp => kvp.Key, kvp => kvp.Value); +} + +/// Converts a dictionary to a read-only dictionary. +/// A read-only version of the dictionary +public ReadOnlyDictionary ToReadOnlyDictionary() +{ + return new ReadOnlyDictionary(dictionary); +} + +/// Merges two dictionaries into a new dictionary. +/// The second dictionary +/// Optional function to resolve conflicts when keys exist in both dictionaries +/// A new dictionary containing all keys and values from both input dictionaries +public Dictionary Merge(IDictionary second, Func? conflictResolver = null) +{ + ArgumentNullException.ThrowIfNull(dictionary); + ArgumentNullException.ThrowIfNull(second); + var result = new Dictionary(dictionary); + foreach (KeyValuePair kvp in second) { - ArgumentNullException.ThrowIfNull(first, nameof(first)); - ArgumentNullException.ThrowIfNull(second, nameof(second)); - var result = new Dictionary(first); - foreach (KeyValuePair kvp in second) + if (result.TryGetValue(kvp.Key, out TValue? existingValue)) { - if (result.TryGetValue(kvp.Key, out TValue? existingValue)) + if (conflictResolver != null) { - if (conflictResolver != null) - { - result[kvp.Key] = conflictResolver(kvp.Key, existingValue, kvp.Value); - } - else - { - result[kvp.Key] = kvp.Value; // Second dictionary wins by default - } + result[kvp.Key] = conflictResolver(kvp.Key, existingValue, kvp.Value); } else { - result.Add(kvp.Key, kvp.Value); + result[kvp.Key] = kvp.Value; // Second dictionary wins by default } } - return result; - } - /// - /// Applies a transformation function to each value in a dictionary. - /// - /// The type of the keys in the dictionary. - /// The type of the values in the dictionary. - /// The type of the result values. - /// The dictionary to transform. - /// A function to transform each value. - /// A new dictionary with the same keys but transformed values. - public static Dictionary TransformValues(this IDictionary dictionary, Func valueSelector) where TKey : notnull - { - ArgumentNullException.ThrowIfNull(valueSelector, nameof(valueSelector)); - var result = new Dictionary(dictionary.Count); - foreach (KeyValuePair kvp in dictionary) + else { - result.Add(kvp.Key, valueSelector(kvp.Value)); + result.Add(kvp.Key, kvp.Value); } - return result; } - /// Filters a dictionary based on a predicate. - /// The dictionary to filter - /// A function to test each key-value pair for a condition - /// A new dictionary containing only the elements that satisfy the condition - public static Dictionary Where(this IDictionary dictionary, Func predicate) where TKey : notnull + return result; +} + +/// +/// Applies a transformation function to each value in a dictionary. +/// +/// The type of the result values. +/// A function to transform each value. +/// A new dictionary with the same keys but transformed values. +public Dictionary TransformValues(Func valueSelector) +{ + ArgumentNullException.ThrowIfNull(valueSelector); + var result = new Dictionary(dictionary.Count); + foreach (KeyValuePair kvp in dictionary) { - ArgumentNullException.ThrowIfNull(predicate, nameof(predicate)); - return dictionary - .Where(kvp => predicate(kvp.Key, kvp.Value)) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + result.Add(kvp.Key, valueSelector(kvp.Value)); } + return result; +} + +/// Filters a dictionary based on a predicate. +/// A function to test each key-value pair for a condition +/// A new dictionary containing only the elements that satisfy the condition +public Dictionary Where(Func predicate) +{ + ArgumentNullException.ThrowIfNull(predicate); + return dictionary + .Where(kvp => predicate(kvp.Key, kvp.Value)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); +} + } + /// Creates a dictionary from an object's properties. /// The type of the object /// The object to convert to a dictionary /// A dictionary with property names as keys and property values as values public static Dictionary ToDictionary(this T obj) where T : class +{ + + ArgumentNullException.ThrowIfNull(obj); + var dictionary = new Dictionary(); + PropertyInfo[] properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance); + foreach (PropertyInfo property in properties) { - ArgumentNullException.ThrowIfNull(obj, nameof(obj)); - var dictionary = new Dictionary(); - PropertyInfo[] properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance); - foreach (PropertyInfo property in properties) - { - dictionary[property.Name] = property.GetValue(obj); - } - return dictionary; + dictionary[property.Name] = property.GetValue(obj); } - - /// Checks if a dictionary is null or empty. - /// The dictionary to check - /// True if the dictionary is null or empty; otherwise, false - public static bool IsNullOrEmpty(this IDictionary? dictionary) + return dictionary; +} + +/// Checks if a dictionary is null or empty. +/// The dictionary to check +/// True if the dictionary is null or empty; otherwise, false +public static bool IsNullOrEmpty(this IDictionary? dictionary) +{ + return dictionary == null || dictionary.Count == 0; +} + +/// The dictionary to modify. +/// The type of the keys in the dictionary. +/// The type of the values in the dictionary. +extension(IDictionary < TKey, TValue > dictionary) { - return dictionary == null || dictionary.Count == 0; - } - /// - /// Removes multiple keys from a dictionary. - /// - /// The type of the keys in the dictionary. - /// The type of the values in the dictionary. - /// The dictionary to modify. - /// The keys to remove. - /// The number of elements removed. - public static int RemoveRange(this IDictionary dictionary, IEnumerable keys) + /// + /// Removes multiple keys from a dictionary. + /// + /// The keys to remove. + /// The number of elements removed. + public int RemoveRange(IEnumerable keys) +{ + ArgumentNullException.ThrowIfNull(keys); + int count = 0; + foreach (TKey key in keys) { - ArgumentNullException.ThrowIfNull(keys, nameof(keys)); - int count = 0; - foreach (TKey key in keys) + if (dictionary.Remove(key)) { - if (dictionary.Remove(key)) - { - count++; - } + count++; } - return count; } - /// - /// Tries to remove a key from the dictionary and returns its value. - /// - /// The type of the keys in the dictionary. - /// The type of the values in the dictionary. - /// The dictionary to modify. - /// The key to remove. - /// The value associated with the key if found, default otherwise. - /// True if the key was found and removed; otherwise, false. - public static bool TryRemove(this IDictionary dictionary, TKey key, [MaybeNullWhen(false)] out TValue value) + return count; +} + +/// +/// Tries to remove a key from the dictionary and returns its value. +/// +/// The key to remove. +/// The value associated with the key if found, default otherwise. +/// True if the key was found and removed; otherwise, false. +public bool TryRemove(TKey key, [MaybeNullWhen(false)] out TValue value) +{ + if (dictionary.TryGetValue(key, out value)) { - if (dictionary.TryGetValue(key, out value)) - { - return dictionary.Remove(key); - } - value = default!; - return false; + return dictionary.Remove(key); } - /// - /// Tries to update a value for an existing key. - /// - /// The type of the keys in the dictionary. - /// The type of the values in the dictionary. - /// The dictionary to modify. - /// The key to update. - /// The new value to set. - /// True if the key was found and updated; otherwise, false. - public static bool TryUpdate(this IDictionary dictionary, TKey key, TValue newValue) + value = default!; + return false; +} + +/// +/// Tries to update a value for an existing key. +/// +/// The key to update. +/// The new value to set. +/// True if the key was found and updated; otherwise, false. +public bool TryUpdate(TKey key, TValue newValue) +{ + if (!dictionary.ContainsKey(key)) { - if (!dictionary.ContainsKey(key)) - { - return false; - } - dictionary[key] = newValue; - return true; + return false; } - /// Performs an action on each element in the dictionary. - /// The dictionary to process - /// The action to perform on each element - public static void ForEach(this IDictionary dictionary, Action action) + dictionary[key] = newValue; + return true; +} + +/// Performs an action on each element in the dictionary. +/// The action to perform on each element +public void ForEach(Action action) +{ + ArgumentNullException.ThrowIfNull(action); + foreach (KeyValuePair kvp in dictionary) { - ArgumentNullException.ThrowIfNull(action, nameof(action)); - foreach (KeyValuePair kvp in dictionary) - { - action(kvp.Key, kvp.Value); - } + action(kvp.Key, kvp.Value); } +} + } + /// Inverts a dictionary, using values as keys and keys as values. /// The type of the keys in the original dictionary /// The type of the values in the original dictionary @@ -253,52 +262,52 @@ public static void ForEach(this IDictionary dictiona public static Dictionary Invert(this IDictionary dictionary) where TKey : notnull where TValue : notnull +{ + var result = new Dictionary(dictionary.Count); + foreach (KeyValuePair kvp in dictionary) { - var result = new Dictionary(dictionary.Count); - foreach (KeyValuePair kvp in dictionary) + if (result.ContainsKey(kvp.Value)) { - if (result.ContainsKey(kvp.Value)) - { - throw new ArgumentException("Dictionary cannot be inverted because it contains duplicate values"); - } - result.Add(kvp.Value, kvp.Key); + throw new ArgumentException("Dictionary cannot be inverted because it contains duplicate values"); } - return result; + result.Add(kvp.Value, kvp.Key); } - /// - /// Increments a numeric value in a dictionary. - /// - /// The type of the keys in the dictionary. - /// The dictionary to modify. - /// The key to increment. - /// The increment value (default 1). - /// The new value after incrementing. - public static int IncrementValue(this IDictionary dictionary, TKey key, int increment = 1) + return result; +} +/// +/// Increments a numeric value in a dictionary. +/// +/// The type of the keys in the dictionary. +/// The dictionary to modify. +/// The key to increment. +/// The increment value (default 1). +/// The new value after incrementing. +public static int IncrementValue(this IDictionary dictionary, TKey key, int increment = 1) +{ + if (dictionary.TryGetValue(key, out int currentValue)) { - if (dictionary.TryGetValue(key, out int currentValue)) - { - int newValue = currentValue + increment; - dictionary[key] = newValue; - return newValue; - } - dictionary[key] = increment; - return increment; + int newValue = currentValue + increment; + dictionary[key] = newValue; + return newValue; } - /// - /// Adds an item to a list value in a dictionary, creating the list if necessary. - /// - /// The type of the keys in the dictionary. - /// The type of items in the list. - /// The dictionary to modify. - /// The key whose list to add to. - /// The item to add to the list. - public static void AddToList(this IDictionary> dictionary, TKey key, TListItem item) + dictionary[key] = increment; + return increment; +} +/// +/// Adds an item to a list value in a dictionary, creating the list if necessary. +/// +/// The type of the keys in the dictionary. +/// The type of items in the list. +/// The dictionary to modify. +/// The key whose list to add to. +/// The item to add to the list. +public static void AddToList(this IDictionary> dictionary, TKey key, TListItem item) +{ + if (!dictionary.TryGetValue(key, out List? list)) { - if (!dictionary.TryGetValue(key, out List? list)) - { - list = new List(); - dictionary[key] = list; - } - list.Add(item); + list = []; + dictionary[key] = list; } + list.Add(item); +} } diff --git a/src/VisionaryCoder.Framework/Extensions/EnumerableExtensions.cs b/src/VisionaryCoder.Framework/Extensions/EnumerableExtensions.cs index 4f0de7e..777b2e9 100644 --- a/src/VisionaryCoder.Framework/Extensions/EnumerableExtensions.cs +++ b/src/VisionaryCoder.Framework/Extensions/EnumerableExtensions.cs @@ -21,7 +21,7 @@ public static bool ContainsDuplicates(this IEnumerable? collection, IEqual { return false; } - HashSet set = comparer == null ? new HashSet() : new HashSet(comparer); + HashSet set = comparer == null ? [] : new HashSet(comparer); return instance.Any(item => !set.Add(item)); } /// Determines whether the sequence is null or empty. diff --git a/src/VisionaryCoder.Framework/Extensions/DivideByZeroExtensions.cs b/src/VisionaryCoder.Framework/Extensions/MathExtensions.cs similarity index 98% rename from src/VisionaryCoder.Framework/Extensions/DivideByZeroExtensions.cs rename to src/VisionaryCoder.Framework/Extensions/MathExtensions.cs index a182099..396b25b 100644 --- a/src/VisionaryCoder.Framework/Extensions/DivideByZeroExtensions.cs +++ b/src/VisionaryCoder.Framework/Extensions/MathExtensions.cs @@ -4,7 +4,7 @@ namespace VisionaryCoder.Framework.Extensions; /// /// Provides extension methods for divide-by-zero validation and safe division operations. /// -public static class DivideByZeroExtensions +public static class MathExtensions { /// /// Throws a if the specified value equals zero. diff --git a/src/VisionaryCoder.Framework/Extensions/ServiceCollectionExtensions.cs b/src/VisionaryCoder.Framework/Extensions/ServiceCollectionExtensions.cs deleted file mode 100644 index 43f51e1..0000000 --- a/src/VisionaryCoder.Framework/Extensions/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,50 +0,0 @@ -using VisionaryCoder.Framework.Providers; - -namespace VisionaryCoder.Framework.Extensions; -/// -/// Extension methods for configuring the VisionaryCoder Framework services. -/// -public static class ServiceCollectionExtensions -{ - /// - /// Adds the VisionaryCoder Framework services to the service collection. - /// - /// The service collection to configure. - /// The service collection for method chaining. - public static IServiceCollection AddVisionaryCoderFramework(this IServiceCollection services) - { - return services.AddVisionaryCoderFramework(_ => { }); - } - - /// - /// Adds the VisionaryCoder Framework services to the service collection with configuration. - /// - /// The service collection to configure. - /// Action to configure framework options. - /// The service collection for method chaining. - public static IServiceCollection AddVisionaryCoderFramework( - this IServiceCollection services, - Action configureOptions) - { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(configureOptions); - // Configure framework options - services.Configure(configureOptions); - // Register core framework services - services.AddSingleton(); - services.AddScoped(); - // Framework services are now registered - return services; - } - - /// - /// Adds framework correlation ID generation services. - /// - /// The service collection to configure. - /// The service collection for method chaining. - public static IServiceCollection AddFrameworkCorrelation(this IServiceCollection services) - { - services.AddScoped(); - return services; - } -} diff --git a/src/VisionaryCoder.Framework/Extensions/TypeExtension.cs b/src/VisionaryCoder.Framework/Extensions/TypeExtension.cs index 0874dd5..db51b54 100644 --- a/src/VisionaryCoder.Framework/Extensions/TypeExtension.cs +++ b/src/VisionaryCoder.Framework/Extensions/TypeExtension.cs @@ -14,583 +14,584 @@ public static class TypeExtension /// The type of the value. /// The value to convert. /// The boolean value, or false if conversion fails. - public static bool AsBoolean(this T value) - { - if (value == null) - { - return false; - } - return (value) switch - { - bool boolValue => boolValue, - string stringValue => bool.TryParse(stringValue, out bool result) && result, - int intValue => intValue != 0, - long longValue => longValue != 0, - double doubleValue => Math.Abs(doubleValue) > double.Epsilon, - decimal decimalValue => decimalValue != 0, - _ => false - }; - } + public static bool AsBoolean(this T value) + { + if (value == null) + { + return false; + } + return (value) switch + { + bool boolValue => boolValue, + string stringValue => bool.TryParse(stringValue, out bool result) && result, + int intValue => intValue != 0, + long longValue => longValue != 0, + double doubleValue => Math.Abs(doubleValue) > double.Epsilon, + decimal decimalValue => decimalValue != 0, + _ => false + }; + } /// Converts the value to an integer. - /// The type of the value. - /// The value to convert. - /// The default value to return if conversion fails. - /// The integer value, or the default value if conversion fails. - public static int AsInteger(this T value, int defaultValue = 0) - { - if (value == null) - return defaultValue; - return value switch - { - int intValue => intValue, - bool boolValue => boolValue ? 1 : 0, - string stringValue => int.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out int result) ? result : defaultValue, - double doubleValue => (int)doubleValue, - decimal decimalValue => (int)decimalValue, - long longValue => longValue > int.MaxValue || longValue < int.MinValue ? defaultValue : (int)longValue, - float floatValue => (int)floatValue, - byte byteValue => byteValue, - short shortValue => shortValue, - uint uintValue => uintValue > int.MaxValue ? defaultValue : (int)uintValue, - _ => defaultValue - }; - } + /// The type of the value. + /// The value to convert. + /// The default value to return if conversion fails. + /// The integer value, or the default value if conversion fails. + public static int AsInteger(this T value, int defaultValue = 0) + { + if (value == null) + return defaultValue; + return value switch + { + int intValue => intValue, + bool boolValue => boolValue ? 1 : 0, + string stringValue => int.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out int result) ? result : defaultValue, + double doubleValue => (int)doubleValue, + decimal decimalValue => (int)decimalValue, + long longValue => longValue > int.MaxValue || longValue < int.MinValue ? defaultValue : (int)longValue, + float floatValue => (int)floatValue, + byte byteValue => byteValue, + short shortValue => shortValue, + uint uintValue => uintValue > int.MaxValue ? defaultValue : (int)uintValue, + _ => defaultValue + }; + } /// Converts the value to a long. /// The long value, or the default value if conversion fails. - public static long AsLong(this T value, long defaultValue = 0) - { - if (value == null) - return defaultValue; - return value switch - { - long longValue => longValue, - int intValue => intValue, - string stringValue => long.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out long result) ? result : defaultValue, - double doubleValue => (long)doubleValue, - decimal decimalValue => (long)decimalValue, - float floatValue => (long)floatValue, - uint uintValue => uintValue, - ulong ulongValue => ulongValue > long.MaxValue ? defaultValue : (long)ulongValue, - _ => defaultValue - }; - } + public static long AsLong(this T value, long defaultValue = 0) + { + if (value == null) + return defaultValue; + return value switch + { + long longValue => longValue, + int intValue => intValue, + string stringValue => long.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out long result) ? result : defaultValue, + double doubleValue => (long)doubleValue, + decimal decimalValue => (long)decimalValue, + float floatValue => (long)floatValue, + uint uintValue => uintValue, + ulong ulongValue => ulongValue > long.MaxValue ? defaultValue : (long)ulongValue, + _ => defaultValue + }; + } /// Converts the value to a double. /// The double value, or the default value if conversion fails. - public static double AsDouble(this T value, double defaultValue = 0.0) - { - if (value == null) - return defaultValue; - return value switch - { - double doubleValue => doubleValue, - int intValue => intValue, - long longValue => longValue, - bool boolValue => boolValue ? 1.0 : 0.0, - string stringValue => double.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out double result) ? result : defaultValue, - decimal decimalValue => (double)decimalValue, - float floatValue => floatValue, - ulong ulongValue => ulongValue, - _ => defaultValue - }; - } + public static double AsDouble(this T value, double defaultValue = 0.0) + { + if (value == null) + return defaultValue; + return value switch + { + double doubleValue => doubleValue, + int intValue => intValue, + long longValue => longValue, + bool boolValue => boolValue ? 1.0 : 0.0, + string stringValue => double.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out double result) ? result : defaultValue, + decimal decimalValue => (double)decimalValue, + float floatValue => floatValue, + ulong ulongValue => ulongValue, + _ => defaultValue + }; + } /// Converts the value to a decimal. /// The decimal value, or the default value if conversion fails. - public static decimal AsDecimal(this T value, decimal defaultValue = 0m) - { - if (value == null) - return defaultValue; - return value switch - { - decimal decimalValue => decimalValue, - bool boolValue => boolValue ? 1m : 0m, - string stringValue => decimal.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal result) ? result : defaultValue, - double doubleValue => (decimal)doubleValue, - float floatValue => (decimal)floatValue, - _ => defaultValue - }; - } + public static decimal AsDecimal(this T value, decimal defaultValue = 0m) + { + if (value == null) + return defaultValue; + return value switch + { + decimal decimalValue => decimalValue, + bool boolValue => boolValue ? 1m : 0m, + string stringValue => decimal.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal result) ? result : defaultValue, + double doubleValue => (decimal)doubleValue, + float floatValue => (decimal)floatValue, + _ => defaultValue + }; + } /// Converts the value to a float. /// The float value, or the default value if conversion fails. - public static float AsFloat(this T value, float defaultValue = 0.0f) - { - if (value == null) - return defaultValue; - return value switch - { - bool boolValue => boolValue ? 1.0f : 0.0f, - string stringValue => float.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out float result) ? result : defaultValue, - double doubleValue => (float)doubleValue, - decimal decimalValue => (float)decimalValue, - _ => defaultValue - }; - } + public static float AsFloat(this T value, float defaultValue = 0.0f) + { + if (value == null) + return defaultValue; + return value switch + { + bool boolValue => boolValue ? 1.0f : 0.0f, + string stringValue => float.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out float result) ? result : defaultValue, + double doubleValue => (float)doubleValue, + decimal decimalValue => (float)decimalValue, + _ => defaultValue + }; + } /// Converts the value to a string. /// The string value, or the default value if conversion fails. - public static string AsString(this T value, string defaultValue = "") - { - return value?.ToString() ?? defaultValue; - } + public static string AsString(this T value, string defaultValue = "") + { + return value?.ToString() ?? defaultValue; + } /// Converts the value to a DateTime. /// The DateTime value, or the default value if conversion fails. - public static DateTime AsDateTime(this T value, DateTime defaultValue = default) - { - if (value == null) - return defaultValue; - return value switch - { - DateTime dateTimeValue => dateTimeValue, - string stringValue => DateTime.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime result) ? result : defaultValue, - long longValue => DateTimeOffset.FromUnixTimeMilliseconds(longValue).DateTime, - int intValue => DateTimeOffset.FromUnixTimeSeconds(intValue).DateTime, - _ => defaultValue - }; - } + public static DateTime AsDateTime(this T value, DateTime defaultValue = default) + { + if (value == null) + return defaultValue; + return value switch + { + DateTime dateTimeValue => dateTimeValue, + string stringValue => DateTime.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime result) ? result : defaultValue, + long longValue => DateTimeOffset.FromUnixTimeMilliseconds(longValue).DateTime, + int intValue => DateTimeOffset.FromUnixTimeSeconds(intValue).DateTime, + _ => defaultValue + }; + } /// Converts the value to a DateTimeOffset. /// The DateTimeOffset value, or the default value if conversion fails. - public static DateTimeOffset AsDateTimeOffset(this T value, DateTimeOffset defaultValue = default) - { - if (value == null) - return defaultValue; - return value switch - { - DateTimeOffset dateTimeOffsetValue => dateTimeOffsetValue, - DateTime dateTimeValue => new DateTimeOffset(dateTimeValue), - string stringValue => DateTimeOffset.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset result) ? result : defaultValue, - long longValue => DateTimeOffset.FromUnixTimeMilliseconds(longValue), - int intValue => DateTimeOffset.FromUnixTimeSeconds(intValue), - _ => defaultValue - }; - } + public static DateTimeOffset AsDateTimeOffset(this T value, DateTimeOffset defaultValue = default) + { + if (value == null) + return defaultValue; + return value switch + { + DateTimeOffset dateTimeOffsetValue => dateTimeOffsetValue, + DateTime dateTimeValue => new DateTimeOffset(dateTimeValue), + string stringValue => DateTimeOffset.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset result) ? result : defaultValue, + long longValue => DateTimeOffset.FromUnixTimeMilliseconds(longValue), + int intValue => DateTimeOffset.FromUnixTimeSeconds(intValue), + _ => defaultValue + }; + } /// Converts the value to a Guid. /// The Guid value, or the default value if conversion fails. - public static Guid AsGuid(this T value, Guid defaultValue = default) - { - if (value == null) - return defaultValue; - return value switch - { - Guid guidValue => guidValue, - string stringValue => Guid.TryParse(stringValue, out Guid result) ? result : defaultValue, - byte[] byteArray => byteArray.Length == 16 ? new Guid(byteArray) : defaultValue, - _ => defaultValue - }; - } + public static Guid AsGuid(this T value, Guid defaultValue = default) + { + if (value == null) + return defaultValue; + return value switch + { + Guid guidValue => guidValue, + string stringValue => Guid.TryParse(stringValue, out Guid result) ? result : defaultValue, + byte[] byteArray => byteArray.Length == 16 ? new Guid(byteArray) : defaultValue, + _ => defaultValue + }; + } /// Converts the value to a byte. /// The byte value, or the default value if conversion fails. - public static byte AsByte(this T value, byte defaultValue = 0) - { - if (value == null) - return defaultValue; - return value switch - { - int intValue => intValue >= byte.MinValue && intValue <= byte.MaxValue ? (byte)intValue : defaultValue, - bool boolValue => boolValue ? (byte)1 : (byte)0, - string stringValue => byte.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out byte result) ? result : defaultValue, - double doubleValue => doubleValue >= byte.MinValue && doubleValue <= byte.MaxValue ? (byte)doubleValue : defaultValue, - decimal decimalValue => decimalValue >= byte.MinValue && decimalValue <= byte.MaxValue ? (byte)decimalValue : defaultValue, - _ => defaultValue - }; - } + public static byte AsByte(this T value, byte defaultValue = 0) + { + if (value == null) + return defaultValue; + return value switch + { + int intValue => intValue >= byte.MinValue && intValue <= byte.MaxValue ? (byte)intValue : defaultValue, + bool boolValue => boolValue ? (byte)1 : (byte)0, + string stringValue => byte.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out byte result) ? result : defaultValue, + double doubleValue => doubleValue >= byte.MinValue && doubleValue <= byte.MaxValue ? (byte)doubleValue : defaultValue, + decimal decimalValue => decimalValue >= byte.MinValue && decimalValue <= byte.MaxValue ? (byte)decimalValue : defaultValue, + _ => defaultValue + }; + } /// Converts the value to a short. /// The short value, or the default value if conversion fails. - public static short AsShort(this T value, short defaultValue = 0) - { - if (value == null) - return defaultValue; - return value switch - { - int intValue => intValue >= short.MinValue && intValue <= short.MaxValue ? (short)intValue : defaultValue, - bool boolValue => boolValue ? (short)1 : (short)0, - string stringValue => short.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out short result) ? result : defaultValue, - double doubleValue => doubleValue >= short.MinValue && doubleValue <= short.MaxValue ? (short)doubleValue : defaultValue, - decimal decimalValue => decimalValue >= short.MinValue && decimalValue <= short.MaxValue ? (short)decimalValue : defaultValue, - _ => defaultValue - }; - } + public static short AsShort(this T value, short defaultValue = 0) + { + if (value == null) + return defaultValue; + return value switch + { + int intValue => intValue >= short.MinValue && intValue <= short.MaxValue ? (short)intValue : defaultValue, + bool boolValue => boolValue ? (short)1 : (short)0, + string stringValue => short.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out short result) ? result : defaultValue, + double doubleValue => doubleValue >= short.MinValue && doubleValue <= short.MaxValue ? (short)doubleValue : defaultValue, + decimal decimalValue => decimalValue >= short.MinValue && decimalValue <= short.MaxValue ? (short)decimalValue : defaultValue, + _ => defaultValue + }; + } /// Converts the value to a char. /// The char value, or the default value if conversion fails. - public static char AsChar(this T value, char defaultValue = default) - { - if (value == null) - return defaultValue; - return value switch - { - char charValue => charValue, - string stringValue => stringValue.Length > 0 ? stringValue[0] : defaultValue, - int intValue => intValue >= char.MinValue && intValue <= char.MaxValue ? (char)intValue : defaultValue, - byte byteValue => (char)byteValue, - _ => defaultValue - }; - } + public static char AsChar(this T value, char defaultValue = default) + { + if (value == null) + return defaultValue; + return value switch + { + char charValue => charValue, + string stringValue => stringValue.Length > 0 ? stringValue[0] : defaultValue, + int intValue => intValue >= char.MinValue && intValue <= char.MaxValue ? (char)intValue : defaultValue, + byte byteValue => (char)byteValue, + _ => defaultValue + }; + } /// Converts the value to a byte array. /// The byte array, or null if conversion fails. - public static byte[]? AsByteArray(this T value) - { - if (value == null) - return null; - return value switch - { - byte[] byteArrayValue => byteArrayValue, - string stringValue => Encoding.UTF8.GetBytes(stringValue), - Guid guidValue => guidValue.ToByteArray(), - _ => null - }; - } + public static byte[]? AsByteArray(this T value) + { + if (value == null) + return null; + return value switch + { + byte[] byteArrayValue => byteArrayValue, + string stringValue => Encoding.UTF8.GetBytes(stringValue), + Guid guidValue => guidValue.ToByteArray(), + _ => null + }; + } /// Converts the value to an enum of type TEnum. - /// The type of the value. - /// The enum type to convert to. - /// The value to convert. - /// The default value to return if conversion fails. - /// The enum value, or the default value if conversion fails. - public static TEnum AsEnum(this T value, TEnum defaultValue = default) where TEnum : struct, Enum - { - if (value == null) - return defaultValue; - return (value) switch - { - TEnum enumValue => enumValue, - string stringValue => Enum.TryParse(stringValue, true, out TEnum result) ? result : defaultValue, - int intValue => Enum.IsDefined(typeof(TEnum), intValue) ? (TEnum)Enum.ToObject(typeof(TEnum), intValue) : defaultValue, - byte byteValue => Enum.IsDefined(typeof(TEnum), byteValue) ? (TEnum)Enum.ToObject(typeof(TEnum), byteValue) : defaultValue, - short shortValue => Enum.IsDefined(typeof(TEnum), shortValue) ? (TEnum)Enum.ToObject(typeof(TEnum), shortValue) : defaultValue, - _ => defaultValue - }; - } + /// The type of the value. + /// The enum type to convert to. + /// The value to convert. + /// The default value to return if conversion fails. + /// The enum value, or the default value if conversion fails. + public static TEnum AsEnum(this T value, TEnum defaultValue = default) where TEnum : struct, Enum + { + if (value == null) + return defaultValue; + return (value) switch + { + TEnum enumValue => enumValue, + string stringValue => Enum.TryParse(stringValue, true, out TEnum result) ? result : defaultValue, + int intValue => Enum.IsDefined(typeof(TEnum), intValue) ? (TEnum)Enum.ToObject(typeof(TEnum), intValue) : defaultValue, + byte byteValue => Enum.IsDefined(typeof(TEnum), byteValue) ? (TEnum)Enum.ToObject(typeof(TEnum), byteValue) : defaultValue, + short shortValue => Enum.IsDefined(typeof(TEnum), shortValue) ? (TEnum)Enum.ToObject(typeof(TEnum), shortValue) : defaultValue, + _ => defaultValue + }; + } /// Converts the value to a TimeSpan. /// The TimeSpan value, or the default value if conversion fails. - public static TimeSpan AsTimeSpan(this T value, TimeSpan defaultValue = default) - { - if (value == null) - return defaultValue; - return (value) switch - { - TimeSpan timeSpanValue => timeSpanValue, - string stringValue => TimeSpan.TryParse(stringValue, CultureInfo.InvariantCulture, out TimeSpan result) ? result : defaultValue, - long longValue => TimeSpan.FromTicks(longValue), - int intValue => TimeSpan.FromMilliseconds(intValue), - double doubleValue => TimeSpan.FromMilliseconds(doubleValue), - _ => defaultValue - }; - } + public static TimeSpan AsTimeSpan(this T value, TimeSpan defaultValue = default) + { + if (value == null) + return defaultValue; + return (value) switch + { + TimeSpan timeSpanValue => timeSpanValue, + string stringValue => TimeSpan.TryParse(stringValue, CultureInfo.InvariantCulture, out TimeSpan result) ? result : defaultValue, + long longValue => TimeSpan.FromTicks(longValue), + int intValue => TimeSpan.FromMilliseconds(intValue), + double doubleValue => TimeSpan.FromMilliseconds(doubleValue), + _ => defaultValue + }; + } /// Converts the value to an array of T where T is the type of the array elements. - /// The type of the value. - /// The type of the array elements. - /// The value to convert. - /// The array, or null if conversion fails. - public static TElement[]? AsList(this T value) - { - if (value == null) - return null; - return (value) switch - { - TElement[] arrayValue => arrayValue, - IEnumerable enumerableValue => enumerableValue.ToArray(), - string stringValue when typeof(TElement) == typeof(char) => stringValue.Cast().ToArray(), - _ => null - }; - } + /// The type of the value. + /// The type of the array elements. + /// The value to convert. + /// The array, or null if conversion fails. + public static TElement[]? AsList(this T value) + { + if (value == null) + return null; + return (value) switch + { + TElement[] arrayValue => arrayValue, + IEnumerable enumerableValue => enumerableValue.ToArray(), + string stringValue when typeof(TElement) == typeof(char) => stringValue.Cast().ToArray(), + _ => null + }; + } #endregion Non-nullable conversions + #region Nullable conversions /// Converts the value to a nullable boolean, similar to the 'as' operator. /// The boolean value, or null if conversion fails. - public static bool? AsBooleanOrNull(this T? value) - { - if (value == null) - return null; - return (value) switch - { - bool boolValue => boolValue, - string stringValue => bool.TryParse(stringValue, out bool result) ? result : (bool?)null, - int intValue => intValue != 0, - _ => null - }; - } + public static bool? AsBooleanOrNull(this T? value) + { + if (value == null) + return null; + return (value) switch + { + bool boolValue => boolValue, + string stringValue => bool.TryParse(stringValue, out bool result) ? result : null, + int intValue => intValue != 0, + _ => null + }; + } /// Converts the value to a nullable integer, similar to the 'as' operator. /// The integer value, or null if conversion fails. - public static int? AsIntegerOrNull(this T? value) - { - if (value == null) - return null; - return (value) switch - { - int intValue => intValue, - string stringValue => int.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out int result) ? result : (int?)null, - double doubleValue => doubleValue >= int.MinValue && doubleValue <= int.MaxValue ? (int)doubleValue : (int?)null, - decimal decimalValue => decimalValue >= int.MinValue && decimalValue <= int.MaxValue ? (int)decimalValue : (int?)null, - long longValue => longValue >= int.MinValue && longValue <= int.MaxValue ? (int)longValue : (int?)null, - float floatValue => floatValue >= int.MinValue && floatValue <= int.MaxValue ? (int)floatValue : (int?)null, - uint uintValue => uintValue <= int.MaxValue ? (int)uintValue : (int?)null, - _ => null - }; - } + public static int? AsIntegerOrNull(this T? value) + { + if (value == null) + return null; + return (value) switch + { + int intValue => intValue, + string stringValue => int.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out int result) ? result : null, + double doubleValue => doubleValue >= int.MinValue && doubleValue <= int.MaxValue ? (int)doubleValue : null, + decimal decimalValue => decimalValue >= int.MinValue && decimalValue <= int.MaxValue ? (int)decimalValue : null, + long longValue => longValue >= int.MinValue && longValue <= int.MaxValue ? (int)longValue : null, + float floatValue => floatValue >= int.MinValue && floatValue <= int.MaxValue ? (int)floatValue : null, + uint uintValue => uintValue <= int.MaxValue ? (int)uintValue : null, + _ => null + }; + } /// Converts the value to a nullable long, similar to the 'as' operator. /// The long value, or null if conversion fails. - public static long? AsLongOrNull(this T? value) - { - if (value == null) - return null; - return (value) switch - { - long longValue => longValue, - bool boolValue => boolValue ? 1L : 0L, - string stringValue => long.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out long result) ? result : (long?)null, - double doubleValue => doubleValue >= long.MinValue && doubleValue <= long.MaxValue ? (long)doubleValue : (long?)null, - decimal decimalValue => decimalValue >= long.MinValue && decimalValue <= long.MaxValue ? (long)decimalValue : (long?)null, - float floatValue => floatValue >= long.MinValue && floatValue <= long.MaxValue ? (long)floatValue : (long?)null, - ulong ulongValue => ulongValue <= long.MaxValue ? (long)ulongValue : (long?)null, - _ => null - }; - } + public static long? AsLongOrNull(this T? value) + { + if (value == null) + return null; + return (value) switch + { + long longValue => longValue, + bool boolValue => boolValue ? 1L : 0L, + string stringValue => long.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out long result) ? result : null, + double doubleValue => doubleValue >= long.MinValue && doubleValue <= long.MaxValue ? (long)doubleValue : null, + decimal decimalValue => decimalValue >= long.MinValue && decimalValue <= long.MaxValue ? (long)decimalValue : null, + float floatValue => floatValue >= long.MinValue && floatValue <= long.MaxValue ? (long)floatValue : null, + ulong ulongValue => ulongValue <= long.MaxValue ? (long)ulongValue : null, + _ => null + }; + } /// Converts the value to a nullable double, similar to the 'as' operator. /// The double value, or null if conversion fails. - public static double? AsDoubleOrNull(this T? value) - { - if (value == null) - return null; - return (value) switch - { - double doubleValue => doubleValue, - string stringValue => double.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out double result) ? result : (double?)null, - _ => null - }; - } + public static double? AsDoubleOrNull(this T? value) + { + if (value == null) + return null; + return (value) switch + { + double doubleValue => doubleValue, + string stringValue => double.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out double result) ? result : null, + _ => null + }; + } /// Converts the value to a nullable decimal, similar to the 'as' operator. /// The decimal value, or null if conversion fails. - public static decimal? AsDecimalOrNull(this T? value) - { - if (value == null) - return null; - return (value) switch - { - decimal decimalValue => decimalValue, - string stringValue => decimal.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal result) ? result : (decimal?)null, - _ => null - }; - } + public static decimal? AsDecimalOrNull(this T? value) + { + if (value == null) + return null; + return (value) switch + { + decimal decimalValue => decimalValue, + string stringValue => decimal.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal result) ? result : null, + _ => null + }; + } /// Converts the value to a nullable float, similar to the 'as' operator. /// The float value, or null if conversion fails. - public static float? AsFloatOrNull(this T? value) - { - if (value == null) - return null; - return (value) switch - { - float floatValue => floatValue, - string stringValue => float.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out float result) ? result : (float?)null, - double doubleValue => doubleValue >= float.MinValue && doubleValue <= float.MaxValue ? (float)doubleValue : (float?)null, - decimal decimalValue => (float)decimalValue, - _ => null - }; - } + public static float? AsFloatOrNull(this T? value) + { + if (value == null) + return null; + return (value) switch + { + float floatValue => floatValue, + string stringValue => float.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out float result) ? result : null, + double doubleValue => doubleValue >= float.MinValue && doubleValue <= float.MaxValue ? (float)doubleValue : null, + decimal decimalValue => (float)decimalValue, + _ => null + }; + } /// Converts the value to a string, similar to the 'as' operator. /// The string value, or null if conversion fails. - public static string? AsStringOrNull(this T? value) - { - if (value == null) - return null; - return value is string stringValue ? stringValue : value.ToString(); - } + public static string? AsStringOrNull(this T? value) + { + if (value == null) + return null; + return value is string stringValue ? stringValue : value.ToString(); + } /// Converts the value to a nullable DateTime, similar to the 'as' operator. /// The DateTime value, or null if conversion fails. - public static DateTime? AsDateTimeOrNull(this T? value) - { - if (value == null) - return null; - return (value) switch - { - DateTime dateTimeValue => dateTimeValue, - DateTimeOffset dateTimeOffsetValue => dateTimeOffsetValue.DateTime, - string stringValue => DateTime.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime result) ? result : (DateTime?)null, - _ => null - }; - } + public static DateTime? AsDateTimeOrNull(this T? value) + { + if (value == null) + return null; + return (value) switch + { + DateTime dateTimeValue => dateTimeValue, + DateTimeOffset dateTimeOffsetValue => dateTimeOffsetValue.DateTime, + string stringValue => DateTime.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime result) ? result : null, + _ => null + }; + } /// Converts the value to a nullable DateTimeOffset, similar to the 'as' operator. /// The DateTimeOffset value, or null if conversion fails. - public static DateTimeOffset? AsDateTimeOffsetOrNull(this T? value) - { - if (value == null) - return null; - return (value) switch - { - DateTimeOffset dateTimeOffsetValue => dateTimeOffsetValue, - string stringValue => DateTimeOffset.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset result) ? result : (DateTimeOffset?)null, - _ => null - }; - } + public static DateTimeOffset? AsDateTimeOffsetOrNull(this T? value) + { + if (value == null) + return null; + return (value) switch + { + DateTimeOffset dateTimeOffsetValue => dateTimeOffsetValue, + string stringValue => DateTimeOffset.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset result) ? result : null, + _ => null + }; + } /// Converts the value to a nullable Guid, similar to the 'as' operator. /// The Guid value, or null if conversion fails. - public static Guid? AsGuidOrNull(this T? value) - { - if (value == null) - return null; - return (value) switch - { - Guid guidValue => guidValue, - string stringValue => Guid.TryParse(stringValue, out Guid result) ? result : (Guid?)null, - byte[] byteArray => byteArray.Length == 16 ? new Guid(byteArray) : (Guid?)null, - _ => null - }; - } + public static Guid? AsGuidOrNull(this T? value) + { + if (value == null) + return null; + return (value) switch + { + Guid guidValue => guidValue, + string stringValue => Guid.TryParse(stringValue, out Guid result) ? result : null, + byte[] byteArray => byteArray.Length == 16 ? new Guid(byteArray) : null, + _ => null + }; + } /// Converts the value to a nullable byte, similar to the 'as' operator. /// The byte value, or null if conversion fails. - public static byte? AsByteOrNull(this T? value) - { - if (value == null) - return null; - return (value) switch - { - byte byteValue => byteValue, - int intValue => intValue >= byte.MinValue && intValue <= byte.MaxValue ? (byte)intValue : (byte?)null, - string stringValue => byte.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out byte result) ? result : (byte?)null, - double doubleValue => doubleValue >= byte.MinValue && doubleValue <= byte.MaxValue ? (byte)doubleValue : (byte?)null, - decimal decimalValue => decimalValue >= byte.MinValue && decimalValue <= byte.MaxValue ? (byte)decimalValue : (byte?)null, - _ => null - }; - } + public static byte? AsByteOrNull(this T? value) + { + if (value == null) + return null; + return (value) switch + { + byte byteValue => byteValue, + int intValue => intValue >= byte.MinValue && intValue <= byte.MaxValue ? (byte)intValue : null, + string stringValue => byte.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out byte result) ? result : null, + double doubleValue => doubleValue >= byte.MinValue && doubleValue <= byte.MaxValue ? (byte)doubleValue : null, + decimal decimalValue => decimalValue >= byte.MinValue && decimalValue <= byte.MaxValue ? (byte)decimalValue : null, + _ => null + }; + } /// Converts the value to a nullable short, similar to the 'as' operator. /// The short value, or null if conversion fails. - public static short? AsShortOrNull(this T? value) - { - if (value == null) - return null; - return (value) switch - { - short shortValue => shortValue, - int intValue => intValue >= short.MinValue && intValue <= short.MaxValue ? (short)intValue : (short?)null, - string stringValue => short.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out short result) ? result : (short?)null, - double doubleValue => doubleValue >= short.MinValue && doubleValue <= short.MaxValue ? (short)doubleValue : (short?)null, - decimal decimalValue => decimalValue >= short.MinValue && decimalValue <= short.MaxValue ? (short)decimalValue : (short?)null, - _ => null - }; - } + public static short? AsShortOrNull(this T? value) + { + if (value == null) + return null; + return (value) switch + { + short shortValue => shortValue, + int intValue => intValue >= short.MinValue && intValue <= short.MaxValue ? (short)intValue : null, + string stringValue => short.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out short result) ? result : null, + double doubleValue => doubleValue >= short.MinValue && doubleValue <= short.MaxValue ? (short)doubleValue : null, + decimal decimalValue => decimalValue >= short.MinValue && decimalValue <= short.MaxValue ? (short)decimalValue : null, + _ => null + }; + } /// Converts the value to a nullable char, similar to the 'as' operator. /// The char value, or null if conversion fails. - public static char? AsCharOrNull(this T? value) - { - if (value == null) - return null; - return (value) switch - { - char charValue => charValue, - string stringValue => stringValue.Length > 0 ? stringValue[0] : (char?)null, - int intValue => intValue >= char.MinValue && intValue <= char.MaxValue ? (char)intValue : (char?)null, - _ => null - }; - } + public static char? AsCharOrNull(this T? value) + { + if (value == null) + return null; + return (value) switch + { + char charValue => charValue, + string stringValue => stringValue.Length > 0 ? stringValue[0] : null, + int intValue => intValue >= char.MinValue && intValue <= char.MaxValue ? (char)intValue : null, + _ => null + }; + } /// Converts the value to a nullable TimeSpan, similar to the 'as' operator. /// The TimeSpan value, or null if conversion fails. - public static TimeSpan? AsTimeSpanOrNull(this T? value) - { - if (value == null) - return null; - return (value) switch - { - TimeSpan timeSpanValue => timeSpanValue, - string stringValue => TimeSpan.TryParse(stringValue, CultureInfo.InvariantCulture, out TimeSpan result) ? result : (TimeSpan?)null, - _ => null - }; - } - /// Converts the value to an enum of type TEnum, similar to the 'as' operator. - /// The enum value, or null if conversion fails. - public static TEnum? AsEnumOrNull(this T? value) where TEnum : struct, Enum - { - if (value == null) - return null; - return (value) switch - { - TEnum enumValue => enumValue, - string stringValue => Enum.TryParse(stringValue, true, out TEnum result) ? result : (TEnum?)null, - int intValue => Enum.IsDefined(typeof(TEnum), intValue) ? (TEnum)Enum.ToObject(typeof(TEnum), intValue) : (TEnum?)null, - byte byteValue => Enum.IsDefined(typeof(TEnum), byteValue) ? (TEnum)Enum.ToObject(typeof(TEnum), byteValue) : (TEnum?)null, - short shortValue => Enum.IsDefined(typeof(TEnum), shortValue) ? (TEnum)Enum.ToObject(typeof(TEnum), shortValue) : (TEnum?)null, - _ => null - }; - } + public static TimeSpan? AsTimeSpanOrNull(this T? value) + { + if (value == null) + return null; + return (value) switch + { + TimeSpan timeSpanValue => timeSpanValue, + string stringValue => TimeSpan.TryParse(stringValue, CultureInfo.InvariantCulture, out TimeSpan result) ? result : null, + _ => null + }; + } + /// Converts the value to an enum of type TEnum, similar to the 'as' operator. + /// The enum value, or null if conversion fails. + public static TEnum? AsEnumOrNull(this T? value) where TEnum : struct, Enum + { + if (value == null) + return null; + return (value) switch + { + TEnum enumValue => enumValue, + string stringValue => Enum.TryParse(stringValue, true, out TEnum result) ? result : null, + int intValue => Enum.IsDefined(typeof(TEnum), intValue) ? (TEnum)Enum.ToObject(typeof(TEnum), intValue) : null, + byte byteValue => Enum.IsDefined(typeof(TEnum), byteValue) ? (TEnum)Enum.ToObject(typeof(TEnum), byteValue) : null, + short shortValue => Enum.IsDefined(typeof(TEnum), shortValue) ? (TEnum)Enum.ToObject(typeof(TEnum), shortValue) : null, + _ => null + }; + } - /// Attempts to convert the value to the specified type, similar to the 'as' operator. - /// The type of the value. - /// The type to convert to. - /// The value to convert. - /// The converted value, or null if conversion fails. - public static TResult? AsTypeOrNull(this T? value) where TResult : class - { - try - { - if (value is TResult result) - { - return result; - } - // Try standard conversions for reference types - Type targetType = typeof(TResult); - if (targetType == typeof(string)) return value.AsStringOrNull() as TResult; - if (targetType == typeof(bool)) return (TResult?)(object?)value.AsBooleanOrNull(); - if (targetType == typeof(int)) return (TResult?)(object?)value.AsIntegerOrNull(); - if (targetType == typeof(long)) return (TResult?)(object?)value.AsLongOrNull(); - if (targetType == typeof(double)) return (TResult?)(object?)value.AsDoubleOrNull(); - if (targetType == typeof(decimal)) return (TResult?)(object?)value.AsDecimalOrNull(); - if (targetType == typeof(float)) return (TResult?)(object?)value.AsFloatOrNull(); - if (targetType == typeof(DateTime)) return (TResult?)(object?)value.AsDateTimeOrNull(); - if (targetType == typeof(Guid)) return (TResult?)(object?)value.AsGuidOrNull(); - if (targetType == typeof(byte)) return (TResult?)(object?)value.AsByteOrNull(); - if (targetType == typeof(short)) return (TResult?)(object?)value.AsShortOrNull(); - if (targetType == typeof(char)) return (TResult?)(object?)value.AsCharOrNull(); - if (targetType == typeof(TimeSpan)) return (TResult?)(object?)value.AsTimeSpanOrNull(); - // Try direct conversion if it's a value type - if (value is TResult resultValue) - return resultValue; - } - catch - { - // Handle exceptions if necessary - } - return null; - } + /// Attempts to convert the value to the specified type, similar to the 'as' operator. + /// The type of the value. + /// The type to convert to. + /// The value to convert. + /// The converted value, or null if conversion fails. + public static TResult? AsTypeOrNull(this T? value) where TResult : class + { + try + { + if (value is TResult result) + { + return result; + } + // Try standard conversions for reference types + Type targetType = typeof(TResult); + if (targetType == typeof(string)) return value.AsStringOrNull() as TResult; + if (targetType == typeof(bool)) return (TResult?)(object?)value.AsBooleanOrNull(); + if (targetType == typeof(int)) return (TResult?)(object?)value.AsIntegerOrNull(); + if (targetType == typeof(long)) return (TResult?)(object?)value.AsLongOrNull(); + if (targetType == typeof(double)) return (TResult?)(object?)value.AsDoubleOrNull(); + if (targetType == typeof(decimal)) return (TResult?)(object?)value.AsDecimalOrNull(); + if (targetType == typeof(float)) return (TResult?)(object?)value.AsFloatOrNull(); + if (targetType == typeof(DateTime)) return (TResult?)(object?)value.AsDateTimeOrNull(); + if (targetType == typeof(Guid)) return (TResult?)(object?)value.AsGuidOrNull(); + if (targetType == typeof(byte)) return (TResult?)(object?)value.AsByteOrNull(); + if (targetType == typeof(short)) return (TResult?)(object?)value.AsShortOrNull(); + if (targetType == typeof(char)) return (TResult?)(object?)value.AsCharOrNull(); + if (targetType == typeof(TimeSpan)) return (TResult?)(object?)value.AsTimeSpanOrNull(); + // Try direct conversion if it's a value type + if (value is TResult resultValue) + return resultValue; + } + catch + { + // Handle exceptions if necessary + } + return null; + } - /// Attempts to convert the value to the specified value type, similar to the 'as' operator. - /// The type of the value. - /// The value type to convert to. - /// The value to convert. - public static TResult? AsValueTypeOrNull(this T? value) where TResult : struct - { - Type targetType = typeof(TResult); - if (targetType == typeof(bool)) return (TResult?)(object?)value.AsBooleanOrNull(); - if (targetType == typeof(int)) return (TResult?)(object?)value.AsIntegerOrNull(); - if (targetType == typeof(long)) return (TResult?)(object?)value.AsLongOrNull(); - if (targetType == typeof(double)) return (TResult?)(object?)value.AsDoubleOrNull(); - if (targetType == typeof(decimal)) return (TResult?)(object?)value.AsDecimalOrNull(); - if (targetType == typeof(float)) return (TResult?)(object?)value.AsFloatOrNull(); - if (targetType == typeof(DateTime)) return (TResult?)(object?)value.AsDateTimeOrNull(); - if (targetType == typeof(Guid)) return (TResult?)(object?)value.AsGuidOrNull(); - if (targetType == typeof(byte)) return (TResult?)(object?)value.AsByteOrNull(); - if (targetType == typeof(short)) return (TResult?)(object?)value.AsShortOrNull(); - if (targetType == typeof(char)) return (TResult?)(object?)value.AsCharOrNull(); - if (targetType == typeof(TimeSpan)) return (TResult?)(object?)value.AsTimeSpanOrNull(); - // Try direct conversion if it's a value type - if (value is TResult resultValue) - return resultValue; - return null; - } + /// Attempts to convert the value to the specified value type, similar to the 'as' operator. + /// The type of the value. + /// The value type to convert to. + /// The value to convert. + public static TResult? AsValueTypeOrNull(this T? value) where TResult : struct + { + Type targetType = typeof(TResult); + if (targetType == typeof(bool)) return (TResult?)(object?)value.AsBooleanOrNull(); + if (targetType == typeof(int)) return (TResult?)(object?)value.AsIntegerOrNull(); + if (targetType == typeof(long)) return (TResult?)(object?)value.AsLongOrNull(); + if (targetType == typeof(double)) return (TResult?)(object?)value.AsDoubleOrNull(); + if (targetType == typeof(decimal)) return (TResult?)(object?)value.AsDecimalOrNull(); + if (targetType == typeof(float)) return (TResult?)(object?)value.AsFloatOrNull(); + if (targetType == typeof(DateTime)) return (TResult?)(object?)value.AsDateTimeOrNull(); + if (targetType == typeof(Guid)) return (TResult?)(object?)value.AsGuidOrNull(); + if (targetType == typeof(byte)) return (TResult?)(object?)value.AsByteOrNull(); + if (targetType == typeof(short)) return (TResult?)(object?)value.AsShortOrNull(); + if (targetType == typeof(char)) return (TResult?)(object?)value.AsCharOrNull(); + if (targetType == typeof(TimeSpan)) return (TResult?)(object?)value.AsTimeSpanOrNull(); + // Try direct conversion if it's a value type + if (value is TResult resultValue) + return resultValue; + return null; + } - #endregion + #endregion - /// Gets a value indicating whether the object is of the specified type. - /// The type to check. - /// The object to check. - /// True if the object is of the specified type; otherwise, false. - public static bool IsOfType(this object obj) - { - return obj is T; - } + /// Gets a value indicating whether the object is of the specified type. + /// The type to check. + /// The object to check. + /// True if the object is of the specified type; otherwise, false. + public static bool IsOfType(this object obj) + { + return obj is T; + } - /// Gets the underlying type for a nullable type. - /// The type to check. - /// The underlying type if the type is nullable; otherwise, the original type. - /// - /// Gets the underlying type for a nullable type. - /// - /// The underlying type if the type is nullable; otherwise, the original type. - public static Type GetUnderlyingType(this Type type) - { - return Nullable.GetUnderlyingType(type) ?? type; - } + /// Gets the underlying type for a nullable type. + /// The type to check. + /// The underlying type if the type is nullable; otherwise, the original type. + /// + /// Gets the underlying type for a nullable type. + /// + /// The underlying type if the type is nullable; otherwise, the original type. + public static Type GetUnderlyingType(this Type type) + { + return Nullable.GetUnderlyingType(type) ?? type; + } } diff --git a/src/VisionaryCoder.Framework/Filtering/Abstractions/IFilterExecutionStrategy.cs b/src/VisionaryCoder.Framework/Filtering/Abstractions/IFilterExecutionStrategy.cs index c9c42f0..a578e71 100644 --- a/src/VisionaryCoder.Framework/Filtering/Abstractions/IFilterExecutionStrategy.cs +++ b/src/VisionaryCoder.Framework/Filtering/Abstractions/IFilterExecutionStrategy.cs @@ -1,3 +1,5 @@ +namespace VisionaryCoder.Framework.Filtering.Abstractions; + public interface IFilterExecutionStrategy { IQueryable Apply(IQueryable source, FilterNode? filter); diff --git a/src/VisionaryCoder.Framework/Filtering/EFCore/EfFilterExecutionStrategy.cs b/src/VisionaryCoder.Framework/Filtering/EFCore/EfFilterExecutionStrategy.cs index 83bcdbb..a0cfff8 100644 --- a/src/VisionaryCoder.Framework/Filtering/EFCore/EfFilterExecutionStrategy.cs +++ b/src/VisionaryCoder.Framework/Filtering/EFCore/EfFilterExecutionStrategy.cs @@ -1,11 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; +using VisionaryCoder.Framework.Filtering.Abstractions; + +namespace VisionaryCoder.Framework.Filtering.EFCore; + public sealed class EfFilterExecutionStrategy(DbContext dbContext) : IFilterExecutionStrategy { public IQueryable Apply(IQueryable source, FilterNode? filter) { if (filter is null) return source; - var parameter = Expression.Parameter(typeof(T), "x"); - var body = EfFilterExpressionBuilder.BuildExpression(filter, parameter, dbContext); + ParameterExpression parameter = Expression.Parameter(typeof(T), "x"); + Expression? body = EfFilterExpressionBuilder.BuildExpression(filter, parameter, dbContext); if (body is null) return source; var lambda = Expression.Lambda>(body, parameter); @@ -16,11 +22,11 @@ public IEnumerable Apply(IEnumerable source, FilterNode? filter) { // reuse same expression, compile to func for in-memory: if (filter is null) return source; - var parameter = Expression.Parameter(typeof(T), "x"); - var body = EfFilterExpressionBuilder.BuildExpression(filter, parameter, dbContext); + ParameterExpression parameter = Expression.Parameter(typeof(T), "x"); + Expression? body = EfFilterExpressionBuilder.BuildExpression(filter, parameter, dbContext); if (body is null) return source; - var lambda = Expression.Lambda>(body, parameter).Compile(); + Func lambda = Expression.Lambda>(body, parameter).Compile(); return source.Where(lambda); } } diff --git a/src/VisionaryCoder.Framework/Filtering/EFCore/EfFilterExpressionBuilder.cs b/src/VisionaryCoder.Framework/Filtering/EFCore/EfFilterExpressionBuilder.cs new file mode 100644 index 0000000..c24da36 --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/EFCore/EfFilterExpressionBuilder.cs @@ -0,0 +1,170 @@ +using Microsoft.EntityFrameworkCore; +using System.Globalization; +using System.Linq.Expressions; +using System.Reflection; + +namespace VisionaryCoder.Framework.Filtering.EFCore; + +internal static class EfFilterExpressionBuilder +{ + public static Expression? BuildExpression(FilterNode? filter, ParameterExpression parameter, DbContext dbContext) + => Build(filter, parameter); + + private static Expression? Build(FilterNode? node, ParameterExpression parameter) + { + if (node is null) return null; + + return node switch + { + FilterGroup g => BuildGroup(g, parameter), + FilterCondition c => BuildCondition(c, parameter), + FilterCollectionCondition cc => BuildCollectionCondition(cc, parameter), + _ => null + }; + } + + private static Expression? BuildGroup(FilterGroup group, ParameterExpression parameter) + { + Expression? combined = null; + foreach (FilterNode child in group.Children) + { + Expression? expr = Build(child, parameter); + if (expr is null) continue; + combined = combined is null + ? expr + : group.Combination == FilterCombination.And + ? Expression.AndAlso(combined, expr) + : Expression.OrElse(combined, expr); + } + return combined; + } + + private static Expression? BuildCondition(FilterCondition condition, ParameterExpression parameter) + { + MemberExpression? member = BuildMemberAccess(parameter, condition.Path); + if (member is null) return null; + + Type targetType = Nullable.GetUnderlyingType(member.Type) ?? member.Type; + object? constantValue = ConvertFromString(condition.Value, targetType); + if (constantValue is null && targetType.IsValueType && targetType != typeof(string)) + return null; + + ConstantExpression constant = Expression.Constant(constantValue, targetType); + Expression left = member; + if (member.Type != constant.Type) + { + // Align types (nullable vs non-nullable) + if (member.Type != constant.Type && Nullable.GetUnderlyingType(member.Type) == constant.Type) + { + // ok as-is + } + else if (constant.Type != member.Type && Nullable.GetUnderlyingType(constant.Type) == member.Type) + { + left = Expression.Convert(member, constant.Type); + } + } + + return condition.Operator switch + { + FilterOperator.Equals => Expression.Equal(left, PromoteNull(constant, left.Type)), + FilterOperator.NotEquals => Expression.NotEqual(left, PromoteNull(constant, left.Type)), + FilterOperator.GreaterThan => Expression.GreaterThan(left, constant), + FilterOperator.GreaterOrEqual => Expression.GreaterThanOrEqual(left, constant), + FilterOperator.LessThan => Expression.LessThan(left, constant), + FilterOperator.LessOrEqual => Expression.LessThanOrEqual(left, constant), + FilterOperator.Contains => StringMethod(member, nameof(string.Contains), condition.Value), + FilterOperator.StartsWith => StringMethod(member, nameof(string.StartsWith), condition.Value), + FilterOperator.EndsWith => StringMethod(member, nameof(string.EndsWith), condition.Value), + _ => null + }; + } + + private static Expression? BuildCollectionCondition(FilterCollectionCondition condition, ParameterExpression parameter) + { + MemberExpression? collection = BuildMemberAccess(parameter, condition.Path); + if (collection is null) return null; + + Type? elementType = GetElementType(collection.Type); + if (elementType is null) return null; + + string? anyAllMethodName = condition.Operator switch + { + FilterOperator.HasElements => nameof(Enumerable.Any), + FilterOperator.Any => nameof(Enumerable.Any), + FilterOperator.All => nameof(Enumerable.All), + _ => null + }; + if (anyAllMethodName is null) return null; + + if (condition.Operator == FilterOperator.HasElements) + { + return Expression.Call( + typeof(Enumerable), anyAllMethodName, [elementType], collection); + } + + if (condition.Predicate is null) return null; + ParameterExpression elemParam = Expression.Parameter(elementType, "e"); + Expression? inner = Build(condition.Predicate, elemParam); + if (inner is null) return null; + LambdaExpression lambda = Expression.Lambda(inner, elemParam); + return Expression.Call( + typeof(Enumerable), anyAllMethodName, [elementType], collection, lambda); + } + + private static Expression? StringMethod(Expression member, string method, string? arg) + { + if (member.Type != typeof(string)) return null; + MethodInfo mi = typeof(string).GetMethod(method, [typeof(string)])!; + return Expression.Call(member, mi, Expression.Constant(arg ?? string.Empty)); + } + + private static MemberExpression? BuildMemberAccess(Expression root, string path) + { + Expression current = root; + foreach (string segment in path.Split('.')) + { + PropertyInfo? prop = current.Type.GetProperty(segment, BindingFlags.Instance | BindingFlags.Public | BindingFlags.IgnoreCase); + if (prop is not null) + { + current = Expression.Property(current, prop); + continue; + } + FieldInfo? field = current.Type.GetField(segment, BindingFlags.Instance | BindingFlags.Public | BindingFlags.IgnoreCase); + if (field is not null) + { + current = Expression.Field(current, field); + continue; + } + return null; + } + return current as MemberExpression ?? (current.NodeType == ExpressionType.MemberAccess ? (MemberExpression)current : null); + } + + private static Type? GetElementType(Type type) + { + if (type.IsArray) return type.GetElementType(); + Type? ienum = type.GetInterfaces().Append(type) + .FirstOrDefault(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + return ienum?.GetGenericArguments()[0]; + } + + private static object? ConvertFromString(string? text, Type targetType) + { + if (text is null) return targetType == typeof(string) ? string.Empty : null; + if (targetType == typeof(string)) return text; + if (targetType == typeof(Guid)) return Guid.TryParse(text, out Guid g) ? g : null; + if (targetType == typeof(bool)) return bool.TryParse(text, out bool b) ? b : null; + if (targetType == typeof(int)) return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int i) ? i : null; + if (targetType == typeof(long)) return long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out long l) ? l : null; + if (targetType == typeof(short)) return short.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out short s) ? s : null; + if (targetType == typeof(decimal)) return decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out decimal d) ? d : null; + if (targetType == typeof(double)) return double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out double dbl) ? dbl : null; + if (targetType == typeof(float)) return float.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out float f) ? f : null; + if (targetType == typeof(DateTime)) return DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime dt) ? dt : null; + if (targetType.IsEnum) return Enum.TryParse(targetType, text, ignoreCase: true, out object? e) ? e : null; + return text; + } + + private static Expression PromoteNull(Expression constant, Type targetType) + => constant.Type == targetType ? constant : Expression.Convert(constant, targetType); +} diff --git a/src/VisionaryCoder.Framework/Filtering/ExpressionToFilterNode.cs b/src/VisionaryCoder.Framework/Filtering/ExpressionToFilterNode.cs index f144c68..738d33c 100644 --- a/src/VisionaryCoder.Framework/Filtering/ExpressionToFilterNode.cs +++ b/src/VisionaryCoder.Framework/Filtering/ExpressionToFilterNode.cs @@ -1,53 +1,48 @@ using System.Linq.Expressions; +namespace VisionaryCoder.Framework.Filtering; + public static class ExpressionToFilterNode { - public static FilterNode Translate(Expression> expression) => - TranslateNode(expression.Body) - ?? throw new NotSupportedException($"Expression '{expression}' is not supported."); + public static FilterNode Translate(Expression> expression) => TranslateNode(expression.Body) ?? throw new NotSupportedException($"Expression '{expression}' is not supported."); - public static FilterNode Translate(Expression expression) => - TranslateNode(expression) - ?? throw new NotSupportedException($"Expression '{expression}' is not supported."); + public static FilterNode Translate(Expression expression) => TranslateNode(expression) ?? throw new NotSupportedException($"Expression '{expression}' is not supported."); - static FilterNode? TranslateNode(Expression expression) => + private static FilterNode? TranslateNode(Expression expression) => expression switch { BinaryExpression binary => TranslateBinary(binary), MethodCallExpression call => TranslateMethodCall(call), - UnaryExpression unary when unary.NodeType == ExpressionType.Not - => TranslateNot(unary), + UnaryExpression { NodeType: ExpressionType.Not } unary => TranslateNot(unary), _ => null }; - static FilterNode? TranslateBinary(BinaryExpression binary) + private static FilterNode? TranslateBinary(BinaryExpression binary) { - // Logical group: && / || - if (binary.NodeType is ExpressionType.AndAlso or ExpressionType.OrElse) + // Comparison: ==, !=, <, <=, >, >= + if (binary.NodeType is not (ExpressionType.AndAlso or ExpressionType.OrElse)) { - var combination = binary.NodeType == ExpressionType.AndAlso - ? FilterCombination.And - : FilterCombination.Or; + return TranslateComparison(binary); + } - var left = TranslateNode(binary.Left); - var right = TranslateNode(binary.Right); + // Logical group: && / || + FilterCombination combination = binary.NodeType == ExpressionType.AndAlso ? FilterCombination.And : FilterCombination.Or; + FilterNode? left = TranslateNode(binary.Left); + FilterNode? right = TranslateNode(binary.Right); - var children = new List(); - if (left is not null) children.AddRange(FlattenIfSameGroup(left, combination)); - if (right is not null) children.AddRange(FlattenIfSameGroup(right, combination)); + var children = new List(); + if (left is not null) children.AddRange(FlattenIfSameGroup(left, combination)); + if (right is not null) children.AddRange(FlattenIfSameGroup(right, combination)); - return new FilterGroup(combination, children); - } + return new FilterGroup(combination, children); - // Comparison: ==, !=, <, <=, >, >= - return TranslateComparison(binary); } - static IEnumerable FlattenIfSameGroup(FilterNode node, FilterCombination combination) + private static IEnumerable FlattenIfSameGroup(FilterNode node, FilterCombination combination) { if (node is FilterGroup group && group.Combination == combination) { - foreach (var child in group.Children) + foreach (FilterNode child in group.Children) { yield return child; } @@ -56,55 +51,59 @@ static IEnumerable FlattenIfSameGroup(FilterNode node, FilterCombina yield return node; } - static FilterNode? TranslateComparison(BinaryExpression binary) + private static FilterNode? TranslateComparison(BinaryExpression binary) { - var (memberExpr, constantExpr, op) = NormalizeBinary(binary); + + (MemberExpression? memberExpr, Expression? constantExpr, FilterOperator? op) = NormalizeBinary(binary); if (memberExpr is null || constantExpr is null || op is null) { return null; } - var path = GetMemberPath(memberExpr); + string? path = GetMemberPath(memberExpr); if (path is null) { return null; } - var value = EvaluateToString(constantExpr); + string? value = EvaluateToString(constantExpr); return new FilterCondition(path, op.Value, value); + } /// /// Normalizes a binary expression so that the member is on the left /// and the constant/value is on the right. Adjusts the operator if needed. /// - static (MemberExpression? member, Expression? constant, FilterOperator?) NormalizeBinary(BinaryExpression binary) + private static (MemberExpression? member, Expression? constant, FilterOperator?) NormalizeBinary(BinaryExpression binary) { - var leftMember = GetMember(binary.Left); - var rightMember = GetMember(binary.Right); - var leftIsConstLike = IsConstantLike(binary.Left); - var rightIsConstLike = IsConstantLike(binary.Right); + MemberExpression? leftMember = GetMember(binary.Left); + MemberExpression? rightMember = GetMember(binary.Right); + + bool leftIsConstLike = IsConstantLike(binary.Left); + bool rightIsConstLike = IsConstantLike(binary.Right); // member op constant if (leftMember is not null && rightIsConstLike) { - var op = MapComparisonOperator(binary.NodeType, invert: false); + FilterOperator? op = MapComparisonOperator(binary.NodeType, invert: false); return (leftMember, binary.Right, op); } // constant op member -> invert operator if (rightMember is not null && leftIsConstLike) { - var op = MapComparisonOperator(binary.NodeType, invert: true); + FilterOperator? op = MapComparisonOperator(binary.NodeType, invert: true); return (rightMember, binary.Left, op); } return (null, null, null); } - static FilterOperator? MapComparisonOperator(ExpressionType nodeType, bool invert) + private static FilterOperator? MapComparisonOperator(ExpressionType nodeType, bool invert) { + return (nodeType, invert) switch { (ExpressionType.Equal, _) => FilterOperator.Equals, @@ -124,36 +123,38 @@ static IEnumerable FlattenIfSameGroup(FilterNode node, FilterCombina _ => null }; + } - static FilterNode? TranslateMethodCall(MethodCallExpression call) + private static FilterNode? TranslateMethodCall(MethodCallExpression call) { + // string.Contains / StartsWith / EndsWith if (call.Object is not null && call.Object.Type == typeof(string) && call.Arguments.Count == 1) { - var targetMember = GetMember(call.Object); + MemberExpression? targetMember = GetMember(call.Object); if (targetMember is null) { return null; } - var path = GetMemberPath(targetMember); + string? path = GetMemberPath(targetMember); if (path is null) { return null; } - var arg = call.Arguments[0]; - var value = EvaluateToString(arg); + Expression arg = call.Arguments[0]; + string? value = EvaluateToString(arg); - var op = call.Method.Name switch + FilterOperator? op = call.Method.Name switch { nameof(string.Contains) => FilterOperator.Contains, nameof(string.StartsWith) => FilterOperator.StartsWith, nameof(string.EndsWith) => FilterOperator.EndsWith, - _ => (FilterOperator?)null + _ => null }; return op is null @@ -161,26 +162,61 @@ static IEnumerable FlattenIfSameGroup(FilterNode node, FilterCombina : new FilterCondition(path, op.Value, value); } - // TODO: extend to Any(), custom methods, etc. - return null; + // Collection methods: Any(), All(), Contains() + if (call.Method.DeclaringType == typeof(Enumerable)) + { + return TranslateEnumerableMethod(call); + } + + // Collection instance methods: Contains() on List/ICollection + if (call.Object is null || !IsCollectionType(call.Object.Type) || call.Method.Name != nameof(List.Contains) || call.Arguments.Count != 1) + { + return null; + } + + { + MemberExpression? collectionMember = GetMember(call.Object); + if (collectionMember is null) + { + return null; + } + + string? path = GetMemberPath(collectionMember); + if (path is null) + { + return null; + } + + string? value = EvaluateToString(call.Arguments[0]); + return new FilterCondition(path, FilterOperator.Contains, value); + } + + // Custom methods can be added here by checking call.Method.DeclaringType and Method.Name + // Example for custom method support: + // if (call.Method.DeclaringType == typeof(MyCustomClass) && call.Method.Name == "MyMethod") + // { + // // Extract parameters and create appropriate FilterNode + // return new FilterCondition(...); + // } + } - static FilterNode? TranslateNot(UnaryExpression unary) + private static FilterNode? TranslateNot(UnaryExpression unary) { // Only handle simple negation of a comparison or method call for now // e.g. !c.IsActive or !c.Name.Contains("x") if (unary.Operand is BinaryExpression binary) { // Flip operator if possible - var (memberExpr, constantExpr, op) = NormalizeBinary(binary); + (MemberExpression? memberExpr, Expression? constantExpr, FilterOperator? op) = NormalizeBinary(binary); if (memberExpr is null || constantExpr is null || op is null) { return null; } - var negated = NegateOperator(op.Value); - var path = GetMemberPath(memberExpr); - var value = EvaluateToString(constantExpr); + FilterOperator negated = NegateOperator(op.Value); + string? path = GetMemberPath(memberExpr); + string? value = EvaluateToString(constantExpr); return new FilterCondition(path!, negated, value); } @@ -196,7 +232,7 @@ static IEnumerable FlattenIfSameGroup(FilterNode node, FilterCombina return null; } - static FilterOperator NegateOperator(FilterOperator op) => + private static FilterOperator NegateOperator(FilterOperator op) => op switch { FilterOperator.Equals => FilterOperator.NotEquals, @@ -208,7 +244,7 @@ static FilterOperator NegateOperator(FilterOperator op) => _ => throw new NotSupportedException($"Cannot negate operator '{op}'.") }; - static MemberExpression? GetMember(Expression expression) => + private static MemberExpression? GetMember(Expression expression) => expression switch { MemberExpression m => m, @@ -217,12 +253,10 @@ static FilterOperator NegateOperator(FilterOperator op) => _ => null }; - static bool IsConstantLike(Expression expression) => - expression.NodeType is ExpressionType.Constant - || expression is MemberExpression m && m.Expression is ConstantExpression - || expression is UnaryExpression u && IsConstantLike(u.Operand); + private static bool IsConstantLike(Expression expression) => + expression.NodeType is ExpressionType.Constant || expression is MemberExpression { Expression: ConstantExpression } || expression is UnaryExpression u && IsConstantLike(u.Operand); - static string? GetMemberPath(MemberExpression member) + private static string? GetMemberPath(MemberExpression member) { var parts = new Stack(); Expression? current = member; @@ -237,7 +271,100 @@ expression.NodeType is ExpressionType.Constant return string.Join('.', parts); } - static string? EvaluateToString(Expression expression) + /// + /// Translates LINQ Enumerable method calls (Any, All, Contains) to FilterNode structures. + /// + /// The method call expression representing a LINQ Enumerable method. + /// A FilterNode representing the collection operation, or null if translation is not supported. + private static FilterNode? TranslateEnumerableMethod(MethodCallExpression call) + { + // First argument should be the collection (source) + if (call.Arguments.Count == 0) + { + return null; + } + + Expression collectionExpr = call.Arguments[0]; + MemberExpression? collectionMember = GetMember(collectionExpr); + if (collectionMember is null) + { + return null; + } + + string? path = GetMemberPath(collectionMember); + if (path is null) + { + return null; + } + + switch (call.Method.Name) + { + case nameof(Enumerable.Any): + switch (call.Arguments.Count) + { + + // Any() without predicate - just check if collection has elements + case 1: + return new FilterCollectionCondition(path, FilterOperator.HasElements, null); + + // Any(predicate) - check if any element matches the predicate + case 2 when call.Arguments[1] is UnaryExpression { Operand: LambdaExpression anyLambdaPredicate }: + { + FilterNode? predicateFilter = TranslateNode(anyLambdaPredicate.Body); + return predicateFilter is null + ? null + : new FilterCollectionCondition(path, FilterOperator.Any, predicateFilter); + } + } + + break; + + case nameof(Enumerable.All): + + // All(predicate) - check if all elements match the predicate + if (call.Arguments.Count == 2 && call.Arguments[1] is UnaryExpression { Operand: LambdaExpression allLambdaPredicate }) + { + FilterNode? predicateFilter = TranslateNode(allLambdaPredicate.Body); + return predicateFilter is null + ? null + : new FilterCollectionCondition(path, FilterOperator.All, predicateFilter); + } + break; + + case nameof(Enumerable.Contains): + // Contains(value) - check if collection contains a specific value + if (call.Arguments.Count == 2) + { + string? value = EvaluateToString(call.Arguments[1]); + return new FilterCondition(path, FilterOperator.Contains, value); + } + break; + } + + return null; + } + + /// + /// Checks if a type is a collection type (array or implements IEnumerable<T>, ICollection<T>, or IList<T>). + /// + /// The type to check. + /// True if the type is a collection type (excluding string); otherwise, false. + private static bool IsCollectionType(Type type) + { + if (type == typeof(string)) + { + return false; + } + + return type.IsArray || + type.GetInterfaces().Any(i => + i.IsGenericType && + (i.GetGenericTypeDefinition() == typeof(IEnumerable<>) || + i.GetGenericTypeDefinition() == typeof(ICollection<>) || + i.GetGenericTypeDefinition() == typeof(IList<>))); + } + + private static string? EvaluateToString(Expression expression) { // Normalize to underlying expression Expression expr = expression; @@ -254,9 +381,8 @@ expression.NodeType is ExpressionType.Constant } // Captured local / closure / more complex constant - var lambda = Expression.Lambda(expr); - var value = lambda.Compile().DynamicInvoke(); + LambdaExpression lambda = Expression.Lambda(expr); + object? value = lambda.Compile().DynamicInvoke(); return value?.ToString(); } - } diff --git a/src/VisionaryCoder.Framework/Filtering/Filter.cs b/src/VisionaryCoder.Framework/Filtering/Filter.cs index af009e4..dfb1124 100644 --- a/src/VisionaryCoder.Framework/Filtering/Filter.cs +++ b/src/VisionaryCoder.Framework/Filtering/Filter.cs @@ -1,3 +1,5 @@ +namespace VisionaryCoder.Framework.Filtering; + public static class Filter { public static FilterBuilder For() => new(); diff --git a/src/VisionaryCoder.Framework/Filtering/FilterBuilder.cs b/src/VisionaryCoder.Framework/Filtering/FilterBuilder.cs index e2fd11e..f94df7c 100644 --- a/src/VisionaryCoder.Framework/Filtering/FilterBuilder.cs +++ b/src/VisionaryCoder.Framework/Filtering/FilterBuilder.cs @@ -1,12 +1,14 @@ using System.Linq.Expressions; +namespace VisionaryCoder.Framework.Filtering; + public sealed class FilterBuilder { - readonly List roots = new(); + private readonly List roots = []; public FilterBuilder Where(Expression> predicate) { - var node = ExpressionToFilterNode.Translate(predicate); + FilterNode node = ExpressionToFilterNode.Translate(predicate); roots.Add(node); return this; } @@ -15,7 +17,7 @@ public FilterNode Build() { return roots.Count switch { - 0 => new FilterGroup(FilterCombination.And, Array.Empty()), + 0 => new FilterGroup(FilterCombination.And, []), 1 => roots[0], _ => new FilterGroup(FilterCombination.And, roots) }; diff --git a/src/VisionaryCoder.Framework/Filtering/FilterCombination.cs b/src/VisionaryCoder.Framework/Filtering/FilterCombination.cs index 7fac1f2..dcf54da 100644 --- a/src/VisionaryCoder.Framework/Filtering/FilterCombination.cs +++ b/src/VisionaryCoder.Framework/Filtering/FilterCombination.cs @@ -1,3 +1,5 @@ +namespace VisionaryCoder.Framework.Filtering; + public enum FilterCombination { And, diff --git a/src/VisionaryCoder.Framework/Filtering/FilterCondition.cs b/src/VisionaryCoder.Framework/Filtering/FilterCondition.cs index 19a31d0..f271dae 100644 --- a/src/VisionaryCoder.Framework/Filtering/FilterCondition.cs +++ b/src/VisionaryCoder.Framework/Filtering/FilterCondition.cs @@ -1 +1,3 @@ +namespace VisionaryCoder.Framework.Filtering; + public sealed record FilterCondition(string Path, FilterOperator Operator, string? Value) : FilterNode; diff --git a/src/VisionaryCoder.Framework/Filtering/FilterGroup.cs b/src/VisionaryCoder.Framework/Filtering/FilterGroup.cs index ac5609f..b90899b 100644 --- a/src/VisionaryCoder.Framework/Filtering/FilterGroup.cs +++ b/src/VisionaryCoder.Framework/Filtering/FilterGroup.cs @@ -1 +1,3 @@ +namespace VisionaryCoder.Framework.Filtering; + public sealed record FilterGroup(FilterCombination Combination, IReadOnlyList Children) : FilterNode; diff --git a/src/VisionaryCoder.Framework/Filtering/FilterNode.cs b/src/VisionaryCoder.Framework/Filtering/FilterNode.cs index a5ea4e0..1fb244d 100644 --- a/src/VisionaryCoder.Framework/Filtering/FilterNode.cs +++ b/src/VisionaryCoder.Framework/Filtering/FilterNode.cs @@ -1 +1,3 @@ +namespace VisionaryCoder.Framework.Filtering; + public abstract record FilterNode; diff --git a/src/VisionaryCoder.Framework/Filtering/FilterOperator.cs b/src/VisionaryCoder.Framework/Filtering/FilterOperator.cs index 107ab2a..3d7e035 100644 --- a/src/VisionaryCoder.Framework/Filtering/FilterOperator.cs +++ b/src/VisionaryCoder.Framework/Filtering/FilterOperator.cs @@ -1,3 +1,5 @@ +namespace VisionaryCoder.Framework.Filtering; + public enum FilterOperator { Equals, diff --git a/src/VisionaryCoder.Framework/Filtering/Poco/PocoFilterExecutionStrategy.cs b/src/VisionaryCoder.Framework/Filtering/Poco/PocoFilterExecutionStrategy.cs index 3c30a03..278af1d 100644 --- a/src/VisionaryCoder.Framework/Filtering/Poco/PocoFilterExecutionStrategy.cs +++ b/src/VisionaryCoder.Framework/Filtering/Poco/PocoFilterExecutionStrategy.cs @@ -1,11 +1,16 @@ +using System.Linq.Expressions; +using VisionaryCoder.Framework.Filtering.Abstractions; + +namespace VisionaryCoder.Framework.Filtering.Poco; + public sealed class PocoFilterExecutionStrategy : IFilterExecutionStrategy { public IQueryable Apply(IQueryable source, FilterNode? filter) { if (filter is null) return source; - var parameter = Expression.Parameter(typeof(T), "x"); - var body = PocoFilterExpressionBuilder.BuildExpression(filter, parameter); + ParameterExpression parameter = Expression.Parameter(typeof(T), "x"); + Expression? body = PocoFilterExpressionBuilder.BuildExpression(filter, parameter); if (body is null) return source; var lambda = Expression.Lambda>(body, parameter); @@ -15,11 +20,11 @@ public IQueryable Apply(IQueryable source, FilterNode? filter) public IEnumerable Apply(IEnumerable source, FilterNode? filter) { if (filter is null) return source; - var parameter = Expression.Parameter(typeof(T), "x"); - var body = PocoFilterExpressionBuilder.BuildExpression(filter, parameter); + ParameterExpression parameter = Expression.Parameter(typeof(T), "x"); + Expression? body = PocoFilterExpressionBuilder.BuildExpression(filter, parameter); if (body is null) return source; - var lambda = Expression.Lambda>(body, parameter).Compile(); + Func lambda = Expression.Lambda>(body, parameter).Compile(); return source.Where(lambda); } } diff --git a/src/VisionaryCoder.Framework/Filtering/Poco/PocoFilterExpressionBuilder.cs b/src/VisionaryCoder.Framework/Filtering/Poco/PocoFilterExpressionBuilder.cs new file mode 100644 index 0000000..df310f4 --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/Poco/PocoFilterExpressionBuilder.cs @@ -0,0 +1,166 @@ +using System.Globalization; +using System.Linq.Expressions; +using System.Reflection; + +namespace VisionaryCoder.Framework.Filtering.Poco; + +internal static class PocoFilterExpressionBuilder +{ + public static Expression? BuildExpression(FilterNode? filter, ParameterExpression parameter) + => Build(filter, parameter); + + private static Expression? Build(FilterNode? node, ParameterExpression parameter) + { + if (node is null) return null; + return node switch + { + FilterGroup g => BuildGroup(g, parameter), + FilterCondition c => BuildCondition(c, parameter), + FilterCollectionCondition cc => BuildCollectionCondition(cc, parameter), + _ => null + }; + } + + private static Expression? BuildGroup(FilterGroup group, ParameterExpression parameter) + { + Expression? combined = null; + foreach (FilterNode child in group.Children) + { + Expression? expr = Build(child, parameter); + if (expr is null) continue; + combined = combined is null + ? expr + : group.Combination == FilterCombination.And + ? Expression.AndAlso(combined, expr) + : Expression.OrElse(combined, expr); + } + return combined; + } + + private static Expression? BuildCondition(FilterCondition condition, ParameterExpression parameter) + { + MemberExpression? member = BuildMemberAccess(parameter, condition.Path); + if (member is null) return null; + + Type targetType = Nullable.GetUnderlyingType(member.Type) ?? member.Type; + object? constantValue = ConvertFromString(condition.Value, targetType); + if (constantValue is null && targetType.IsValueType && targetType != typeof(string)) + return null; + + ConstantExpression constant = Expression.Constant(constantValue, targetType); + Expression left = member; + if (member.Type != constant.Type) + { + if (Nullable.GetUnderlyingType(member.Type) == constant.Type) + { + // ok + } + else + { + left = Expression.Convert(member, constant.Type); + } + } + + return condition.Operator switch + { + FilterOperator.Equals => Expression.Equal(left, PromoteNull(constant, left.Type)), + FilterOperator.NotEquals => Expression.NotEqual(left, PromoteNull(constant, left.Type)), + FilterOperator.GreaterThan => Expression.GreaterThan(left, constant), + FilterOperator.GreaterOrEqual => Expression.GreaterThanOrEqual(left, constant), + FilterOperator.LessThan => Expression.LessThan(left, constant), + FilterOperator.LessOrEqual => Expression.LessThanOrEqual(left, constant), + FilterOperator.Contains => StringMethod(member, nameof(string.Contains), condition.Value), + FilterOperator.StartsWith => StringMethod(member, nameof(string.StartsWith), condition.Value), + FilterOperator.EndsWith => StringMethod(member, nameof(string.EndsWith), condition.Value), + _ => null + }; + } + + private static Expression? BuildCollectionCondition(FilterCollectionCondition condition, ParameterExpression parameter) + { + MemberExpression? collection = BuildMemberAccess(parameter, condition.Path); + if (collection is null) return null; + Type? elementType = GetElementType(collection.Type); + if (elementType is null) return null; + + string? anyAllMethodName = condition.Operator switch + { + FilterOperator.HasElements => nameof(Enumerable.Any), + FilterOperator.Any => nameof(Enumerable.Any), + FilterOperator.All => nameof(Enumerable.All), + _ => null + }; + if (anyAllMethodName is null) return null; + + if (condition.Operator == FilterOperator.HasElements) + { + return Expression.Call( + typeof(Enumerable), anyAllMethodName, [elementType], collection); + } + + if (condition.Predicate is null) return null; + ParameterExpression elemParam = Expression.Parameter(elementType, "e"); + Expression? inner = Build(condition.Predicate, elemParam); + if (inner is null) return null; + LambdaExpression lambda = Expression.Lambda(inner, elemParam); + return Expression.Call( + typeof(Enumerable), anyAllMethodName, [elementType], collection, lambda); + } + + private static Expression? StringMethod(Expression member, string method, string? arg) + { + if (member.Type != typeof(string)) return null; + MethodInfo mi = typeof(string).GetMethod(method, [typeof(string)])!; + return Expression.Call(member, mi, Expression.Constant(arg ?? string.Empty)); + } + + private static MemberExpression? BuildMemberAccess(Expression root, string path) + { + Expression current = root; + foreach (string segment in path.Split('.')) + { + PropertyInfo? prop = current.Type.GetProperty(segment, BindingFlags.Instance | BindingFlags.Public | BindingFlags.IgnoreCase); + if (prop is not null) + { + current = Expression.Property(current, prop); + continue; + } + FieldInfo? field = current.Type.GetField(segment, BindingFlags.Instance | BindingFlags.Public | BindingFlags.IgnoreCase); + if (field is not null) + { + current = Expression.Field(current, field); + continue; + } + return null; + } + return current as MemberExpression ?? (current.NodeType == ExpressionType.MemberAccess ? (MemberExpression)current : null); + } + + private static Type? GetElementType(Type type) + { + if (type.IsArray) return type.GetElementType(); + Type? ienum = type.GetInterfaces().Append(type) + .FirstOrDefault(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + return ienum?.GetGenericArguments()[0]; + } + + private static object? ConvertFromString(string? text, Type targetType) + { + if (text is null) return targetType == typeof(string) ? string.Empty : null; + if (targetType == typeof(string)) return text; + if (targetType == typeof(Guid)) return Guid.TryParse(text, out Guid g) ? g : null; + if (targetType == typeof(bool)) return bool.TryParse(text, out bool b) ? b : null; + if (targetType == typeof(int)) return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int i) ? i : null; + if (targetType == typeof(long)) return long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out long l) ? l : null; + if (targetType == typeof(short)) return short.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out short s) ? s : null; + if (targetType == typeof(decimal)) return decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out decimal d) ? d : null; + if (targetType == typeof(double)) return double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out double dbl) ? dbl : null; + if (targetType == typeof(float)) return float.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out float f) ? f : null; + if (targetType == typeof(DateTime)) return DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime dt) ? dt : null; + if (targetType.IsEnum) return Enum.TryParse(targetType, text, ignoreCase: true, out object? e) ? e : null; + return text; + } + + private static Expression PromoteNull(Expression constant, Type targetType) + => constant.Type == targetType ? constant : Expression.Convert(constant, targetType); +} diff --git a/src/VisionaryCoder.Framework/Filtering/Serialization/ExpressionToFilterNode.cs b/src/VisionaryCoder.Framework/Filtering/Serialization/ExpressionToFilterNode.cs deleted file mode 100644 index 6d581c4..0000000 --- a/src/VisionaryCoder.Framework/Filtering/Serialization/ExpressionToFilterNode.cs +++ /dev/null @@ -1,391 +0,0 @@ -using System.Linq.Expressions; - -namespace VisionaryCoder.Framework.Filtering.Serialization; - -public static class ExpressionToFilterNode -{ - - public static FilterNode Translate(Expression> expression) => TranslateNode(expression.Body) ?? throw new NotSupportedException($"Expression '{expression}' is not supported."); - public static FilterNode Translate(Expression expression) => TranslateNode(expression) ?? throw new NotSupportedException($"Expression '{expression}' is not supported."); - - static FilterNode? TranslateNode(Expression expression) => - expression switch - { - BinaryExpression binary => TranslateBinary(binary), - MethodCallExpression call => TranslateMethodCall(call), - UnaryExpression unary when unary.NodeType == ExpressionType.Not - => TranslateNot(unary), - _ => null - }; - - static FilterNode? TranslateBinary(BinaryExpression binary) - { - // Logical group: && / || - if (binary.NodeType is ExpressionType.AndAlso or ExpressionType.OrElse) - { - var combination = binary.NodeType == ExpressionType.AndAlso - ? FilterCombination.And - : FilterCombination.Or; - - var left = TranslateNode(binary.Left); - var right = TranslateNode(binary.Right); - - var children = new List(); - if (left is not null) children.AddRange(FlattenIfSameGroup(left, combination)); - if (right is not null) children.AddRange(FlattenIfSameGroup(right, combination)); - - return new FilterGroup(combination, children); - } - // Comparison: ==, !=, <, <=, >, >= - return TranslateComparison(binary); - - } - - static IEnumerable FlattenIfSameGroup(FilterNode node, FilterCombination combination) - { - if (node is FilterGroup group && group.Combination == combination) - { - foreach (var child in group.Children) - { - yield return child; - } - yield break; - } - yield return node; - } - - static FilterNode? TranslateComparison(BinaryExpression binary) - { - - var (memberExpr, constantExpr, op) = NormalizeBinary(binary); - if (memberExpr is null || constantExpr is null || op is null) - { - return null; - } - - var path = GetMemberPath(memberExpr); - if (path is null) - { - return null; - } - - var value = EvaluateToString(constantExpr); - return new FilterCondition(path, op.Value, value); - - } - - /// - /// Normalizes a binary expression so that the member is on the left - /// and the constant/value is on the right. Adjusts the operator if needed. - /// - static (MemberExpression? member, Expression? constant, FilterOperator?) NormalizeBinary(BinaryExpression binary) - { - var leftMember = GetMember(binary.Left); - var rightMember = GetMember(binary.Right); - - var leftIsConstLike = IsConstantLike(binary.Left); - var rightIsConstLike = IsConstantLike(binary.Right); - - // member op constant - if (leftMember is not null && rightIsConstLike) - { - var op = MapComparisonOperator(binary.NodeType, invert: false); - return (leftMember, binary.Right, op); - } - - // constant op member -> invert operator - if (rightMember is not null && leftIsConstLike) - { - var op = MapComparisonOperator(binary.NodeType, invert: true); - return (rightMember, binary.Left, op); - } - - return (null, null, null); - } - - static FilterOperator? MapComparisonOperator(ExpressionType nodeType, bool invert) - { - - return (nodeType, invert) switch - { - (ExpressionType.Equal, _) => FilterOperator.Equals, - (ExpressionType.NotEqual, _) => FilterOperator.NotEquals, - - (ExpressionType.GreaterThan, false) => FilterOperator.GreaterThan, - (ExpressionType.GreaterThan, true) => FilterOperator.LessThan, - - (ExpressionType.GreaterThanOrEqual, false) => FilterOperator.GreaterOrEqual, - (ExpressionType.GreaterThanOrEqual, true) => FilterOperator.LessOrEqual, - - (ExpressionType.LessThan, false) => FilterOperator.LessThan, - (ExpressionType.LessThan, true) => FilterOperator.GreaterThan, - - (ExpressionType.LessThanOrEqual, false) => FilterOperator.LessOrEqual, - (ExpressionType.LessThanOrEqual, true) => FilterOperator.GreaterOrEqual, - - _ => null - }; - - } - - static FilterNode? TranslateMethodCall(MethodCallExpression call) - { - - // string.Contains / StartsWith / EndsWith - if (call.Object is not null && - call.Object.Type == typeof(string) && - call.Arguments.Count == 1) - { - var targetMember = GetMember(call.Object); - if (targetMember is null) - { - return null; - } - - var path = GetMemberPath(targetMember); - if (path is null) - { - return null; - } - - var arg = call.Arguments[0]; - var value = EvaluateToString(arg); - - var op = call.Method.Name switch - { - nameof(string.Contains) => FilterOperator.Contains, - nameof(string.StartsWith) => FilterOperator.StartsWith, - nameof(string.EndsWith) => FilterOperator.EndsWith, - _ => (FilterOperator?)null - }; - - return op is null - ? null - : new FilterCondition(path, op.Value, value); - } - - // Collection methods: Any(), All(), Contains() - if (call.Method.DeclaringType == typeof(Enumerable)) - { - return TranslateEnumerableMethod(call); - } - - // Collection instance methods: Contains() on List/ICollection - if (call.Object is not null && - IsCollectionType(call.Object.Type) && - call.Method.Name == nameof(List.Contains) && - call.Arguments.Count == 1) - { - var collectionMember = GetMember(call.Object); - if (collectionMember is null) - { - return null; - } - - var path = GetMemberPath(collectionMember); - if (path is null) - { - return null; - } - - var value = EvaluateToString(call.Arguments[0]); - return new FilterCondition(path, FilterOperator.Contains, value); - } - - // Custom methods can be added here by checking call.Method.DeclaringType and Method.Name - // Example for custom method support: - // if (call.Method.DeclaringType == typeof(MyCustomClass) && call.Method.Name == "MyMethod") - // { - // // Extract parameters and create appropriate FilterNode - // return new FilterCondition(...); - // } - return null; - - } - - static FilterNode? TranslateNot(UnaryExpression unary) - { - // Only handle simple negation of a comparison or method call for now - // e.g. !c.IsActive or !c.Name.Contains("x") - if (unary.Operand is BinaryExpression binary) - { - // Flip operator if possible - var (memberExpr, constantExpr, op) = NormalizeBinary(binary); - if (memberExpr is null || constantExpr is null || op is null) - { - return null; - } - - var negated = NegateOperator(op.Value); - var path = GetMemberPath(memberExpr); - var value = EvaluateToString(constantExpr); - - return new FilterCondition(path!, negated, value); - } - - if (unary.Operand is MethodCallExpression call) - { - // e.g. !c.Name.Contains("x") => NotContains (or NotEquals on Contains semantics) - // For now, treat as NotEquals with Contains semantics if you like, - // or just not support and return null. - return null; - } - - return null; - } - - static FilterOperator NegateOperator(FilterOperator op) => - op switch - { - FilterOperator.Equals => FilterOperator.NotEquals, - FilterOperator.NotEquals => FilterOperator.Equals, - FilterOperator.GreaterThan => FilterOperator.LessOrEqual, - FilterOperator.GreaterOrEqual => FilterOperator.LessThan, - FilterOperator.LessThan => FilterOperator.GreaterOrEqual, - FilterOperator.LessOrEqual => FilterOperator.GreaterThan, - _ => throw new NotSupportedException($"Cannot negate operator '{op}'.") - }; - - static MemberExpression? GetMember(Expression expression) => - expression switch - { - MemberExpression m => m, - UnaryExpression u when u.NodeType == ExpressionType.Convert && u.Operand is MemberExpression inner - => inner, - _ => null - }; - - static bool IsConstantLike(Expression expression) => - expression.NodeType is ExpressionType.Constant - || expression is MemberExpression m && m.Expression is ConstantExpression - || expression is UnaryExpression u && IsConstantLike(u.Operand); - - static string? GetMemberPath(MemberExpression member) - { - var parts = new Stack(); - Expression? current = member; - - while (current is MemberExpression m) - { - parts.Push(m.Member.Name); - current = m.Expression; - } - - // Stop at the root parameter (e.g. x) - return string.Join('.', parts); - } - - /// - /// Translates LINQ Enumerable method calls (Any, All, Contains) to FilterNode structures. - /// - /// The method call expression representing a LINQ Enumerable method. - /// A FilterNode representing the collection operation, or null if translation is not supported. - static FilterNode? TranslateEnumerableMethod(MethodCallExpression call) - { - // First argument should be the collection (source) - if (call.Arguments.Count == 0) - { - return null; - } - - var collectionExpr = call.Arguments[0]; - var collectionMember = GetMember(collectionExpr); - if (collectionMember is null) - { - return null; - } - - var path = GetMemberPath(collectionMember); - if (path is null) - { - return null; - } - - switch (call.Method.Name) - { - case nameof(Enumerable.Any): - // Any() without predicate - just check if collection has elements - if (call.Arguments.Count == 1) - { - return new FilterCollectionCondition(path, FilterOperator.HasElements, null); - } - // Any(predicate) - check if any element matches the predicate - else if (call.Arguments.Count == 2 && call.Arguments[1] is UnaryExpression { Operand: LambdaExpression lambdaPredicate }) - { - var predicateFilter = TranslateNode(lambdaPredicate.Body); - if (predicateFilter is null) - { - return null; - } - return new FilterCollectionCondition(path, FilterOperator.Any, predicateFilter); - } - break; - - case nameof(Enumerable.All): - // All(predicate) - check if all elements match the predicate - if (call.Arguments.Count == 2 && call.Arguments[1] is UnaryExpression { Operand: LambdaExpression lambdaPredicate }) - { - var predicateFilter = TranslateNode(lambdaPredicate.Body); - if (predicateFilter is null) - { - return null; - } - return new FilterCollectionCondition(path, FilterOperator.All, predicateFilter); - } - break; - - case nameof(Enumerable.Contains): - // Contains(value) - check if collection contains a specific value - if (call.Arguments.Count == 2) - { - var value = EvaluateToString(call.Arguments[1]); - return new FilterCondition(path, FilterOperator.Contains, value); - } - break; - } - - return null; - } - - /// - /// Checks if a type is a collection type (array or implements IEnumerable<T>, ICollection<T>, or IList<T>). - /// - /// The type to check. - /// True if the type is a collection type (excluding string); otherwise, false. - static bool IsCollectionType(Type type) - { - if (type == typeof(string)) - { - return false; - } - - return type.IsArray || - type.GetInterfaces().Any(i => - i.IsGenericType && - (i.GetGenericTypeDefinition() == typeof(IEnumerable<>) || - i.GetGenericTypeDefinition() == typeof(ICollection<>) || - i.GetGenericTypeDefinition() == typeof(IList<>))); - } - - static string? EvaluateToString(Expression expression) - { - // Normalize to underlying expression - Expression expr = expression; - - // Strip conversions - while (expr is UnaryExpression u && expr.NodeType == ExpressionType.Convert) - { - expr = u.Operand; - } - - if (expr is ConstantExpression constant) - { - return constant.Value?.ToString(); - } - - // Captured local / closure / more complex constant - var lambda = Expression.Lambda(expr); - var value = lambda.Compile().DynamicInvoke(); - return value?.ToString(); - } -} diff --git a/src/VisionaryCoder.Framework/Logging/LogHelper.cs b/src/VisionaryCoder.Framework/Logging/LogHelper.cs index e51724b..2c98fce 100644 --- a/src/VisionaryCoder.Framework/Logging/LogHelper.cs +++ b/src/VisionaryCoder.Framework/Logging/LogHelper.cs @@ -1,10 +1,12 @@ +using Microsoft.Extensions.Logging; + namespace VisionaryCoder.Framework.Logging; public static class LogHelper { // Synchronous Methods - public static void LogTraceMessage(ILogger logger, string logMessage, Exception? exception = null) + public static void LogTraceMessage(ILogger logger, string logMessage, Exception? exception = null) { LogTrace(logger, logMessage, exception); } diff --git a/src/VisionaryCoder.Framework/Logging/LoggingServiceCollectionExtensions.cs b/src/VisionaryCoder.Framework/Logging/LoggingExtensions.cs similarity index 86% rename from src/VisionaryCoder.Framework/Logging/LoggingServiceCollectionExtensions.cs rename to src/VisionaryCoder.Framework/Logging/LoggingExtensions.cs index 9156a65..2480651 100644 --- a/src/VisionaryCoder.Framework/Logging/LoggingServiceCollectionExtensions.cs +++ b/src/VisionaryCoder.Framework/Logging/LoggingExtensions.cs @@ -1,8 +1,11 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Logging.Interceptors; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using VisionaryCoder.Framework.Proxy; +using VisionaryCoder.Framework.Proxy.Interceptors; namespace VisionaryCoder.Framework.Logging; @@ -10,7 +13,7 @@ namespace VisionaryCoder.Framework.Logging; /// Extension methods for adding comprehensive logging services to the dependency injection container. /// Provides fluent configuration for logging interceptors with various options and behaviors. /// -public static class LoggingServiceCollectionExtensions +public static class LoggingExtensions { /// /// Adds logging infrastructure with null object fallbacks (SOLID principle). @@ -54,7 +57,7 @@ public static IServiceCollection AddTimingInterceptor( { services.TryAddSingleton(provider => { - var logger = provider.GetRequiredService>(); + ILogger logger = provider.GetRequiredService>(); return new TimingInterceptor(logger) { SlowOperationThresholdMs = slowThresholdMs, @@ -112,7 +115,7 @@ public static IServiceCollection AddLogging( { services.TryAddSingleton(provider => { - var logger = provider.GetRequiredService>(); + ILogger logger = provider.GetRequiredService>(); return new TimingInterceptor(logger) { SlowOperationThresholdMs = options.SlowOperationThresholdMs, @@ -162,7 +165,7 @@ public static IServiceCollection UseTimingInterceptor( // Add timing interceptor with explicit configuration services.AddSingleton(provider => { - var logger = provider.GetRequiredService>(); + ILogger logger = provider.GetRequiredService>(); return new TimingInterceptor(logger) { SlowOperationThresholdMs = slowThresholdMs, @@ -190,29 +193,3 @@ public static IServiceCollection UseDefaultLoggingInterceptors(this IServiceColl return services; } } - -/// -/// Configuration options for logging interceptors. -/// -public class LoggingOptions -{ - /// - /// Gets or sets whether to enable standard logging interceptor. - /// - public bool EnableStandardLogging { get; set; } = true; - - /// - /// Gets or sets whether to enable timing measurements. - /// - public bool EnableTiming { get; set; } = true; - - /// - /// Gets or sets the threshold for slow operation warnings in milliseconds. - /// - public long SlowOperationThresholdMs { get; set; } = 1000; - - /// - /// Gets or sets the threshold for critical operation errors in milliseconds. - /// - public long CriticalOperationThresholdMs { get; set; } = 5000; -} diff --git a/src/VisionaryCoder.Framework/Logging/LoggingOptions.cs b/src/VisionaryCoder.Framework/Logging/LoggingOptions.cs new file mode 100644 index 0000000..1bf6e6a --- /dev/null +++ b/src/VisionaryCoder.Framework/Logging/LoggingOptions.cs @@ -0,0 +1,27 @@ +namespace VisionaryCoder.Framework.Logging; + +/// +/// Configuration options for logging interceptors. +/// +public class LoggingOptions +{ + /// + /// Gets or sets whether to enable standard logging interceptor. + /// + public bool EnableStandardLogging { get; set; } = true; + + /// + /// Gets or sets whether to enable timing measurements. + /// + public bool EnableTiming { get; set; } = true; + + /// + /// Gets or sets the threshold for slow operation warnings in milliseconds. + /// + public long SlowOperationThresholdMs { get; set; } = 1000; + + /// + /// Gets or sets the threshold for critical operation errors in milliseconds. + /// + public long CriticalOperationThresholdMs { get; set; } = 5000; +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Models/Month.cs b/src/VisionaryCoder.Framework/Models/Month.cs index ea091e9..f72452b 100644 --- a/src/VisionaryCoder.Framework/Models/Month.cs +++ b/src/VisionaryCoder.Framework/Models/Month.cs @@ -88,7 +88,7 @@ public Month(int ordinal) public Month(string name) { - ArgumentNullException.ThrowIfNull(name, nameof(name)); + ArgumentNullException.ThrowIfNull(name); if (longMonthNames.Contains(name)) { Ordinal = longMonthNames.IndexOf(name); @@ -106,7 +106,7 @@ public Month(string name) public Month(Month other) { - ArgumentNullException.ThrowIfNull(other, nameof(other)); + ArgumentNullException.ThrowIfNull(other); Name = other.Name; Ordinal = other.Ordinal; } diff --git a/src/VisionaryCoder.Framework/FrameworkOptions.cs b/src/VisionaryCoder.Framework/Options.cs similarity index 95% rename from src/VisionaryCoder.Framework/FrameworkOptions.cs rename to src/VisionaryCoder.Framework/Options.cs index 9237236..1593dbf 100644 --- a/src/VisionaryCoder.Framework/FrameworkOptions.cs +++ b/src/VisionaryCoder.Framework/Options.cs @@ -3,7 +3,7 @@ namespace VisionaryCoder.Framework; /// /// Configuration options for the VisionaryCoder Framework. /// -public sealed class FrameworkOptions +public sealed class Options { /// /// Gets or sets whether correlation ID generation is enabled. diff --git a/src/VisionaryCoder.Framework/Pagination/PageExtensions.cs b/src/VisionaryCoder.Framework/Pagination/PageExtensions.cs index 842194a..6d7abf6 100644 --- a/src/VisionaryCoder.Framework/Pagination/PageExtensions.cs +++ b/src/VisionaryCoder.Framework/Pagination/PageExtensions.cs @@ -1,3 +1,5 @@ +using Microsoft.EntityFrameworkCore; + namespace VisionaryCoder.Framework.Pagination; public static class PageExtensions { @@ -17,7 +19,7 @@ public static async Task> ToPageAsync(this IQueryable query, PageR // Token-based hook (for high-scale/unstable ordering; implement per store) public static Task> ToPageWithTokenAsync(this IQueryable query, PageRequest request, Func, string?, int, CancellationToken, Task<(IReadOnlyList Items, string? NextToken)>> pageFn, CancellationToken cancellationToken = default) => ExecuteAsync(query, request, pageFn, cancellationToken); - static async Task> ExecuteAsync(IQueryable source, PageRequest request, Func, string?, int, CancellationToken, Task<(IReadOnlyList, string?)>> fn, CancellationToken cancellationToken) + private static async Task> ExecuteAsync(IQueryable source, PageRequest request, Func, string?, int, CancellationToken, Task<(IReadOnlyList, string?)>> fn, CancellationToken cancellationToken) { (IReadOnlyList items, string? next) = await fn(source, request.ContinuationToken, request.PageSize, cancellationToken); return new Page(items, count: 0, pageNumber: 0, pageSize: request.PageSize, nextToken: next); diff --git a/src/VisionaryCoder.Framework/Pipeline/Abstractions/EndpointResolution.cs b/src/VisionaryCoder.Framework/Pipeline/Abstractions/EndpointResolution.cs new file mode 100644 index 0000000..c0539d8 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Abstractions/EndpointResolution.cs @@ -0,0 +1,3 @@ +namespace VisionaryCoder.Framework.Pipeline.Abstractions; + +public record EndpointResolution(bool IsLocal, string? ServiceName = null, Uri? Uri = null); diff --git a/src/VisionaryCoder.Framework/Pipeline/Abstractions/ICache.cs b/src/VisionaryCoder.Framework/Pipeline/Abstractions/ICache.cs new file mode 100644 index 0000000..1b6f71e --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Abstractions/ICache.cs @@ -0,0 +1,7 @@ +namespace VisionaryCoder.Framework.Pipeline.Abstractions; + +public interface ICache +{ + Task<(bool Hit, T value)> TryGetAsync(string key); + Task SetAsync(string key, T value, TimeSpan ttl); +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Pipeline/Abstractions/IEndpointResolver.cs b/src/VisionaryCoder.Framework/Pipeline/Abstractions/IEndpointResolver.cs new file mode 100644 index 0000000..f97158c --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Abstractions/IEndpointResolver.cs @@ -0,0 +1,7 @@ +ο»Ώnamespace VisionaryCoder.Framework.Pipeline.Abstractions; + +public interface IEndpointResolver +{ + // Decide local vs. remote routing for a given request type + EndpointResolution Resolve(Type requestType); +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Pipeline/Abstractions/IInterceptor.cs b/src/VisionaryCoder.Framework/Pipeline/Abstractions/IInterceptor.cs new file mode 100644 index 0000000..beaf1a3 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Abstractions/IInterceptor.cs @@ -0,0 +1,6 @@ +namespace VisionaryCoder.Framework.Pipeline.Abstractions; + +public interface IInterceptor +{ + Task InvokeAsync(TRequest request, Func> next) where TRequest : IRequest; +} diff --git a/src/VisionaryCoder.Framework/Pipeline/Abstractions/IInvoker.cs b/src/VisionaryCoder.Framework/Pipeline/Abstractions/IInvoker.cs new file mode 100644 index 0000000..d81229a --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Abstractions/IInvoker.cs @@ -0,0 +1,7 @@ +ο»Ώnamespace VisionaryCoder.Framework.Pipeline.Abstractions; + +public interface IInvoker +{ + Task InvokeAsync(TRequest request) + where TRequest : IRequest; +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Pipeline/Abstractions/ILocalDispatcher.cs b/src/VisionaryCoder.Framework/Pipeline/Abstractions/ILocalDispatcher.cs new file mode 100644 index 0000000..f887e9d --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Abstractions/ILocalDispatcher.cs @@ -0,0 +1,7 @@ +namespace VisionaryCoder.Framework.Pipeline.Abstractions; + +public interface ILocalDispatcher +{ + Task DispatchAsync(TRequest request) + where TRequest : IRequest; +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Pipeline/Abstractions/IRemoteDispatcher.cs b/src/VisionaryCoder.Framework/Pipeline/Abstractions/IRemoteDispatcher.cs new file mode 100644 index 0000000..8cdf1d1 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Abstractions/IRemoteDispatcher.cs @@ -0,0 +1,7 @@ +namespace VisionaryCoder.Framework.Pipeline.Abstractions; + +public interface IRemoteDispatcher +{ + Task DispatchAsync(TRequest request, EndpointResolution endpoint) + where TRequest : IRequest; +} diff --git a/src/VisionaryCoder.Framework/Pipeline/Abstractions/IRequest.cs b/src/VisionaryCoder.Framework/Pipeline/Abstractions/IRequest.cs new file mode 100644 index 0000000..f18c29d --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Abstractions/IRequest.cs @@ -0,0 +1,3 @@ +ο»Ώnamespace VisionaryCoder.Framework.Pipeline.Abstractions; + +public interface IRequest { } \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Pipeline/Abstractions/IRequestHandler.cs b/src/VisionaryCoder.Framework/Pipeline/Abstractions/IRequestHandler.cs new file mode 100644 index 0000000..8f16eab --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Abstractions/IRequestHandler.cs @@ -0,0 +1,7 @@ +namespace VisionaryCoder.Framework.Pipeline.Abstractions; + +public interface IRequestHandler + where TRequest : IRequest +{ + Task HandleAsync(TRequest request, CancellationToken ct); +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Pipeline/Abstractions/IServiceRegistry.cs b/src/VisionaryCoder.Framework/Pipeline/Abstractions/IServiceRegistry.cs new file mode 100644 index 0000000..26e9757 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Abstractions/IServiceRegistry.cs @@ -0,0 +1,8 @@ +using VisionaryCoder.Framework.Pipeline.Routing; + +namespace VisionaryCoder.Framework.Pipeline.Abstractions; + +public interface IServiceRegistry +{ + ServiceEntry? Lookup(Type requestType); +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Pipeline/Abstractions/ISpan.cs b/src/VisionaryCoder.Framework/Pipeline/Abstractions/ISpan.cs new file mode 100644 index 0000000..364cbef --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Abstractions/ISpan.cs @@ -0,0 +1,7 @@ +namespace VisionaryCoder.Framework.Pipeline.Abstractions; + +public interface ISpan : IDisposable +{ + void SetTag(string key, string value); + void End(); +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Pipeline/Correlation.cs b/src/VisionaryCoder.Framework/Pipeline/Correlation.cs new file mode 100644 index 0000000..4db6293 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Correlation.cs @@ -0,0 +1,12 @@ +namespace VisionaryCoder.Framework.Pipeline; + +public static class Correlation +{ + + private static readonly AsyncLocal id = new(); + public static string? CurrentId + { + get => id.Value; + set => id.Value = value; + } +} diff --git a/src/VisionaryCoder.Framework/Pipeline/Dispatch/Abstractions/GenericInvoker.proto b/src/VisionaryCoder.Framework/Pipeline/Dispatch/Abstractions/GenericInvoker.proto new file mode 100644 index 0000000..7ca0283 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Dispatch/Abstractions/GenericInvoker.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +service GenericInvoker { + rpc Invoke (InvokeRequest) returns (InvokeResponse); +} + +message InvokeRequest { + string requestType = 1; + string payload = 2; +} + +message InvokeResponse { + string payload = 1; +} diff --git a/src/VisionaryCoder.Framework/Pipeline/Dispatch/Abstractions/ISerializer.cs b/src/VisionaryCoder.Framework/Pipeline/Dispatch/Abstractions/ISerializer.cs new file mode 100644 index 0000000..7b02981 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Dispatch/Abstractions/ISerializer.cs @@ -0,0 +1,7 @@ +namespace VisionaryCoder.Framework.Pipeline.Dispatch.Abstractions; + +public interface ISerializer +{ + string Serialize(T value); + T Deserialize(string json); +} diff --git a/src/VisionaryCoder.Framework/Pipeline/Dispatch/GenericGrpcClient.cs b/src/VisionaryCoder.Framework/Pipeline/Dispatch/GenericGrpcClient.cs new file mode 100644 index 0000000..4b1d80f --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Dispatch/GenericGrpcClient.cs @@ -0,0 +1,25 @@ +namespace VisionaryCoder.Framework.Pipeline.Dispatch; + +public sealed class GenericGrpcClient +{ + private readonly GrpcChannel channel; + private readonly GenericInvoker.GenericInvokerClient client; + + public GenericGrpcClient(GrpcChannel channel) + { + this.channel = channel; + client = new GenericInvoker.GenericInvokerClient(channel); + } + + public async Task InvokeAsync(string payload, string requestType) + { + var req = new InvokeRequest + { + RequestType = requestType, + Payload = payload + }; + + var resp = await client.InvokeAsync(req); + return resp.Payload; + } +} diff --git a/src/VisionaryCoder.Framework/Pipeline/Dispatch/GrpcRemoteDispatcher.cs b/src/VisionaryCoder.Framework/Pipeline/Dispatch/GrpcRemoteDispatcher.cs new file mode 100644 index 0000000..063b848 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Dispatch/GrpcRemoteDispatcher.cs @@ -0,0 +1,33 @@ +using Grpc.Net.Client; +using VisionaryCoder.Framework.Pipeline.Abstractions; +using VisionaryCoder.Framework.Pipeline.Dispatch.Abstractions; + +namespace VisionaryCoder.Framework.Pipeline.Dispatch; + +public sealed class GrpcRemoteDispatcher(ISerializer serializer) : IRemoteDispatcher +{ + private readonly ISerializer serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + + public async Task DispatchAsync( + TRequest request, EndpointResolution endpoint) + where TRequest : IRequest + { + if (endpoint.Uri is null) + throw new InvalidOperationException("Remote endpoint URI required for gRPC dispatch."); + + // Create channel dynamically based on endpoint + using var channel = GrpcChannel.ForAddress(endpoint.Uri); + + // Generic gRPC client stub (you’d generate this from .proto in real apps) + var client = new GenericGrpcClient(channel); + + // Serialize request to JSON (or protobuf if you define contracts) + var payload = serializer.Serialize(request); + + // Send request over gRPC + var responseJson = await client.InvokeAsync(payload, typeof(TRequest).Name); + + // Deserialize back into response type + return serializer.Deserialize(responseJson); + } +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Pipeline/Dispatch/HttpRemoteDispatcher.cs b/src/VisionaryCoder.Framework/Pipeline/Dispatch/HttpRemoteDispatcher.cs new file mode 100644 index 0000000..faf28e9 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Dispatch/HttpRemoteDispatcher.cs @@ -0,0 +1,37 @@ +using System.Diagnostics; +using System.Text; +using VisionaryCoder.Framework.Pipeline.Abstractions; +using VisionaryCoder.Framework.Pipeline.Dispatch.Abstractions; + +namespace VisionaryCoder.Framework.Pipeline.Dispatch; + +public sealed class HttpRemoteDispatcher(HttpClient http, ISerializer serializer) : IRemoteDispatcher +{ + public async Task DispatchAsync( + TRequest request, EndpointResolution endpoint) + where TRequest : IRequest + { + string payload = serializer.Serialize(request); + using var msg = new HttpRequestMessage(HttpMethod.Post, endpoint.Uri) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; + + // Propagate current Activity context + Activity? activity = Activity.Current; + if (activity is not null) + { + // System.Net.Http instrumentation will also do this, but explicit is ok + msg.Headers.TryAddWithoutValidation("traceparent", activity.Id); + foreach ((string key, string? value) in activity.Baggage) + msg.Headers.TryAddWithoutValidation($"baggage-{key}", value); + } + + using HttpResponseMessage resp = await http.SendAsync(msg); + resp.EnsureSuccessStatusCode(); + string json = await resp.Content.ReadAsStringAsync(); + return serializer.Deserialize(json); + } +} + +// Example generic gRPC client stub diff --git a/src/VisionaryCoder.Framework/Pipeline/Dispatch/LocalDispatcher.cs b/src/VisionaryCoder.Framework/Pipeline/Dispatch/LocalDispatcher.cs new file mode 100644 index 0000000..5c1747a --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Dispatch/LocalDispatcher.cs @@ -0,0 +1,16 @@ +using VisionaryCoder.Framework.Pipeline.Abstractions; + +namespace VisionaryCoder.Framework.Pipeline.Dispatch; + +public sealed class LocalDispatcher(IServiceProvider sp) : ILocalDispatcher +{ + public Task DispatchAsync(TRequest request) + where TRequest : IRequest + { + var handler = sp.GetService(typeof(IRequestHandler)) + as IRequestHandler; + if (handler == null) + throw new InvalidOperationException($"No handler for {typeof(TRequest).Name}"); + return handler.HandleAsync(request, CancellationToken.None); + } +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Pipeline/Dispatch/SystemTextJsonSerializer.cs b/src/VisionaryCoder.Framework/Pipeline/Dispatch/SystemTextJsonSerializer.cs new file mode 100644 index 0000000..50a8b72 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Dispatch/SystemTextJsonSerializer.cs @@ -0,0 +1,10 @@ +using System.Text.Json; +using VisionaryCoder.Framework.Pipeline.Dispatch.Abstractions; + +namespace VisionaryCoder.Framework.Pipeline.Dispatch; + +public sealed class SystemTextJsonSerializer : ISerializer +{ + public string Serialize(T value) => JsonSerializer.Serialize(value); + public T Deserialize(string json) => JsonSerializer.Deserialize(json)!; +} diff --git a/src/VisionaryCoder.Framework/Pipeline/Interceptors/Abstractions/IAuthorizationService.cs b/src/VisionaryCoder.Framework/Pipeline/Interceptors/Abstractions/IAuthorizationService.cs new file mode 100644 index 0000000..2aff722 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Interceptors/Abstractions/IAuthorizationService.cs @@ -0,0 +1,6 @@ +namespace VisionaryCoder.Framework.Pipeline.Interceptors.Abstractions; + +public interface IAuthorizationService +{ + Task AuthorizeAsync(object request); +} diff --git a/src/VisionaryCoder.Framework/Pipeline/Interceptors/AuthInterceptor.cs b/src/VisionaryCoder.Framework/Pipeline/Interceptors/AuthInterceptor.cs new file mode 100644 index 0000000..343d08e --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Interceptors/AuthInterceptor.cs @@ -0,0 +1,15 @@ +ο»Ώusing VisionaryCoder.Framework.Pipeline.Abstractions; +using VisionaryCoder.Framework.Pipeline.Interceptors.Abstractions; + +namespace VisionaryCoder.Framework.Pipeline.Interceptors; + +public sealed class AuthInterceptor(IAuthorizationService auth) : IInterceptor +{ + public async Task InvokeAsync( + TRequest request, Func> next) + where TRequest : IRequest + { + await auth.AuthorizeAsync(request); + return await next(request); + } +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Pipeline/Interceptors/CachingInterceptor.cs b/src/VisionaryCoder.Framework/Pipeline/Interceptors/CachingInterceptor.cs new file mode 100644 index 0000000..2692942 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Interceptors/CachingInterceptor.cs @@ -0,0 +1,24 @@ +using VisionaryCoder.Framework.Pipeline.Abstractions; + +namespace VisionaryCoder.Framework.Pipeline.Interceptors; + +public sealed class CachingInterceptor(ICache cache, Func keySelector, TimeSpan ttl) + : IInterceptor +{ + public async Task InvokeAsync( + TRequest request, Func> next) + where TRequest : IRequest + { + // Skip for commands by convention (queries only) + bool isQuery = typeof(TRequest).Name.EndsWith("Query", StringComparison.Ordinal); + if (!isQuery) return await next(request); + + string key = keySelector(request); + (bool hit, TResponse value) = await cache.TryGetAsync(key); + if (hit) return value; + + TResponse response = await next(request); + await cache.SetAsync(key, response, ttl); + return response; + } +} diff --git a/src/VisionaryCoder.Framework/Pipeline/Interceptors/LoggingInterceptor.cs b/src/VisionaryCoder.Framework/Pipeline/Interceptors/LoggingInterceptor.cs new file mode 100644 index 0000000..4b2fac7 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Interceptors/LoggingInterceptor.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using VisionaryCoder.Framework.Pipeline.Abstractions; + +namespace VisionaryCoder.Framework.Pipeline.Interceptors; + +public sealed class LoggingInterceptor(ILogger logger) : IInterceptor +{ + + public async Task InvokeAsync(TRequest request, Func> next) where TRequest : IRequest + { + + string name = typeof(TRequest).Name; + using IDisposable? scope = logger.BeginScope(new Dictionary + { + ["RequestType"] = name, + ["CorrelationId"] = Correlation.CurrentId ?? Guid.NewGuid().ToString("N") + }); + + logger.LogInformation("Handling {RequestType}", name); + var sw = Stopwatch.StartNew(); + try + { + TResponse response = await next(request); + logger.LogInformation("Handled {RequestType} in {Elapsed}ms", name, sw.ElapsedMilliseconds); + return response; + } + catch (Exception ex) + { + logger.LogError(ex, "Error handling {RequestType}", name); + throw; + } + } +} diff --git a/src/VisionaryCoder.Framework/Pipeline/Interceptors/MetricsInterceptor.cs b/src/VisionaryCoder.Framework/Pipeline/Interceptors/MetricsInterceptor.cs new file mode 100644 index 0000000..09e151d --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Interceptors/MetricsInterceptor.cs @@ -0,0 +1,36 @@ +using System.Diagnostics; +using VisionaryCoder.Framework.Pipeline.Abstractions; +using VisionaryCoder.Framework.Pipeline.Observibility.Abstractions; + +namespace VisionaryCoder.Framework.Pipeline.Interceptors; + +public sealed class MetricsInterceptor(IMetrics metrics) + : IInterceptor +{ + public async Task InvokeAsync( + TRequest request, + Func> next) + where TRequest : IRequest + { + string name = typeof(TRequest).Name; + var sw = Stopwatch.StartNew(); + + try + { + TResponse response = await next(request); + sw.Stop(); + + metrics.IncrementCounter("requests_total", name); + metrics.ObserveHistogram("request_duration_ms", name, sw.ElapsedMilliseconds); + + return response; + } + catch (Exception ex) + { + sw.Stop(); + metrics.IncrementCounter("requests_failed_total", name); + metrics.ObserveHistogram("request_duration_ms", name, sw.ElapsedMilliseconds); + throw; + } + } +} diff --git a/src/VisionaryCoder.Framework/Pipeline/Interceptors/ResilienceInterceptor.cs b/src/VisionaryCoder.Framework/Pipeline/Interceptors/ResilienceInterceptor.cs new file mode 100644 index 0000000..6b85ff6 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Interceptors/ResilienceInterceptor.cs @@ -0,0 +1,20 @@ +using VisionaryCoder.Framework.Pipeline.Abstractions; + +namespace VisionaryCoder.Framework.Pipeline.Interceptors; + +public sealed class ResilienceInterceptor(AsyncPolicy policy) : IInterceptor +{ + private readonly AsyncPolicy policy = policy; + + public Task InvokeAsync( + TRequest request, Func> next) + where TRequest : IRequest + { + return policy.ExecuteAsync(() => next(request)); + } + + public static AsyncPolicy DefaultPolicy() => + Policy.WrapAsync(Policy.Handle().CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)), Policy.Handle().WaitAndRetryAsync( + [TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(300), TimeSpan.FromMilliseconds(1000)]) + ); +} diff --git a/src/VisionaryCoder.Framework/Pipeline/Interceptors/TracingInterceptor.cs b/src/VisionaryCoder.Framework/Pipeline/Interceptors/TracingInterceptor.cs new file mode 100644 index 0000000..ccd251a --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Interceptors/TracingInterceptor.cs @@ -0,0 +1,37 @@ +using VisionaryCoder.Framework.Pipeline.Abstractions; +using VisionaryCoder.Framework.Pipeline.Observibility.Abstractions; + +namespace VisionaryCoder.Framework.Pipeline.Interceptors; + +public sealed class TracingInterceptor(ITracer tracer) : IInterceptor +{ + + public async Task InvokeAsync(TRequest request, Func> next) + where TRequest : IRequest + { + string spanName = typeof(TRequest).Name; + using ISpan span = tracer.StartSpan(spanName); + try + { + span.SetTag("request.type", spanName); + span.SetTag("correlation.id", Correlation.CurrentId ?? Guid.NewGuid().ToString()); + + TResponse response = await next(request); + + span.SetTag("status", "success"); + return response; + } + catch (Exception ex) + { + span.SetTag("status", "error"); + span.SetTag("error.message", ex.Message); + throw; + } + finally + { + span.End(); + } + + } + +} diff --git a/src/VisionaryCoder.Framework/Pipeline/Observibility/Abstractions/IMetrics.cs b/src/VisionaryCoder.Framework/Pipeline/Observibility/Abstractions/IMetrics.cs new file mode 100644 index 0000000..4dd84f3 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Observibility/Abstractions/IMetrics.cs @@ -0,0 +1,7 @@ +namespace VisionaryCoder.Framework.Pipeline.Observibility.Abstractions; + +public interface IMetrics +{ + void IncrementCounter(string metric, string label); + void ObserveHistogram(string metric, string label, long value); +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Pipeline/Observibility/Abstractions/ITracer.cs b/src/VisionaryCoder.Framework/Pipeline/Observibility/Abstractions/ITracer.cs new file mode 100644 index 0000000..5ef2d7a --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Observibility/Abstractions/ITracer.cs @@ -0,0 +1,8 @@ +using VisionaryCoder.Framework.Pipeline.Abstractions; + +namespace VisionaryCoder.Framework.Pipeline.Observibility.Abstractions; + +public interface ITracer +{ + ISpan StartSpan(string name); +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Pipeline/Observibility/OpenTelemetryMetrics.cs b/src/VisionaryCoder.Framework/Pipeline/Observibility/OpenTelemetryMetrics.cs new file mode 100644 index 0000000..a7fc018 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Observibility/OpenTelemetryMetrics.cs @@ -0,0 +1,26 @@ +using System.Collections.Concurrent; +using System.Diagnostics.Metrics; +using VisionaryCoder.Framework.Pipeline.Observibility.Abstractions; + +namespace VisionaryCoder.Framework.Pipeline.Observibility; + +public sealed class OpenTelemetryMetrics : IMetrics +{ + private static readonly Meter meter = new("PipelineInvoker.Metrics"); + private readonly ConcurrentDictionary> counters = new(); + private readonly ConcurrentDictionary> histograms = new(); + + public void IncrementCounter(string metric, string label) + { + Counter c = counters.GetOrAdd($"{metric}:{label}", + _ => meter.CreateCounter(metric, unit: "count", description: $"Counter for {label}")); + c.Add(1, new KeyValuePair("request", label)); + } + + public void ObserveHistogram(string metric, string label, long valueMs) + { + Histogram h = histograms.GetOrAdd($"{metric}:{label}", + _ => meter.CreateHistogram(metric, unit: "ms", description: $"Latency for {label}")); + h.Record(valueMs, new KeyValuePair("request", label)); + } +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Pipeline/Observibility/OpenTelemetryTracer.cs b/src/VisionaryCoder.Framework/Pipeline/Observibility/OpenTelemetryTracer.cs new file mode 100644 index 0000000..bd4c70a --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Observibility/OpenTelemetryTracer.cs @@ -0,0 +1,23 @@ +using System.Diagnostics; +using VisionaryCoder.Framework.Pipeline.Abstractions; +using VisionaryCoder.Framework.Pipeline.Observibility.Abstractions; + +namespace VisionaryCoder.Framework.Pipeline.Observibility; + +public sealed class OpenTelemetryTracer : ITracer +{ + private static readonly ActivitySource source = new("PipelineInvoker"); + + public ISpan StartSpan(string name) + { + Activity? activity = source.StartActivity(name, ActivityKind.Internal); + return new ActivitySpan(activity); + } + + private sealed class ActivitySpan(Activity? activity) : ISpan + { + public void SetTag(string key, string value) => activity?.SetTag(key, value); + public void End() => activity?.Stop(); + public void Dispose() => End(); + } +} diff --git a/src/VisionaryCoder.Framework/Pipeline/PipelineInvoker.cs b/src/VisionaryCoder.Framework/Pipeline/PipelineInvoker.cs new file mode 100644 index 0000000..5056c5c --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/PipelineInvoker.cs @@ -0,0 +1,34 @@ +using VisionaryCoder.Framework.Pipeline.Abstractions; + +namespace VisionaryCoder.Framework.Pipeline; +public sealed class PipelineInvoker( + IEnumerable interceptors, + IEndpointResolver resolver, + ILocalDispatcher local, + IRemoteDispatcher remote) + : IInvoker +{ + private readonly IReadOnlyList interceptors = interceptors.ToList(); + + public Task InvokeAsync(TRequest request) + where TRequest : IRequest + { + EndpointResolution resolution = resolver.Resolve(typeof(TRequest)); + + Func> terminal = resolution.IsLocal + ? (req) => local.DispatchAsync(req) + : (req) => remote.DispatchAsync(req, resolution); + + Func> next = terminal; + + // Build chain in reverse so first registered runs first + for (int i = interceptors.Count - 1; i >= 0; i--) + { + Func> current = next; + IInterceptor interceptor = interceptors[i]; + next = (req) => interceptor.InvokeAsync(req, current); + } + + return next(request); + } +} diff --git a/src/VisionaryCoder.Framework/Pipeline/Routing/InMemoryServiceRegistry.cs b/src/VisionaryCoder.Framework/Pipeline/Routing/InMemoryServiceRegistry.cs new file mode 100644 index 0000000..4d6b1f7 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Routing/InMemoryServiceRegistry.cs @@ -0,0 +1,19 @@ +using VisionaryCoder.Framework.Pipeline.Abstractions; + +namespace VisionaryCoder.Framework.Pipeline.Routing; + +public sealed class InMemoryServiceRegistry : IServiceRegistry +{ + private readonly Dictionary map = new(); + + public void Register(ServiceEntry entry) + { + map[typeof(TRequest)] = entry; + } + + public ServiceEntry? Lookup(Type requestType) + { + map.TryGetValue(requestType, out ServiceEntry? entry); + return entry; + } +} diff --git a/src/VisionaryCoder.Framework/Pipeline/Routing/KubernetesDnsRegistry.cs b/src/VisionaryCoder.Framework/Pipeline/Routing/KubernetesDnsRegistry.cs new file mode 100644 index 0000000..64af538 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Routing/KubernetesDnsRegistry.cs @@ -0,0 +1,13 @@ +ο»Ώusing VisionaryCoder.Framework.Pipeline.Abstractions; + +namespace VisionaryCoder.Framework.Pipeline.Routing; + +public sealed class KubernetesDnsRegistry : IServiceRegistry +{ + public ServiceEntry? Lookup(Type requestType) + { + string serviceName = requestType.Name.Replace("Request", "").ToLowerInvariant(); + var uri = new Uri($"http://{serviceName}.default.svc.cluster.local/api/dispatch"); + return new ServiceEntry(serviceName, uri, isLocal: false); + } +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Pipeline/Routing/RegistryBasedResolver.cs b/src/VisionaryCoder.Framework/Pipeline/Routing/RegistryBasedResolver.cs new file mode 100644 index 0000000..8d2e7a1 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Routing/RegistryBasedResolver.cs @@ -0,0 +1,44 @@ +using System.Collections.Concurrent; +using VisionaryCoder.Framework.Pipeline.Abstractions; + +namespace VisionaryCoder.Framework.Pipeline.Routing; + +public sealed class RegistryBasedResolver(IServiceRegistry registry) : IEndpointResolver +{ + + private readonly IServiceRegistry registry = registry ?? throw new ArgumentNullException(nameof(registry)); + private readonly ConcurrentDictionary cache = new(); + + public EndpointResolution Resolve(Type requestType) + { + if (requestType == null) + throw new ArgumentNullException(nameof(requestType)); + + // Cache lookups for performance + return cache.GetOrAdd(requestType, ResolveInternal); + } + + private EndpointResolution ResolveInternal(Type requestType) + { + // Ask registry for service info + ServiceEntry? entry = registry.Lookup(requestType); + + if (entry == null) + { + // Default: assume local if not registered + return new EndpointResolution(IsLocal: true); + } + + if (entry.IsLocal) + { + return new EndpointResolution(IsLocal: true); + } + + // Remote resolution + return new EndpointResolution( + IsLocal: false, + ServiceName: entry.ServiceName, + Uri: entry.EndpointUri + ); + } +} diff --git a/src/VisionaryCoder.Framework/Pipeline/Routing/ServiceEntry.cs b/src/VisionaryCoder.Framework/Pipeline/Routing/ServiceEntry.cs new file mode 100644 index 0000000..a97b7af --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Routing/ServiceEntry.cs @@ -0,0 +1,8 @@ +namespace VisionaryCoder.Framework.Pipeline.Routing; + +public sealed class ServiceEntry(string serviceName, Uri endpointUri, bool isLocal = false) +{ + public string ServiceName { get; } = serviceName; + public Uri EndpointUri { get; } = endpointUri; + public bool IsLocal { get; } = isLocal; +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Primitives/Data/EFCore/EntityIdModelBuilderExtensions.cs b/src/VisionaryCoder.Framework/Primitives/Data/EFCore/EntityIdModelBuilderExtensions.cs index 0b602f5..5e020ea 100644 --- a/src/VisionaryCoder.Framework/Primitives/Data/EFCore/EntityIdModelBuilderExtensions.cs +++ b/src/VisionaryCoder.Framework/Primitives/Data/EFCore/EntityIdModelBuilderExtensions.cs @@ -1,18 +1,15 @@ +using Microsoft.EntityFrameworkCore.Metadata.Builders; + namespace VisionaryCoder.Framework.Primitives.Data.EFCore; + public static class EntityIdModelBuilderExtensions { - public static PropertyBuilder> UseEntityId( - this PropertyBuilder> builder) + public static PropertyBuilder> UseEntityId(this PropertyBuilder> builder) where TEntity : class where TKey : notnull { var converter = new EntityIdValueConverter(); - var comparer = new ValueComparer>( - (a, b) => EqualityComparer.Default.Equals(a.Value, b.Value), - v => v.Value.GetHashCode(), - v => new EntityId(v.Value)); builder.HasConversion(converter); - builder.Metadata.SetValueComparer(comparer); return builder; } } diff --git a/src/VisionaryCoder.Framework/Primitives/Data/EFCore/EntityIdValueConverter.cs b/src/VisionaryCoder.Framework/Primitives/Data/EFCore/EntityIdValueConverter.cs index cf1d921..1aab9ba 100644 --- a/src/VisionaryCoder.Framework/Primitives/Data/EFCore/EntityIdValueConverter.cs +++ b/src/VisionaryCoder.Framework/Primitives/Data/EFCore/EntityIdValueConverter.cs @@ -1,2 +1,4 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + namespace VisionaryCoder.Framework.Primitives.Data.EFCore; public sealed class EntityIdValueConverter() : ValueConverter, TKey>(id => id.Value, v => new EntityId(v)) where TEntity : class where TKey : notnull; diff --git a/src/VisionaryCoder.Framework/Primitives/Web/AspNetCore/EntityIdModelBinder.cs b/src/VisionaryCoder.Framework/Primitives/Web/AspNetCore/EntityIdModelBinder.cs index a08c77d..65a84fd 100644 --- a/src/VisionaryCoder.Framework/Primitives/Web/AspNetCore/EntityIdModelBinder.cs +++ b/src/VisionaryCoder.Framework/Primitives/Web/AspNetCore/EntityIdModelBinder.cs @@ -1,4 +1,5 @@ using System.Reflection; +using Microsoft.AspNetCore.Mvc.ModelBinding; namespace VisionaryCoder.Framework.Primitives.Web.AspNetCore; public sealed class EntityIdModelBinder : IModelBinder diff --git a/src/VisionaryCoder.Framework/Primitives/Web/AspNetCore/EntityIdModelBinderProvider.cs b/src/VisionaryCoder.Framework/Primitives/Web/AspNetCore/EntityIdModelBinderProvider.cs index 1327316..5306716 100644 --- a/src/VisionaryCoder.Framework/Primitives/Web/AspNetCore/EntityIdModelBinderProvider.cs +++ b/src/VisionaryCoder.Framework/Primitives/Web/AspNetCore/EntityIdModelBinderProvider.cs @@ -1,8 +1,11 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; + namespace VisionaryCoder.Framework.Primitives.Web.AspNetCore; + public sealed class EntityIdModelBinderProvider : IModelBinderProvider { public IModelBinder? GetBinder(ModelBinderProviderContext ctx) - => ctx.Metadata.ModelType.IsGenericType - && ctx.Metadata.ModelType.GetGenericTypeDefinition() == typeof(EntityId<,>) - ? new EntityIdModelBinder() : null; + => ctx.Metadata.ModelType.IsGenericType && ctx.Metadata.ModelType.GetGenericTypeDefinition() == typeof(EntityId<,>) + ? new EntityIdModelBinder() + : null; } diff --git a/src/VisionaryCoder.Framework/Proxy/Caching/CachePolicy.cs b/src/VisionaryCoder.Framework/Proxy/Caching/CachePolicy.cs deleted file mode 100644 index 371effe..0000000 --- a/src/VisionaryCoder.Framework/Proxy/Caching/CachePolicy.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace VisionaryCoder.Framework.Proxy.Caching; -/// -/// Represents a cache policy for proxy operations. -/// -public class CachePolicy -{ - /// - /// Gets or sets a value indicating whether caching is enabled. - /// - public bool IsCachingEnabled { get; set; } = true; - /// Gets or sets the cache duration. - public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(5); - /// Gets or sets the cache priority. - public CacheItemPriority Priority { get; set; } = CacheItemPriority.Normal; - /// Gets or sets a function to determine if a response should be cached. - public Func ShouldCache { get; set; } = _ => true; - /// Gets or sets a function to determine if a cached response should be refreshed. - public Func ShouldRefresh { get; set; } = _ => false; -} diff --git a/src/VisionaryCoder.Framework/Proxy/Caching/CachingOptions.cs b/src/VisionaryCoder.Framework/Proxy/Caching/CachingOptions.cs deleted file mode 100644 index 4f1f036..0000000 --- a/src/VisionaryCoder.Framework/Proxy/Caching/CachingOptions.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace VisionaryCoder.Framework.Proxy.Caching; -/// -/// Configuration options for the caching interceptor. -/// -public sealed class CachingOptions -{ - /// - /// Gets or sets the default cache duration. - /// - public TimeSpan DefaultDuration { get; set; } = TimeSpan.FromMinutes(5); - /// Gets or sets the default cache priority. - public CacheItemPriority DefaultPriority { get; set; } = CacheItemPriority.Normal; - /// Gets or sets a value indicating whether to enable eviction logging. - public bool EnableEvictionLogging { get; set; } = false; - /// Gets or sets operation-specific cache policies. - public Dictionary OperationPolicies { get; set; } = new(); - /// The maximum size of the cache in entries. - public int? MaxCacheSize { get; set; } - /// Custom cache key generator function. - public Func? KeyGenerator { get; set; } - /// Predicate to determine if a response should be cached based on context. - public Func? ShouldCache { get; set; } -} diff --git a/src/VisionaryCoder.Framework/Proxy/Caching/IProxyCache.cs b/src/VisionaryCoder.Framework/Proxy/Caching/IProxyCache.cs deleted file mode 100644 index bb9380d..0000000 --- a/src/VisionaryCoder.Framework/Proxy/Caching/IProxyCache.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace VisionaryCoder.Framework.Proxy.Caching; - -/// -/// Defines a contract for proxy caching operations. -/// -public interface IProxyCache -{ - /// - /// Gets a cached response for the given key. - /// - /// The type of the cached value. - /// The cache key. - /// The cached response, or null if not found. - Task?> GetAsync(string key); - /// - /// Sets a proxyResponse in the cache with the given key and expiration. - /// - /// The type of the value to cache. - /// The cache key. - /// The proxyResponse to cache. - /// The cache expiration time. - /// A task representing the asynchronous operation. - Task SetAsync(string key, ProxyResponse proxyResponse, TimeSpan expiration); -} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Proxy/Caching/MemoryProxyCache.cs b/src/VisionaryCoder.Framework/Proxy/Caching/MemoryProxyCache.cs deleted file mode 100644 index 4753250..0000000 --- a/src/VisionaryCoder.Framework/Proxy/Caching/MemoryProxyCache.cs +++ /dev/null @@ -1,35 +0,0 @@ -// VisionaryCoder.Framework.Proxy.Caching - -namespace VisionaryCoder.Framework.Proxy.Caching; - -public sealed class MemoryProxyCache(IMemoryCache cache) : IProxyCache -{ - /// - /// Gets a cached response for the given key. - /// - /// The type of the cached value. - /// The cache key. - /// The cached response, or null if not found. - public Task?> GetAsync(string key) - { - if (cache.TryGetValue(key, out object? obj) && obj is ProxyResponse typed) - { - return Task.FromResult?>(typed); - } - return Task.FromResult?>(null); - } - - /// - /// Sets a proxyResponse in the cache with the given key and expiration. - /// - /// The type of the value to cache. - /// The cache key. - /// The proxyResponse to cache. - /// The cache expiration time. - /// A task representing the asynchronous operation. - public Task SetAsync(string key, ProxyResponse proxyResponse, TimeSpan expiration) - { - cache.Set(key, proxyResponse, expiration); - return Task.CompletedTask; - } -} diff --git a/src/VisionaryCoder.Framework/Proxy/Exceptions/ProxyExceptions.cs b/src/VisionaryCoder.Framework/Proxy/Exceptions/ProxyException.cs similarity index 100% rename from src/VisionaryCoder.Framework/Proxy/Exceptions/ProxyExceptions.cs rename to src/VisionaryCoder.Framework/Proxy/Exceptions/ProxyException.cs diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Auditing/AuditingInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Auditing/AuditingInterceptor.cs index 658eab7..85a4388 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Auditing/AuditingInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Auditing/AuditingInterceptor.cs @@ -1,6 +1,8 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.Logging; + namespace VisionaryCoder.Framework.Proxy.Interceptors.Auditing; /// /// Auditing interceptor that emits audit records for proxy operations. @@ -107,7 +109,7 @@ private async Task EmitAuditRecord(AuditRecord auditRecord, CancellationToken ca } private static bool IsSensitiveKey(string key) { - string[] sensitiveKeys = new[] { "Authorization", "Password", "Secret", "Token", "Key" }; + string[] sensitiveKeys = ["Authorization", "Password", "Secret", "Token", "Key"]; return sensitiveKeys.Any(sensitive => key.Contains(sensitive, StringComparison.OrdinalIgnoreCase)); } diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Auditing/LoggingAuditSink.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Auditing/LoggingAuditSink.cs index 8f66c48..312948b 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Auditing/LoggingAuditSink.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Auditing/LoggingAuditSink.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; + namespace VisionaryCoder.Framework.Proxy.Interceptors.Auditing; /// /// Default audit sink that logs audit records. diff --git a/src/VisionaryCoder.Framework/Authentication/AuthenticationServiceCollectionExtensions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/AuthenticationExtensions.cs similarity index 96% rename from src/VisionaryCoder.Framework/Authentication/AuthenticationServiceCollectionExtensions.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/AuthenticationExtensions.cs index cf07c37..fa95075 100644 --- a/src/VisionaryCoder.Framework/Authentication/AuthenticationServiceCollectionExtensions.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/AuthenticationExtensions.cs @@ -3,18 +3,17 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Interceptors; +using VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Jwt; +using VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Providers; -using VisionaryCoder.Framework.Authentication.Interceptors; -using VisionaryCoder.Framework.Authentication.Jwt; -using VisionaryCoder.Framework.Authentication.Providers; - -namespace VisionaryCoder.Framework.Authentication; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authentication; /// /// Extension methods for configuring authentication services in the dependency injection container. /// Provides comprehensive setup for JWT authentication, token providers, and authentication interceptors. /// -public static class AuthenticationServiceCollectionExtensions +public static class AuthenticationExtensions { /// /// Adds JWT authentication services to the dependency injection container with explicit provider registration. @@ -26,7 +25,7 @@ public static class AuthenticationServiceCollectionExtensions /// Thrown when services is null. /// Thrown when JWT options are invalid. public static IServiceCollection AddJwtAuthentication(this IServiceCollection services, Action configureOptions) - { + { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configureOptions); @@ -316,7 +315,7 @@ public static IServiceCollection AddAuthenticationWithValidation( { ArgumentNullException.ThrowIfNull(services); - var result = services.AddCompleteAuthentication(configureOptions); + IServiceCollection result = services.AddCompleteAuthentication(configureOptions); if (validateSetup) { @@ -334,13 +333,13 @@ public static IServiceCollection AddAuthenticationWithValidation( /// Thrown when required services are missing. private static void ValidateAuthenticationSetup(IServiceCollection services) { - var requiredServices = new[] - { + Type[] requiredServices = + [ typeof(IUserContextProvider), typeof(ITenantContextProvider), typeof(ITokenProvider), typeof(JwtAuthenticationInterceptor) - }; + ]; var missingServices = requiredServices .Where(serviceType => !services.Any(s => s.ServiceType == serviceType)) @@ -348,7 +347,7 @@ private static void ValidateAuthenticationSetup(IServiceCollection services) if (missingServices.Count != 0) { - var missingServiceNames = string.Join(", ", missingServices.Select(t => t.Name)); + string missingServiceNames = string.Join(", ", missingServices.Select(t => t.Name)); throw new InvalidOperationException($"Authentication setup is incomplete. Missing services: {missingServiceNames}"); } } diff --git a/src/VisionaryCoder.Framework/Authentication/Interceptors/JwtAuthenticationInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Interceptors/JwtAuthenticationInterceptor.cs similarity index 96% rename from src/VisionaryCoder.Framework/Authentication/Interceptors/JwtAuthenticationInterceptor.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Interceptors/JwtAuthenticationInterceptor.cs index b58431d..c312b74 100644 --- a/src/VisionaryCoder.Framework/Authentication/Interceptors/JwtAuthenticationInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Interceptors/JwtAuthenticationInterceptor.cs @@ -1,10 +1,10 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Authentication.Jwt; -using VisionaryCoder.Framework.Proxy; +using Microsoft.Extensions.Logging; +using VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Jwt; -namespace VisionaryCoder.Framework.Authentication.Interceptors; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Interceptors; /// /// JWT interceptor for web-based authentication scenarios. @@ -103,7 +103,7 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe options.Audience, tokenResult.Error, tokenResult.ErrorDescription); // Optionally fail the request based on configuration - if (options.CustomProperties.TryGetValue("FailOnTokenError", out var failOnError) && + if (options.CustomProperties.TryGetValue("FailOnTokenError", out object? failOnError) && failOnError is bool fail && fail) { throw new InvalidOperationException($"JWT token acquisition failed: {tokenResult.Error}"); @@ -121,7 +121,7 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe options.RequestTimeout, options.Audience); // Continue with request without token based on configuration - if (options.CustomProperties.TryGetValue("FailOnTimeout", out var failOnTimeout) && + if (options.CustomProperties.TryGetValue("FailOnTimeout", out object? failOnTimeout) && failOnTimeout is bool fail && fail) { throw; @@ -133,7 +133,7 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe // Continue with request without token to avoid breaking the flow // unless configured to fail on errors - if (options.CustomProperties.TryGetValue("FailOnError", out var failOnError) && + if (options.CustomProperties.TryGetValue("FailOnError", out object? failOnError) && failOnError is bool fail && fail) { throw; diff --git a/src/VisionaryCoder.Framework/Authentication/Interceptors/KeyVaultJwtInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Interceptors/KeyVaultJwtInterceptor.cs similarity index 71% rename from src/VisionaryCoder.Framework/Authentication/Interceptors/KeyVaultJwtInterceptor.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Interceptors/KeyVaultJwtInterceptor.cs index a83ce4c..e2e3b1c 100644 --- a/src/VisionaryCoder.Framework/Authentication/Interceptors/KeyVaultJwtInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Interceptors/KeyVaultJwtInterceptor.cs @@ -1,10 +1,10 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Proxy; +using Microsoft.Extensions.Logging; using VisionaryCoder.Framework.Secrets; -namespace VisionaryCoder.Framework.Authentication.Interceptors; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Interceptors; /// /// JWT interceptor specialized for Azure Key Vault authentication scenarios. @@ -137,7 +137,7 @@ protected virtual bool IsTokenValid(string token) try { // Basic JWT format validation (should have 3 parts separated by dots) - var parts = token.Split('.'); + string[] parts = token.Split('.'); if (parts.Length != 3) { logger.LogDebug("JWT token has invalid format - expected 3 parts, got {PartCount}", parts.Length); @@ -236,98 +236,3 @@ protected virtual void HandleTokenFailure() } } } - -/// -/// Configuration options for Key Vault JWT interceptor. -/// Provides comprehensive settings for retrieving and using JWT tokens from Azure Key Vault. -/// -public class KeyVaultJwtOptions -{ - /// - /// Gets or sets the name of the secret in Key Vault containing the JWT token. - /// - /// The secret name. Defaults to an empty string. - public string SecretName { get; set; } = string.Empty; - - /// - /// Gets or sets the name of the secret in Key Vault containing the refresh token. - /// Used for automatic token refresh when enabled. - /// - /// The refresh token secret name. Defaults to null. - public string? RefreshSecretName { get; set; } - - /// - /// Gets or sets the HTTP header name to add the JWT token to. - /// - /// The header name. Defaults to "Authorization". - public string HeaderName { get; set; } = "Authorization"; - - /// - /// Gets or sets whether to validate the JWT token before using it. - /// When true, tokens are checked for basic format and expiration. - /// - /// True to validate tokens; otherwise, false. Defaults to true. - public bool ValidateToken { get; set; } = true; - - /// - /// Gets or sets whether to automatically refresh expired tokens. - /// Requires RefreshSecretName to be configured. - /// - /// True to auto-refresh tokens; otherwise, false. Defaults to false. - public bool AutoRefresh { get; set; } = false; - - /// - /// Gets or sets the timeout duration for Key Vault operations. - /// - /// The timeout duration. Defaults to 30 seconds. - public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30); - - /// - /// Gets or sets whether to fail the request if token acquisition fails. - /// When false, the request continues without authentication. - /// - /// True to fail on errors; otherwise, false. Defaults to false. - public bool FailOnError { get; set; } = false; - - /// - /// Gets or sets whether to fail the request if token acquisition times out. - /// - /// True to fail on timeout; otherwise, false. Defaults to false. - public bool FailOnTimeout { get; set; } = false; - - /// - /// Gets or sets whether to fail the request if the token is missing from Key Vault. - /// - /// True to fail on missing token; otherwise, false. Defaults to false. - public bool FailOnMissingToken { get; set; } = false; - - /// - /// Gets or sets whether to include metadata headers in the request. - /// - /// True to include metadata; otherwise, false. Defaults to false. - public bool IncludeMetadata { get; set; } = false; - - /// - /// Gets or sets the correlation ID for request tracing. - /// - /// The correlation ID. Defaults to null. - public string? CorrelationId { get; set; } - - /// - /// Validates the Key Vault JWT options configuration. - /// - /// True if the configuration is valid; otherwise, false. - public bool IsValid() - { - if (string.IsNullOrWhiteSpace(SecretName)) - return false; - - if (string.IsNullOrWhiteSpace(HeaderName)) - return false; - - if (RequestTimeout <= TimeSpan.Zero) - return false; - - return true; - } -} diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Interceptors/KeyVaultJwtOptions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Interceptors/KeyVaultJwtOptions.cs new file mode 100644 index 0000000..66a07ce --- /dev/null +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Interceptors/KeyVaultJwtOptions.cs @@ -0,0 +1,96 @@ +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Interceptors; + +/// +/// Configuration options for Key Vault JWT interceptor. +/// Provides comprehensive settings for retrieving and using JWT tokens from Azure Key Vault. +/// +public class KeyVaultJwtOptions +{ + /// + /// Gets or sets the name of the secret in Key Vault containing the JWT token. + /// + /// The secret name. Defaults to an empty string. + public string SecretName { get; set; } = string.Empty; + + /// + /// Gets or sets the name of the secret in Key Vault containing the refresh token. + /// Used for automatic token refresh when enabled. + /// + /// The refresh token secret name. Defaults to null. + public string? RefreshSecretName { get; set; } + + /// + /// Gets or sets the HTTP header name to add the JWT token to. + /// + /// The header name. Defaults to "Authorization". + public string HeaderName { get; set; } = "Authorization"; + + /// + /// Gets or sets whether to validate the JWT token before using it. + /// When true, tokens are checked for basic format and expiration. + /// + /// True to validate tokens; otherwise, false. Defaults to true. + public bool ValidateToken { get; set; } = true; + + /// + /// Gets or sets whether to automatically refresh expired tokens. + /// Requires RefreshSecretName to be configured. + /// + /// True to auto-refresh tokens; otherwise, false. Defaults to false. + public bool AutoRefresh { get; set; } = false; + + /// + /// Gets or sets the timeout duration for Key Vault operations. + /// + /// The timeout duration. Defaults to 30 seconds. + public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Gets or sets whether to fail the request if token acquisition fails. + /// When false, the request continues without authentication. + /// + /// True to fail on errors; otherwise, false. Defaults to false. + public bool FailOnError { get; set; } = false; + + /// + /// Gets or sets whether to fail the request if token acquisition times out. + /// + /// True to fail on timeout; otherwise, false. Defaults to false. + public bool FailOnTimeout { get; set; } = false; + + /// + /// Gets or sets whether to fail the request if the token is missing from Key Vault. + /// + /// True to fail on missing token; otherwise, false. Defaults to false. + public bool FailOnMissingToken { get; set; } = false; + + /// + /// Gets or sets whether to include metadata headers in the request. + /// + /// True to include metadata; otherwise, false. Defaults to false. + public bool IncludeMetadata { get; set; } = false; + + /// + /// Gets or sets the correlation ID for request tracing. + /// + /// The correlation ID. Defaults to null. + public string? CorrelationId { get; set; } + + /// + /// Validates the Key Vault JWT options configuration. + /// + /// True if the configuration is valid; otherwise, false. + public bool IsValid() + { + if (string.IsNullOrWhiteSpace(SecretName)) + return false; + + if (string.IsNullOrWhiteSpace(HeaderName)) + return false; + + if (RequestTimeout <= TimeSpan.Zero) + return false; + + return true; + } +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Authentication/Jwt/ITokenProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Jwt/ITokenProvider.cs similarity index 97% rename from src/VisionaryCoder.Framework/Authentication/Jwt/ITokenProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Jwt/ITokenProvider.cs index ad8ea34..d724629 100644 --- a/src/VisionaryCoder.Framework/Authentication/Jwt/ITokenProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Jwt/ITokenProvider.cs @@ -1,7 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -namespace VisionaryCoder.Framework.Authentication.Jwt; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Jwt; /// /// Defines a contract for JWT token providers that handle token acquisition and validation. diff --git a/src/VisionaryCoder.Framework/Authentication/Jwt/JwtOptions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Jwt/JwtOptions.cs similarity index 98% rename from src/VisionaryCoder.Framework/Authentication/Jwt/JwtOptions.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Jwt/JwtOptions.cs index dd97fc0..43ec0c0 100644 --- a/src/VisionaryCoder.Framework/Authentication/Jwt/JwtOptions.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Jwt/JwtOptions.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; -namespace VisionaryCoder.Framework.Authentication.Jwt; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Jwt; /// /// Configuration options for JWT authentication interceptors and providers. @@ -54,7 +54,7 @@ public class JwtOptions /// Scopes define the level of access that the application is requesting. /// /// An array of scope strings. Defaults to an empty array. - public string[] Scopes { get; set; } = Array.Empty(); + public string[] Scopes { get; set; } = []; /// /// Gets or sets whether to automatically refresh expired tokens. @@ -209,4 +209,4 @@ public static JwtOptions CreateForApiClient(string authority, string audience, p ValidateIssuerSigningKey = true }; } -} \ No newline at end of file +} diff --git a/src/VisionaryCoder.Framework/Authentication/Jwt/TokenRequest.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Jwt/TokenRequest.cs similarity index 96% rename from src/VisionaryCoder.Framework/Authentication/Jwt/TokenRequest.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Jwt/TokenRequest.cs index 4fc1e53..befab91 100644 --- a/src/VisionaryCoder.Framework/Authentication/Jwt/TokenRequest.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Jwt/TokenRequest.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; -namespace VisionaryCoder.Framework.Authentication.Jwt; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Jwt; /// /// Represents a JWT token request containing authentication parameters and configuration options. @@ -24,7 +24,7 @@ public class TokenRequest /// Scopes define the level of access that the application is requesting. /// /// An array of scope strings. Defaults to an empty array. - public string[] Scopes { get; set; } = Array.Empty(); + public string[] Scopes { get; set; } = []; /// /// Gets or sets the client identifier for the application. @@ -130,7 +130,7 @@ public static TokenRequest CreateClientCredentials(string clientId, string clien GrantType = "client_credentials", ClientId = clientId, ClientSecret = clientSecret, - Scopes = scopes ?? Array.Empty(), + Scopes = scopes ?? [], Audience = audience ?? string.Empty }; } @@ -151,7 +151,7 @@ public static TokenRequest CreatePasswordCredentials(string clientId, string use ClientId = clientId, Username = username, Password = password, - Scopes = scopes ?? Array.Empty() + Scopes = scopes ?? [] }; } -} \ No newline at end of file +} diff --git a/src/VisionaryCoder.Framework/Authentication/Jwt/TokenResult.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Jwt/TokenResult.cs similarity index 98% rename from src/VisionaryCoder.Framework/Authentication/Jwt/TokenResult.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Jwt/TokenResult.cs index 27e4c39..9c3a450 100644 --- a/src/VisionaryCoder.Framework/Authentication/Jwt/TokenResult.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Jwt/TokenResult.cs @@ -3,7 +3,7 @@ using System.Text.Json; -namespace VisionaryCoder.Framework.Authentication.Jwt; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Jwt; /// /// Represents the result of a JWT token request, containing the token details and metadata. @@ -107,7 +107,7 @@ public class TokenResult /// True if the token expires within the threshold; otherwise, false. public bool IsCloseToExpiry(TimeSpan? threshold = null) { - var thresholdTime = threshold ?? TimeSpan.FromMinutes(5); + TimeSpan thresholdTime = threshold ?? TimeSpan.FromMinutes(5); return TimeUntilExpiry <= thresholdTime; } diff --git a/src/VisionaryCoder.Framework/Authentication/Providers/DefaultTenantContextProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/DefaultTenantContextProvider.cs similarity index 87% rename from src/VisionaryCoder.Framework/Authentication/Providers/DefaultTenantContextProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/DefaultTenantContextProvider.cs index 50d9ddb..5b9de52 100644 --- a/src/VisionaryCoder.Framework/Authentication/Providers/DefaultTenantContextProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/DefaultTenantContextProvider.cs @@ -1,9 +1,12 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; using System.Security.Claims; -namespace VisionaryCoder.Framework.Authentication.Providers; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Providers; /// /// Default implementation of that extracts tenant information from HTTP context. @@ -37,7 +40,7 @@ public DefaultTenantContextProvider( { try { - var tenantContext = GetCurrentTenant(); + TenantContext tenantContext = GetCurrentTenant(); return tenantContext.TenantId; } catch (Exception ex) @@ -56,7 +59,7 @@ public DefaultTenantContextProvider( { try { - var tenantContext = await GetCurrentTenantAsync(cancellationToken); + TenantContext tenantContext = await GetCurrentTenantAsync(cancellationToken); return tenantContext.TenantId; } catch (Exception ex) @@ -142,18 +145,18 @@ protected TenantContext GetCurrentTenant() { try { - var httpContext = httpContextAccessor.HttpContext; + HttpContext? httpContext = httpContextAccessor.HttpContext; if (httpContext == null) { logger.LogDebug("No HTTP context available for tenant extraction"); return CreateDefaultTenantContext(); } - var tenantContext = ExtractTenantFromClaims(httpContext) ?? - ExtractTenantFromHeaders(httpContext) ?? - ExtractTenantFromSubdomain(httpContext) ?? - ExtractTenantFromPath(httpContext) ?? - CreateDefaultTenantContext(); + TenantContext tenantContext = ExtractTenantFromClaims(httpContext) ?? + ExtractTenantFromHeaders(httpContext) ?? + ExtractTenantFromSubdomain(httpContext) ?? + ExtractTenantFromPath(httpContext) ?? + CreateDefaultTenantContext(); // Enrich with additional context EnrichTenantContext(tenantContext, httpContext); @@ -180,7 +183,7 @@ public async Task GetCurrentTenantAsync(CancellationToken cancell { try { - var tenantContext = GetCurrentTenant(); + TenantContext tenantContext = GetCurrentTenant(); // Perform additional async enrichment (e.g., database lookups) await EnrichTenantContextAsync(tenantContext, cancellationToken); @@ -246,7 +249,7 @@ public bool SwitchTenant(string tenantId) try { - var httpContext = httpContextAccessor.HttpContext; + HttpContext? httpContext = httpContextAccessor.HttpContext; if (httpContext == null) { logger.LogWarning("No HTTP context available for tenant switch"); @@ -279,18 +282,18 @@ public bool SwitchTenant(string tenantId) if (httpContext.User?.Identity?.IsAuthenticated != true) return null; - var principal = httpContext.User; + ClaimsPrincipal principal = httpContext.User; - var tenantId = GetClaimValue(principal, "tenant_id") ?? - GetClaimValue(principal, "tid") ?? - GetClaimValue(principal, "tenantid"); + string? tenantId = GetClaimValue(principal, "tenant_id") ?? + GetClaimValue(principal, "tid") ?? + GetClaimValue(principal, "tenantid"); if (string.IsNullOrEmpty(tenantId)) return null; - var tenantName = GetClaimValue(principal, "tenant_name") ?? - GetClaimValue(principal, "tenant") ?? - tenantId; + string tenantName = GetClaimValue(principal, "tenant_name") ?? + GetClaimValue(principal, "tenant") ?? + tenantId; return CreateTenantContext(tenantId, tenantName, "Claims"); } @@ -302,15 +305,15 @@ public bool SwitchTenant(string tenantId) /// A TenantContext if found in headers, otherwise null. protected virtual TenantContext? ExtractTenantFromHeaders(HttpContext httpContext) { - var headers = httpContext.Request.Headers; + IHeaderDictionary headers = httpContext.Request.Headers; // Check for explicit tenant header - if (headers.TryGetValue("X-Tenant-ID", out var tenantIdHeader)) + if (headers.TryGetValue("X-Tenant-ID", out StringValues tenantIdHeader)) { - var tenantId = tenantIdHeader.ToString(); + string tenantId = tenantIdHeader.ToString(); if (!string.IsNullOrEmpty(tenantId)) { - var tenantName = headers.TryGetValue("X-Tenant-Name", out var nameHeader) + string tenantName = headers.TryGetValue("X-Tenant-Name", out StringValues nameHeader) ? nameHeader.ToString() : tenantId; @@ -319,13 +322,13 @@ public bool SwitchTenant(string tenantId) } // Check for tenant in authorization header (custom format) - if (headers.TryGetValue("Authorization", out var authHeader)) + if (headers.TryGetValue("Authorization", out StringValues authHeader)) { - var authValue = authHeader.ToString(); + string authValue = authHeader.ToString(); const string tenantPrefix = "Tenant "; if (authValue.StartsWith(tenantPrefix, StringComparison.OrdinalIgnoreCase)) { - var tenantId = authValue.Substring(tenantPrefix.Length).Trim(); + string tenantId = authValue.Substring(tenantPrefix.Length).Trim(); return CreateTenantContext(tenantId, tenantId, "Authorization"); } } @@ -340,18 +343,18 @@ public bool SwitchTenant(string tenantId) /// A TenantContext if found in subdomain, otherwise null. protected virtual TenantContext? ExtractTenantFromSubdomain(HttpContext httpContext) { - var host = httpContext.Request.Host.Host; + string host = httpContext.Request.Host.Host; if (string.IsNullOrEmpty(host)) return null; // Extract subdomain (e.g., tenant1.example.com -> tenant1) - var hostParts = host.Split('.'); + string[] hostParts = host.Split('.'); if (hostParts.Length >= 3) { - var subdomain = hostParts[0]; + string subdomain = hostParts[0]; // Skip common subdomains that aren't tenants - var commonSubdomains = new[] { "www", "api", "admin", "app", "mail", "ftp" }; + string[] commonSubdomains = ["www", "api", "admin", "app", "mail", "ftp"]; if (!commonSubdomains.Contains(subdomain, StringComparer.OrdinalIgnoreCase)) { return CreateTenantContext(subdomain, subdomain, "Subdomain"); @@ -368,20 +371,20 @@ public bool SwitchTenant(string tenantId) /// A TenantContext if found in path, otherwise null. protected virtual TenantContext? ExtractTenantFromPath(HttpContext httpContext) { - var path = httpContext.Request.Path.Value; + string? path = httpContext.Request.Path.Value; if (string.IsNullOrEmpty(path)) return null; // Check for path patterns like /tenant/{tenantId}/... or /t/{tenantId}/... - var pathSegments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + string[] pathSegments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); for (int i = 0; i < pathSegments.Length - 1; i++) { - var segment = pathSegments[i]; + string segment = pathSegments[i]; if (segment.Equals("tenant", StringComparison.OrdinalIgnoreCase) || segment.Equals("t", StringComparison.OrdinalIgnoreCase)) { - var tenantId = pathSegments[i + 1]; + string tenantId = pathSegments[i + 1]; return CreateTenantContext(tenantId, tenantId, "Path"); } } @@ -389,7 +392,7 @@ public bool SwitchTenant(string tenantId) // Check if the first segment is a tenant identifier if (pathSegments.Length > 0 && IsPotentialTenantId(pathSegments[0])) { - var tenantId = pathSegments[0]; + string tenantId = pathSegments[0]; return CreateTenantContext(tenantId, tenantId, "Path"); } @@ -430,7 +433,7 @@ protected virtual void EnrichTenantContext(TenantContext tenantContext, HttpCont tenantContext.Settings["RequestMethod"] = httpContext.Request.Method; // Add any tenant context override from HTTP items - if (httpContext.Items.TryGetValue("CurrentTenantId", out var overrideTenantId) && + if (httpContext.Items.TryGetValue("CurrentTenantId", out object? overrideTenantId) && overrideTenantId is string overrideId) { tenantContext.TenantId = overrideId; @@ -438,7 +441,7 @@ protected virtual void EnrichTenantContext(TenantContext tenantContext, HttpCont } // Add correlation ID - if (httpContext.Request.Headers.TryGetValue("X-Correlation-ID", out var correlationId)) + if (httpContext.Request.Headers.TryGetValue("X-Correlation-ID", out StringValues correlationId)) { tenantContext.Settings["CorrelationId"] = correlationId.ToString(); } diff --git a/src/VisionaryCoder.Framework/Authentication/Providers/DefaultTokenProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/DefaultTokenProvider.cs similarity index 89% rename from src/VisionaryCoder.Framework/Authentication/Providers/DefaultTokenProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/DefaultTokenProvider.cs index fcd7c9e..ab64f87 100644 --- a/src/VisionaryCoder.Framework/Authentication/Providers/DefaultTokenProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/DefaultTokenProvider.cs @@ -1,11 +1,14 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text.Json; -using VisionaryCoder.Framework.Authentication.Jwt; +using VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Jwt; -namespace VisionaryCoder.Framework.Authentication.Providers; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Providers; /// /// Default implementation of that handles JWT token acquisition and validation. @@ -34,7 +37,7 @@ public DefaultTokenProvider( this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); this.options = options ?? throw new ArgumentNullException(nameof(options)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.tokenHandler = new JwtSecurityTokenHandler(); + tokenHandler = new JwtSecurityTokenHandler(); ConfigureHttpClient(); } @@ -53,7 +56,7 @@ public async Task GetTokenAsync(CancellationToken cancellationToken = de options.Scopes, options.Audience); - var result = await GetTokenAsync(defaultRequest, cancellationToken); + TokenResult result = await GetTokenAsync(defaultRequest, cancellationToken); if (result.IsSuccess && !string.IsNullOrEmpty(result.AccessToken)) { @@ -87,23 +90,23 @@ public async Task GetTokenAsync(TokenRequest request, CancellationT using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(options.RequestTimeout); - var tokenEndpoint = GetTokenEndpoint(); - var requestData = BuildTokenRequestData(request); + string tokenEndpoint = GetTokenEndpoint(); + Dictionary requestData = BuildTokenRequestData(request); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint) { Content = new FormUrlEncodedContent(requestData) }; - using var response = await httpClient.SendAsync(httpRequest, timeoutCts.Token); - var responseContent = await response.Content.ReadAsStringAsync(timeoutCts.Token); + using HttpResponseMessage response = await httpClient.SendAsync(httpRequest, timeoutCts.Token); + string responseContent = await response.Content.ReadAsStringAsync(timeoutCts.Token); if (response.IsSuccessStatusCode) { - var tokenResponse = JsonSerializer.Deserialize(responseContent); + TokenResponse? tokenResponse = JsonSerializer.Deserialize(responseContent); if (tokenResponse != null) { - var result = MapToTokenResult(tokenResponse); + TokenResult result = MapToTokenResult(tokenResponse); logger.LogDebug("Successfully acquired JWT token. Expires in {ExpiresIn}s", result.ExpiresIn); return result; } @@ -140,8 +143,8 @@ public async Task ValidateTokenAsync(string token, CancellationToken cance try { - var validationParameters = await GetValidationParametersAsync(cancellationToken); - var principal = tokenHandler.ValidateToken(token, validationParameters, out var validatedToken); + TokenValidationParameters validationParameters = await GetValidationParametersAsync(cancellationToken); + ClaimsPrincipal? principal = tokenHandler.ValidateToken(token, validationParameters, out SecurityToken? validatedToken); logger.LogDebug("JWT token validation successful for subject: {Subject}", principal.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "unknown"); @@ -168,7 +171,7 @@ public bool ValidateToken(string token) try { - var jsonToken = tokenHandler.ReadJwtToken(token); + JwtSecurityToken? jsonToken = tokenHandler.ReadJwtToken(token); // Check expiration if (options.ValidateLifetime && jsonToken.ValidTo < DateTime.UtcNow) @@ -256,9 +259,9 @@ public Dictionary ExtractClaims(string token) try { - var jsonToken = tokenHandler.ReadJwtToken(token); + JwtSecurityToken? jsonToken = tokenHandler.ReadJwtToken(token); - foreach (var claim in jsonToken.Claims) + foreach (Claim? claim in jsonToken.Claims) { if (claims.ContainsKey(claim.Type)) { @@ -321,7 +324,7 @@ private string GetTokenEndpoint() } // Construct from authority if not explicitly set - var authority = options.Authority.TrimEnd('/'); + string authority = options.Authority.TrimEnd('/'); return $"{authority}/token"; } @@ -348,7 +351,7 @@ private Dictionary BuildTokenRequestData(TokenRequest request) data["audience"] = request.Audience; } - var scopeString = request.GetScopeString(); + string? scopeString = request.GetScopeString(); if (!string.IsNullOrEmpty(scopeString)) { data["scope"] = scopeString; @@ -373,7 +376,7 @@ private Dictionary BuildTokenRequestData(TokenRequest request) } // Add custom parameters - foreach (var kvp in request.CustomParameters) + foreach (KeyValuePair kvp in request.CustomParameters) { data[kvp.Key] = kvp.Value; } @@ -409,7 +412,7 @@ private static TokenResult ParseErrorResponse(string responseContent) { try { - var errorResponse = JsonSerializer.Deserialize(responseContent); + TokenErrorResponse? errorResponse = JsonSerializer.Deserialize(responseContent); if (errorResponse != null) { return TokenResult.Failure( @@ -431,13 +434,13 @@ private static TokenResult ParseErrorResponse(string responseContent) /// /// The cancellation token. /// Token validation parameters. - private async Task GetValidationParametersAsync(CancellationToken cancellationToken) + private async Task GetValidationParametersAsync(CancellationToken cancellationToken) { // This is a simplified implementation // In a real-world scenario, you would fetch the signing keys from the JWKS endpoint await Task.CompletedTask; // Placeholder for async operations - return new Microsoft.IdentityModel.Tokens.TokenValidationParameters + return new TokenValidationParameters { ValidateIssuer = options.ValidateIssuer, ValidIssuer = options.Issuer, diff --git a/src/VisionaryCoder.Framework/Authentication/Providers/DefaultUserContextProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/DefaultUserContextProvider.cs similarity index 84% rename from src/VisionaryCoder.Framework/Authentication/Providers/DefaultUserContextProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/DefaultUserContextProvider.cs index b7f2802..0105f0f 100644 --- a/src/VisionaryCoder.Framework/Authentication/Providers/DefaultUserContextProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/DefaultUserContextProvider.cs @@ -1,9 +1,12 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; using System.Security.Claims; -namespace VisionaryCoder.Framework.Authentication.Providers; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Providers; /// /// Default implementation of that extracts user information from HTTP context. @@ -90,15 +93,15 @@ protected UserContext GetCurrentUser() { try { - var httpContext = httpContextAccessor.HttpContext; + HttpContext? httpContext = httpContextAccessor.HttpContext; if (httpContext?.User?.Identity?.IsAuthenticated != true) { logger.LogDebug("No authenticated user found in HTTP context"); return CreateAnonymousUser(); } - var principal = httpContext.User; - var userContext = ExtractUserContextFromPrincipal(principal); + ClaimsPrincipal principal = httpContext.User; + UserContext userContext = ExtractUserContextFromPrincipal(principal); // Add additional context from HTTP headers if available EnrichFromHttpHeaders(userContext, httpContext); @@ -125,7 +128,7 @@ protected UserContext GetCurrentUser() { try { - var userContext = GetCurrentUser(); + UserContext userContext = GetCurrentUser(); // Perform any additional async enrichment here await EnrichUserContextAsync(userContext, cancellationToken); @@ -157,11 +160,11 @@ public bool HasPermission(string permission) try { - var httpContext = httpContextAccessor.HttpContext; + HttpContext? httpContext = httpContextAccessor.HttpContext; if (httpContext?.User?.Identity?.IsAuthenticated != true) return false; - var principal = httpContext.User; + ClaimsPrincipal principal = httpContext.User; // Check for permission claim if (principal.HasClaim("permission", permission) || @@ -193,7 +196,7 @@ public bool IsInRole(string role) try { - var httpContext = httpContextAccessor.HttpContext; + HttpContext? httpContext = httpContextAccessor.HttpContext; if (httpContext?.User?.Identity?.IsAuthenticated != true) return false; @@ -213,27 +216,27 @@ public bool IsInRole(string role) /// A UserContext extracted from the principal's claims. protected virtual UserContext ExtractUserContextFromPrincipal(ClaimsPrincipal principal) { - var userId = GetClaimValue(principal, ClaimTypes.NameIdentifier) ?? - GetClaimValue(principal, "sub") ?? - GetClaimValue(principal, "user_id") ?? - string.Empty; + string userId = GetClaimValue(principal, ClaimTypes.NameIdentifier) ?? + GetClaimValue(principal, "sub") ?? + GetClaimValue(principal, "user_id") ?? + string.Empty; - var userName = GetClaimValue(principal, ClaimTypes.Name) ?? - GetClaimValue(principal, "name") ?? - GetClaimValue(principal, "preferred_username") ?? - string.Empty; + string userName = GetClaimValue(principal, ClaimTypes.Name) ?? + GetClaimValue(principal, "name") ?? + GetClaimValue(principal, "preferred_username") ?? + string.Empty; - var email = GetClaimValue(principal, ClaimTypes.Email) ?? - GetClaimValue(principal, "email") ?? - string.Empty; + string email = GetClaimValue(principal, ClaimTypes.Email) ?? + GetClaimValue(principal, "email") ?? + string.Empty; - var roles = GetClaimValues(principal, ClaimTypes.Role) + string[] roles = GetClaimValues(principal, ClaimTypes.Role) .Concat(GetClaimValues(principal, "role")) .Concat(GetClaimValues(principal, "roles")) .Distinct() .ToArray(); - var permissions = GetClaimValues(principal, "permission") + string[] permissions = GetClaimValues(principal, "permission") .Concat(GetClaimValues(principal, "permissions")) .Distinct() .ToArray(); @@ -241,19 +244,19 @@ protected virtual UserContext ExtractUserContextFromPrincipal(ClaimsPrincipal pr // Extract additional attributes var attributes = new Dictionary(); - var firstName = GetClaimValue(principal, ClaimTypes.GivenName) ?? GetClaimValue(principal, "given_name"); + string? firstName = GetClaimValue(principal, ClaimTypes.GivenName) ?? GetClaimValue(principal, "given_name"); if (!string.IsNullOrEmpty(firstName)) attributes["FirstName"] = firstName; - var lastName = GetClaimValue(principal, ClaimTypes.Surname) ?? GetClaimValue(principal, "family_name"); + string? lastName = GetClaimValue(principal, ClaimTypes.Surname) ?? GetClaimValue(principal, "family_name"); if (!string.IsNullOrEmpty(lastName)) attributes["LastName"] = lastName; - var tenantId = GetClaimValue(principal, "tenant_id") ?? GetClaimValue(principal, "tid"); + string? tenantId = GetClaimValue(principal, "tenant_id") ?? GetClaimValue(principal, "tid"); if (!string.IsNullOrEmpty(tenantId)) attributes["TenantId"] = tenantId; - var correlationId = GetClaimValue(principal, "correlation_id") ?? GetClaimValue(principal, "cid"); + string? correlationId = GetClaimValue(principal, "correlation_id") ?? GetClaimValue(principal, "cid"); if (!string.IsNullOrEmpty(correlationId)) attributes["CorrelationId"] = correlationId; @@ -282,22 +285,22 @@ protected virtual UserContext ExtractUserContextFromPrincipal(ClaimsPrincipal pr /// The HTTP context. protected virtual void EnrichFromHttpHeaders(UserContext userContext, HttpContext httpContext) { - var headers = httpContext.Request.Headers; + IHeaderDictionary headers = httpContext.Request.Headers; // Add correlation ID from header if not already present if (!userContext.Claims.ContainsKey("CorrelationId") && - headers.TryGetValue("X-Correlation-ID", out var correlationId)) + headers.TryGetValue("X-Correlation-ID", out StringValues correlationId)) { userContext.Claims["CorrelationId"] = correlationId.ToString(); } // Add client information - if (headers.TryGetValue("User-Agent", out var userAgent)) + if (headers.TryGetValue("User-Agent", out StringValues userAgent)) { userContext.Claims["UserAgent"] = userAgent.ToString(); } - if (headers.TryGetValue("X-Forwarded-For", out var forwardedFor)) + if (headers.TryGetValue("X-Forwarded-For", out StringValues forwardedFor)) { userContext.Claims["ClientIP"] = forwardedFor.ToString(); } @@ -307,12 +310,12 @@ protected virtual void EnrichFromHttpHeaders(UserContext userContext, HttpContex } // Add custom headers that might contain user context - if (headers.TryGetValue("X-User-Timezone", out var timezone)) + if (headers.TryGetValue("X-User-Timezone", out StringValues timezone)) { userContext.Claims["Timezone"] = timezone.ToString(); } - if (headers.TryGetValue("X-User-Locale", out var locale)) + if (headers.TryGetValue("X-User-Locale", out StringValues locale)) { userContext.Claims["Locale"] = locale.ToString(); } @@ -345,9 +348,9 @@ protected virtual bool CheckRoleBasedPermissions(ClaimsPrincipal principal, stri // In a real implementation, this would likely come from a database or configuration var rolePermissionMap = new Dictionary { - ["Admin"] = new[] { "read", "write", "delete", "manage" }, - ["Editor"] = new[] { "read", "write" }, - ["Viewer"] = new[] { "read" } + ["Admin"] = ["read", "write", "delete", "manage"], + ["Editor"] = ["read", "write"], + ["Viewer"] = ["read"] }; var userRoles = GetClaimValues(principal, ClaimTypes.Role) diff --git a/src/VisionaryCoder.Framework/Authentication/Providers/ITenantContextProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/ITenantContextProvider.cs similarity index 78% rename from src/VisionaryCoder.Framework/Authentication/Providers/ITenantContextProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/ITenantContextProvider.cs index f7269e3..8bfd7fc 100644 --- a/src/VisionaryCoder.Framework/Authentication/Providers/ITenantContextProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/ITenantContextProvider.cs @@ -1,7 +1,21 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -namespace VisionaryCoder.Framework.Authentication.Providers; + +// Copyright (c) 2025 VisionaryCoder. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for license information. + + +// Copyright (c) 2025 VisionaryCoder. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for license information. + + +// Copyright (c) 2025 VisionaryCoder. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for license information. + +using VisionaryCoder.Framework.Proxy.Interceptors.Authentication; + +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Providers; /// /// Defines a contract for providing tenant context information in multi-tenant scenarios. diff --git a/src/VisionaryCoder.Framework/Authentication/Providers/IUserContextProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/IUserContextProvider.cs similarity index 72% rename from src/VisionaryCoder.Framework/Authentication/Providers/IUserContextProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/IUserContextProvider.cs index 29e5fab..f4577bf 100644 --- a/src/VisionaryCoder.Framework/Authentication/Providers/IUserContextProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/IUserContextProvider.cs @@ -1,7 +1,21 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -namespace VisionaryCoder.Framework.Authentication.Providers; + +// Copyright (c) 2025 VisionaryCoder. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for license information. + + +// Copyright (c) 2025 VisionaryCoder. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for license information. + + +// Copyright (c) 2025 VisionaryCoder. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for license information. + +using VisionaryCoder.Framework.Proxy.Interceptors.Authentication; + +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Providers; /// /// Defines a contract for providing authenticated user context information. diff --git a/src/VisionaryCoder.Framework/Authentication/Providers/NullTenantContextProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/NullTenantContextProvider.cs similarity index 94% rename from src/VisionaryCoder.Framework/Authentication/Providers/NullTenantContextProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/NullTenantContextProvider.cs index b90220d..9ea3788 100644 --- a/src/VisionaryCoder.Framework/Authentication/Providers/NullTenantContextProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/NullTenantContextProvider.cs @@ -1,7 +1,9 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -namespace VisionaryCoder.Framework.Authentication.Providers; +using VisionaryCoder.Framework.Proxy.Interceptors.Authentication; + +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Providers; /// /// Null Object pattern implementation of that returns no tenant context. diff --git a/src/VisionaryCoder.Framework/Authentication/Providers/NullTokenProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/NullTokenProvider.cs similarity index 85% rename from src/VisionaryCoder.Framework/Authentication/Providers/NullTokenProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/NullTokenProvider.cs index 2992dac..8ca157f 100644 --- a/src/VisionaryCoder.Framework/Authentication/Providers/NullTokenProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/NullTokenProvider.cs @@ -1,9 +1,21 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Authentication.Jwt; -namespace VisionaryCoder.Framework.Authentication.Providers; +// Copyright (c) 2025 VisionaryCoder. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for license information. + + +// Copyright (c) 2025 VisionaryCoder. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for license information. + + +// Copyright (c) 2025 VisionaryCoder. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for license information. + +using VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Jwt; + +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Providers; /// /// Null Object pattern implementation of that provides no token functionality. diff --git a/src/VisionaryCoder.Framework/Authentication/Providers/NullUserContextProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/NullUserContextProvider.cs similarity index 93% rename from src/VisionaryCoder.Framework/Authentication/Providers/NullUserContextProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/NullUserContextProvider.cs index e6d32bf..d5fc6f3 100644 --- a/src/VisionaryCoder.Framework/Authentication/Providers/NullUserContextProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/Providers/NullUserContextProvider.cs @@ -1,7 +1,9 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -namespace VisionaryCoder.Framework.Authentication.Providers; +using VisionaryCoder.Framework.Proxy.Interceptors.Authentication; + +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Providers; /// /// Null Object pattern implementation of that returns no user context. diff --git a/src/VisionaryCoder.Framework/Authentication/TenantContext.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/TenantContext.cs similarity index 87% rename from src/VisionaryCoder.Framework/Authentication/TenantContext.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/TenantContext.cs index 3581054..490f76b 100644 --- a/src/VisionaryCoder.Framework/Authentication/TenantContext.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/TenantContext.cs @@ -1,7 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -namespace VisionaryCoder.Framework.Authentication; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authentication; /// /// Represents tenant context information for multi-tenant applications. @@ -65,7 +65,7 @@ public class TenantContext /// /// The feature name to check. /// True if the tenant has the specified feature enabled. - public bool HasFeature(string featureName) => + public bool HasFeature(string featureName) => EnabledFeatures.Any(f => f.Equals(featureName, StringComparison.OrdinalIgnoreCase)); /// @@ -73,8 +73,8 @@ public bool HasFeature(string featureName) => /// /// The resource name to check. /// The resource limit, or null if not specified. - public int? GetResourceLimit(string resourceName) => - ResourceLimits.TryGetValue(resourceName, out var limit) ? limit : null; + public int? GetResourceLimit(string resourceName) => + ResourceLimits.TryGetValue(resourceName, out int limit) ? limit : null; /// /// Gets a tenant setting value. @@ -82,6 +82,6 @@ public bool HasFeature(string featureName) => /// The type of the setting value. /// The setting name. /// The setting value, or default if not found. - public T? GetSetting(string settingName) => - Settings.TryGetValue(settingName, out var value) && value is T typedValue ? typedValue : default; -} \ No newline at end of file + public T? GetSetting(string settingName) => + Settings.TryGetValue(settingName, out object? value) && value is T typedValue ? typedValue : default; +} diff --git a/src/VisionaryCoder.Framework/Authentication/UserContext.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/UserContext.cs similarity index 98% rename from src/VisionaryCoder.Framework/Authentication/UserContext.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/UserContext.cs index 6d55517..a8eb776 100644 --- a/src/VisionaryCoder.Framework/Authentication/UserContext.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authentication/UserContext.cs @@ -1,7 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -namespace VisionaryCoder.Framework.Authentication; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authentication; /// /// Represents authenticated user context information including identity, roles, and permissions. diff --git a/src/VisionaryCoder.Framework/Authorization/AuthorizationServiceCollectionExtensions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/AuthorizationExtensions.cs similarity index 94% rename from src/VisionaryCoder.Framework/Authorization/AuthorizationServiceCollectionExtensions.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/AuthorizationExtensions.cs index 8aad248..9bc56f9 100644 --- a/src/VisionaryCoder.Framework/Authorization/AuthorizationServiceCollectionExtensions.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/AuthorizationExtensions.cs @@ -1,16 +1,20 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using VisionaryCoder.Framework.Authorization.Policies; +using VisionaryCoder.Framework.Proxy.Authorization.Policies; +using VisionaryCoder.Framework.Proxy.Interceptors.Authorization.Policies; -namespace VisionaryCoder.Framework.Authorization; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authorization; /// /// Extension methods for adding comprehensive authorization services to the dependency injection container. /// Provides fluent configuration for authorization policies following SOLID principles. /// Supports explicit policy registration with null object fallbacks for safe operation. /// -public static class AuthorizationServiceCollectionExtensions +public static class AuthorizationExtensions { /// /// Adds authorization infrastructure with null object fallbacks (SOLID principle). @@ -85,7 +89,7 @@ public static IServiceCollection AddAuthorizationPolicy(this IServiceCollection ArgumentNullException.ThrowIfNull(policy); // Add specific policy instance - services.AddSingleton(policy); + services.AddSingleton(policy); return services; } diff --git a/src/VisionaryCoder.Framework/Authorization/Policies/IAuthorizationPolicy.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/Policies/IAuthorizationPolicy.cs similarity index 72% rename from src/VisionaryCoder.Framework/Authorization/Policies/IAuthorizationPolicy.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/Policies/IAuthorizationPolicy.cs index 29929ee..4e14c83 100644 --- a/src/VisionaryCoder.Framework/Authorization/Policies/IAuthorizationPolicy.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/Policies/IAuthorizationPolicy.cs @@ -1,9 +1,21 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Authorization.Results; -namespace VisionaryCoder.Framework.Authorization.Policies; +// Copyright (c) 2025 VisionaryCoder. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for license information. + + +// Copyright (c) 2025 VisionaryCoder. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for license information. + + +// Copyright (c) 2025 VisionaryCoder. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for license information. + +using VisionaryCoder.Framework.Proxy.Interceptors.Authorization.Results; + +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authorization.Policies; /// /// Defines a contract for authorization policies that determine access permissions. diff --git a/src/VisionaryCoder.Framework/Authorization/Policies/NullAuthorizationPolicy.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/Policies/NullAuthorizationPolicy.cs similarity index 93% rename from src/VisionaryCoder.Framework/Authorization/Policies/NullAuthorizationPolicy.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/Policies/NullAuthorizationPolicy.cs index e1cc954..f5c6dcd 100644 --- a/src/VisionaryCoder.Framework/Authorization/Policies/NullAuthorizationPolicy.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/Policies/NullAuthorizationPolicy.cs @@ -1,9 +1,9 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Authorization.Results; +using VisionaryCoder.Framework.Proxy.Interceptors.Authorization.Results; -namespace VisionaryCoder.Framework.Authorization.Policies; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authorization.Policies; /// /// Null Object implementation of that provides safe fallback behavior. diff --git a/src/VisionaryCoder.Framework/Authorization/Policies/RoleBasedAuthorizationPolicy.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/Policies/RoleBasedAuthorizationPolicy.cs similarity index 94% rename from src/VisionaryCoder.Framework/Authorization/Policies/RoleBasedAuthorizationPolicy.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/Policies/RoleBasedAuthorizationPolicy.cs index 8ec1c12..268e444 100644 --- a/src/VisionaryCoder.Framework/Authorization/Policies/RoleBasedAuthorizationPolicy.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/Policies/RoleBasedAuthorizationPolicy.cs @@ -1,10 +1,9 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Authorization.Results; -using VisionaryCoder.Framework.Proxy; +using VisionaryCoder.Framework.Proxy.Interceptors.Authorization.Results; -namespace VisionaryCoder.Framework.Authorization.Policies; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authorization.Policies; /// /// Role-based authorization policy that validates user access based on required roles. @@ -58,8 +57,8 @@ public Task EvaluateAsync(object context, CancellationToken return Task.FromResult(AuthorizationResult.Failure("Invalid authorization context type")); } - var evaluation = EvaluateRoles(proxyContext); - + RoleEvaluationResult evaluation = EvaluateRoles(proxyContext); + if (evaluation.HasRequiredRole) { var result = AuthorizationResult.Success(); @@ -83,7 +82,7 @@ public Task EvaluateAsync(object context, CancellationToken /// Role evaluation result with success/failure information. private RoleEvaluationResult EvaluateRoles(ProxyContext context) { - if (!context.Metadata.TryGetValue("Roles", out object? rolesObj) || + if (!context.Metadata.TryGetValue("Roles", out object? rolesObj) || rolesObj is not ICollection userRoles) { return new RoleEvaluationResult @@ -94,14 +93,14 @@ private RoleEvaluationResult EvaluateRoles(ProxyContext context) }; } - bool hasRequiredRole = requiredRoles.Any(requiredRole => + bool hasRequiredRole = requiredRoles.Any(requiredRole => userRoles.Contains(requiredRole, StringComparer.OrdinalIgnoreCase)); return new RoleEvaluationResult { HasRequiredRole = hasRequiredRole, UserRoles = userRoles, - FailureReason = hasRequiredRole ? null : + FailureReason = hasRequiredRole ? null : $"User roles [{string.Join(", ", userRoles)}] do not include any of required roles [{string.Join(", ", requiredRoles)}]" }; } @@ -115,4 +114,4 @@ private class RoleEvaluationResult public ICollection UserRoles { get; set; } = Array.Empty(); public string? FailureReason { get; set; } } -} \ No newline at end of file +} diff --git a/src/VisionaryCoder.Framework/Authorization/Results/AuthorizationResult.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/Results/AuthorizationResult.cs similarity index 97% rename from src/VisionaryCoder.Framework/Authorization/Results/AuthorizationResult.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/Results/AuthorizationResult.cs index 37cf255..c191702 100644 --- a/src/VisionaryCoder.Framework/Authorization/Results/AuthorizationResult.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/Results/AuthorizationResult.cs @@ -1,7 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -namespace VisionaryCoder.Framework.Authorization.Results; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Authorization.Results; /// /// Represents the result of an authorization check with comprehensive context and failure information. @@ -63,4 +63,4 @@ public static AuthorizationResult Failure(string reason) FailureReason = reason }; } -} \ No newline at end of file +} diff --git a/src/VisionaryCoder.Framework/Caching/CachePolicy.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachePolicy.cs similarity index 94% rename from src/VisionaryCoder.Framework/Caching/CachePolicy.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachePolicy.cs index cce0857..ceee55a 100644 --- a/src/VisionaryCoder.Framework/Caching/CachePolicy.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachePolicy.cs @@ -1,7 +1,9 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -namespace VisionaryCoder.Framework.Caching; +using Microsoft.Extensions.Caching.Memory; + +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching; /// /// Represents a caching policy that defines how items should be cached, including duration, priority, and conditions. diff --git a/src/VisionaryCoder.Framework/Caching/CachingServiceCollectionExtensions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingExtensions.cs similarity index 90% rename from src/VisionaryCoder.Framework/Caching/CachingServiceCollectionExtensions.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingExtensions.cs index 0ae1636..6b8b042 100644 --- a/src/VisionaryCoder.Framework/Caching/CachingServiceCollectionExtensions.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingExtensions.cs @@ -1,17 +1,17 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Caching.Interceptors; -using VisionaryCoder.Framework.Caching.Providers; -using VisionaryCoder.Framework.Proxy; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers; -namespace VisionaryCoder.Framework.Caching; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching; /// /// Extension methods for adding comprehensive caching services to the dependency injection container. /// Provides fluent configuration for caching interceptors, providers, and policies. /// -public static class CachingServiceCollectionExtensions +public static class CachingExtensions { /// /// Adds caching infrastructure with null object fallbacks (SOLID principle). @@ -40,7 +40,7 @@ public static IServiceCollection AddCaching( services.TryAddSingleton(); // Register the caching interceptor - services.TryAddSingleton(); + services.TryAddSingleton(); return services; } @@ -64,10 +64,10 @@ public static IServiceCollection AddCaching( services.Configure(configure); } - services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(); return services; } @@ -96,7 +96,7 @@ public static IServiceCollection AddCaching( services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(); return services; } @@ -217,8 +217,8 @@ public static IServiceCollection UseDefaultCachingProviders(this IServiceCollect { ArgumentNullException.ThrowIfNull(services); - services.ReplaceCacheKeyProvider(); - services.ReplaceCachePolicyProvider(); + services.ReplaceCacheKeyProvider(); + services.ReplaceCachePolicyProvider(); services.ReplaceProxyCache(); return services; diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingInterceptor.cs index b2a4218..23b5a78 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingInterceptor.cs @@ -1,7 +1,8 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Proxy.Caching; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching; /// @@ -122,15 +123,15 @@ private TimeSpan GetCacheDuration(ProxyContext context) private static bool IsRelevantForCaching(string metadataKey) { // Exclude non-relevant keys from cache key generation - string[] excludeKeys = new[] - { + string[] excludeKeys = + [ "CorrelationId", "ExecutionTimeMs", "RetryAttempts", "CircuitBreakerState", "CacheHit", "Authorization" // Sensitive data - }; + ]; return !excludeKeys.Contains(metadataKey, StringComparer.OrdinalIgnoreCase); } diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingInterceptorServiceCollectionExtensions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingInterceptorExtensions.cs similarity index 90% rename from src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingInterceptorServiceCollectionExtensions.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingInterceptorExtensions.cs index b979db6..59a54bb 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingInterceptorServiceCollectionExtensions.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingInterceptorExtensions.cs @@ -1,10 +1,13 @@ -using VisionaryCoder.Framework.Proxy.Caching; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching; /// /// Extension methods for adding caching interceptor services. /// -public static class CachingInterceptorServiceCollectionExtensions +public static class CachingInterceptorExtensions { /// /// Adds the caching interceptor to the service collection with default options. diff --git a/src/VisionaryCoder.Framework/Caching/CachingOptions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingOptions.cs similarity index 96% rename from src/VisionaryCoder.Framework/Caching/CachingOptions.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingOptions.cs index 0867d97..23e2b9c 100644 --- a/src/VisionaryCoder.Framework/Caching/CachingOptions.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingOptions.cs @@ -1,9 +1,9 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Proxy; +using Microsoft.Extensions.Caching.Memory; -namespace VisionaryCoder.Framework.Caching; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching; /// /// Configuration options for caching behavior including default policies and operation-specific overrides. diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/DefaultCacheKeyProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/DefaultCacheKeyProvider.cs index 0b644cf..f22cb1d 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/DefaultCacheKeyProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/DefaultCacheKeyProvider.cs @@ -1,5 +1,3 @@ -using VisionaryCoder.Framework.Proxy.Caching; - namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching; /// diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/DefaultCachePolicyProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/DefaultCachePolicyProvider.cs index ce341e7..96f2aae 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/DefaultCachePolicyProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/DefaultCachePolicyProvider.cs @@ -1,5 +1,3 @@ -using VisionaryCoder.Framework.Proxy.Caching; - namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching; /// diff --git a/src/VisionaryCoder.Framework/Proxy/Caching/ICacheKeyProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/ICacheKeyProvider.cs similarity index 85% rename from src/VisionaryCoder.Framework/Proxy/Caching/ICacheKeyProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/ICacheKeyProvider.cs index a292fab..e52b523 100644 --- a/src/VisionaryCoder.Framework/Proxy/Caching/ICacheKeyProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/ICacheKeyProvider.cs @@ -1,4 +1,4 @@ -namespace VisionaryCoder.Framework.Proxy.Caching; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching; /// /// Defines a contract for generating cache keys. diff --git a/src/VisionaryCoder.Framework/Proxy/Caching/ICachePolicyProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/ICachePolicyProvider.cs similarity index 90% rename from src/VisionaryCoder.Framework/Proxy/Caching/ICachePolicyProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/ICachePolicyProvider.cs index 6ee40ed..5d4323f 100644 --- a/src/VisionaryCoder.Framework/Proxy/Caching/ICachePolicyProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/ICachePolicyProvider.cs @@ -1,4 +1,4 @@ -namespace VisionaryCoder.Framework.Proxy.Caching; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching; /// /// Defines a contract for cache policy providers. diff --git a/src/VisionaryCoder.Framework/Proxy/Caching/ICachingInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/ICachingInterceptor.cs similarity index 84% rename from src/VisionaryCoder.Framework/Proxy/Caching/ICachingInterceptor.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/ICachingInterceptor.cs index c80ce6d..1386d08 100644 --- a/src/VisionaryCoder.Framework/Proxy/Caching/ICachingInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/ICachingInterceptor.cs @@ -1,6 +1,4 @@ -using VisionaryCoder.Framework.Proxy.Interceptors; - -namespace VisionaryCoder.Framework.Proxy.Caching; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching; /// Interface for caching interceptors. public interface ICachingInterceptor : IInterceptor diff --git a/src/VisionaryCoder.Framework/Caching/IProxyCache.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/IProxyCache.cs similarity index 92% rename from src/VisionaryCoder.Framework/Caching/IProxyCache.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/IProxyCache.cs index cd6a6f5..56a97e3 100644 --- a/src/VisionaryCoder.Framework/Caching/IProxyCache.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/IProxyCache.cs @@ -1,9 +1,11 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Proxy; -namespace VisionaryCoder.Framework.Caching; +// Copyright (c) 2025 VisionaryCoder. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for license information. + +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching; /// /// Defines a contract for proxy caching operations that store and retrieve proxy responses. diff --git a/src/VisionaryCoder.Framework/Caching/Interceptors/CachingInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Interceptors/CachingInterceptor.cs similarity index 83% rename from src/VisionaryCoder.Framework/Caching/Interceptors/CachingInterceptor.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Interceptors/CachingInterceptor.cs index fb90c08..7f61d7b 100644 --- a/src/VisionaryCoder.Framework/Caching/Interceptors/CachingInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Interceptors/CachingInterceptor.cs @@ -1,20 +1,16 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Caching.Providers; -using VisionaryCoder.Framework.Proxy; +using Microsoft.Extensions.Logging; -namespace VisionaryCoder.Framework.Caching.Interceptors; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching.Interceptors; /// /// Interceptor that provides intelligent caching for proxy operations to improve performance. /// Uses configurable cache policies and providers for flexible caching strategies. /// -public sealed class CachingInterceptor( - ILogger logger, - IProxyCache proxyCache, - ICacheKeyProvider keyProvider, - ICachePolicyProvider policyProvider) : IOrderedProxyInterceptor +public sealed class CachingInterceptor(ILogger logger, IProxyCache proxyCache, ICacheKeyProvider keyProvider, ICachePolicyProvider policyProvider) + : IOrderedProxyInterceptor { /// public int Order => 50; // Caching typically runs in the middle of the pipeline @@ -35,8 +31,8 @@ public sealed class CachingInterceptor( /// A task representing the async operation with the response. public async Task> InvokeAsync(ProxyContext context, ProxyDelegate next, CancellationToken cancellationToken = default) { - var operationName = context.OperationName ?? "Unknown"; - var correlationId = context.CorrelationId ?? "None"; + string operationName = context.OperationName ?? "Unknown"; + string correlationId = context.CorrelationId ?? "None"; // Check if caching is explicitly disabled for this request if (IsCachingDisabled(context)) @@ -47,7 +43,7 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe } // Get cache policy to determine if we should cache this operation - var cachePolicy = policyProvider.GetPolicy(context); + CachePolicy cachePolicy = policyProvider.GetPolicy(context); if (!cachePolicy.IsCachingEnabled || !policyProvider.ShouldCache(context)) { logger.LogDebug("Caching policy disabled for operation '{OperationName}'. Correlation ID: '{CorrelationId}'", @@ -56,7 +52,7 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe } // Generate and try to retrieve from cache - var cacheKey = keyProvider.GenerateKey(context); + string? cacheKey = keyProvider.GenerateKey(context); if (cacheKey == null) { logger.LogDebug("No cache key generated for operation '{OperationName}', bypassing cache. Correlation ID: '{CorrelationId}'", @@ -64,7 +60,7 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe return await next(context, cancellationToken); } - var cachedResponse = await proxyCache.GetAsync(cacheKey, cancellationToken); + ProxyResponse? cachedResponse = await proxyCache.GetAsync(cacheKey, cancellationToken); if (cachedResponse != null) { @@ -81,12 +77,12 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe logger.LogDebug("Cache miss for operation '{OperationName}' with key '{CacheKey}'. Correlation ID: '{CorrelationId}'", operationName, cacheKey, correlationId); - var response = await next(context, cancellationToken); + ProxyResponse response = await next(context, cancellationToken); // Cache successful responses based on policy if (ShouldCacheResponse(response, cachePolicy)) { - var expiration = policyProvider.GetExpiration(context) ?? cachePolicy.Duration; + TimeSpan expiration = policyProvider.GetExpiration(context) ?? cachePolicy.Duration; await proxyCache.SetAsync(cacheKey, response, expiration, cancellationToken); @@ -108,16 +104,16 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe private static bool IsCachingDisabled(ProxyContext context) { // Check for explicit cache disable flag - if (context.Metadata.TryGetValue("DisableCache", out var disableCache) && + if (context.Metadata.TryGetValue("DisableCache", out object? disableCache) && disableCache is bool disabled && disabled) { return true; } // Check for cache-control headers that indicate no-cache - if (context.Headers.TryGetValue("Cache-Control", out var cacheControl)) + if (context.Headers.TryGetValue("Cache-Control", out string? cacheControl)) { - var cacheControlValue = cacheControl?.ToString()?.ToLowerInvariant(); + string? cacheControlValue = cacheControl?.ToString()?.ToLowerInvariant(); if (cacheControlValue?.Contains("no-cache") == true || cacheControlValue?.Contains("no-store") == true) { diff --git a/src/VisionaryCoder.Framework/Caching/MemoryProxyCache.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/MemoryProxyCache.cs similarity index 94% rename from src/VisionaryCoder.Framework/Caching/MemoryProxyCache.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/MemoryProxyCache.cs index f02846d..4def54a 100644 --- a/src/VisionaryCoder.Framework/Caching/MemoryProxyCache.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/MemoryProxyCache.cs @@ -1,9 +1,11 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Proxy; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; -namespace VisionaryCoder.Framework.Caching; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching; /// /// In-memory implementation of using . @@ -29,7 +31,7 @@ public sealed class MemoryProxyCache(IMemoryCache cache, ILogger typedResponse) + if (cache.TryGetValue(key, out object? cachedValue) && cachedValue is ProxyResponse typedResponse) { logger?.LogDebug("Cache hit for key: {CacheKey}", key); return Task.FromResult?>(typedResponse); @@ -145,7 +147,7 @@ public Task ExistsAsync(string key, CancellationToken cancellationToken = try { - var exists = cache.TryGetValue(key, out _); + bool exists = cache.TryGetValue(key, out _); return Task.FromResult(exists); } catch (Exception ex) diff --git a/src/VisionaryCoder.Framework/Proxy/Caching/NullCachingInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/NullCachingInterceptor.cs similarity index 90% rename from src/VisionaryCoder.Framework/Proxy/Caching/NullCachingInterceptor.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/NullCachingInterceptor.cs index 464fd0a..c2dee49 100644 --- a/src/VisionaryCoder.Framework/Proxy/Caching/NullCachingInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/NullCachingInterceptor.cs @@ -1,7 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -namespace VisionaryCoder.Framework.Proxy.Caching; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching; /// Null object pattern implementation of caching interceptor that performs no operations. public sealed class NullCachingInterceptor : IOrderedProxyInterceptor diff --git a/src/VisionaryCoder.Framework/Caching/Providers/DefaultCacheKeyProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/DefaultCacheKeyProvider.cs similarity index 94% rename from src/VisionaryCoder.Framework/Caching/Providers/DefaultCacheKeyProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/DefaultCacheKeyProvider.cs index 7db7ef3..808abed 100644 --- a/src/VisionaryCoder.Framework/Caching/Providers/DefaultCacheKeyProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/DefaultCacheKeyProvider.cs @@ -3,9 +3,8 @@ using System.Security.Cryptography; using System.Text; -using VisionaryCoder.Framework.Proxy; -namespace VisionaryCoder.Framework.Caching.Providers; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers; /// /// Default implementation of that generates cache keys @@ -74,7 +73,7 @@ private static void AddRelevantHeaders(ProxyContext context, List keyCom { if (context.Headers.Count > 0) { - var headerString = string.Join(";", context.Headers + string headerString = string.Join(";", context.Headers .Where(h => IsRelevantHeader(h.Key)) .OrderBy(h => h.Key, StringComparer.OrdinalIgnoreCase) .Select(h => $"{h.Key}={h.Value}")); @@ -104,7 +103,7 @@ private static bool IsRelevantHeader(string headerName) private static string HashKey(string combinedKey) { using var sha256 = SHA256.Create(); - var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(combinedKey)); + byte[] hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(combinedKey)); return Convert.ToBase64String(hashBytes); } } diff --git a/src/VisionaryCoder.Framework/Caching/Providers/DefaultCachePolicyProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/DefaultCachePolicyProvider.cs similarity index 94% rename from src/VisionaryCoder.Framework/Caching/Providers/DefaultCachePolicyProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/DefaultCachePolicyProvider.cs index 2126f6e..445c2e6 100644 --- a/src/VisionaryCoder.Framework/Caching/Providers/DefaultCachePolicyProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/DefaultCachePolicyProvider.cs @@ -1,9 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Proxy; - -namespace VisionaryCoder.Framework.Caching.Providers; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers; /// /// Default implementation of that provides caching policies @@ -31,7 +29,7 @@ public CachePolicy GetPolicy(ProxyContext context) } // Return operation-specific policy if configured - if (options.OperationPolicies.TryGetValue(context.OperationName ?? string.Empty, out var policy)) + if (options.OperationPolicies.TryGetValue(context.OperationName ?? string.Empty, out CachePolicy? policy)) { return policy; } @@ -56,7 +54,7 @@ public bool ShouldCache(ProxyContext context) } // Check operation-specific policy - if (options.OperationPolicies.TryGetValue(context.OperationName ?? string.Empty, out var policy)) + if (options.OperationPolicies.TryGetValue(context.OperationName ?? string.Empty, out CachePolicy? policy)) { return policy.IsCachingEnabled; } @@ -80,7 +78,7 @@ public bool ShouldCache(ProxyContext context) } // Return operation-specific duration - if (options.OperationPolicies.TryGetValue(context.OperationName ?? string.Empty, out var policy)) + if (options.OperationPolicies.TryGetValue(context.OperationName ?? string.Empty, out CachePolicy? policy)) { return policy.Duration; } diff --git a/src/VisionaryCoder.Framework/Caching/Providers/ICacheKeyProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/ICacheKeyProvider.cs similarity index 77% rename from src/VisionaryCoder.Framework/Caching/Providers/ICacheKeyProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/ICacheKeyProvider.cs index 9ab2e8a..7e8beda 100644 --- a/src/VisionaryCoder.Framework/Caching/Providers/ICacheKeyProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/ICacheKeyProvider.cs @@ -1,9 +1,11 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Proxy; -namespace VisionaryCoder.Framework.Caching.Providers; +// Copyright (c) 2025 VisionaryCoder. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for license information. + +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers; /// /// Defines a contract for generating cache keys based on proxy context. diff --git a/src/VisionaryCoder.Framework/Caching/Providers/ICachePolicyProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/ICachePolicyProvider.cs similarity index 93% rename from src/VisionaryCoder.Framework/Caching/Providers/ICachePolicyProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/ICachePolicyProvider.cs index 500c75d..f1c913c 100644 --- a/src/VisionaryCoder.Framework/Caching/Providers/ICachePolicyProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/ICachePolicyProvider.cs @@ -1,9 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Proxy; - -namespace VisionaryCoder.Framework.Caching.Providers; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers; /// /// Defines a contract for cache policy providers that determine caching behavior. diff --git a/src/VisionaryCoder.Framework/Caching/Providers/NullCacheKeyProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullCacheKeyProvider.cs similarity index 94% rename from src/VisionaryCoder.Framework/Caching/Providers/NullCacheKeyProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullCacheKeyProvider.cs index 93a3fed..0d1c241 100644 --- a/src/VisionaryCoder.Framework/Caching/Providers/NullCacheKeyProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullCacheKeyProvider.cs @@ -1,9 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Proxy; - -namespace VisionaryCoder.Framework.Caching.Providers; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers; /// /// Null Object implementation of that provides safe fallback behavior. diff --git a/src/VisionaryCoder.Framework/Caching/Providers/NullCachePolicyProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullCachePolicyProvider.cs similarity index 95% rename from src/VisionaryCoder.Framework/Caching/Providers/NullCachePolicyProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullCachePolicyProvider.cs index fe72daf..d56e632 100644 --- a/src/VisionaryCoder.Framework/Caching/Providers/NullCachePolicyProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullCachePolicyProvider.cs @@ -1,9 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Proxy; - -namespace VisionaryCoder.Framework.Caching.Providers; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers; /// /// Null Object implementation of that provides safe fallback behavior. diff --git a/src/VisionaryCoder.Framework/Caching/Providers/NullProxyCache.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullProxyCache.cs similarity index 97% rename from src/VisionaryCoder.Framework/Caching/Providers/NullProxyCache.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullProxyCache.cs index 90f0cbe..438b3b9 100644 --- a/src/VisionaryCoder.Framework/Caching/Providers/NullProxyCache.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullProxyCache.cs @@ -1,9 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Proxy; - -namespace VisionaryCoder.Framework.Caching.Providers; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers; /// /// Null Object implementation of that provides safe fallback behavior. diff --git a/src/VisionaryCoder.Framework/Configuration/Azure/AzureAppConfigurationProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Azure/AzureConfigurationProvider.cs similarity index 51% rename from src/VisionaryCoder.Framework/Configuration/Azure/AzureAppConfigurationProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Azure/AzureConfigurationProvider.cs index 8baa2d3..ab7ef3b 100644 --- a/src/VisionaryCoder.Framework/Configuration/Azure/AzureAppConfigurationProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Azure/AzureConfigurationProvider.cs @@ -1,46 +1,36 @@ -using System.Collections.Concurrent; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using VisionaryCoder.Framework.Proxy.Interceptors.Configuration; -using VisionaryCoder.Framework.AppConfiguration; - -namespace VisionaryCoder.Framework.AppConfiguration.Azure; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Configuration.Azure; /// /// Provides Azure App Configuration-based configuration operations following Microsoft configuration patterns. /// This service wraps Azure App Configuration with logging, error handling, caching, and async support. /// Supports both connection string and managed identity authentication with automatic refresh capabilities. /// -public sealed class AzureAppConfigurationProvider : ServiceBase, IAppConfigurationProvider +public sealed class AzureConfigurationProvider + : ConfigurationProvider, IConfigurationProvider { - private readonly AzureAppConfigurationProviderOptions options; - private readonly IConfiguration configuration; - private readonly ConcurrentDictionary cache; - private readonly SemaphoreSlim refreshSemaphore; - private DateTimeOffset lastRefresh; - private bool isDisposed; - - public AzureAppConfigurationProvider( - AzureAppConfigurationProviderOptions options, - ILogger logger) - : base(logger) + + private readonly AzureConfigurationProviderOptions options; + + public AzureConfigurationProvider(AzureConfigurationProviderOptions options, ILogger logger) + : base(options, logger) { - ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(options); this.options = options; this.options.Validate(); - this.cache = new ConcurrentDictionary(); - this.refreshSemaphore = new SemaphoreSlim(1, 1); - this.lastRefresh = DateTimeOffset.MinValue; + configuration = BuildConfiguration(); - this.configuration = BuildConfiguration(); + Logger.LogInformation("Azure App Configuration provider initialized for endpoint {Endpoint} with label {Label}", options.Endpoint?.ToString() ?? "[Connection String]", options.Label); - Logger.LogInformation( - "Azure App Configuration provider initialized for endpoint {Endpoint} with label {Label}", - options.Endpoint?.ToString() ?? "[Connection String]", - options.Label); } - public string ProviderName => "Azure"; + public override string ProviderName => "Azure"; public bool IsAvailable { @@ -60,7 +50,7 @@ public bool IsAvailable } } - public T GetValue(string key, T defaultValue = default!) + public override T GetValue(string key, T defaultValue) { ArgumentException.ThrowIfNullOrWhiteSpace(key); @@ -69,10 +59,10 @@ public T GetValue(string key, T defaultValue = default!) string fullKey = GetFullKey(key); // Check cache first - if (cache.TryGetValue(fullKey, out object? cachedValue) && cachedValue is T typedValue) + if (options.EnableCaching && TryGetFromCache(fullKey, out T cachedValue)) { Logger.LogTrace("Configuration value retrieved from cache for key {Key}", key); - return typedValue; + return cachedValue; } // Get from Azure App Configuration @@ -83,10 +73,13 @@ public T GetValue(string key, T defaultValue = default!) return defaultValue; } - T result = AppConfigurationHelper.ConvertValue(stringValue, defaultValue); + T result = ConfigurationHelper.ConvertValue(stringValue, defaultValue); // Cache the result - cache.TryAdd(fullKey, result); + if (options.EnableCaching) + { + AddToCache(fullKey, result); + } Logger.LogTrace("Configuration value retrieved for key {Key}", key); return result; @@ -98,13 +91,7 @@ public T GetValue(string key, T defaultValue = default!) } } - public async Task GetValueAsync(string key, T defaultValue = default!, CancellationToken cancellationToken = default) - { - // Azure App Configuration is synchronous in nature, but we provide async wrapper for consistency - return await Task.FromResult(GetValue(key, defaultValue)); - } - - public T GetSection(string sectionName) where T : class, new() + public override T GetSection(string sectionName) { ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); @@ -130,118 +117,15 @@ public async Task GetValueAsync(string key, T defaultValue = default!, Can } } - public async Task GetSectionAsync(string sectionName, CancellationToken cancellationToken = default) where T : class, new() - { - return await Task.FromResult(GetSection(sectionName)); - } - - public IDictionary GetAllValues() - { - try - { - var result = new Dictionary(); - string keyPrefix = options.KeyPrefix ?? string.Empty; - - foreach (KeyValuePair kvp in configuration.AsEnumerable()) - { - if (string.IsNullOrEmpty(keyPrefix) || kvp.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase)) - { - string cleanKey = string.IsNullOrEmpty(keyPrefix) - ? kvp.Key - : kvp.Key[keyPrefix.Length..].TrimStart(':'); - - result[cleanKey] = kvp.Value; - } - } - - Logger.LogTrace("Retrieved {Count} configuration values", result.Count); - return result; - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to get all configuration values"); - return new Dictionary(); - } - } - - public async Task> GetAllValuesAsync(CancellationToken cancellationToken = default) - { - return await Task.FromResult(GetAllValues()); - } - - public bool SetValue(string key, T value) + public override bool SetValue(string key, T value) { Logger.LogWarning("SetValue operation not supported by Azure App Configuration provider. Use Azure portal or REST API to modify values."); throw new NotSupportedException("Azure App Configuration provider is read-only. Use Azure portal or REST API to modify configuration values."); } - public Task SetValueAsync(string key, T value, CancellationToken cancellationToken = default) - { - return Task.FromResult(SetValue(key, value)); - } - - public bool UpdateSection(string sectionName, T value) where T : class - { - Logger.LogWarning("UpdateSection operation not supported by Azure App Configuration provider. Use Azure portal or REST API to modify values."); - throw new NotSupportedException("Azure App Configuration provider is read-only. Use Azure portal or REST API to modify configuration values."); - } - - public Task UpdateSectionAsync(string sectionName, T value, CancellationToken cancellationToken = default) where T : class + public override async Task RefreshAsync(CancellationToken cancellationToken = default) { - return Task.FromResult(UpdateSection(sectionName, value)); - } - - public bool Refresh() - { - if (!options.EnableRefresh) - { - Logger.LogDebug("Configuration refresh is disabled"); - return true; - } - try - { - if (refreshSemaphore.Wait(TimeSpan.FromSeconds(5))) - { - try - { - // Check if we need to refresh based on cache expiration - if (DateTimeOffset.UtcNow - lastRefresh < options.CacheExpiration) - { - Logger.LogTrace("Configuration refresh skipped - cache is still valid"); - return true; - } - - // Clear cache to force refresh - cache.Clear(); - lastRefresh = DateTimeOffset.UtcNow; - - // Force refresh the configuration (this is handled by Azure App Configuration middleware) - // In a real implementation, you might need to rebuild the configuration or trigger refresh - - Logger.LogDebug("Configuration refreshed successfully"); - return true; - } - finally - { - refreshSemaphore.Release(); - } - } - else - { - Logger.LogWarning("Configuration refresh timeout - another refresh operation is in progress"); - return false; - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to refresh configuration"); - return false; - } - } - - public async Task RefreshAsync(CancellationToken cancellationToken = default) - { if (!options.EnableRefresh) { Logger.LogDebug("Configuration refresh is disabled"); @@ -262,7 +146,7 @@ public async Task RefreshAsync(CancellationToken cancellationToken = defau } // Clear cache to force refresh - cache.Clear(); + ClearCache(); lastRefresh = DateTimeOffset.UtcNow; Logger.LogDebug("Configuration refreshed successfully"); @@ -273,17 +157,15 @@ public async Task RefreshAsync(CancellationToken cancellationToken = defau refreshSemaphore.Release(); } } - else - { - Logger.LogWarning("Configuration refresh timeout - another refresh operation is in progress"); - return false; - } + Logger.LogWarning("Configuration refresh timeout - another refresh operation is in progress"); + return false; } catch (Exception ex) { Logger.LogError(ex, "Failed to refresh configuration"); return false; } + } private IConfiguration BuildConfiguration() @@ -318,12 +200,10 @@ private IConfiguration BuildConfiguration() { configOptions.ConfigureRefresh(refresh => { - refresh.Register(options.SentinelKey, options.Label) - .SetRefreshInterval(options.CacheExpiration); + refresh.Register(options.SentinelKey, options.Label).SetRefreshInterval(options.CacheExpiration); }); } }); - return builder.Build(); } catch (Exception ex) @@ -346,7 +226,7 @@ protected override void Dispose(bool disposing) if (!isDisposed && disposing) { refreshSemaphore?.Dispose(); - cache?.Clear(); + ClearCache(); isDisposed = true; } diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Azure/AzureConfigurationProviderOptions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Azure/AzureConfigurationProviderOptions.cs new file mode 100644 index 0000000..d96f50e --- /dev/null +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Azure/AzureConfigurationProviderOptions.cs @@ -0,0 +1,41 @@ +using VisionaryCoder.Framework.Proxy.Interceptors.Configuration; + +namespace VisionaryCoder.Framework.Proxy.Interceptors.Configuration.Azure; + +/// +/// Configuration options for Azure App Configuration provider. +/// +public sealed class AzureConfigurationProviderOptions + : ConfigurationProviderOptions +{ + /// + /// The endpoint URI for the Azure App Configuration service. + /// + /// https://your-config.azconfig.io + public Uri? Endpoint { get; init; } + + /// + /// The label to use for environment-specific configuration (e.g., "Development", "Testing", "Staging", "Production"). + /// + public string Label { get; init; } = "Production"; + + /// + /// The sentinel key used to trigger configuration refresh. + /// + public string SentinelKey { get; init; } = "App:Sentinel"; + + /// + /// Whether to use connection string authentication instead of managed identity. + /// + public bool UseConnectionString { get; init; } = false; + + /// + /// The connection string for Azure App Configuration (when UseConnectionString is true). + /// + public string? ConnectionString { get; init; } + + /// + /// Whether to enable automatic refresh of configuration values. + /// + public bool EnableRefresh { get; init; } = true; +} diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Azure/AzureConfigurationProviderOptionsExtensions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Azure/AzureConfigurationProviderOptionsExtensions.cs new file mode 100644 index 0000000..b129517 --- /dev/null +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Azure/AzureConfigurationProviderOptionsExtensions.cs @@ -0,0 +1,33 @@ +using VisionaryCoder.Framework.Proxy.Interceptors.Configuration; + +namespace VisionaryCoder.Framework.Proxy.Interceptors.Configuration.Azure; + +/// +/// Azure provider-specific validation extensions. +/// +public static class AzureConfigurationProviderOptionsExtensions +{ + public static void Validate(this AzureConfigurationProviderOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (options.UseConnectionString) + { + if (string.IsNullOrWhiteSpace(options.ConnectionString)) + throw new InvalidOperationException("ConnectionString must be provided when UseConnectionString is true."); + } + else if (options.Endpoint is null) + { + throw new InvalidOperationException("Endpoint must be provided when not using connection string authentication."); + } + + if (string.IsNullOrWhiteSpace(options.Label)) + throw new InvalidOperationException("Label cannot be null or empty."); + + if (string.IsNullOrWhiteSpace(options.SentinelKey)) + throw new InvalidOperationException("SentinelKey cannot be null or empty."); + + // Call shared validation + ((ConfigurationProviderOptions)options).Validate(); + } +} diff --git a/src/VisionaryCoder.Framework/Configuration/AppConfigurationServiceCollectionExtensions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationExtensions.cs similarity index 66% rename from src/VisionaryCoder.Framework/Configuration/AppConfigurationServiceCollectionExtensions.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationExtensions.cs index 78c2fb2..226c5bb 100644 --- a/src/VisionaryCoder.Framework/Configuration/AppConfigurationServiceCollectionExtensions.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationExtensions.cs @@ -1,12 +1,18 @@ -using VisionaryCoder.Framework.AppConfiguration; -using VisionaryCoder.Framework.AppConfiguration.Azure; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using VisionaryCoder.Framework.Proxy.Interceptors.Configuration; + +namespace VisionaryCoder.Framework.Proxy.Interceptors.Configuration; -namespace VisionaryCoder.Framework.AppConfiguration; /// /// Extension methods for configuring Azure App Configuration services. /// -public static class AppConfigurationServiceCollectionExtensions +public static class ConfigurationExtensions { + + public static string ConfigurationKey { get; set; } = "AzureAppConfiguration"; + /// /// Adds Azure App Configuration to the service collection with proper authentication and caching. /// @@ -14,30 +20,29 @@ public static class AppConfigurationServiceCollectionExtensions /// The configuration to read settings from. /// Optional configuration action for AppConfigurationOptions. /// The service collection for chaining. - public static IServiceCollection AddAzureAppConfiguration( - this IServiceCollection services, - IConfiguration configuration, - Action? configure = null) + public static IServiceCollection AddAzureAppConfiguration(this IServiceCollection services, IConfiguration configuration, Action? configure = null) { - AppConfigurationOptions options = configuration.GetSection("AzureAppConfiguration").Get() ?? new AppConfigurationOptions(); + ConfigurationOptions options = configuration.GetSection(ConfigurationKey).Get() ?? new ConfigurationOptions(); configure?.Invoke(options); services.AddSingleton(options); return services; } + /// Adds Azure App Configuration to the configuration builder with proper authentication and refresh settings. /// The configuration builder to configure. /// The App Configuration options. /// The configuration builder for chaining. - public static IConfigurationBuilder AddAzureAppConfiguration( - this IConfigurationBuilder builder, - AppConfigurationOptions options) + public static IConfigurationBuilder AddAzureAppConfiguration(this IConfigurationBuilder builder, ConfigurationOptions options) { + + if (options.Endpoint is null) { // Skip if no endpoint configured return builder; } + return builder.AddAzureAppConfiguration(configOptions => { // Use managed identity by default, connection string if specified @@ -55,13 +60,13 @@ public static IConfigurationBuilder AddAzureAppConfiguration( configOptions.Connect(options.Endpoint, credential); } // Select keys with the specified label - configOptions.Select("*", options.Label) - .ConfigureRefresh(refresh => - { - // Use sentinel key for refresh - refresh.Register(options.SentinelKey, options.Label) - .SetRefreshInterval(options.CacheExpiration); - }); + configOptions.Select("*", options.Label).ConfigureRefresh(refresh => + { + // Use sentinel key for refresh + refresh.Register(options.SentinelKey, options.Label).SetRefreshInterval(options.CacheExpiration); + }); }); + } + } diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationHelper.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationHelper.cs new file mode 100644 index 0000000..aa3b10c --- /dev/null +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationHelper.cs @@ -0,0 +1,35 @@ +using System.Text.Json; + +namespace VisionaryCoder.Framework.Proxy.Interceptors.Configuration; + +internal static class ConfigurationHelper +{ + + public static T ConvertValue(string stringValue, T defaultValue) + { + + try + { + Type t = typeof(T); + switch (t) + { + case not null when t == typeof(string): return (T)(object)stringValue; + case not null when t == typeof(int): return (T)(object)int.Parse(stringValue); + case not null when t == typeof(long): return (T)(object)long.Parse(stringValue); + case not null when t == typeof(double): return (T)(object)double.Parse(stringValue); + case not null when t == typeof(decimal): return (T)(object)decimal.Parse(stringValue); + case not null when t == typeof(bool): return (T)(object)bool.Parse(stringValue); + case not null when t == typeof(DateTime): return (T)(object)DateTime.Parse(stringValue); + case not null when t == typeof(DateTimeOffset): return (T)(object)DateTimeOffset.Parse(stringValue); + case not null when t == typeof(TimeSpan): return (T)(object)TimeSpan.Parse(stringValue); + case not null when t == typeof(Guid): return (T)(object)Guid.Parse(stringValue); + default: return JsonSerializer.Deserialize(stringValue) ?? defaultValue; + } + + } + catch + { + return defaultValue; + } + } +} diff --git a/src/VisionaryCoder.Framework/Configuration/AppConfigurationOptions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationOptions.cs similarity index 90% rename from src/VisionaryCoder.Framework/Configuration/AppConfigurationOptions.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationOptions.cs index da18182..13e3e33 100644 --- a/src/VisionaryCoder.Framework/Configuration/AppConfigurationOptions.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationOptions.cs @@ -1,9 +1,9 @@ -namespace VisionaryCoder.Framework.AppConfiguration.Azure; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Configuration; /// /// Configuration options for Azure App Configuration service integration. /// -public sealed record AppConfigurationOptions +public sealed record ConfigurationOptions { /// /// The endpoint URI for the Azure App Configuration service. @@ -22,7 +22,7 @@ public sealed record AppConfigurationOptions /// Whether to use connection string authentication instead of managed identity. public bool UseConnectionString { get; init; } = false; - + /// The connection string for Azure App Configuration (when UseConnectionString is true). public string? ConnectionString { get; init; } } diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationProvider.cs new file mode 100644 index 0000000..14a329f --- /dev/null +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationProvider.cs @@ -0,0 +1,180 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; + +namespace VisionaryCoder.Framework.Proxy.Interceptors.Configuration; + +public abstract class ConfigurationProvider + : ServiceBase, IConfigurationProvider +{ + + protected internal IConfiguration configuration = null!; + private protected readonly ConcurrentDictionary cache = new(); + private protected readonly SemaphoreSlim refreshSemaphore = new(1, 1); + protected DateTimeOffset lastRefresh = DateTimeOffset.UtcNow; + private protected bool isDisposed; + private protected ConfigurationProviderOptions options; + + /// + public virtual string ProviderName => string.Empty; + + /// + protected ConfigurationProvider(ConfigurationProviderOptions options, ILogger logger) + : base(logger) + { + this.options = options ?? throw new ArgumentNullException(nameof(options)); + this.options.Validate(); + } + + /// + public abstract T GetValue(string key, T defaultValue) where T : class, new(); + + /// + public virtual async Task GetValueAsync(string key, T defaultValue = default!, CancellationToken cancellationToken = default) where T : class, new() + { + return cancellationToken.IsCancellationRequested + ? throw new OperationCanceledException(cancellationToken) + : await Task.FromResult(GetValue(key, defaultValue)); + } + + /// + public abstract bool SetValue(string key, T value) where T : class, new(); + + /// + public virtual async Task SetValueAsync(string key, T value, CancellationToken cancellationToken = default) where T : class, new() + { + return cancellationToken.IsCancellationRequested + ? throw new OperationCanceledException(cancellationToken) + : await Task.FromResult(SetValue(key, value)); + } + + /// + public abstract T GetSection(string sectionName) where T : class, new(); + + /// + public virtual async Task GetSectionAsync(string sectionName, CancellationToken cancellationToken = default) where T : class, new() + { + return cancellationToken.IsCancellationRequested + ? throw new OperationCanceledException(cancellationToken) + : await Task.FromResult(GetSection(sectionName)); + } + + /// + public IDictionary GetAllValues() + { + try + { + var result = new Dictionary(); + string keyPrefix = options.KeyPrefix ?? string.Empty; + bool hasPrefix = !string.IsNullOrEmpty(keyPrefix); + int prefixLength = hasPrefix ? keyPrefix.Length : 0; + + foreach (KeyValuePair kvp in configuration.AsEnumerable()) + { + if (string.IsNullOrEmpty(kvp.Key)) + continue; + + if (hasPrefix && !kvp.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string cleanKey = hasPrefix + ? kvp.Key.Length > prefixLength ? kvp.Key[prefixLength..].TrimStart(':') : string.Empty + : kvp.Key; + + result[cleanKey] = kvp.Value; + } + + Logger.LogTrace("Retrieved {Count} configuration values", result.Count); + return result; + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to get all configuration values"); + return new Dictionary(); + } + } + + /// + public async Task> GetAllValuesAsync(CancellationToken cancellationToken = default) + { + return await Task.FromResult(GetAllValues()); + } + + /// + public virtual bool Refresh() + { + // Default refresh behavior simply clears cache and updates timestamp + try + { + if (refreshSemaphore.Wait(TimeSpan.FromSeconds(5))) + { + try + { + ClearCache(); + lastRefresh = DateTimeOffset.UtcNow; + Logger.LogDebug("Configuration refreshed successfully"); + return true; + } + finally + { + refreshSemaphore.Release(); + } + } + Logger.LogWarning("Configuration refresh timeout - another refresh operation is in progress"); + return false; + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to refresh configuration"); + return false; + } + } + + /// + public abstract Task RefreshAsync(CancellationToken cancellationToken = default); + + /// + /// Try get a cached value of type T using the provider's CacheExpiration option. + /// + protected bool TryGetFromCache(string key, out T value) + { + value = default!; + + if (!cache.TryGetValue(key, out (object? Value, DateTimeOffset CachedAt) cached)) + return false; + + // Check if cache entry is expired + if (DateTimeOffset.UtcNow - cached.CachedAt > options.CacheExpiration) + { + cache.TryRemove(key, out _); + return false; + } + + if (cached.Value is T typedValue) + { + value = typedValue; + return true; + } + + return false; + } + + /// + /// Add or update cache entry with current timestamp. + /// + protected void AddToCache(string key, object? value) + { + cache[key] = (value, DateTimeOffset.UtcNow); + } + + /// + /// Clear the shared cache. + /// + protected void ClearCache() + { + cache.Clear(); + } + +} diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationProviderOptions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationProviderOptions.cs new file mode 100644 index 0000000..2bc0664 --- /dev/null +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationProviderOptions.cs @@ -0,0 +1,25 @@ +using System; + +namespace VisionaryCoder.Framework.Proxy.Interceptors.Configuration; + +/// +/// Base configuration options shared by provider options. +/// +public abstract class ConfigurationProviderOptions +{ + /// + /// The prefix to filter configuration keys (optional). + /// + public string? KeyPrefix { get; init; } + + /// + /// Whether to enable caching of configuration values. + /// + public bool EnableCaching { get; init; } = true; + + /// + /// The cache expiration time for configuration values. + /// Providers should validate this value via validation extensions. + /// + public TimeSpan CacheExpiration { get; init; } = TimeSpan.FromMinutes(5); +} diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationProviderOptionsExtensions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationProviderOptionsExtensions.cs new file mode 100644 index 0000000..ad0067d --- /dev/null +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/ConfigurationProviderOptionsExtensions.cs @@ -0,0 +1,16 @@ +namespace VisionaryCoder.Framework.Proxy.Interceptors.Configuration; + +/// +/// Validation extension methods for configuration provider options. +/// Base/common validation lives here. +/// +public static class ConfigurationProviderOptionsExtensions +{ + public static void Validate(this ConfigurationProviderOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (options.CacheExpiration <= TimeSpan.Zero) + throw new InvalidOperationException("CacheExpiration must be greater than zero."); + } +} diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/IConfigurationProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/IConfigurationProvider.cs new file mode 100644 index 0000000..b369dae --- /dev/null +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/IConfigurationProvider.cs @@ -0,0 +1,27 @@ +namespace VisionaryCoder.Framework.Proxy.Interceptors.Configuration; + +/// +/// Defines the contract for application configuration providers. +/// Supports async operations, caching, refresh capabilities, and type-safe configuration access. +/// +public interface IConfigurationProvider +{ + + string ProviderName { get; } + + T GetValue(string key, T defaultValue) where T : class, new(); + Task GetValueAsync(string key, T defaultValue = default!, CancellationToken cancellationToken = default) where T : class, new(); + + bool SetValue(string key, T value) where T : class, new(); + Task SetValueAsync(string key, T value, CancellationToken cancellationToken = default) where T : class, new(); + + T GetSection(string sectionName) where T : class, new(); + Task GetSectionAsync(string sectionName, CancellationToken cancellationToken = default) where T : class, new(); + + IDictionary GetAllValues(); + Task> GetAllValuesAsync(CancellationToken cancellationToken = default); + + bool Refresh(); + Task RefreshAsync(CancellationToken cancellationToken = default); + +} diff --git a/src/VisionaryCoder.Framework/Configuration/Local/LocalAppConfigurationProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Local/LocalConfigurationProvider.cs similarity index 57% rename from src/VisionaryCoder.Framework/Configuration/Local/LocalAppConfigurationProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Local/LocalConfigurationProvider.cs index 8f2a5bd..01d7e44 100644 --- a/src/VisionaryCoder.Framework/Configuration/Local/LocalAppConfigurationProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Local/LocalConfigurationProvider.cs @@ -1,51 +1,37 @@ -using System.Collections.Concurrent; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using VisionaryCoder.Framework.Proxy.Interceptors.Configuration; -namespace VisionaryCoder.Framework.AppConfiguration.Local; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Configuration.Local; /// /// Provides local file-based configuration operations following Microsoft configuration patterns. /// This service wraps local JSON configuration files with logging, error handling, caching, and async support. /// Supports file watching for automatic reloading and multiple configuration file sources. /// -public sealed class LocalAppConfigurationProvider : ServiceBase, IAppConfigurationProvider +public sealed class LocalConfigurationProvider + : ConfigurationProvider, IConfigurationProvider { - private readonly LocalAppConfigurationProviderOptions options; - private readonly IConfiguration configuration; - private readonly ConcurrentDictionary cache; - private readonly SemaphoreSlim refreshSemaphore; + + private readonly LocalConfigurationProviderOptions options; private readonly FileSystemWatcher? fileWatcher; - private DateTimeOffset lastRefresh; - private bool isDisposed; - public LocalAppConfigurationProvider( - LocalAppConfigurationProviderOptions options, - ILogger logger) - : base(logger) + public LocalConfigurationProvider(LocalConfigurationProviderOptions options, ILogger logger) + : base(options, logger) { - ArgumentNullException.ThrowIfNull(options); - - this.options = options; + this.options = options ?? throw new ArgumentNullException(nameof(options)); this.options.Validate(); - - this.cache = new ConcurrentDictionary(); - this.refreshSemaphore = new SemaphoreSlim(1, 1); - this.lastRefresh = DateTimeOffset.UtcNow; - - this.configuration = BuildConfiguration(); + configuration = BuildConfiguration(); // Set up file watcher if reload on change is enabled if (options.ReloadOnChange) { - this.fileWatcher = SetupFileWatcher(); + fileWatcher = SetupFileWatcher(); } - - Logger.LogInformation( - "Local App Configuration provider initialized for file {FilePath} with {AdditionalFileCount} additional files", - options.FilePath, - options.AdditionalFiles.Count()); + Logger.LogInformation("Local App Configuration provider initialized for file {FilePath} with {AdditionalFileCount} additional files", options.FilePath, options.AdditionalFiles.Count()); } - public string ProviderName => "Local"; + public override string ProviderName => "Local"; public bool IsAvailable { @@ -64,57 +50,111 @@ public bool IsAvailable } } - public T GetValue(string key, T defaultValue = default!) + public override bool Refresh() { - ArgumentException.ThrowIfNullOrWhiteSpace(key); + try + { + if (refreshSemaphore.Wait(TimeSpan.FromSeconds(5))) + { + try + { + // Clear cache to force refresh + if (options.EnableCaching) + { + ClearCache(); + } + + lastRefresh = DateTimeOffset.UtcNow; + + Logger.LogDebug("Configuration refreshed successfully"); + return true; + } + finally + { + refreshSemaphore.Release(); + } + } + Logger.LogWarning("Configuration refresh timeout - another refresh operation is in progress"); + return false; + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to refresh configuration"); + return false; + } + } + public override async Task RefreshAsync(CancellationToken cancellationToken = default) + { + try + { + if (await refreshSemaphore.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken)) + { + try + { + // Clear cache to force refresh + if (options.EnableCaching) + { + ClearCache(); + } + + lastRefresh = DateTimeOffset.UtcNow; + + Logger.LogDebug("Configuration refreshed successfully"); + return true; + } + finally + { + refreshSemaphore.Release(); + } + } + Logger.LogWarning("Configuration refresh timeout - another refresh operation is in progress"); + return false; + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to refresh configuration"); + return false; + } + } + + public override T GetValue(string key, T defaultValue) + { try { string fullKey = GetFullKey(key); - // Check cache first if caching is enabled - if (options.EnableCaching && TryGetFromCache(fullKey, out T cachedValue)) + if (options.EnableCaching && TryGetFromCache(fullKey, out T cachedValue)) { - Logger.LogTrace("Configuration value retrieved from cache for key {Key}", key); + Logger.LogTrace("Cache hit for key '{Key}'", key); return cachedValue; } - // Get from configuration string? stringValue = configuration[fullKey]; if (string.IsNullOrEmpty(stringValue)) { - Logger.LogDebug("Configuration key {Key} not found, returning default value", key); + Logger.LogWarning("Configuration key '{Key}' not found. Returning default value.", key); return defaultValue; } - T result = AppConfigurationHelper.ConvertValue(stringValue, defaultValue); - - // Cache the result if caching is enabled + T value = ConfigurationHelper.ConvertValue(stringValue, defaultValue); if (options.EnableCaching) { - cache.TryAdd(fullKey, (result, DateTimeOffset.UtcNow)); + AddToCache(fullKey, value); + Logger.LogTrace("Cached value for key '{Key}'", key); } - - Logger.LogTrace("Configuration value retrieved for key {Key}", key); - return result; + return value; } catch (Exception ex) { - Logger.LogError(ex, "Failed to get configuration value for key {Key}", key); + Logger.LogError(ex, "Error retrieving configuration value for key '{Key}'. Returning default value.", key); return defaultValue; } } - public async Task GetValueAsync(string key, T defaultValue = default!, CancellationToken cancellationToken = default) - { - // Local file operations are typically fast, but we provide async wrapper for consistency - return await Task.FromResult(GetValue(key, defaultValue)); - } - - public T GetSection(string sectionName) where T : class, new() + public override T GetSection(string sectionName) { ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); - try { string fullSectionName = GetFullKey(sectionName); @@ -126,7 +166,7 @@ public async Task GetValueAsync(string key, T defaultValue = default!, Can return new T(); } - T? result = section.Get() ?? new T(); + T result = section.Get() ?? new T(); Logger.LogTrace("Configuration section retrieved for {SectionName}", sectionName); return result; } @@ -137,139 +177,60 @@ public async Task GetValueAsync(string key, T defaultValue = default!, Can } } - public async Task GetSectionAsync(string sectionName, CancellationToken cancellationToken = default) where T : class, new() - { - return await Task.FromResult(GetSection(sectionName)); - } - - public IDictionary GetAllValues() - { - try - { - var result = new Dictionary(); - string keyPrefix = options.KeyPrefix ?? string.Empty; - - foreach (KeyValuePair kvp in configuration.AsEnumerable()) - { - if (string.IsNullOrEmpty(keyPrefix) || kvp.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase)) - { - string cleanKey = string.IsNullOrEmpty(keyPrefix) - ? kvp.Key - : kvp.Key[keyPrefix.Length..].TrimStart(':'); - - result[cleanKey] = kvp.Value; - } - } - - Logger.LogTrace("Retrieved {Count} configuration values", result.Count); - return result; - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to get all configuration values"); - return new Dictionary(); - } - } - - public async Task> GetAllValuesAsync(CancellationToken cancellationToken = default) - { - return await Task.FromResult(GetAllValues()); - } - - public bool SetValue(string key, T value) + public override bool SetValue(string key, T value) { Logger.LogWarning("SetValue operation not supported by Local App Configuration provider. Modify the configuration files directly."); throw new NotSupportedException("Local App Configuration provider is read-only. Modify the configuration files directly."); } - public Task SetValueAsync(string key, T value, CancellationToken cancellationToken = default) + public bool UpdateSection(string sectionName, T value) { - return Task.FromResult(SetValue(key, value)); + Logger.LogWarning("UpdateSection operation not supported by Local App Configuration provider. Modify the configuration files directly."); + throw new NotSupportedException("Local App Configuration provider is read-only. Modify the configuration files directly."); } - public bool UpdateSection(string sectionName, T value) where T : class + protected override void Dispose(bool disposing) { - Logger.LogWarning("UpdateSection operation not supported by Local App Configuration provider. Modify the configuration files directly."); - throw new NotSupportedException("Local App Configuration provider is read-only. Modify the configuration files directly."); + if (!isDisposed && disposing) + { + fileWatcher?.Dispose(); + refreshSemaphore?.Dispose(); + ClearCache(); + isDisposed = true; + } + base.Dispose(disposing); } - public Task UpdateSectionAsync(string sectionName, T value, CancellationToken cancellationToken = default) where T : class + private string GetFullKey(string key) { - return Task.FromResult(UpdateSection(sectionName, value)); + if (string.IsNullOrEmpty(options.KeyPrefix)) + return key; + + return $"{options.KeyPrefix}:{key}"; } - public bool Refresh() + private string GetFullPath(string filePath) { - try - { - if (refreshSemaphore.Wait(TimeSpan.FromSeconds(5))) - { - try - { - // Clear cache to force refresh - if (options.EnableCaching) - { - cache.Clear(); - } + if (Path.IsPathRooted(filePath)) + return filePath; - lastRefresh = DateTimeOffset.UtcNow; + if (!string.IsNullOrEmpty(options.BasePath)) + return Path.Combine(options.BasePath, filePath); - Logger.LogDebug("Configuration refreshed successfully"); - return true; - } - finally - { - refreshSemaphore.Release(); - } - } - else - { - Logger.LogWarning("Configuration refresh timeout - another refresh operation is in progress"); - return false; - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to refresh configuration"); - return false; - } + return Path.Combine(Directory.GetCurrentDirectory(), filePath); } - public async Task RefreshAsync(CancellationToken cancellationToken = default) + private void OnConfigurationFileChanged(object sender, FileSystemEventArgs e) { - try - { - if (await refreshSemaphore.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken)) - { - try - { - // Clear cache to force refresh - if (options.EnableCaching) - { - cache.Clear(); - } - - lastRefresh = DateTimeOffset.UtcNow; + Logger.LogDebug("Configuration file {FilePath} changed, clearing cache", e.FullPath); - Logger.LogDebug("Configuration refreshed successfully"); - return true; - } - finally - { - refreshSemaphore.Release(); - } - } - else - { - Logger.LogWarning("Configuration refresh timeout - another refresh operation is in progress"); - return false; - } - } - catch (Exception ex) + // Clear cache when file changes + if (options.EnableCaching) { - Logger.LogError(ex, "Failed to refresh configuration"); - return false; + ClearCache(); } + + lastRefresh = DateTimeOffset.UtcNow; } private IConfiguration BuildConfiguration() @@ -340,74 +301,4 @@ private IConfiguration BuildConfiguration() } } - private void OnConfigurationFileChanged(object sender, FileSystemEventArgs e) - { - Logger.LogDebug("Configuration file {FilePath} changed, clearing cache", e.FullPath); - - // Clear cache when file changes - if (options.EnableCaching) - { - cache.Clear(); - } - - lastRefresh = DateTimeOffset.UtcNow; - } - - private string GetFullPath(string filePath) - { - if (Path.IsPathRooted(filePath)) - return filePath; - - if (!string.IsNullOrEmpty(options.BasePath)) - return Path.Combine(options.BasePath, filePath); - - return Path.Combine(Directory.GetCurrentDirectory(), filePath); - } - - private string GetFullKey(string key) - { - if (string.IsNullOrEmpty(options.KeyPrefix)) - return key; - - return $"{options.KeyPrefix}:{key}"; - } - - private bool TryGetFromCache(string key, out T value) - { - value = default!; - - if (!options.EnableCaching) - return false; - - if (!cache.TryGetValue(key, out (object? Value, DateTimeOffset CachedAt) cached)) - return false; - - // Check if cache entry is expired - if (DateTimeOffset.UtcNow - cached.CachedAt > options.CacheExpiration) - { - cache.TryRemove(key, out _); - return false; - } - - if (cached.Value is T typedValue) - { - value = typedValue; - return true; - } - - return false; - } - - protected override void Dispose(bool disposing) - { - if (!isDisposed && disposing) - { - fileWatcher?.Dispose(); - refreshSemaphore?.Dispose(); - cache?.Clear(); - isDisposed = true; - } - - base.Dispose(disposing); - } } diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Local/LocalConfigurationProviderOptions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Local/LocalConfigurationProviderOptions.cs new file mode 100644 index 0000000..e9bcaf5 --- /dev/null +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Local/LocalConfigurationProviderOptions.cs @@ -0,0 +1,37 @@ +namespace VisionaryCoder.Framework.Proxy.Interceptors.Configuration.Local; + +/// +/// Configuration options for Local (file-based) App Configuration provider. +/// +public sealed class LocalConfigurationProviderOptions : ConfigurationProviderOptions +{ + /// + /// The file path for the configuration file. + /// + public string FilePath { get; init; } = "appsettings.json"; + + /// + /// Whether to watch the file for changes and automatically reload. + /// + public bool ReloadOnChange { get; init; } = true; + + /// + /// Whether the configuration file is optional. + /// + public bool Optional { get; init; } = false; + + /// + /// Additional configuration files to include (e.g., environment-specific files). + /// + public IEnumerable AdditionalFiles { get; init; } = []; + + /// + /// The base path for configuration files. + /// + public string? BasePath { get; init; } + + /// + /// Whether to enable caching of configuration values. + /// + public bool EnableCaching { get; init; } = true; +} diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Local/LocalConfigurationProviderOptionsExtensions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Local/LocalConfigurationProviderOptionsExtensions.cs new file mode 100644 index 0000000..e2ec9bf --- /dev/null +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Local/LocalConfigurationProviderOptionsExtensions.cs @@ -0,0 +1,23 @@ +using VisionaryCoder.Framework.Proxy.Interceptors.Configuration; + +namespace VisionaryCoder.Framework.Proxy.Interceptors.Configuration.Local; + +/// +/// Local provider-specific validation extensions. +/// +public static class LocalConfigurationProviderOptionsExtensions +{ + public static void Validate(this LocalConfigurationProviderOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (string.IsNullOrWhiteSpace(options.FilePath)) + throw new InvalidOperationException("FilePath cannot be null or empty."); + + if (options.AdditionalFiles.Any(string.IsNullOrWhiteSpace)) + throw new InvalidOperationException("Additional file paths cannot be null or empty."); + + // Call shared validation + ((ConfigurationProviderOptions)options).Validate(); + } +} diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Correlation/CorrelationInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Correlation/CorrelationInterceptor.cs index ef84148..6963995 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Correlation/CorrelationInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Correlation/CorrelationInterceptor.cs @@ -1,6 +1,8 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.Logging; + namespace VisionaryCoder.Framework.Proxy.Interceptors.Correlation; /// /// Correlation interceptor that manages correlation IDs for proxy operations. diff --git a/src/VisionaryCoder.Framework/Logging/Interceptors/ILoggingInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/ILoggingInterceptor.cs similarity index 90% rename from src/VisionaryCoder.Framework/Logging/Interceptors/ILoggingInterceptor.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/ILoggingInterceptor.cs index 4bc6666..5090b09 100644 --- a/src/VisionaryCoder.Framework/Logging/Interceptors/ILoggingInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/ILoggingInterceptor.cs @@ -1,9 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Proxy.Interceptors; - -namespace VisionaryCoder.Framework.Logging.Interceptors; +namespace VisionaryCoder.Framework.Proxy.Interceptors; /// /// Defines a contract for logging interceptors that capture method call information. diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Logging/LoggingInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Logging/LoggingInterceptor.cs index 235c30b..e16e01f 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Logging/LoggingInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Logging/LoggingInterceptor.cs @@ -1,6 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.Logging; using VisionaryCoder.Framework.Proxy.Exceptions; namespace VisionaryCoder.Framework.Proxy.Interceptors.Logging; /// diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Logging/LoggingInterceptorServiceCollectionExtensions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Logging/LoggingInterceptorExtensions.cs similarity index 86% rename from src/VisionaryCoder.Framework/Proxy/Interceptors/Logging/LoggingInterceptorServiceCollectionExtensions.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Logging/LoggingInterceptorExtensions.cs index fa9bba2..6442274 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Logging/LoggingInterceptorServiceCollectionExtensions.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Logging/LoggingInterceptorExtensions.cs @@ -1,8 +1,10 @@ +using Microsoft.Extensions.DependencyInjection; + namespace VisionaryCoder.Framework.Proxy.Interceptors.Logging; /// /// Extension methods for adding logging interceptor services. /// -public static class LoggingInterceptorServiceCollectionExtensions +public static class LoggingInterceptorExtensions { /// /// Adds the logging interceptor to the service collection. diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Logging/TimingInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Logging/TimingInterceptor.cs index 767fda1..c2994bb 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Logging/TimingInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Logging/TimingInterceptor.cs @@ -1,6 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.Logging; using System.Diagnostics; namespace VisionaryCoder.Framework.Proxy.Interceptors.Logging; diff --git a/src/VisionaryCoder.Framework/Logging/Interceptors/LoggingInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/LoggingInterceptor.cs similarity index 87% rename from src/VisionaryCoder.Framework/Logging/Interceptors/LoggingInterceptor.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/LoggingInterceptor.cs index a5ec8d7..d1703c4 100644 --- a/src/VisionaryCoder.Framework/Logging/Interceptors/LoggingInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/LoggingInterceptor.cs @@ -1,10 +1,11 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.Logging; using VisionaryCoder.Framework.Proxy; using VisionaryCoder.Framework.Proxy.Exceptions; -namespace VisionaryCoder.Framework.Logging.Interceptors; +namespace VisionaryCoder.Framework.Proxy.Interceptors; /// /// Interceptor that provides comprehensive logging for proxy operations including success, failure, and exception scenarios. @@ -28,17 +29,17 @@ public sealed class LoggingInterceptor(ILogger logger) : IOr /// A task representing the async operation with the response. public async Task> InvokeAsync(ProxyContext context, ProxyDelegate next, CancellationToken cancellationToken = default) { - var operationName = context.OperationName ?? "Unknown"; - var correlationId = context.CorrelationId ?? "None"; - var startTime = DateTimeOffset.UtcNow; + string operationName = context.OperationName ?? "Unknown"; + string correlationId = context.CorrelationId ?? "None"; + DateTimeOffset startTime = DateTimeOffset.UtcNow; logger.LogDebug("Starting proxy operation '{OperationName}' with correlation ID '{CorrelationId}' at {StartTime}", operationName, correlationId, startTime); try { - var response = await next(context, cancellationToken); - var duration = DateTimeOffset.UtcNow - startTime; + ProxyResponse response = await next(context, cancellationToken); + TimeSpan duration = DateTimeOffset.UtcNow - startTime; if (response.IsSuccess) { @@ -63,7 +64,7 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe } catch (ProxyException ex) { - var duration = DateTimeOffset.UtcNow - startTime; + TimeSpan duration = DateTimeOffset.UtcNow - startTime; logger.LogError(ex, "Proxy operation '{OperationName}' failed with proxy exception in {Duration}ms. Correlation ID: '{CorrelationId}'", operationName, duration.TotalMilliseconds, correlationId); @@ -75,7 +76,7 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - var duration = DateTimeOffset.UtcNow - startTime; + TimeSpan duration = DateTimeOffset.UtcNow - startTime; logger.LogWarning("Proxy operation '{OperationName}' was cancelled after {Duration}ms. Correlation ID: '{CorrelationId}'", operationName, duration.TotalMilliseconds, correlationId); @@ -87,7 +88,7 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe } catch (Exception ex) { - var duration = DateTimeOffset.UtcNow - startTime; + TimeSpan duration = DateTimeOffset.UtcNow - startTime; logger.LogError(ex, "Proxy operation '{OperationName}' failed with unexpected exception in {Duration}ms. Correlation ID: '{CorrelationId}'", operationName, duration.TotalMilliseconds, correlationId); diff --git a/src/VisionaryCoder.Framework/Logging/Interceptors/NullLoggingInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/NullLoggingInterceptor.cs similarity index 87% rename from src/VisionaryCoder.Framework/Logging/Interceptors/NullLoggingInterceptor.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/NullLoggingInterceptor.cs index 503aef3..0e88d51 100644 --- a/src/VisionaryCoder.Framework/Logging/Interceptors/NullLoggingInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/NullLoggingInterceptor.cs @@ -1,9 +1,13 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. + +// Copyright (c) 2025 VisionaryCoder. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for license information. + using VisionaryCoder.Framework.Proxy; -namespace VisionaryCoder.Framework.Logging.Interceptors; +namespace VisionaryCoder.Framework.Proxy.Interceptors; /// /// Null object pattern implementation of a logging interceptor that performs no logging operations. diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/ProxyInterceptorServiceCollectionExtensions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/ProxyInterceptorExtensions.cs similarity index 95% rename from src/VisionaryCoder.Framework/Proxy/Interceptors/ProxyInterceptorServiceCollectionExtensions.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/ProxyInterceptorExtensions.cs index e810ee8..f599e29 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/ProxyInterceptorServiceCollectionExtensions.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/ProxyInterceptorExtensions.cs @@ -1,8 +1,10 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using System.Diagnostics; - using VisionaryCoder.Framework.Proxy.Interceptors.Auditing; using VisionaryCoder.Framework.Proxy.Interceptors.Caching; using VisionaryCoder.Framework.Proxy.Interceptors.Correlation; @@ -17,7 +19,7 @@ namespace VisionaryCoder.Framework.Proxy.Interceptors; /// /// Extension methods for configuring proxy interceptors in the dependency injection container. /// -public static class ProxyInterceptorServiceCollectionExtensions +public static class ProxyInterceptorExtensions { /// /// Adds all proxy interceptors with their default configurations and proper ordering. @@ -136,7 +138,7 @@ public static IServiceCollection AddRetryInterceptor(this IServiceCollection ser public static IServiceCollection AddAuditingInterceptor(this IServiceCollection services) { services.TryAddTransient(); - services.TryAddTransient(); + services.TryAddTransient(); return services; } /// Adds an audit sink. diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/QueryFiltering/QueryFilterInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/QueryFiltering/QueryFilterInterceptor.cs index 7dd87e4..65c2c2b 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/QueryFiltering/QueryFilterInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/QueryFiltering/QueryFilterInterceptor.cs @@ -16,12 +16,12 @@ public async Task> InvokeAsync( if (context.Body is string json) { // Validate against schema - var validationErrors = QueryFilterSchemaValidator.Validate(json); + IReadOnlyList validationErrors = QueryFilterSchemaValidator.Validate(json); if (validationErrors.Count > 0) { throw new ArgumentException($"Invalid query filter JSON: {string.Join(", ", validationErrors)}"); } - + // Deserialize and rehydrate FilterNode? node = QueryFilterSerializer.Deserialize(json); if (node != null && typeof(T).IsGenericType && @@ -33,7 +33,7 @@ public async Task> InvokeAsync( .GetMethod(nameof(QueryFilterRehydrator.ToQueryFilter))! .MakeGenericMethod(innerType); - object rehydrated = method.Invoke(null, new object[] { node })!; + object rehydrated = method.Invoke(null, [node])!; return ProxyResponse.Success((T)rehydrated, 200); } } diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Resilience/RateLimitingInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Resilience/RateLimitingInterceptor.cs index 481d15a..6708894 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Resilience/RateLimitingInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Resilience/RateLimitingInterceptor.cs @@ -1,8 +1,8 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.Logging; using System.Collections.Concurrent; - using VisionaryCoder.Framework.Proxy.Exceptions; namespace VisionaryCoder.Framework.Proxy.Interceptors.Resilience; diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Resilience/ResilienceInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Resilience/ResilienceInterceptor.cs index 9488480..c1da74e 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Resilience/ResilienceInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Resilience/ResilienceInterceptor.cs @@ -1,6 +1,9 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.Logging; +using Polly; + namespace VisionaryCoder.Framework.Proxy.Interceptors.Resilience; /// diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Retries/CircuitBreakerInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Retries/CircuitBreakerInterceptor.cs index 3c7cdeb..4804e31 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Retries/CircuitBreakerInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Retries/CircuitBreakerInterceptor.cs @@ -1,6 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.Logging; using VisionaryCoder.Framework.Proxy.Exceptions; namespace VisionaryCoder.Framework.Proxy.Interceptors.Retries; diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Retries/RetryInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Retries/RetryInterceptor.cs index cabebf3..eae5b9e 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Retries/RetryInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Retries/RetryInterceptor.cs @@ -1,6 +1,8 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using VisionaryCoder.Framework.Proxy.Exceptions; namespace VisionaryCoder.Framework.Proxy.Interceptors.Retries; @@ -45,13 +47,13 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe { attempt++; TimeSpan delay = CalculateDelay(baseDelay, attempt); - LoggerExtensions.LogWarning((ILogger)logger, (Exception?)ex, "Retryable exception on attempt {Attempt}/{MaxAttempts}, retrying in {Delay}ms", + LoggerExtensions.LogWarning(logger, ex, "Retryable exception on attempt {Attempt}/{MaxAttempts}, retrying in {Delay}ms", attempt, maxRetries + 1, delay.TotalMilliseconds); await Task.Delay(delay, context.CancellationToken); } catch (RetryableTransportException ex) when (attempt >= maxRetries) { - LoggerExtensions.LogError((ILogger)logger, (Exception?)ex, "Operation failed after {MaxAttempts} attempts, giving up", maxRetries + 1); + LoggerExtensions.LogError(logger, ex, "Operation failed after {MaxAttempts} attempts, giving up", maxRetries + 1); throw; } catch (BusinessException ex) diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/SecurityInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/SecurityInterceptor.cs index 1e0aeb6..4df21d4 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/SecurityInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/SecurityInterceptor.cs @@ -1,6 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.Logging; using VisionaryCoder.Framework.Proxy.Exceptions; namespace VisionaryCoder.Framework.Proxy.Interceptors.Security; diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/SecurityInterceptorServiceCollectionExtensions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/SecurityInterceptorExtensions.cs similarity index 95% rename from src/VisionaryCoder.Framework/Proxy/Interceptors/Security/SecurityInterceptorServiceCollectionExtensions.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Security/SecurityInterceptorExtensions.cs index 9888278..f46c15e 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/SecurityInterceptorServiceCollectionExtensions.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/SecurityInterceptorExtensions.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using VisionaryCoder.Framework.Proxy.Interceptors.Security.Web; using VisionaryCoder.Framework.Secrets; @@ -5,7 +7,7 @@ namespace VisionaryCoder.Framework.Proxy.Interceptors.Security; /// /// Extension methods for adding security interceptor services. /// -public static class SecurityInterceptorServiceCollectionExtensions +public static class SecurityInterceptorExtensions { /// /// Adds the JWT Bearer interceptor to the service collection with a token provider function. diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/JwtBearerEnricher.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/JwtBearerEnricher.cs index 661b4a5..d498878 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/JwtBearerEnricher.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/JwtBearerEnricher.cs @@ -1,6 +1,8 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.Logging; + namespace VisionaryCoder.Framework.Proxy.Interceptors.Security.Web; /// /// Helper class for enriching proxy context with JWT Bearer authentication. diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/JwtBearerInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/JwtBearerInterceptor.cs index 31699f0..77a1e0b 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/JwtBearerInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/JwtBearerInterceptor.cs @@ -1,6 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.Logging; using VisionaryCoder.Framework.Proxy.Exceptions; namespace VisionaryCoder.Framework.Proxy.Interceptors.Security.Web; diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/KeyVaultJwtInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/KeyVaultJwtInterceptor.cs index 2d052f4..feaf495 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/KeyVaultJwtInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/KeyVaultJwtInterceptor.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using VisionaryCoder.Framework.Secrets; namespace VisionaryCoder.Framework.Proxy.Interceptors.Security.Web; diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/TokenRequest.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/TokenRequest.cs index 83b18fc..1ad1252 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/TokenRequest.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/TokenRequest.cs @@ -16,7 +16,7 @@ public class TokenRequest /// /// Gets or sets the scopes. /// - public string[] Scopes { get; set; } = Array.Empty(); + public string[] Scopes { get; set; } = []; /// /// Gets or sets the client ID. diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/WebJwtInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/WebJwtInterceptor.cs index 367f229..43d6617 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/WebJwtInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/WebJwtInterceptor.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; + namespace VisionaryCoder.Framework.Proxy.Interceptors.Security.Web; /// /// JWT interceptor for web-based authentication scenarios. diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/WebJwtOptions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/WebJwtOptions.cs index 20f840d..9e76d5c 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/WebJwtOptions.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Security/Web/WebJwtOptions.cs @@ -36,7 +36,7 @@ public class WebJwtOptions /// /// Gets or sets the scopes for the JWT token. /// - public string[] Scopes { get; set; } = Array.Empty(); + public string[] Scopes { get; set; } = []; /// /// Gets or sets whether to refresh the token if it's expired. diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Telemetry/TelemetryInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Telemetry/TelemetryInterceptor.cs index 1332d77..1d2f11b 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Telemetry/TelemetryInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Telemetry/TelemetryInterceptor.cs @@ -1,7 +1,9 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.Logging; using System.Diagnostics; + namespace VisionaryCoder.Framework.Proxy.Interceptors.Telemetry; /// /// Telemetry interceptor that creates activities and tracks proxy operations. diff --git a/src/VisionaryCoder.Framework/Logging/Interceptors/TimingInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/TimingInterceptor.cs similarity index 87% rename from src/VisionaryCoder.Framework/Logging/Interceptors/TimingInterceptor.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/TimingInterceptor.cs index 5c1ff1d..42d6533 100644 --- a/src/VisionaryCoder.Framework/Logging/Interceptors/TimingInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/TimingInterceptor.cs @@ -1,10 +1,11 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.Logging; using System.Diagnostics; using VisionaryCoder.Framework.Proxy; -namespace VisionaryCoder.Framework.Logging.Interceptors; +namespace VisionaryCoder.Framework.Proxy.Interceptors; /// /// Interceptor that measures and logs the execution time of proxy operations. @@ -40,21 +41,21 @@ public sealed class TimingInterceptor(ILogger logger) : IOrde /// A task representing the async operation with the response. public async Task> InvokeAsync(ProxyContext context, ProxyDelegate next, CancellationToken cancellationToken = default) { - var operationName = context.OperationName ?? "Unknown"; - var correlationId = context.CorrelationId ?? "None"; + string operationName = context.OperationName ?? "Unknown"; + string correlationId = context.CorrelationId ?? "None"; var stopwatch = Stopwatch.StartNew(); // Record start time for detailed metrics - var startTime = DateTimeOffset.UtcNow; + DateTimeOffset startTime = DateTimeOffset.UtcNow; context.Metadata["StartTime"] = startTime; try { - var response = await next(context, cancellationToken); + ProxyResponse response = await next(context, cancellationToken); stopwatch.Stop(); - var elapsedMs = stopwatch.ElapsedMilliseconds; - var elapsedTicks = stopwatch.ElapsedTicks; + long elapsedMs = stopwatch.ElapsedMilliseconds; + long elapsedTicks = stopwatch.ElapsedTicks; // Store comprehensive timing metrics in context context.Metadata["ExecutionTimeMs"] = elapsedMs; @@ -69,7 +70,7 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { stopwatch.Stop(); - var elapsedMs = stopwatch.ElapsedMilliseconds; + long elapsedMs = stopwatch.ElapsedMilliseconds; context.Metadata["ExecutionTimeMs"] = elapsedMs; context.Metadata["EndTime"] = DateTimeOffset.UtcNow; @@ -82,7 +83,7 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe catch (Exception ex) { stopwatch.Stop(); - var elapsedMs = stopwatch.ElapsedMilliseconds; + long elapsedMs = stopwatch.ElapsedMilliseconds; context.Metadata["ExecutionTimeMs"] = elapsedMs; context.Metadata["EndTime"] = DateTimeOffset.UtcNow; @@ -103,7 +104,7 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe /// Whether the operation was successful. private void LogOperationTiming(string operationName, string correlationId, long elapsedMs, bool isSuccess) { - var statusMessage = isSuccess ? "completed successfully" : "completed with failure"; + string statusMessage = isSuccess ? "completed successfully" : "completed with failure"; if (elapsedMs >= CriticalOperationThresholdMs) { diff --git a/src/VisionaryCoder.Framework/Proxy/ProxyServiceCollectionExtensions.cs b/src/VisionaryCoder.Framework/Proxy/ProxyExtensions.cs similarity index 69% rename from src/VisionaryCoder.Framework/Proxy/ProxyServiceCollectionExtensions.cs rename to src/VisionaryCoder.Framework/Proxy/ProxyExtensions.cs index ba50b87..e3d39f6 100644 --- a/src/VisionaryCoder.Framework/Proxy/ProxyServiceCollectionExtensions.cs +++ b/src/VisionaryCoder.Framework/Proxy/ProxyExtensions.cs @@ -1,16 +1,24 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + namespace VisionaryCoder.Framework.Proxy; /// /// Extension methods for configuring proxy pipeline services. /// -public static class ProxyServiceCollectionExtensions +public static class ProxyExtensions { - /// - /// Adds the default proxy pipeline. - /// + /// The service collection. - /// The service collection for chaining. - public static IServiceCollection AddProxyPipeline(this IServiceCollection services) + extension(IServiceCollection services) + { + + /// + /// Adds the default proxy pipeline. + /// + /// The service collection for chaining. + public IServiceCollection AddProxyPipeline() { // Register core pipeline components services.TryAddSingleton(); @@ -20,13 +28,13 @@ public static IServiceCollection AddProxyPipeline(this IServiceCollection servic return services; } + /// /// Adds a custom proxy transport implementation. /// /// The transport implementation type. - /// The service collection. /// The service collection for chaining. - public static IServiceCollection AddProxyTransport(this IServiceCollection services) + public IServiceCollection AddProxyTransport() where TTransport : class, IProxyTransport { services.TryAddSingleton(); @@ -37,10 +45,9 @@ public static IServiceCollection AddProxyTransport(this IServiceColl /// Adds a custom interceptor to the proxy pipeline. /// /// The interceptor implementation type. - /// The service collection. /// The service lifetime for the interceptor. /// The service collection for chaining. - public static IServiceCollection AddProxyInterceptor(this IServiceCollection services, ServiceLifetime lifetime = ServiceLifetime.Transient) + public IServiceCollection AddProxyInterceptor(ServiceLifetime lifetime = ServiceLifetime.Transient) where TInterceptor : class, IProxyInterceptor { services.TryAdd(ServiceDescriptor.Describe(typeof(TInterceptor), typeof(TInterceptor), lifetime)); @@ -48,3 +55,4 @@ public static IServiceCollection AddProxyInterceptor(this IService return services; } } +} diff --git a/src/VisionaryCoder.Framework/Querying/QueryFilterExtensions.cs b/src/VisionaryCoder.Framework/Querying/QueryFilterExtensions.cs index 04d123e..972e6aa 100644 --- a/src/VisionaryCoder.Framework/Querying/QueryFilterExtensions.cs +++ b/src/VisionaryCoder.Framework/Querying/QueryFilterExtensions.cs @@ -221,7 +221,7 @@ public static QueryFilter Join(this IEnumerable> filters, b /// /// Joins multiple filters using AND semantics by default. If is false, uses OR semantics. /// - public static QueryFilter Join(bool useAnd, params QueryFilter[] filters) => (filters ?? Array.Empty>()).Join(useAnd); + public static QueryFilter Join(bool useAnd, params QueryFilter[] filters) => (filters ?? []).Join(useAnd); private static QueryFilter True() { @@ -257,7 +257,7 @@ public static QueryFilter ContainsIgnoreCase(Expression> s ParameterExpression param = selector.Parameters[0]; // x => x.Prop != null && x.Prop.ToLowerInvariant().Contains(value.ToLowerInvariant()) MethodInfo toLowerInvariant = typeof(string).GetMethod(nameof(string.ToLowerInvariant), Type.EmptyTypes)!; - MethodInfo contains = typeof(string).GetMethod(nameof(string.Contains), new[] { typeof(string) })!; + MethodInfo contains = typeof(string).GetMethod(nameof(string.Contains), [typeof(string)])!; BinaryExpression notNull = Expression.NotEqual(selector.Body, Expression.Constant(null, typeof(string))); MethodCallExpression left = Expression.Call(selector.Body, toLowerInvariant); @@ -285,7 +285,7 @@ public static QueryFilter StartsWithIgnoreCase(Expression> ParameterExpression param = selector.Parameters[0]; MethodInfo toLowerInvariant = typeof(string).GetMethod(nameof(string.ToLowerInvariant), Type.EmptyTypes)!; - MethodInfo startsWith = typeof(string).GetMethod(nameof(string.StartsWith), new[] { typeof(string) })!; + MethodInfo startsWith = typeof(string).GetMethod(nameof(string.StartsWith), [typeof(string)])!; BinaryExpression notNull = Expression.NotEqual(selector.Body, Expression.Constant(null, typeof(string))); MethodCallExpression left = Expression.Call(selector.Body, toLowerInvariant); @@ -311,7 +311,7 @@ public static QueryFilter EndsWithIgnoreCase(Expression> s ParameterExpression param = selector.Parameters[0]; MethodInfo toLowerInvariant = typeof(string).GetMethod(nameof(string.ToLowerInvariant), Type.EmptyTypes)!; - MethodInfo endsWith = typeof(string).GetMethod(nameof(string.EndsWith), new[] { typeof(string) })!; + MethodInfo endsWith = typeof(string).GetMethod(nameof(string.EndsWith), [typeof(string)])!; BinaryExpression notNull = Expression.NotEqual(selector.Body, Expression.Constant(null, typeof(string))); MethodCallExpression left = Expression.Call(selector.Body, toLowerInvariant); diff --git a/src/VisionaryCoder.Framework/Querying/QueryFilterSchemaValidator.cs b/src/VisionaryCoder.Framework/Querying/QueryFilterSchemaValidator.cs new file mode 100644 index 0000000..0bc7fac --- /dev/null +++ b/src/VisionaryCoder.Framework/Querying/QueryFilterSchemaValidator.cs @@ -0,0 +1,127 @@ +using System.Text.Json; + +namespace VisionaryCoder.Framework.Querying; + +/// +/// Validates QueryFilter JSON against expected structure. +/// This is a lightweight, self-contained validator tailored to the project's tests +/// and avoids an external JSON Schema dependency. +/// +public static class QueryFilterSchemaValidator +{ + /// + /// Validates a QueryFilter JSON string against expected structure. + /// + /// The JSON string to validate. + /// A list of validation errors, or empty if valid. + public static IReadOnlyList Validate(string json) + { + try + { + using JsonDocument doc = JsonDocument.Parse(json); + return ValidateElement(doc.RootElement); + } + catch (JsonException je) + { + return new List { $"Invalid JSON: {je.Message}" }; + } + } + + /// + /// Validates a QueryFilter JSON document against the expected structure. + /// + /// The JSON document to validate. + /// True if valid, false otherwise. + public static bool IsValid(JsonDocument jsonDocument) + { + string json = jsonDocument.RootElement.GetRawText(); + return Validate(json).Count == 0; + } + + private static List ValidateElement(JsonElement element) + { + var errors = new List(); + + if (element.ValueKind != JsonValueKind.Object) + { + errors.Add("Root element must be a JSON object."); + return errors; + } + + if (!element.TryGetProperty("operator", out JsonElement opElem) || opElem.ValueKind != JsonValueKind.String) + { + errors.Add("operator is required and must be a string."); + return errors; + } + + string? op = opElem.GetString(); + if (string.IsNullOrWhiteSpace(op)) + { + errors.Add("operator must be a non-empty string."); + return errors; + } + + // If 'children' exists, treat as composite filter + if (element.TryGetProperty("children", out JsonElement childrenElem)) + { + if (childrenElem.ValueKind != JsonValueKind.Array) + { + errors.Add("children must be an array."); + return errors; + } + + if (childrenElem.GetArrayLength() == 0) + { + errors.Add("children must contain at least one item."); + } + + // Basic composite operators allowed + var allowedComposite = new HashSet(StringComparer.OrdinalIgnoreCase) { "And", "Or", "Not" }; + if (!allowedComposite.Contains(op)) + { + errors.Add($"Invalid composite operator: {op}"); + } + + int idx = 0; + foreach (JsonElement child in childrenElem.EnumerateArray()) + { + List childErrors = ValidateElement(child); + foreach (string ce in childErrors) + { + errors.Add($"children[{idx}]: {ce}"); + } + idx++; + } + + return errors; + } + + // Otherwise treat as a property filter + if (!element.TryGetProperty("property", out JsonElement propElem) || propElem.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(propElem.GetString())) + { + errors.Add("property is required."); + } + + // Basic property operators allowed + var allowedPropertyOps = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Contains", + "Equals", + "StartsWith", + "EndsWith", + "GreaterThan", + "LessThan", + "GreaterThanOrEqual", + "LessThanOrEqual", + "In", + "NotIn" + }; + + if (!allowedPropertyOps.Contains(op)) + { + errors.Add($"Invalid operator: {op}"); + } + + return errors; + } +} diff --git a/src/VisionaryCoder.Framework/Querying/QueryFilterValidator.cs b/src/VisionaryCoder.Framework/Querying/QueryFilterValidator.cs deleted file mode 100644 index 36aa49e..0000000 --- a/src/VisionaryCoder.Framework/Querying/QueryFilterValidator.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Text.Json; - -namespace VisionaryCoder.Framework.Querying.Serialization; - -/// -/// Validates QueryFilter JSON against the JSON Schema. -/// -public static class QueryFilterSchemaValidator -{ - private static readonly Lazy schema = new(LoadSchema); - - private static JsonSchema LoadSchema() - { - string schemaPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ".json", "schemas", "queryfilter.schema.json"); - string schemaJson = File.ReadAllText(schemaPath); - return JsonSchema.FromJsonAsync(schemaJson).Result; - } - - /// - /// Validates a QueryFilter JSON string against the schema. - /// - /// The JSON string to validate. - /// A list of validation errors, or empty if valid. - public static IReadOnlyList Validate(string json) - { - ICollection errors = schema.Value.Validate(json); - return errors.Select(e => e.ToString()).ToList(); - } - - /// - /// Validates a QueryFilter JSON document against the schema. - /// - /// The JSON document to validate. - /// True if valid, false otherwise. - public static bool IsValid(JsonDocument jsonDocument) - { - string json = jsonDocument.RootElement.GetRawText(); - return schema.Value.Validate(json).Count == 0; - } -} diff --git a/src/VisionaryCoder.Framework/Querying/Serialization/QueryFilterRehydrator.cs b/src/VisionaryCoder.Framework/Querying/Serialization/QueryFilterRehydrator.cs index d454492..178b8a1 100644 --- a/src/VisionaryCoder.Framework/Querying/Serialization/QueryFilterRehydrator.cs +++ b/src/VisionaryCoder.Framework/Querying/Serialization/QueryFilterRehydrator.cs @@ -34,27 +34,27 @@ private static QueryFilter BuildPropertyFilter(PropertyFilter pf) private static QueryFilter BuildCompositeFilter(CompositeFilter cf) { - if (cf.Operator == "Not" && cf.Children.Count == 1) + if (cf is { Operator: "Not", Children.Count: 1 }) return cf.Children[0].ToQueryFilter().Not(); - if (cf.Operator == "And") - return cf.Children.Select(c => c.ToQueryFilter()).Join(useAnd: true); - - if (cf.Operator == "Or") - return cf.Children.Select(c => c.ToQueryFilter()).Join(useAnd: false); - - throw new NotSupportedException($"Unsupported composite operator {cf.Operator}"); + return cf.Operator switch + { + "And" => cf.Children.Select(c => c.ToQueryFilter()).Join(useAnd: true), + "Or" => cf.Children.Select(c => c.ToQueryFilter()).Join(useAnd: false), + _ => throw new NotSupportedException($"Unsupported composite operator {cf.Operator}") + }; } private static Expression CallStringMethod(Expression prop, string method, ConstantExpression constant, bool ignoreCase) { - if (ignoreCase) + if (!ignoreCase) { - MethodInfo toLower = typeof(string).GetMethod(nameof(string.ToLowerInvariant), Type.EmptyTypes)!; - MethodCallExpression loweredProp = Expression.Call(prop, toLower); - ConstantExpression loweredConst = Expression.Constant(((string)constant.Value!).ToLowerInvariant()); - return Expression.Call(loweredProp, typeof(string).GetMethod(method, new[] { typeof(string) })!, loweredConst); + return Expression.Call(prop, typeof(string).GetMethod(method, [typeof(string)])!, constant); } - return Expression.Call(prop, typeof(string).GetMethod(method, new[] { typeof(string) })!, constant); + + MethodInfo toLower = typeof(string).GetMethod(nameof(string.ToLowerInvariant), Type.EmptyTypes)!; + MethodCallExpression loweredProp = Expression.Call(prop, toLower); + ConstantExpression loweredConst = Expression.Constant(((string)constant.Value!).ToLowerInvariant()); + return Expression.Call(loweredProp, typeof(string).GetMethod(method, [typeof(string)])!, loweredConst); } } diff --git a/src/VisionaryCoder.Framework/Secrets/Azure/KeyVault/KeyVaultServiceCollectionExtensions.cs b/src/VisionaryCoder.Framework/Secrets/Azure/KeyVault/KeyVaultExtensions.cs similarity index 93% rename from src/VisionaryCoder.Framework/Secrets/Azure/KeyVault/KeyVaultServiceCollectionExtensions.cs rename to src/VisionaryCoder.Framework/Secrets/Azure/KeyVault/KeyVaultExtensions.cs index cc61c42..4f7d0f5 100644 --- a/src/VisionaryCoder.Framework/Secrets/Azure/KeyVault/KeyVaultServiceCollectionExtensions.cs +++ b/src/VisionaryCoder.Framework/Secrets/Azure/KeyVault/KeyVaultExtensions.cs @@ -1,3 +1,9 @@ +using Azure.Core; +using Azure.Identity; +using Azure.Security.KeyVault.Secrets; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using VisionaryCoder.Framework.Secrets.Local; namespace VisionaryCoder.Framework.Secrets.Azure.KeyVault; @@ -5,7 +11,7 @@ namespace VisionaryCoder.Framework.Secrets.Azure.KeyVault; /// /// Extension methods for configuring Azure Key Vault secret services. /// -public static class KeyVaultServiceCollectionExtensions +public static class KeyVaultExtensions { /// /// Adds Azure Key Vault secret provider to the service collection. diff --git a/src/VisionaryCoder.Framework/Secrets/Azure/KeyVault/KeyVaultSecretProvider.cs b/src/VisionaryCoder.Framework/Secrets/Azure/KeyVault/KeyVaultSecretProvider.cs index 5e5e74d..d820f9d 100644 --- a/src/VisionaryCoder.Framework/Secrets/Azure/KeyVault/KeyVaultSecretProvider.cs +++ b/src/VisionaryCoder.Framework/Secrets/Azure/KeyVault/KeyVaultSecretProvider.cs @@ -1,84 +1,83 @@ +using Azure; +using Azure.Security.KeyVault.Secrets; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + namespace VisionaryCoder.Framework.Secrets.Azure.KeyVault; +/// +/// Azure Key Vault implementation of ISecretProvider with caching support. +/// +public sealed class KeyVaultSecretProvider( + SecretClient client, + IOptions options, + IMemoryCache cache, + ILogger logger) + : ISecretProvider +{ + private readonly SecretClient client = client ?? throw new ArgumentNullException(nameof(client)); + private readonly IMemoryCache cache = cache ?? throw new ArgumentNullException(nameof(cache)); + private readonly ILogger logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly KeyVaultOptions options = options.Value ?? throw new ArgumentNullException(nameof(options)); + /// - /// Azure Key Vault implementation of ISecretProvider with caching support. + /// Retrieves a secret from Azure Key Vault with caching support. /// - public sealed class KeyVaultSecretProvider : ISecretProvider + public async Task GetAsync(string name, CancellationToken cancellationToken = default) { - private readonly SecretClient client; - private readonly IMemoryCache cache; - private readonly ILogger logger; - private readonly KeyVaultOptions options; - - public KeyVaultSecretProvider( - SecretClient client, - IOptions options, - IMemoryCache cache, - ILogger logger) + if (string.IsNullOrEmpty(name)) { - this.client = client ?? throw new ArgumentNullException(nameof(client)); - this.cache = cache ?? throw new ArgumentNullException(nameof(cache)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.options = options.Value ?? throw new ArgumentNullException(nameof(options)); + throw new ArgumentException("Secret name cannot be null or empty.", nameof(name)); } - - /// - /// Retrieves a secret from Azure Key Vault with caching support. - /// - public async Task GetAsync(string name, CancellationToken cancellationToken = default) + string cacheKey = $"secret:{name}"; + // Try cache first + if (cache.TryGetValue(cacheKey, out string? cachedValue)) { - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentException("Secret name cannot be null or empty.", nameof(name)); - } - string cacheKey = $"secret:{name}"; - // Try cache first - if (cache.TryGetValue(cacheKey, out string? cachedValue)) - { - logger.LogDebug("Secret '{SecretName}' retrieved from cache", name); - return cachedValue; - } - try + logger.LogDebug("Secret '{SecretName}' retrieved from cache", name); + return cachedValue; + } + try + { + logger.LogDebug("Retrieving secret '{SecretName}' from Key Vault", name); + Response? response = await client.GetSecretAsync(name, cancellationToken: cancellationToken); + string? value = response.Value?.Value; + if (!string.IsNullOrEmpty(value)) { - logger.LogDebug("Retrieving secret '{SecretName}' from Key Vault", name); - Response? response = await client.GetSecretAsync(name, cancellationToken: cancellationToken); - string? value = response.Value?.Value; - if (!string.IsNullOrEmpty(value)) - { - // Cache the secret with configured TTL - cache.Set(cacheKey, value, options.CacheTtl); - logger.LogDebug("Secret '{SecretName}' cached for {CacheTtl}", name, options.CacheTtl); - } - else - { - logger.LogWarning("Secret '{SecretName}' returned empty value from Key Vault", name); - } - return value; + // Cache the secret with configured TTL + cache.Set(cacheKey, value, options.CacheTtl); + logger.LogDebug("Secret '{SecretName}' cached for {CacheTtl}", name, options.CacheTtl); } - catch (Exception ex) + else { - logger.LogError(ex, "Failed to retrieve secret '{SecretName}' from Key Vault", name); - // Don't cache failures, but don't rethrow to allow fallback behavior - return null; + logger.LogWarning("Secret '{SecretName}' returned empty value from Key Vault", name); } + return value; } + catch (Exception ex) + { + logger.LogError(ex, "Failed to retrieve secret '{SecretName}' from Key Vault", name); + // Don't cache failures, but don't rethrow to allow fallback behavior + return null; + } + } - /// - /// Retrieves multiple secrets efficiently with parallel execution and caching. - /// - public async Task> GetMultipleAsync(IEnumerable names, CancellationToken cancellationToken = default) + /// + /// Retrieves multiple secrets efficiently with parallel execution and caching. + /// + public async Task> GetMultipleAsync(IEnumerable names, CancellationToken cancellationToken = default) + { + List secretNames = names?.ToList() ?? throw new ArgumentNullException(nameof(names)); + if (!secretNames.Any()) { - List secretNames = names?.ToList() ?? throw new ArgumentNullException(nameof(names)); - if (!secretNames.Any()) - { - return new Dictionary(); - } - logger.LogDebug("Retrieving {SecretCount} secrets from Key Vault", secretNames.Count); - var tasks = secretNames.Select(async name => - { - string? value = await GetAsync(name, cancellationToken); - return new { Name = name, Value = value }; - }); - var results = await Task.WhenAll(tasks); - return results.ToDictionary(r => r.Name, r => r.Value); + return new Dictionary(); } + logger.LogDebug("Retrieving {SecretCount} secrets from Key Vault", secretNames.Count); + var tasks = secretNames.Select(async name => + { + string? value = await GetAsync(name, cancellationToken); + return new { Name = name, Value = value }; + }); + var results = await Task.WhenAll(tasks); + return results.ToDictionary(r => r.Name, r => r.Value); } +} diff --git a/src/VisionaryCoder.Framework/Secrets/Local/LocalSecretProvider.cs b/src/VisionaryCoder.Framework/Secrets/Local/LocalSecretProvider.cs index 9bdfdf1..2076cfd 100644 --- a/src/VisionaryCoder.Framework/Secrets/Local/LocalSecretProvider.cs +++ b/src/VisionaryCoder.Framework/Secrets/Local/LocalSecretProvider.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Configuration; using VisionaryCoder.Framework.Secrets.Azure.KeyVault; namespace VisionaryCoder.Framework.Secrets.Local; diff --git a/src/VisionaryCoder.Framework/Secrets/SecretProviderServiceCollectionExtensions.cs b/src/VisionaryCoder.Framework/Secrets/SecretProviderServiceCollectionExtensions.cs deleted file mode 100644 index 7fca5e2..0000000 --- a/src/VisionaryCoder.Framework/Secrets/SecretProviderServiceCollectionExtensions.cs +++ /dev/null @@ -1,3 +0,0 @@ -// Deprecated: This file was a malformed draft of secrets configuration extensions. -// Use the implementations under Secrets\Azure\KeyVault (KeyVaultServiceCollectionExtensions) instead. -namespace VisionaryCoder.Framework.Secrets; diff --git a/src/VisionaryCoder.Framework/ServiceBase.cs b/src/VisionaryCoder.Framework/ServiceBase.cs index 8917af5..f8f1ccf 100644 --- a/src/VisionaryCoder.Framework/ServiceBase.cs +++ b/src/VisionaryCoder.Framework/ServiceBase.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; + namespace VisionaryCoder.Framework; /// diff --git a/src/VisionaryCoder.Framework/ServiceResult.cs b/src/VisionaryCoder.Framework/ServiceResult.cs index e52c30b..f40c801 100644 --- a/src/VisionaryCoder.Framework/ServiceResult.cs +++ b/src/VisionaryCoder.Framework/ServiceResult.cs @@ -1,61 +1,113 @@ namespace VisionaryCoder.Framework; -/// Non-generic result wrapper for operations that don't return a value. -public class ServiceResponse +/// +/// Base result class for all operation outcomes. +/// +public abstract class ServiceResultBase(bool isSuccess, string? errorMessage, Exception? exception) { - protected ServiceResponse(bool isSuccess, string? errorMessage, Exception? exception) - { - IsSuccess = isSuccess; - ErrorMessage = errorMessage; - Exception = exception; - } + /// + /// Gets a value indicating whether the operation was successful. + /// + public bool IsSuccess { get; } = isSuccess; + + /// + /// Gets a value indicating whether the operation failed. + /// + public bool IsFailure => !IsSuccess; - /// Creates a successful result. - public static ServiceResponse Success() + /// + /// Gets the error message if the operation failed. + /// + public string? ErrorMessage { get; } = errorMessage; + + /// + /// Gets the exception if the operation failed with an exception. + /// + public Exception? Exception { get; } = exception; +} + +/// +/// Result for operations that don't return a value. +/// +public sealed class ServiceResult : ServiceResultBase +{ + private ServiceResult(bool isSuccess, string? errorMessage, Exception? exception) + : base(isSuccess, errorMessage, exception) { - return new(true, null, null); } - public static ServiceResponse Failure(string errorMessage) + public static ServiceResult Success() => new(true, null, null); + public static ServiceResult Failure(string errorMessage) => new(false, errorMessage, null); + public static ServiceResult Failure(Exception exception) => new(false, exception.Message, exception); + public static ServiceResult Failure(string errorMessage, Exception exception) => new(false, errorMessage, exception); + + public void Match(Action onSuccess, Action onFailure) { - return new(false, errorMessage, null); + if (IsSuccess) + onSuccess(); + else + onFailure(ErrorMessage ?? "Unknown error", Exception); } +} - public static ServiceResponse Failure(Exception exception) +/// +/// Result for operations that return a value. +/// +/// The type of the result value. +public sealed class ServiceResult : ServiceResultBase +{ + private ServiceResult(bool isSuccess, T? value, string? errorMessage, Exception? exception) + : base(isSuccess, errorMessage, exception) { - return new(false, exception.Message, exception); + Value = value; } - public static ServiceResponse Failure(string errorMessage, Exception exception) + /// + /// Gets the result value if the operation was successful. + /// + public T? Value { get; } + + public static ServiceResult Success(T value) => new(true, value, null, null); + public static ServiceResult Failure(string errorMessage) => new(false, default, errorMessage, null); + public static ServiceResult Failure(Exception exception) => new(false, default, exception.Message, exception); + public static ServiceResult Failure(string errorMessage, Exception exception) => new(false, default, errorMessage, exception); + + public void Match(Action onSuccess, Action onFailure) { - return new(false, errorMessage, exception); + if (IsSuccess && Value is not null) + onSuccess(Value); + else + onFailure(ErrorMessage ?? "Unknown error", Exception); } - public void Match(Action onSuccess, Action onFailure) + public ServiceResult Map(Func mapper) { + if (!IsSuccess || Value is null) + return ServiceResult.Failure(ErrorMessage ?? "Value is null"); - if (IsSuccess) + try { - onSuccess(); + return ServiceResult.Success(mapper(Value)); } - else + catch (Exception ex) { - onFailure(ErrorMessage ?? "Unknown error", Exception); + return ServiceResult.Failure(ex); } - } - /// Gets a value indicating whether the operation was successful. - public bool IsSuccess { get; } - - /// Gets a value indicating whether the operation failed. - public bool IsFailure => !IsSuccess; - - /// Gets the error message if the operation failed. - public string? ErrorMessage { get; } - - /// Gets the exception if the operation failed with an exception. - public Exception? Exception { get; } + public async Task> MapAsync(Func> mapper) + { + if (!IsSuccess || Value is null) + return ServiceResult.Failure(ErrorMessage ?? "Value is null"); + try + { + TNew result = await mapper(Value); + return ServiceResult.Success(result); + } + catch (Exception ex) + { + return ServiceResult.Failure(ex); + } + } } - diff --git a/src/VisionaryCoder.Framework/ServiceResultOfType.cs b/src/VisionaryCoder.Framework/ServiceResultOfType.cs deleted file mode 100644 index 8a42e15..0000000 --- a/src/VisionaryCoder.Framework/ServiceResultOfType.cs +++ /dev/null @@ -1,85 +0,0 @@ -namespace VisionaryCoder.Framework; - -/// -/// Result wrapper for framework operations that provides consistent success/failure handling. -/// -/// The type of the result value. - -public class ServiceResponse : ServiceResponse -{ - - private ServiceResponse(bool isSuccess, T? value, string? errorMessage, Exception? exception) - : base(isSuccess, errorMessage, exception) - { - IsSuccess = isSuccess; - Value = value; - ErrorMessage = errorMessage; - Exception = exception; - } - - /// - /// Gets a value indicating whether the operation was successful. - /// - public new bool IsSuccess { get; } = false; - - /// Gets a value indicating whether the operation failed. - public new bool IsFailure => !IsSuccess; - - /// Gets the result value if the operation was successful. - public T? Value { get; } - - /// Gets the error message if the operation failed. - public new string? ErrorMessage { get; } = null; - - /// Gets the exception if the operation failed with an exception. - public new Exception? Exception { get; } - - /// Creates a successful result with a value. - /// The result value. - /// A successful result. - public static ServiceResponse Success(T value) => new(true, value, null, null); - - /// Creates a failed result with an error message. - /// The error message. - /// A failed result. - public static new ServiceResponse Failure(string errorMessage) => new(false, default, errorMessage, null); - - /// Creates a failed result with an exception. - /// The exception that caused the failure. - public static new ServiceResponse Failure(Exception exception) => new(false, default, exception.Message, exception); - /// Creates a failed result with an error message and exception. - public static new ServiceResponse Failure(string errorMessage, Exception exception) => new(false, default, errorMessage, exception); - - /// Matches the result and executes the appropriate action. - /// Action to execute if the result is successful. - /// Action to execute if the result is a failure. - public void Match(Action onSuccess, Action onFailure) - { - if (IsSuccess && Value is not null) - { - onSuccess(Value); - } - else - { - onFailure(ErrorMessage ?? "Unknown error", Exception); - } - } - - /// Maps the result value to a new type if the operation was successful. - /// The new result type. - /// Function to map the value. - /// A new result with the mapped value or the original failure. - public ServiceResponse Map(Func mapper) - { - try - { - return Value is null - ? ServiceResponse.Failure("Value is null.") - : ServiceResponse.Success(mapper(Value)); - } - catch (Exception ex) - { - return ServiceResponse.Failure(ex); - } - } -} diff --git a/src/VisionaryCoder.Framework/Storage/Azure/Blob/AzureBlobStorageOptions.cs b/src/VisionaryCoder.Framework/Storage/Azure/Blob/AzureBlobStorageOptions.cs index e8dd562..28e99bd 100644 --- a/src/VisionaryCoder.Framework/Storage/Azure/Blob/AzureBlobStorageOptions.cs +++ b/src/VisionaryCoder.Framework/Storage/Azure/Blob/AzureBlobStorageOptions.cs @@ -1,4 +1,6 @@ -namespace VisionaryCoder.Framework.Storage.Azure; +using Azure.Storage.Blobs.Models; + +namespace VisionaryCoder.Framework.Storage.Azure.Blob; /// /// Configuration options for Azure Blob Storage operations. @@ -56,11 +58,11 @@ public sealed class AzureBlobStorageOptions /// public void Validate() { - ArgumentException.ThrowIfNullOrWhiteSpace(ContainerName, nameof(ContainerName)); + ArgumentException.ThrowIfNullOrWhiteSpace(ContainerName); if (UseManagedIdentity) { - ArgumentException.ThrowIfNullOrWhiteSpace(StorageAccountUri, nameof(StorageAccountUri)); + ArgumentException.ThrowIfNullOrWhiteSpace(StorageAccountUri); if (!Uri.TryCreate(StorageAccountUri, UriKind.Absolute, out _)) { throw new ArgumentException("StorageAccountUri must be a valid absolute URI.", nameof(StorageAccountUri)); @@ -68,7 +70,7 @@ public void Validate() } else { - ArgumentException.ThrowIfNullOrWhiteSpace(ConnectionString, nameof(ConnectionString)); + ArgumentException.ThrowIfNullOrWhiteSpace(ConnectionString); } if (TimeoutMilliseconds <= 0) diff --git a/src/VisionaryCoder.Framework/Storage/Azure/Blob/AzureBlobStorageProvider.cs b/src/VisionaryCoder.Framework/Storage/Azure/Blob/AzureBlobStorageProvider.cs index ef4fa21..4c3d118 100644 --- a/src/VisionaryCoder.Framework/Storage/Azure/Blob/AzureBlobStorageProvider.cs +++ b/src/VisionaryCoder.Framework/Storage/Azure/Blob/AzureBlobStorageProvider.cs @@ -1,7 +1,12 @@ +using Azure; +using Azure.Identity; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Microsoft.Extensions.Logging; using System.Runtime.CompilerServices; using System.Text; -namespace VisionaryCoder.Framework.Storage.Azure; +namespace VisionaryCoder.Framework.Storage.Azure.Blob; /// /// Provides Azure Blob Storage-based storage operations implementation following Microsoft I/O patterns. @@ -296,7 +301,7 @@ public DirectoryInfo CreateDirectory(string path) string blobName = NormalizeBlobName(directoryMarkerPath); BlobClient? blobClient = containerClient.GetBlobClient(blobName); - using var emptyStream = new MemoryStream(Array.Empty()); + using var emptyStream = new MemoryStream([]); blobClient.Upload(emptyStream, overwrite: true); Logger.LogTrace("Successfully created directory '{Path}'", path); @@ -322,7 +327,7 @@ public async Task CreateDirectoryAsync(string path, CancellationT string blobName = NormalizeBlobName(directoryMarkerPath); BlobClient? blobClient = containerClient.GetBlobClient(blobName); - using var emptyStream = new MemoryStream(Array.Empty()); + using var emptyStream = new MemoryStream([]); await blobClient.UploadAsync(emptyStream, overwrite: true, cancellationToken: cancellationToken); Logger.LogTrace("Successfully created directory async '{Path}'", path); diff --git a/src/VisionaryCoder.Framework/Storage/Azure/Queue/AzureQueueStorageOptions.cs b/src/VisionaryCoder.Framework/Storage/Azure/Queue/AzureQueueStorageOptions.cs index 48ee9bd..ad73fd9 100644 --- a/src/VisionaryCoder.Framework/Storage/Azure/Queue/AzureQueueStorageOptions.cs +++ b/src/VisionaryCoder.Framework/Storage/Azure/Queue/AzureQueueStorageOptions.cs @@ -1,4 +1,4 @@ -namespace VisionaryCoder.Framework.Storage.Azure; +namespace VisionaryCoder.Framework.Storage.Azure.Queue; /// /// Configuration options for Azure Queue Storage operations. @@ -62,11 +62,11 @@ public sealed class AzureQueueStorageOptions /// public void Validate() { - ArgumentException.ThrowIfNullOrWhiteSpace(QueueName, nameof(QueueName)); + ArgumentException.ThrowIfNullOrWhiteSpace(QueueName); if (UseManagedIdentity) { - ArgumentException.ThrowIfNullOrWhiteSpace(StorageAccountUri, nameof(StorageAccountUri)); + ArgumentException.ThrowIfNullOrWhiteSpace(StorageAccountUri); if (!Uri.TryCreate(StorageAccountUri, UriKind.Absolute, out _)) { throw new ArgumentException("StorageAccountUri must be a valid absolute URI.", nameof(StorageAccountUri)); @@ -74,7 +74,7 @@ public void Validate() } else { - ArgumentException.ThrowIfNullOrWhiteSpace(ConnectionString, nameof(ConnectionString)); + ArgumentException.ThrowIfNullOrWhiteSpace(ConnectionString); } if (TimeoutMilliseconds <= 0) diff --git a/src/VisionaryCoder.Framework/Storage/Azure/Queue/AzureQueueStorageProvider.cs b/src/VisionaryCoder.Framework/Storage/Azure/Queue/AzureQueueStorageProvider.cs index 173a68f..958f7c1 100644 --- a/src/VisionaryCoder.Framework/Storage/Azure/Queue/AzureQueueStorageProvider.cs +++ b/src/VisionaryCoder.Framework/Storage/Azure/Queue/AzureQueueStorageProvider.cs @@ -1,7 +1,12 @@ +using Azure; +using Azure.Identity; +using Azure.Storage.Queues; +using Azure.Storage.Queues.Models; +using Microsoft.Extensions.Logging; using System.Text; using System.Text.Json; -namespace VisionaryCoder.Framework.Storage.Azure; +namespace VisionaryCoder.Framework.Storage.Azure.Queue; /// /// Provides Azure Queue Storage-based message queue operations implementation. diff --git a/src/VisionaryCoder.Framework/Storage/Azure/Table/AzureTableStorageOptions.cs b/src/VisionaryCoder.Framework/Storage/Azure/Table/AzureTableStorageOptions.cs index b8149ad..33186b9 100644 --- a/src/VisionaryCoder.Framework/Storage/Azure/Table/AzureTableStorageOptions.cs +++ b/src/VisionaryCoder.Framework/Storage/Azure/Table/AzureTableStorageOptions.cs @@ -1,4 +1,4 @@ -namespace VisionaryCoder.Framework.Storage.Azure; +namespace VisionaryCoder.Framework.Storage.Azure.Table; /// /// Configuration options for Azure Table Storage operations. @@ -66,11 +66,11 @@ public sealed class AzureTableStorageOptions /// public void Validate() { - ArgumentException.ThrowIfNullOrWhiteSpace(TableName, nameof(TableName)); + ArgumentException.ThrowIfNullOrWhiteSpace(TableName); if (UseManagedIdentity) { - ArgumentException.ThrowIfNullOrWhiteSpace(StorageAccountUri, nameof(StorageAccountUri)); + ArgumentException.ThrowIfNullOrWhiteSpace(StorageAccountUri); if (!Uri.TryCreate(StorageAccountUri, UriKind.Absolute, out _)) { throw new ArgumentException("StorageAccountUri must be a valid absolute URI.", nameof(StorageAccountUri)); @@ -78,7 +78,7 @@ public void Validate() } else { - ArgumentException.ThrowIfNullOrWhiteSpace(ConnectionString, nameof(ConnectionString)); + ArgumentException.ThrowIfNullOrWhiteSpace(ConnectionString); } if (TimeoutMilliseconds <= 0) diff --git a/src/VisionaryCoder.Framework/Storage/Azure/Table/AzureTableStorageProvider.cs b/src/VisionaryCoder.Framework/Storage/Azure/Table/AzureTableStorageProvider.cs index a6b1482..fc1c3fa 100644 --- a/src/VisionaryCoder.Framework/Storage/Azure/Table/AzureTableStorageProvider.cs +++ b/src/VisionaryCoder.Framework/Storage/Azure/Table/AzureTableStorageProvider.cs @@ -1,6 +1,11 @@ +using Azure; +using Azure.Data.Tables; +using Azure.Data.Tables.Models; +using Azure.Identity; +using Microsoft.Extensions.Logging; using System.Runtime.CompilerServices; -namespace VisionaryCoder.Framework.Storage.Azure; +namespace VisionaryCoder.Framework.Storage.Azure.Table; /// /// Provides Azure Table Storage-based NoSQL table operations implementation. @@ -55,8 +60,8 @@ public bool TableExists() { Logger.LogTrace("Table existence check for '{TableName}'", options.TableName); - NullableResponse response = tableServiceClient.Query(filter: $"TableName eq '{options.TableName}'").FirstOrDefault(); - bool exists = response != null; + TableItem? item = tableServiceClient.Query(filter: $"TableName eq '{options.TableName}'").FirstOrDefault(); + bool exists = item is not null; Logger.LogTrace("Table existence check for '{TableName}': {Exists}", options.TableName, exists); return exists; diff --git a/src/VisionaryCoder.Framework/Storage/Ftp/FtpStorageProvider.cs b/src/VisionaryCoder.Framework/Storage/Ftp/FtpStorageProvider.cs index f83e5f2..1f12a5f 100644 --- a/src/VisionaryCoder.Framework/Storage/Ftp/FtpStorageProvider.cs +++ b/src/VisionaryCoder.Framework/Storage/Ftp/FtpStorageProvider.cs @@ -1,3 +1,5 @@ +using FluentFTP; +using Microsoft.Extensions.Logging; using System.Net; using System.Runtime.CompilerServices; using System.Text; diff --git a/src/VisionaryCoder.Framework/Storage/Local/LocalStorageProvider.cs b/src/VisionaryCoder.Framework/Storage/Local/LocalStorageProvider.cs index 22a028f..9cb08e3 100644 --- a/src/VisionaryCoder.Framework/Storage/Local/LocalStorageProvider.cs +++ b/src/VisionaryCoder.Framework/Storage/Local/LocalStorageProvider.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; + namespace VisionaryCoder.Framework.Storage.Local; public class LocalStorageProvider(ILogger logger) : IStorageProvider diff --git a/src/VisionaryCoder.Framework/Storage/StorageServiceCollectionExtensions.cs b/src/VisionaryCoder.Framework/Storage/StorageExtensions.cs similarity index 91% rename from src/VisionaryCoder.Framework/Storage/StorageServiceCollectionExtensions.cs rename to src/VisionaryCoder.Framework/Storage/StorageExtensions.cs index f52b88f..461d067 100644 --- a/src/VisionaryCoder.Framework/Storage/StorageServiceCollectionExtensions.cs +++ b/src/VisionaryCoder.Framework/Storage/StorageExtensions.cs @@ -1,4 +1,6 @@ -using VisionaryCoder.Framework.Storage.Azure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using VisionaryCoder.Framework.Storage.Azure.Blob; using VisionaryCoder.Framework.Storage.Ftp; using VisionaryCoder.Framework.Storage.Local; @@ -7,7 +9,7 @@ namespace VisionaryCoder.Framework.Storage; /// /// Extension methods for registering storage services with dependency injection. /// -public static class StorageServiceCollectionExtensions +public static class StorageExtensions { /// @@ -55,7 +57,7 @@ public static IServiceCollection AddAzureBlobStorage(this IServiceCollection ser return services; } - /// + /// /// Registers the Azure Blob storage provider implementation. /// public static IServiceCollection AddAzureBlobStorage(this IServiceCollection services, string name, AzureBlobStorageOptions options) diff --git a/src/VisionaryCoder.Framework/Storage/StorageRegistrationBuilder.cs b/src/VisionaryCoder.Framework/Storage/StorageRegistrationBuilder.cs index 80544e4..0c9b645 100644 --- a/src/VisionaryCoder.Framework/Storage/StorageRegistrationBuilder.cs +++ b/src/VisionaryCoder.Framework/Storage/StorageRegistrationBuilder.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using VisionaryCoder.Framework.Storage.Ftp; using VisionaryCoder.Framework.Storage.Local; diff --git a/src/VisionaryCoder.Framework/Storage/StorageService.cs b/src/VisionaryCoder.Framework/Storage/StorageService.cs index f248623..dec4d69 100644 --- a/src/VisionaryCoder.Framework/Storage/StorageService.cs +++ b/src/VisionaryCoder.Framework/Storage/StorageService.cs @@ -1,23 +1,17 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.Logging; + namespace VisionaryCoder.Framework.Storage; /// /// Provides storage operations for files and data. /// -public class StorageService +public class StorageService(ILogger logger) : ServiceBase(logger) { - private readonly ILogger logger; - /// - /// Initializes a new instance of the class. - /// - /// The logger instance. - public StorageService(ILogger logger) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } + private readonly ILogger logger = logger ?? throw new ArgumentNullException(nameof(logger)); // File operations public bool FileExists(string path) @@ -136,7 +130,7 @@ public string[] GetDirectories(string path, string searchPattern) public async IAsyncEnumerable EnumerateFilesAsync(string path) { await Task.Yield(); - foreach (var file in Directory.EnumerateFiles(path)) + foreach (string file in Directory.EnumerateFiles(path)) { yield return file; } @@ -145,7 +139,7 @@ public async IAsyncEnumerable EnumerateFilesAsync(string path) public async IAsyncEnumerable EnumerateFilesAsync(string path, string searchPattern) { await Task.Yield(); - foreach (var file in Directory.EnumerateFiles(path, searchPattern)) + foreach (string file in Directory.EnumerateFiles(path, searchPattern)) { yield return file; } @@ -154,7 +148,7 @@ public async IAsyncEnumerable EnumerateFilesAsync(string path, string se public async IAsyncEnumerable EnumerateFilesAsync(string path, string searchPattern, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { await Task.Yield(); - foreach (var file in Directory.EnumerateFiles(path, searchPattern)) + foreach (string file in Directory.EnumerateFiles(path, searchPattern)) { cancellationToken.ThrowIfCancellationRequested(); yield return file; diff --git a/src/VisionaryCoder.Framework/VisionaryCoder.Framework.csproj b/src/VisionaryCoder.Framework/VisionaryCoder.Framework.csproj index d9fda87..4e22db2 100644 --- a/src/VisionaryCoder.Framework/VisionaryCoder.Framework.csproj +++ b/src/VisionaryCoder.Framework/VisionaryCoder.Framework.csproj @@ -1,60 +1,88 @@ ο»Ώ - - - net8.0 - enable - enable - true - VisionaryCoder.Framework - VisionaryCoder Framework - Core Library - Core framework library providing foundational features and utilities for the VisionaryCoder framework following Microsoft best practices. - Ivan Jones - VisionaryCoder - VisionaryCoder Framework - framework;core;library;microsoft;patterns - https://github.com/visionarycoder/vc - git - main - MIT - README.md - See CHANGELOG.md or GitHub releases for detailed release notes. - - - - - - + + + net8.0 + + enable + enable + latest + + true + + VisionaryCoder.Framework + 1.0.0 + VisionaryCoder + VisionaryCoder + VisionaryCoder Framework + + VisionaryCoder.Framework Library + Core framework library providing foundational features and utilities for the VisionaryCoder framework following Microsoft best practices. + framework;core;library;microsoft;patterns + MIT + README.md + See CHANGELOG.md or GitHub releases for detailed release notes. + + https://github.com/visionarycoder/Framework + git + main + - + + + + Schemas\queryfilter.schema.json VisionaryCoder.Framework.Schemas.queryfilter.schema.json - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/VisionaryCoder.Framework.Tests/AssemblyInfo.cs b/tests/VisionaryCoder.Framework.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..f7539ed --- /dev/null +++ b/tests/VisionaryCoder.Framework.Tests/AssemblyInfo.cs @@ -0,0 +1,5 @@ + + +// Enable parallel test execution to satisfy MSTEST0001 analyzer warning. +// Workers = 0 lets the test framework choose an appropriate number of threads. +[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.MethodLevel)] diff --git a/tests/VisionaryCoder.Framework.Tests/Authentication/AuthenticationServiceCollectionExtensionsTests.cs b/tests/VisionaryCoder.Framework.Tests/Authentication/AuthenticationServiceCollectionExtensionsTests.cs index 1d79495..ea85131 100644 --- a/tests/VisionaryCoder.Framework.Tests/Authentication/AuthenticationServiceCollectionExtensionsTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Authentication/AuthenticationServiceCollectionExtensionsTests.cs @@ -1,9 +1,10 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Authentication; -using VisionaryCoder.Framework.Authentication.Providers; -using VisionaryCoder.Framework.Authentication.Jwt; +using Microsoft.Extensions.DependencyInjection; +using VisionaryCoder.Framework.Proxy.Interceptors.Authentication; +using VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Jwt; +using VisionaryCoder.Framework.Proxy.Interceptors.Authentication.Providers; namespace VisionaryCoder.Framework.Tests.Authentication; @@ -38,17 +39,17 @@ public void UseDefaultAuthenticationProviders_ShouldRegisterDefaultProviders() services.UseDefaultAuthenticationProviders(); // Assert - var serviceProvider = services.BuildServiceProvider(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); - var userProvider = serviceProvider.GetService(); + IUserContextProvider? userProvider = serviceProvider.GetService(); userProvider.Should().NotBeNull(); userProvider.Should().BeOfType(); - var tenantProvider = serviceProvider.GetService(); + ITenantContextProvider? tenantProvider = serviceProvider.GetService(); tenantProvider.Should().NotBeNull(); tenantProvider.Should().BeOfType(); - var tokenProvider = serviceProvider.GetService(); + ITokenProvider? tokenProvider = serviceProvider.GetService(); tokenProvider.Should().NotBeNull(); tokenProvider.Should().BeOfType(); } @@ -71,8 +72,8 @@ public void ReplaceUserContextProvider_ShouldReplaceWithSpecifiedProvider() services.ReplaceUserContextProvider(); // Assert - var serviceProvider = services.BuildServiceProvider(); - var provider = serviceProvider.GetService(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + IUserContextProvider? provider = serviceProvider.GetService(); provider.Should().NotBeNull(); provider.Should().BeOfType(); } @@ -92,8 +93,8 @@ public void ReplaceUserContextProvider_CalledMultipleTimes_ShouldUseLastRegistra services.ReplaceUserContextProvider(); // Assert - var serviceProvider = services.BuildServiceProvider(); - var provider = serviceProvider.GetService(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + IUserContextProvider? provider = serviceProvider.GetService(); provider.Should().BeOfType(); } @@ -115,8 +116,8 @@ public void ReplaceTenantContextProvider_ShouldReplaceWithSpecifiedProvider() services.ReplaceTenantContextProvider(); // Assert - var serviceProvider = services.BuildServiceProvider(); - var provider = serviceProvider.GetService(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + ITenantContextProvider? provider = serviceProvider.GetService(); provider.Should().NotBeNull(); provider.Should().BeOfType(); } @@ -136,8 +137,8 @@ public void ReplaceTenantContextProvider_CalledMultipleTimes_ShouldUseLastRegist services.ReplaceTenantContextProvider(); // Assert - var serviceProvider = services.BuildServiceProvider(); - var provider = serviceProvider.GetService(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + ITenantContextProvider? provider = serviceProvider.GetService(); provider.Should().BeOfType(); } @@ -159,8 +160,8 @@ public void ReplaceTokenProvider_ShouldReplaceWithSpecifiedProvider() services.ReplaceTokenProvider(); // Assert - var serviceProvider = services.BuildServiceProvider(); - var provider = serviceProvider.GetService(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + ITokenProvider? provider = serviceProvider.GetService(); provider.Should().NotBeNull(); provider.Should().BeOfType(); } @@ -180,15 +181,15 @@ public void AddJwtAuthentication_ShouldRegisterNullProvidersByDefault() }); // Assert - var serviceProvider = services.BuildServiceProvider(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); - var userProvider = serviceProvider.GetService(); + IUserContextProvider? userProvider = serviceProvider.GetService(); userProvider.Should().BeOfType(); - var tenantProvider = serviceProvider.GetService(); + ITenantContextProvider? tenantProvider = serviceProvider.GetService(); tenantProvider.Should().BeOfType(); - var tokenProvider = serviceProvider.GetService(); + ITokenProvider? tokenProvider = serviceProvider.GetService(); tokenProvider.Should().BeOfType(); } diff --git a/tests/VisionaryCoder.Framework.Tests/Authentication/TenantContextTests.cs b/tests/VisionaryCoder.Framework.Tests/Authentication/TenantContextTests.cs index 180ef5e..ba8570a 100644 --- a/tests/VisionaryCoder.Framework.Tests/Authentication/TenantContextTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Authentication/TenantContextTests.cs @@ -1,7 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Authentication; +using VisionaryCoder.Framework.Proxy.Interceptors.Authentication; namespace VisionaryCoder.Framework.Tests.Authentication; @@ -137,8 +137,8 @@ public void Settings_ShouldBeModifiable() public void CreatedAt_ShouldHaveReasonableDefault() { // Arrange - var beforeCreation = DateTimeOffset.UtcNow.AddSeconds(-1); - var afterCreation = DateTimeOffset.UtcNow.AddSeconds(1); + DateTimeOffset beforeCreation = DateTimeOffset.UtcNow.AddSeconds(-1); + DateTimeOffset afterCreation = DateTimeOffset.UtcNow.AddSeconds(1); // Act var newContext = new TenantContext(); @@ -428,7 +428,7 @@ public void TenantContext_ShouldHandleComplexSettingValues() { // Arrange var complexObject = new { Name = "Config", Limits = new[] { 10, 20, 30 } }; - var dateTime = DateTimeOffset.UtcNow; + DateTimeOffset dateTime = DateTimeOffset.UtcNow; // Act tenantContext.Settings["complex"] = complexObject; @@ -470,7 +470,7 @@ public void GetSetting_WithComplexTypes_ShouldWork() tenantContext.Settings["config"] = complexConfig; // Act - var retrieved = tenantContext.GetSetting("config"); + object? retrieved = tenantContext.GetSetting("config"); // Assert retrieved.Should().Be(complexConfig, "Should retrieve complex objects correctly"); diff --git a/tests/VisionaryCoder.Framework.Tests/Authentication/UserContextTests.cs b/tests/VisionaryCoder.Framework.Tests/Authentication/UserContextTests.cs index 95b7f3f..7e29d62 100644 --- a/tests/VisionaryCoder.Framework.Tests/Authentication/UserContextTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Authentication/UserContextTests.cs @@ -1,7 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Authentication; +using VisionaryCoder.Framework.Proxy.Interceptors.Authentication; namespace VisionaryCoder.Framework.Tests.Authentication; @@ -156,8 +156,8 @@ public void Claims_ShouldBeModifiable() public void AuthenticatedAt_ShouldHaveReasonableDefault() { // Arrange - var beforeCreation = DateTimeOffset.UtcNow.AddSeconds(-1); - var afterCreation = DateTimeOffset.UtcNow.AddSeconds(1); + DateTimeOffset beforeCreation = DateTimeOffset.UtcNow.AddSeconds(-1); + DateTimeOffset afterCreation = DateTimeOffset.UtcNow.AddSeconds(1); // Act var newContext = new UserContext(); @@ -398,7 +398,7 @@ public void UserContext_ShouldHandleComplexClaimValues() { // Arrange var complexObject = new { Name = "Test", Values = new[] { 1, 2, 3 } }; - var dateTime = DateTimeOffset.UtcNow; + DateTimeOffset dateTime = DateTimeOffset.UtcNow; // Act userContext.Claims["complex"] = complexObject; diff --git a/tests/VisionaryCoder.Framework.Tests/Authorization/AuthorizationServiceCollectionExtensionsTests.cs b/tests/VisionaryCoder.Framework.Tests/Authorization/AuthorizationServiceCollectionExtensionsTests.cs index 4cc7b6a..a49319f 100644 --- a/tests/VisionaryCoder.Framework.Tests/Authorization/AuthorizationServiceCollectionExtensionsTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Authorization/AuthorizationServiceCollectionExtensionsTests.cs @@ -1,7 +1,8 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Authorization.Policies; +using Microsoft.Extensions.DependencyInjection; +using VisionaryCoder.Framework.Proxy.Interceptors.Authorization.Policies; namespace VisionaryCoder.Framework.Tests.Authorization; @@ -29,8 +30,8 @@ public void AddRoleBasedAuthorizationPolicy_ShouldRegisterCorrectly() services.AddScoped(); // Assert - var serviceProvider = services.BuildServiceProvider(); - var policy = serviceProvider.GetService(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + IAuthorizationPolicy? policy = serviceProvider.GetService(); policy.Should().NotBeNull(); policy.Should().BeOfType(); } @@ -42,8 +43,8 @@ public void AddNullAuthorizationPolicy_ShouldRegisterCorrectly() services.AddScoped(); // Assert - var serviceProvider = services.BuildServiceProvider(); - var policy = serviceProvider.GetService(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + IAuthorizationPolicy? policy = serviceProvider.GetService(); policy.Should().NotBeNull(); policy.Should().BeOfType(); } @@ -56,8 +57,8 @@ public void AddMultipleAuthorizationPolicies_ShouldRegisterAll() services.AddScoped(); // Assert - var serviceProvider = services.BuildServiceProvider(); - var policies = serviceProvider.GetServices(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + IEnumerable policies = serviceProvider.GetServices(); policies.Should().HaveCount(2); policies.Should().ContainSingle(p => p.GetType() == typeof(RoleBasedAuthorizationPolicy)); policies.Should().ContainSingle(p => p.GetType() == typeof(NullAuthorizationPolicy)); @@ -70,8 +71,8 @@ public void RegisterAuthorizationPolicies_ShouldUseCorrectServiceLifetime() services.AddScoped(); // Assert - var descriptor = services.FirstOrDefault(s => s.ServiceType == typeof(IAuthorizationPolicy) - && s.ImplementationType == typeof(RoleBasedAuthorizationPolicy)); + ServiceDescriptor? descriptor = services.FirstOrDefault(s => s.ServiceType == typeof(IAuthorizationPolicy) + && s.ImplementationType == typeof(RoleBasedAuthorizationPolicy)); descriptor.Should().NotBeNull(); descriptor!.Lifetime.Should().Be(ServiceLifetime.Scoped); } diff --git a/tests/VisionaryCoder.Framework.Tests/Authorization/Results/AuthorizationResultTests.cs b/tests/VisionaryCoder.Framework.Tests/Authorization/Results/AuthorizationResultTests.cs index 29aa7d6..17860b9 100644 --- a/tests/VisionaryCoder.Framework.Tests/Authorization/Results/AuthorizationResultTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Authorization/Results/AuthorizationResultTests.cs @@ -1,7 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Authorization.Results; +using VisionaryCoder.Framework.Proxy.Interceptors.Authorization.Results; namespace VisionaryCoder.Framework.Tests.Authorization.Results; @@ -233,7 +233,7 @@ public void Failure_ResultShouldBeModifiable() public void Failure_WithLongReason_ShouldHandleGracefully() { // Arrange - var longReason = new string('A', 1000) + " - Access denied due to insufficient permissions for the requested resource"; + string longReason = new string('A', 1000) + " - Access denied due to insufficient permissions for the requested resource"; // Act var result = AuthorizationResult.Failure(longReason); @@ -247,7 +247,7 @@ public void Failure_WithLongReason_ShouldHandleGracefully() public void Failure_WithSpecialCharacters_ShouldHandleGracefully() { // Arrange - var specialReason = "Access denied: η”¨ζˆ·ζƒι™δΈθΆ³ Γ±oΓ±Γ³ @#$%^&*()"; + string specialReason = "Access denied: η”¨ζˆ·ζƒι™δΈθΆ³ Γ±oΓ±Γ³ @#$%^&*()"; // Act var result = AuthorizationResult.Failure(specialReason); @@ -398,7 +398,7 @@ public void AuthorizationResult_StaticMethodsShouldBeThreadSafe() } } - var results = Task.WhenAll(tasks).Result; + AuthorizationResult[] results = Task.WhenAll(tasks).Result; // Assert var successResults = results.Where(r => r.IsAuthorized).ToList(); diff --git a/tests/VisionaryCoder.Framework.Tests/BasicTests.cs b/tests/VisionaryCoder.Framework.Tests/BasicTests.cs index 9b4ed46..e231112 100644 --- a/tests/VisionaryCoder.Framework.Tests/BasicTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/BasicTests.cs @@ -1,6 +1,8 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Microsoft.Extensions.DependencyInjection; + namespace VisionaryCoder.Framework.Tests; /// diff --git a/tests/VisionaryCoder.Framework.Tests/Caching/CachingServiceCollectionExtensionsTests.cs b/tests/VisionaryCoder.Framework.Tests/Caching/CachingServiceCollectionExtensionsTests.cs index ea7f4f3..505062a 100644 --- a/tests/VisionaryCoder.Framework.Tests/Caching/CachingServiceCollectionExtensionsTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Caching/CachingServiceCollectionExtensionsTests.cs @@ -1,8 +1,13 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Caching; -using VisionaryCoder.Framework.Caching.Providers; +using Microsoft.Extensions.DependencyInjection; +using VisionaryCoder.Framework.Proxy.Interceptors.Caching; +using VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers; +using DefaultCacheKeyProvider = VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers.DefaultCacheKeyProvider; +using DefaultCachePolicyProvider = VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers.DefaultCachePolicyProvider; +using ICacheKeyProvider = VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers.ICacheKeyProvider; +using ICachePolicyProvider = VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers.ICachePolicyProvider; namespace VisionaryCoder.Framework.Tests.Caching; @@ -30,17 +35,17 @@ public void AddCaching_ShouldRegisterNullProvidersByDefault() services.AddCaching(); // Assert - var serviceProvider = services.BuildServiceProvider(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); - var keyProvider = serviceProvider.GetService(); + ICacheKeyProvider? keyProvider = serviceProvider.GetService(); keyProvider.Should().NotBeNull(); keyProvider.Should().BeOfType(); - var policyProvider = serviceProvider.GetService(); + ICachePolicyProvider? policyProvider = serviceProvider.GetService(); policyProvider.Should().NotBeNull(); policyProvider.Should().BeOfType(); - var cache = serviceProvider.GetService(); + IProxyCache? cache = serviceProvider.GetService(); cache.Should().NotBeNull(); cache.Should().BeOfType(); } @@ -56,8 +61,8 @@ public void AddCaching_WithConfiguration_ShouldApplyOptions() }); // Assert - var serviceProvider = services.BuildServiceProvider(); - var cache = serviceProvider.GetService(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + IProxyCache? cache = serviceProvider.GetService(); cache.Should().NotBeNull(); } @@ -68,8 +73,8 @@ public void AddCaching_WithTimeSpan_ShouldRegisterWithDefaultDuration() services.AddCaching(TimeSpan.FromMinutes(15)); // Assert - var serviceProvider = services.BuildServiceProvider(); - var cache = serviceProvider.GetService(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + IProxyCache? cache = serviceProvider.GetService(); cache.Should().NotBeNull(); cache.Should().BeOfType(); } @@ -85,16 +90,16 @@ public void AddCaching_WithGenericCache_ShouldRegisterSpecifiedCache() services.AddCaching(); // Assert - var serviceProvider = services.BuildServiceProvider(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); - var cache = serviceProvider.GetService(); + IProxyCache? cache = serviceProvider.GetService(); cache.Should().NotBeNull(); cache.Should().BeOfType(); - var keyProvider = serviceProvider.GetService(); + ICacheKeyProvider? keyProvider = serviceProvider.GetService(); keyProvider.Should().BeOfType(); - var policyProvider = serviceProvider.GetService(); + ICachePolicyProvider? policyProvider = serviceProvider.GetService(); policyProvider.Should().BeOfType(); } @@ -105,15 +110,15 @@ public void AddCaching_WithGenericProviders_ShouldRegisterSpecifiedProviders() services.AddCaching(); // Assert - var serviceProvider = services.BuildServiceProvider(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); - var keyProvider = serviceProvider.GetService(); + ICacheKeyProvider? keyProvider = serviceProvider.GetService(); keyProvider.Should().BeOfType(); - var policyProvider = serviceProvider.GetService(); + ICachePolicyProvider? policyProvider = serviceProvider.GetService(); policyProvider.Should().BeOfType(); - var cache = serviceProvider.GetService(); + IProxyCache? cache = serviceProvider.GetService(); cache.Should().BeOfType(); } @@ -132,8 +137,8 @@ public void AddDistributedCaching_ShouldRegisterCachingWithConfiguration() }); // Assert - var serviceProvider = services.BuildServiceProvider(); - var cache = serviceProvider.GetService(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + IProxyCache? cache = serviceProvider.GetService(); cache.Should().NotBeNull(); } @@ -148,15 +153,15 @@ public void AddCaching_ShouldRegisterProvidersAsSingleton() services.AddCaching(); // Assert - var keyProviderDescriptor = services.FirstOrDefault(s => s.ServiceType == typeof(ICacheKeyProvider)); + ServiceDescriptor? keyProviderDescriptor = services.FirstOrDefault(s => s.ServiceType == typeof(ICacheKeyProvider)); keyProviderDescriptor.Should().NotBeNull(); keyProviderDescriptor!.Lifetime.Should().Be(ServiceLifetime.Singleton); - var policyProviderDescriptor = services.FirstOrDefault(s => s.ServiceType == typeof(ICachePolicyProvider)); + ServiceDescriptor? policyProviderDescriptor = services.FirstOrDefault(s => s.ServiceType == typeof(ICachePolicyProvider)); policyProviderDescriptor.Should().NotBeNull(); policyProviderDescriptor!.Lifetime.Should().Be(ServiceLifetime.Singleton); - var cacheDescriptor = services.FirstOrDefault(s => s.ServiceType == typeof(IProxyCache)); + ServiceDescriptor? cacheDescriptor = services.FirstOrDefault(s => s.ServiceType == typeof(IProxyCache)); cacheDescriptor.Should().NotBeNull(); cacheDescriptor!.Lifetime.Should().Be(ServiceLifetime.Singleton); } diff --git a/tests/VisionaryCoder.Framework.Tests/Caching/Providers/DefaultCacheKeyProviderTests.cs b/tests/VisionaryCoder.Framework.Tests/Caching/Providers/DefaultCacheKeyProviderTests.cs index a4300e8..5f9ecf5 100644 --- a/tests/VisionaryCoder.Framework.Tests/Caching/Providers/DefaultCacheKeyProviderTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Caching/Providers/DefaultCacheKeyProviderTests.cs @@ -1,8 +1,8 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Caching.Providers; using VisionaryCoder.Framework.Proxy; +using VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers; namespace VisionaryCoder.Framework.Tests.Caching.Providers; @@ -35,7 +35,7 @@ public void GenerateKey_WithValidContext_ShouldReturnHashedKey() }; // Act - var result = provider.GenerateKey(context); + string result = provider.GenerateKey(context); // Assert result.Should().NotBeNullOrEmpty("Should generate a valid cache key"); @@ -59,7 +59,7 @@ public void GenerateKey_WithDifferentHttpMethods_ShouldGenerateUniqueKeys(string }; // Act - var result = provider.GenerateKey(context); + string result = provider.GenerateKey(context); // Assert result.Should().NotBeNullOrEmpty($"Should generate key for method: {method}"); @@ -78,8 +78,8 @@ public void GenerateKey_WithSameContextTwice_ShouldReturnSameKey() }; // Act - var result1 = provider.GenerateKey(context); - var result2 = provider.GenerateKey(context); + string result1 = provider.GenerateKey(context); + string result2 = provider.GenerateKey(context); // Assert result1.Should().Be(result2, "Same context should generate identical keys"); @@ -104,8 +104,8 @@ public void GenerateKey_WithDifferentContexts_ShouldReturnDifferentKeys() }; // Act - var result1 = provider.GenerateKey(context1); - var result2 = provider.GenerateKey(context2); + string result1 = provider.GenerateKey(context1); + string result2 = provider.GenerateKey(context2); // Assert result1.Should().NotBe(result2, "Different contexts should generate different keys"); @@ -135,8 +135,8 @@ public void GenerateKey_WithHeaders_ShouldIncludeRelevantHeaders() }; // Act - var keyWithoutHeaders = provider.GenerateKey(contextWithoutHeaders); - var keyWithHeaders = provider.GenerateKey(contextWithHeaders); + string keyWithoutHeaders = provider.GenerateKey(contextWithoutHeaders); + string keyWithHeaders = provider.GenerateKey(contextWithHeaders); // Assert keyWithoutHeaders.Should().NotBe(keyWithHeaders, "Keys should differ when relevant headers are present"); @@ -167,8 +167,8 @@ public void GenerateKey_WithRelevantHeaders_ShouldAffectKey(string headerName, s }; // Act - var keyWithoutHeader = provider.GenerateKey(contextWithoutHeader); - var keyWithHeader = provider.GenerateKey(contextWithHeader); + string keyWithoutHeader = provider.GenerateKey(contextWithoutHeader); + string keyWithHeader = provider.GenerateKey(contextWithHeader); // Assert keyWithoutHeader.Should().NotBe(keyWithHeader, $"Key should change when {headerName} header is present"); @@ -199,8 +199,8 @@ public void GenerateKey_WithIrrelevantHeaders_ShouldIgnoreThem() }; // Act - var keyWithoutHeaders = provider.GenerateKey(contextWithoutHeaders); - var keyWithIrrelevantHeaders = provider.GenerateKey(contextWithIrrelevantHeaders); + string keyWithoutHeaders = provider.GenerateKey(contextWithoutHeaders); + string keyWithIrrelevantHeaders = provider.GenerateKey(contextWithIrrelevantHeaders); // Assert keyWithoutHeaders.Should().Be(keyWithIrrelevantHeaders, "Irrelevant headers should not affect the cache key"); @@ -222,7 +222,7 @@ public void GenerateKey_Generic_WithValidContext_ShouldReturnHashedKey() }; // Act - var result = provider.GenerateKey(context); + string result = provider.GenerateKey(context); // Assert result.Should().NotBeNullOrEmpty("Should generate a valid cache key"); @@ -241,9 +241,9 @@ public void GenerateKey_Generic_WithDifferentTypes_ShouldGenerateDifferentKeys() }; // Act - var stringKey = provider.GenerateKey(context); - var intKey = provider.GenerateKey(context); - var listKey = provider.GenerateKey>(context); + string stringKey = provider.GenerateKey(context); + string intKey = provider.GenerateKey(context); + string listKey = provider.GenerateKey>(context); // Assert stringKey.Should().NotBe(intKey, "Different generic types should generate different keys"); @@ -263,8 +263,8 @@ public void GenerateKey_Generic_WithSameTypeMultipleTimes_ShouldReturnSameKey() }; // Act - var result1 = provider.GenerateKey(context); - var result2 = provider.GenerateKey(context); + string result1 = provider.GenerateKey(context); + string result2 = provider.GenerateKey(context); // Assert result1.Should().Be(result2, "Same generic type and context should generate identical keys"); @@ -282,8 +282,8 @@ public void GenerateKey_GenericVsNonGeneric_ShouldGenerateDifferentKeys() }; // Act - var nonGenericKey = provider.GenerateKey(context); - var genericKey = provider.GenerateKey(context); + string nonGenericKey = provider.GenerateKey(context); + string genericKey = provider.GenerateKey(context); // Assert nonGenericKey.Should().NotBe(genericKey, "Generic and non-generic methods should generate different keys"); @@ -297,19 +297,19 @@ public void GenerateKey_GenericVsNonGeneric_ShouldGenerateDifferentKeys() public void GenerateKey_ShouldAlwaysReturnValidKey() { // Arrange - var contexts = new[] - { + ProxyContext[] contexts = + [ new ProxyContext { OperationName = "GetUsers", Method = "GET", Url = "https://api.example.com/users" }, new ProxyContext { OperationName = "CreateUser", Method = "POST", Url = "https://api.example.com/users" }, new ProxyContext { OperationName = "UpdateUser", Method = "PUT", Url = "https://api.example.com/users/123" }, new ProxyContext { OperationName = "DeleteUser", Method = "DELETE", Url = "https://api.example.com/users/123" }, new ProxyContext { OperationName = "PatchUser", Method = "PATCH", Url = "https://api.example.com/users/123" } - }; + ]; // Act & Assert - foreach (var context in contexts) + foreach (ProxyContext context in contexts) { - var result = provider.GenerateKey(context); + string result = provider.GenerateKey(context); result.Should().NotBeNullOrEmpty($"Should always generate valid key for {context.Method} method"); result.Should().MatchRegex("^[0-9a-fA-F]{64}$", $"Should be valid SHA256 hash for {context.Method}"); } @@ -332,7 +332,7 @@ public void GenerateKey_WithComplexScenarios_ShouldHandleGracefully() }; // Act - var result = provider.GenerateKey(complexContext); + string result = provider.GenerateKey(complexContext); // Assert result.Should().NotBeNullOrEmpty("Should generate keys for complex contexts"); @@ -361,7 +361,7 @@ public void GenerateKey_WithNullOrEmptyValues_ShouldHandleGracefully(string? ope }; // Act - var result = provider.GenerateKey(context); + string result = provider.GenerateKey(context); // Assert result.Should().NotBeNullOrEmpty("Should generate key even with null/empty values by using defaults"); @@ -398,7 +398,7 @@ public void GenerateKey_WithEmptyHeaders_ShouldHandleGracefully() }; // Act - var result = provider.GenerateKey(context); + string result = provider.GenerateKey(context); // Assert result.Should().NotBeNullOrEmpty("Should generate key with empty headers"); @@ -417,7 +417,7 @@ public void GenerateKey_WithSpecialCharactersInUrl_ShouldHandleGracefully() }; // Act - var result = provider.GenerateKey(context); + string result = provider.GenerateKey(context); // Assert result.Should().NotBeNullOrEmpty("Should handle special characters in URL"); @@ -428,7 +428,7 @@ public void GenerateKey_WithSpecialCharactersInUrl_ShouldHandleGracefully() public void GenerateKey_WithVeryLongUrl_ShouldHandleGracefully() { // Arrange - var longUrl = "https://api.example.com/" + new string('a', 2000) + "?" + string.Join("&", + string longUrl = "https://api.example.com/" + new string('a', 2000) + "?" + string.Join("&", Enumerable.Range(1, 100).Select(i => $"param{i}=value{i}")); var context = new ProxyContext @@ -439,7 +439,7 @@ public void GenerateKey_WithVeryLongUrl_ShouldHandleGracefully() }; // Act - var result = provider.GenerateKey(context); + string result = provider.GenerateKey(context); // Assert result.Should().NotBeNullOrEmpty("Should handle very long URLs"); @@ -470,7 +470,7 @@ public void GenerateKey_ShouldBeThreadSafe() tasks.Add(Task.Run(() => provider.GenerateKey(context))); } - var results = Task.WhenAll(tasks).Result; + string[] results = Task.WhenAll(tasks).Result; // Assert results.Should().AllSatisfy(result => @@ -502,8 +502,8 @@ public void GenerateKey_ShouldBeConsistentAcrossInstances() }; // Act - var key1 = provider1.GenerateKey(context); - var key2 = provider2.GenerateKey(context); + string key1 = provider1.GenerateKey(context); + string key2 = provider2.GenerateKey(context); // Assert key1.Should().Be(key2, "Different instances should generate same key for same context"); @@ -528,9 +528,9 @@ public void GenerateKey_WithManyDifferentContexts_ShouldGenerateUniqueKeys() } // Act - foreach (var context in contexts) + foreach (ProxyContext context in contexts) { - var key = provider.GenerateKey(context); + string key = provider.GenerateKey(context); keys.Add(key); } diff --git a/tests/VisionaryCoder.Framework.Tests/Caching/Providers/NullCacheKeyProviderTests.cs b/tests/VisionaryCoder.Framework.Tests/Caching/Providers/NullCacheKeyProviderTests.cs index 1bf60f4..4f441e3 100644 --- a/tests/VisionaryCoder.Framework.Tests/Caching/Providers/NullCacheKeyProviderTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Caching/Providers/NullCacheKeyProviderTests.cs @@ -1,8 +1,8 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. -using VisionaryCoder.Framework.Caching.Providers; using VisionaryCoder.Framework.Proxy; +using VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers; namespace VisionaryCoder.Framework.Tests.Caching.Providers; @@ -35,7 +35,7 @@ public void GenerateKey_WithValidContext_ShouldReturnNull() }; // Act - var result = provider.GenerateKey(context); + string? result = provider.GenerateKey(context); // Assert result.Should().BeNull("NullCacheKeyProvider should always return null to bypass caching"); @@ -58,7 +58,7 @@ public void GenerateKey_WithDifferentHttpMethods_ShouldAlwaysReturnNull(string m }; // Act - var result = provider.GenerateKey(context); + string? result = provider.GenerateKey(context); // Assert result.Should().BeNull($"NullCacheKeyProvider should return null regardless of HTTP method: {method}"); @@ -81,7 +81,7 @@ public void GenerateKey_WithNullOrEmptyValues_ShouldReturnNull(string? operation }; // Act - var result = provider.GenerateKey(context); + string? result = provider.GenerateKey(context); // Assert result.Should().BeNull("NullCacheKeyProvider should return null even with null/empty context values"); @@ -106,7 +106,7 @@ public void GenerateKey_WithComplexContext_ShouldReturnNull() }; // Act - var result = provider.GenerateKey(context); + string? result = provider.GenerateKey(context); // Assert result.Should().BeNull("NullCacheKeyProvider should return null regardless of context complexity"); @@ -124,9 +124,9 @@ public void GenerateKey_CalledMultipleTimes_ShouldConsistentlyReturnNull() }; // Act - var result1 = provider.GenerateKey(context); - var result2 = provider.GenerateKey(context); - var result3 = provider.GenerateKey(context); + string? result1 = provider.GenerateKey(context); + string? result2 = provider.GenerateKey(context); + string? result3 = provider.GenerateKey(context); // Assert result1.Should().BeNull(); @@ -151,7 +151,7 @@ public void CanGenerateKey_WithValidContext_ShouldReturnFalse() }; // Act - var result = provider.CanGenerateKey(context); + bool result = provider.CanGenerateKey(context); // Assert result.Should().BeFalse("NullCacheKeyProvider should always return false to indicate caching is not available"); @@ -174,7 +174,7 @@ public void CanGenerateKey_WithDifferentHttpMethods_ShouldAlwaysReturnFalse(stri }; // Act - var result = provider.CanGenerateKey(context); + bool result = provider.CanGenerateKey(context); // Assert result.Should().BeFalse($"NullCacheKeyProvider should return false regardless of HTTP method: {method}"); @@ -197,7 +197,7 @@ public void CanGenerateKey_WithNullOrEmptyValues_ShouldReturnFalse(string? opera }; // Act - var result = provider.CanGenerateKey(context); + bool result = provider.CanGenerateKey(context); // Assert result.Should().BeFalse("NullCacheKeyProvider should return false even with null/empty context values"); @@ -221,7 +221,7 @@ public void CanGenerateKey_WithComplexContext_ShouldReturnFalse() }; // Act - var result = provider.CanGenerateKey(context); + bool result = provider.CanGenerateKey(context); // Assert result.Should().BeFalse("NullCacheKeyProvider should return false regardless of context complexity"); @@ -239,9 +239,9 @@ public void CanGenerateKey_CalledMultipleTimes_ShouldConsistentlyReturnFalse() }; // Act - var result1 = provider.CanGenerateKey(context); - var result2 = provider.CanGenerateKey(context); - var result3 = provider.CanGenerateKey(context); + bool result1 = provider.CanGenerateKey(context); + bool result2 = provider.CanGenerateKey(context); + bool result3 = provider.CanGenerateKey(context); // Assert result1.Should().BeFalse(); @@ -299,13 +299,13 @@ public void Provider_ShouldBeThreadSafe() { tasks.Add(Task.Run(() => { - var key = provider.GenerateKey(context); - var canGenerate = provider.CanGenerateKey(context); + string? key = provider.GenerateKey(context); + bool canGenerate = provider.CanGenerateKey(context); return (key, canGenerate); })); } - var results = Task.WhenAll(tasks).Result; + (string?, bool)[] results = Task.WhenAll(tasks).Result; // Assert results.Should().AllSatisfy(result => @@ -327,16 +327,16 @@ public void Provider_ShouldBeStateless() var context2 = new ProxyContext { OperationName = "Op2", Method = "POST", Url = "https://example.com/2" }; // Act - var result1a = provider.GenerateKey(context1); - var result2a = provider.GenerateKey(context2); - var result1b = provider.GenerateKey(context1); - var result2b = provider.GenerateKey(context2); + string? result1A = provider.GenerateKey(context1); + string? result2A = provider.GenerateKey(context2); + string? result1B = provider.GenerateKey(context1); + string? result2B = provider.GenerateKey(context2); // Assert - result1a.Should().BeNull(); - result2a.Should().BeNull(); - result1b.Should().BeNull(); - result2b.Should().BeNull(); + result1A.Should().BeNull(); + result2A.Should().BeNull(); + result1B.Should().BeNull(); + result2B.Should().BeNull(); "Provider should maintain consistent stateless behavior".Should().NotBeNull(); } @@ -344,7 +344,7 @@ public void Provider_ShouldBeStateless() public void Provider_ShouldBeSealed() { // Assert - var type = typeof(NullCacheKeyProvider); + Type type = typeof(NullCacheKeyProvider); type.IsSealed.Should().BeTrue("NullCacheKeyProvider should be sealed to prevent inheritance"); } diff --git a/tests/VisionaryCoder.Framework.Tests/Configuration/AppConfigurationOptionsTests.cs.skip b/tests/VisionaryCoder.Framework.Tests/Configuration/AppConfigurationOptionsTests.cs.skip deleted file mode 100644 index 469f1fe..0000000 --- a/tests/VisionaryCoder.Framework.Tests/Configuration/AppConfigurationOptionsTests.cs.skip +++ /dev/null @@ -1,601 +0,0 @@ -using FluentAssertions; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using VisionaryCoder.Framework.Configuration.Azure; - -namespace VisionaryCoder.Framework.Tests.Configuration; - -/// -/// Data-driven unit tests for the record. -/// Tests Azure App Configuration connection options with various scenarios. -/// -[TestClass] -public class AppConfigurationOptionsTests -{ - #region Constructor Tests - - [TestMethod] - public void Constructor_WithDefaults_ShouldSetDefaultValues() - { - // Act - var options = new AppConfigurationOptions(); - - // Assert - options.Endpoint.Should().BeNull(); - options.Label.Should().Be("Production"); - options.SentinelKey.Should().Be("App:Sentinel"); - options.CacheExpiration.Should().Be(TimeSpan.FromSeconds(30)); - options.UseConnectionString.Should().BeFalse(); - options.ConnectionString.Should().BeNull(); - } - - [TestMethod] - public void Constructor_WithAllProperties_ShouldSetAllValues() - { - // Arrange - var endpoint = new Uri("https://test.azconfig.io"); - var label = "Development"; - var sentinelKey = "Custom:Sentinel"; - var cacheExpiration = TimeSpan.FromMinutes(5); - var useConnectionString = true; - var connectionString = "Endpoint=https://test.azconfig.io;Id=test;Secret=secret"; - - // Act - var options = new AppConfigurationOptions - { - Endpoint = endpoint, - Label = label, - SentinelKey = sentinelKey, - CacheExpiration = cacheExpiration, - UseConnectionString = useConnectionString, - ConnectionString = connectionString - }; - - // Assert - options.Endpoint.Should().Be(endpoint); - options.Label.Should().Be(label); - options.SentinelKey.Should().Be(sentinelKey); - options.CacheExpiration.Should().Be(cacheExpiration); - options.UseConnectionString.Should().Be(useConnectionString); - options.ConnectionString.Should().Be(connectionString); - } - - #endregion - - #region Endpoint Property Tests - - [TestMethod] - [DataRow("https://myconfig.azconfig.io")] - [DataRow("https://prod.azconfig.io")] - [DataRow("https://dev.azconfig.io")] - public void Endpoint_WithValidUri_ShouldSetCorrectly(string uriString) - { - // Arrange - var uri = new Uri(uriString); - - // Act - var options = new AppConfigurationOptions { Endpoint = uri }; - - // Assert - options.Endpoint.Should().Be(uri); - options.Endpoint.ToString().Should().Be(uriString); - } - - [TestMethod] - public void Endpoint_WhenNull_ShouldBeNull() - { - // Act - var options = new AppConfigurationOptions { Endpoint = null }; - - // Assert - options.Endpoint.Should().BeNull(); - } - - #endregion - - #region Label Property Tests - - [TestMethod] - [DataRow("Production")] - [DataRow("Development")] - [DataRow("Staging")] - [DataRow("Test")] - [DataRow("")] - public void Label_WithDifferentValues_ShouldSetCorrectly(string label) - { - // Act - var options = new AppConfigurationOptions { Label = label }; - - // Assert - options.Label.Should().Be(label); - } - - [TestMethod] - public void Label_DefaultValue_ShouldBeProduction() - { - // Act - var options = new AppConfigurationOptions(); - - // Assert - options.Label.Should().Be("Production"); - } - - #endregion - - #region SentinelKey Property Tests - - [TestMethod] - [DataRow("App:Sentinel")] - [DataRow("Config:Reload")] - [DataRow("Custom:Key")] - [DataRow("")] - public void SentinelKey_WithDifferentValues_ShouldSetCorrectly(string key) - { - // Act - var options = new AppConfigurationOptions { SentinelKey = key }; - - // Assert - options.SentinelKey.Should().Be(key); - } - - [TestMethod] - public void SentinelKey_DefaultValue_ShouldBeAppSentinel() - { - // Act - var options = new AppConfigurationOptions(); - - // Assert - options.SentinelKey.Should().Be("App:Sentinel"); - } - - #endregion - - #region CacheExpiration Property Tests - - [TestMethod] - [DataRow(1)] - [DataRow(30)] - [DataRow(60)] - [DataRow(300)] - [DataRow(3600)] - public void CacheExpiration_WithDifferentSeconds_ShouldSetCorrectly(int seconds) - { - // Arrange - var expiration = TimeSpan.FromSeconds(seconds); - - // Act - var options = new AppConfigurationOptions { CacheExpiration = expiration }; - - // Assert - options.CacheExpiration.Should().Be(expiration); - options.CacheExpiration.TotalSeconds.Should().Be(seconds); - } - - [TestMethod] - public void CacheExpiration_DefaultValue_ShouldBe30Seconds() - { - // Act - var options = new AppConfigurationOptions(); - - // Assert - options.CacheExpiration.Should().Be(TimeSpan.FromSeconds(30)); - options.CacheExpiration.TotalSeconds.Should().Be(30); - } - - [TestMethod] - public void CacheExpiration_WithZero_ShouldAccept() - { - // Act - var options = new AppConfigurationOptions { CacheExpiration = TimeSpan.Zero }; - - // Assert - options.CacheExpiration.Should().Be(TimeSpan.Zero); - } - - [TestMethod] - public void CacheExpiration_WithMaxValue_ShouldAccept() - { - // Act - var options = new AppConfigurationOptions { CacheExpiration = TimeSpan.MaxValue }; - - // Assert - options.CacheExpiration.Should().Be(TimeSpan.MaxValue); - } - - #endregion - - #region UseConnectionString Property Tests - - [TestMethod] - [DataRow(true)] - [DataRow(false)] - public void UseConnectionString_WithDifferentValues_ShouldSetCorrectly(bool value) - { - // Act - var options = new AppConfigurationOptions { UseConnectionString = value }; - - // Assert - options.UseConnectionString.Should().Be(value); - } - - [TestMethod] - public void UseConnectionString_DefaultValue_ShouldBeFalse() - { - // Act - var options = new AppConfigurationOptions(); - - // Assert - options.UseConnectionString.Should().BeFalse(); - } - - #endregion - - #region ConnectionString Property Tests - - [TestMethod] - [DataRow("Endpoint=https://test.azconfig.io;Id=test;Secret=secret")] - [DataRow("Endpoint=https://prod.azconfig.io;Id=prod;Secret=prodSecret")] - [DataRow("")] - public void ConnectionString_WithDifferentValues_ShouldSetCorrectly(string connectionString) - { - // Act - var options = new AppConfigurationOptions { ConnectionString = connectionString }; - - // Assert - options.ConnectionString.Should().Be(connectionString); - } - - [TestMethod] - public void ConnectionString_WhenNull_ShouldBeNull() - { - // Act - var options = new AppConfigurationOptions { ConnectionString = null }; - - // Assert - options.ConnectionString.Should().BeNull(); - } - - [TestMethod] - public void ConnectionString_DefaultValue_ShouldBeNull() - { - // Act - var options = new AppConfigurationOptions(); - - // Assert - options.ConnectionString.Should().BeNull(); - } - - #endregion - - #region Scenario Tests - - [TestMethod] - public void Scenario_ManagedIdentityConfiguration_ShouldBeValid() - { - // Arrange & Act - var options = new AppConfigurationOptions - { - Endpoint = new Uri("https://myconfig.azconfig.io"), - Label = "Production", - UseConnectionString = false - }; - - // Assert - options.Endpoint.Should().NotBeNull(); - options.UseConnectionString.Should().BeFalse(); - options.ConnectionString.Should().BeNull(); - } - - [TestMethod] - public void Scenario_ConnectionStringConfiguration_ShouldBeValid() - { - // Arrange & Act - var options = new AppConfigurationOptions - { - ConnectionString = "Endpoint=https://test.azconfig.io;Id=test;Secret=secret", - UseConnectionString = true, - Label = "Development" - }; - - // Assert - options.ConnectionString.Should().NotBeNullOrEmpty(); - options.UseConnectionString.Should().BeTrue(); - } - - [TestMethod] - public void Scenario_CustomLabelAndSentinel_ShouldBeValid() - { - // Arrange & Act - var options = new AppConfigurationOptions - { - Endpoint = new Uri("https://staging.azconfig.io"), - Label = "Staging", - SentinelKey = "Config:Reload", - CacheExpiration = TimeSpan.FromMinutes(5) - }; - - // Assert - options.Label.Should().Be("Staging"); - options.SentinelKey.Should().Be("Config:Reload"); - options.CacheExpiration.Should().Be(TimeSpan.FromMinutes(5)); - } - - #endregion - - #region Record Equality Tests - - [TestMethod] - public void Equals_WithSameValues_ShouldBeEqual() - { - // Arrange - var options1 = new AppConfigurationOptions - { - Endpoint = new Uri("https://test.azconfig.io"), - Label = "Development", - SentinelKey = "App:Sentinel", - CacheExpiration = TimeSpan.FromSeconds(30), - UseConnectionString = false, - ConnectionString = null - }; - - var options2 = new AppConfigurationOptions - { - Endpoint = new Uri("https://test.azconfig.io"), - Label = "Development", - SentinelKey = "App:Sentinel", - CacheExpiration = TimeSpan.FromSeconds(30), - UseConnectionString = false, - ConnectionString = null - }; - - // Assert - options1.Should().Be(options2); - } - - [TestMethod] - public void Equals_WithDifferentEndpoint_ShouldNotBeEqual() - { - // Arrange - var options1 = new AppConfigurationOptions { Endpoint = new Uri("https://test1.azconfig.io") }; - var options2 = new AppConfigurationOptions { Endpoint = new Uri("https://test2.azconfig.io") }; - - // Assert - options1.Should().NotBe(options2); - } - - [TestMethod] - public void Equals_WithDifferentLabel_ShouldNotBeEqual() - { - // Arrange - var options1 = new AppConfigurationOptions { Label = "Production" }; - var options2 = new AppConfigurationOptions { Label = "Development" }; - - // Assert - options1.Should().NotBe(options2); - } - - #endregion - - #region GetHashCode Tests - - [TestMethod] - public void GetHashCode_WithSameValues_ShouldReturnSameHashCode() - { - // Arrange - var options1 = new AppConfigurationOptions - { - Endpoint = new Uri("https://test.azconfig.io"), - Label = "Production" - }; - - var options2 = new AppConfigurationOptions - { - Endpoint = new Uri("https://test.azconfig.io"), - Label = "Production" - }; - - // Assert - options1.GetHashCode().Should().Be(options2.GetHashCode()); - } - - [TestMethod] - public void GetHashCode_WithDifferentValues_ShouldReturnDifferentHashCodes() - { - // Arrange - var options1 = new AppConfigurationOptions { Label = "Production" }; - var options2 = new AppConfigurationOptions { Label = "Development" }; - - // Assert - options1.GetHashCode().Should().NotBe(options2.GetHashCode()); - } - - #endregion - - #region ToString Tests - - [TestMethod] - public void ToString_ShouldIncludePropertyNames() - { - // Arrange - var options = new AppConfigurationOptions - { - Endpoint = new Uri("https://test.azconfig.io"), - Label = "Development" - }; - - // Act - var result = options.ToString(); - - // Assert - result.Should().Contain("Endpoint"); - result.Should().Contain("Label"); - } - - #endregion - - #region Deconstruction Tests - - [TestMethod] - public void Deconstruct_ShouldExtractAllProperties() - { - // Arrange - var endpoint = new Uri("https://test.azconfig.io"); - var label = "Development"; - var sentinelKey = "Custom:Key"; - var cacheExpiration = TimeSpan.FromMinutes(5); - var useConnectionString = true; - var connectionString = "test-connection-string"; - - var options = new AppConfigurationOptions - { - Endpoint = endpoint, - Label = label, - SentinelKey = sentinelKey, - CacheExpiration = cacheExpiration, - UseConnectionString = useConnectionString, - ConnectionString = connectionString - }; - - // Act - Note: C# records don't auto-generate positional deconstruction for init-only properties - // We test the properties directly instead - - // Assert - options.Endpoint.Should().Be(endpoint); - options.Label.Should().Be(label); - options.SentinelKey.Should().Be(sentinelKey); - options.CacheExpiration.Should().Be(cacheExpiration); - options.UseConnectionString.Should().Be(useConnectionString); - options.ConnectionString.Should().Be(connectionString); - } - - #endregion - - #region With Expression Tests - - [TestMethod] - public void WithExpression_ModifyingEndpoint_ShouldCreateNewInstance() - { - // Arrange - var original = new AppConfigurationOptions - { - Endpoint = new Uri("https://original.azconfig.io"), - Label = "Production" - }; - - var newEndpoint = new Uri("https://modified.azconfig.io"); - - // Act - var modified = original with { Endpoint = newEndpoint }; - - // Assert - modified.Endpoint.Should().Be(newEndpoint); - modified.Label.Should().Be("Production"); - original.Endpoint.ToString().Should().Be("https://original.azconfig.io"); - } - - [TestMethod] - public void WithExpression_ModifyingMultipleProperties_ShouldCreateNewInstance() - { - // Arrange - var original = new AppConfigurationOptions - { - Label = "Production", - CacheExpiration = TimeSpan.FromSeconds(30) - }; - - // Act - var modified = original with - { - Label = "Development", - CacheExpiration = TimeSpan.FromMinutes(10), - UseConnectionString = true - }; - - // Assert - modified.Label.Should().Be("Development"); - modified.CacheExpiration.Should().Be(TimeSpan.FromMinutes(10)); - modified.UseConnectionString.Should().BeTrue(); - original.Label.Should().Be("Production"); - original.CacheExpiration.Should().Be(TimeSpan.FromSeconds(30)); - original.UseConnectionString.Should().BeFalse(); - } - - #endregion - - #region Type System Tests - - [TestMethod] - public void AppConfigurationOptions_ShouldBeRecord() - { - // Arrange & Act - var type = typeof(AppConfigurationOptions); - - // Assert - type.GetMethod("$", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) - .Should().NotBeNull("records have a public $ method"); - } - - [TestMethod] - public void AppConfigurationOptions_ShouldBeSealed() - { - // Arrange & Act - var type = typeof(AppConfigurationOptions); - - // Assert - type.IsSealed.Should().BeTrue(); - } - - #endregion - - #region Edge Cases Tests - - [TestMethod] - public void CacheExpiration_WithNegativeValue_ShouldAccept() - { - // Arrange - var negativeExpiration = TimeSpan.FromSeconds(-1); - - // Act - var options = new AppConfigurationOptions { CacheExpiration = negativeExpiration }; - - // Assert - options.CacheExpiration.Should().Be(negativeExpiration); - } - - [TestMethod] - public void Label_WithWhitespace_ShouldAccept() - { - // Act - var options = new AppConfigurationOptions { Label = " " }; - - // Assert - options.Label.Should().Be(" "); - } - - [TestMethod] - public void ConnectionString_WithWhitespace_ShouldAccept() - { - // Act - var options = new AppConfigurationOptions { ConnectionString = " " }; - - // Assert - options.ConnectionString.Should().Be(" "); - } - - [TestMethod] - public void MultipleInstances_ShouldBeIndependent() - { - // Arrange & Act - var options1 = new AppConfigurationOptions { Label = "Production" }; - var options2 = new AppConfigurationOptions { Label = "Development" }; - var options3 = new AppConfigurationOptions(); - - // Assert - options1.Label.Should().Be("Production"); - options2.Label.Should().Be("Development"); - options3.Label.Should().Be("Production"); // Default value - options1.Should().NotBe(options2); - options2.Should().NotBe(options3); - } - - #endregion -} diff --git a/tests/VisionaryCoder.Framework.Tests/CorrelationIdProviderTests.cs b/tests/VisionaryCoder.Framework.Tests/CorrelationIdProviderTests.cs index 1cb367d..69c40d7 100644 --- a/tests/VisionaryCoder.Framework.Tests/CorrelationIdProviderTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/CorrelationIdProviderTests.cs @@ -168,8 +168,8 @@ public void SetCorrelationId_WithWhitespace_ShouldThrowArgumentException() public void SetCorrelationId_ShouldAcceptAnyNonEmptyString() { // Arrange - string[] testIds = new[] - { + string[] testIds = + [ "A", "123", "lowercase", @@ -177,7 +177,7 @@ public void SetCorrelationId_ShouldAcceptAnyNonEmptyString() "Mixed-Case_123", "Special@Characters#!", "Very-Long-Correlation-Id-With-Many-Characters" - }; + ]; foreach (string testId in testIds) { diff --git a/tests/VisionaryCoder.Framework.Tests/Extensions/CliInputUtilitiesTests.cs b/tests/VisionaryCoder.Framework.Tests/Extensions/CliInputUtilitiesTests.cs deleted file mode 100644 index f7d50ae..0000000 --- a/tests/VisionaryCoder.Framework.Tests/Extensions/CliInputUtilitiesTests.cs +++ /dev/null @@ -1,567 +0,0 @@ -namespace VisionaryCoder.Framework.Tests.Extensions; - -[TestClass] -public class CliInputUtilitiesTests -{ - private StringWriter consoleOutput = null!; - private StringReader? consoleInput; - - [TestInitialize] - public void Setup() - { - consoleOutput = new StringWriter(); - Console.SetOut(consoleOutput); - } - - [TestCleanup] - public void Cleanup() - { - consoleOutput?.Dispose(); - consoleInput?.Dispose(); - - // Restore original console - Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); - Console.SetIn(new StreamReader(Console.OpenStandardInput())); - } - - private void SetConsoleInput(params string[] inputs) - { - string inputString = string.Join(Environment.NewLine, inputs); - consoleInput = new StringReader(inputString); - Console.SetIn(consoleInput); - } - - [TestMethod] - public void GetDecimalInput_WithValidInput_ShouldReturnDecimal() - { - // Arrange - SetConsoleInput("123.45"); - - // Act - decimal result = CliInputUtilities.GetDecimalInput(); - - // Assert - result.Should().Be(123.45m); - } - - [TestMethod] - public void GetDecimalInput_WithInvalidThenValidInput_ShouldReturnDecimalAfterErrorMessage() - { - // Arrange - SetConsoleInput("invalid", "456.78"); - - // Act - decimal result = CliInputUtilities.GetDecimalInput(); - - // Assert - result.Should().Be(456.78m); - consoleOutput.ToString().Should().Contain("Invalid input. Please try again."); - } - - [TestMethod] - public void GetDecimalInput_WithWhitespaceAroundValidInput_ShouldReturnDecimal() - { - // Arrange - SetConsoleInput(" 789.12 "); - - // Act - decimal result = CliInputUtilities.GetDecimalInput(); - - // Assert - result.Should().Be(789.12m); - } - - [TestMethod] - public void GetDecimalInput_WithZero_ShouldReturnZero() - { - // Arrange - SetConsoleInput("0"); - - // Act - decimal result = CliInputUtilities.GetDecimalInput(); - - // Assert - result.Should().Be(0m); - } - - [TestMethod] - public void GetDecimalInput_WithNegativeNumber_ShouldReturnNegativeDecimal() - { - // Arrange - SetConsoleInput("-123.45"); - - // Act - decimal result = CliInputUtilities.GetDecimalInput(); - - // Assert - result.Should().Be(-123.45m); - } - - [TestMethod] - public void GetIntegerInput_WithValidInput_ShouldReturnInteger() - { - // Arrange - SetConsoleInput("42"); - - // Act - int result = CliInputUtilities.GetIntegerInput(); - - // Assert - result.Should().Be(42); - } - - [TestMethod] - public void GetIntegerInput_WithInvalidThenValidInput_ShouldReturnIntegerAfterErrorMessage() - { - // Arrange - SetConsoleInput("invalid", "123"); - - // Act - int result = CliInputUtilities.GetIntegerInput(); - - // Assert - result.Should().Be(123); - consoleOutput.ToString().Should().Contain("Invalid input. Please try again."); - } - - [TestMethod] - public void GetIntegerInput_WithWhitespaceAroundValidInput_ShouldReturnInteger() - { - // Arrange - SetConsoleInput(" 999 "); - - // Act - int result = CliInputUtilities.GetIntegerInput(); - - // Assert - result.Should().Be(999); - } - - [TestMethod] - public void GetIntegerInput_WithZero_ShouldReturnZero() - { - // Arrange - SetConsoleInput("0"); - - // Act - int result = CliInputUtilities.GetIntegerInput(); - - // Assert - result.Should().Be(0); - } - - [TestMethod] - public void GetIntegerInput_WithNegativeNumber_ShouldReturnNegativeInteger() - { - // Arrange - SetConsoleInput("-42"); - - // Act - int result = CliInputUtilities.GetIntegerInput(); - - // Assert - result.Should().Be(-42); - } - - [TestMethod] - public void GetIntegerInput_WithDecimalInput_ShouldShowErrorAndRetryUntilValidInteger() - { - // Arrange - SetConsoleInput("123.45", "100"); - - // Act - int result = CliInputUtilities.GetIntegerInput(); - - // Assert - result.Should().Be(100); - consoleOutput.ToString().Should().Contain("Invalid input. Please try again."); - } - - [TestMethod] - public void GetStringInput_WithValidInput_ShouldReturnUppercaseString() - { - // Arrange - SetConsoleInput("hello world"); - - // Act - string result = CliInputUtilities.GetStringInput(); - - // Assert - result.Should().Be("HELLO WORLD"); - } - - [TestMethod] - public void GetStringInput_WithWhitespaceAroundInput_ShouldReturnTrimmedUppercaseString() - { - // Arrange - SetConsoleInput(" test "); - - // Act - string result = CliInputUtilities.GetStringInput(); - - // Assert - result.Should().Be("TEST"); - } - - [TestMethod] - public void GetStringInput_WithEmptyThenValidInput_ShouldReturnStringAfterErrorMessage() - { - // Arrange - SetConsoleInput("", "valid"); - - // Act - string result = CliInputUtilities.GetStringInput(); - - // Assert - result.Should().Be("VALID"); - consoleOutput.ToString().Should().Contain("Invalid input. Please try again."); - } - - [TestMethod] - public void GetStringInput_WithWhitespaceOnlyThenValidInput_ShouldReturnStringAfterErrorMessage() - { - // Arrange - SetConsoleInput(" ", "test"); - - // Act - string result = CliInputUtilities.GetStringInput(); - - // Assert - result.Should().Be("TEST"); - consoleOutput.ToString().Should().Contain("Invalid input. Please try again."); - } - - [TestMethod] - public void GetStringInput_WithMixedCaseInput_ShouldReturnUppercaseString() - { - // Arrange - SetConsoleInput("MiXeD cAsE"); - - // Act - string result = CliInputUtilities.GetStringInput(); - - // Assert - result.Should().Be("MIXED CASE"); - } - - [TestMethod] - public void PromptForInputFile_WithValidFilePath_ShouldReturnFileInfo() - { - // Arrange - string tempFile = Path.GetTempFileName(); - try - { - SetConsoleInput(tempFile); - - // Act - FileInfo? result = CliInputUtilities.PromptForInputFile(); - - // Assert - result.Should().NotBeNull(); - result!.FullName.Should().Be(tempFile); - consoleOutput.ToString().Should().Contain("Please enter the path to your file (or type 'exit' to quit):"); - } - finally - { - if (File.Exists(tempFile)) - File.Delete(tempFile); - } - } - - [TestMethod] - public void PromptForInputFile_WithNonExistentFile_ShouldShowErrorAndRetry() - { - // Arrange - string tempFile = Path.GetTempFileName(); - string nonExistentFile = Path.Combine(Path.GetTempPath(), "nonexistent.txt"); - try - { - SetConsoleInput(nonExistentFile, tempFile); - - // Act - FileInfo? result = CliInputUtilities.PromptForInputFile(); - - // Assert - result.Should().NotBeNull(); - result!.FullName.Should().Be(tempFile); - consoleOutput.ToString().Should().Contain("File does not exist."); - } - finally - { - if (File.Exists(tempFile)) - File.Delete(tempFile); - } - } - - [TestMethod] - public void PromptForInputFile_WithEmptyInput_ShouldShowErrorAndRetry() - { - // Arrange - string tempFile = Path.GetTempFileName(); - try - { - SetConsoleInput("", tempFile); - - // Act - FileInfo? result = CliInputUtilities.PromptForInputFile(); - - // Assert - result.Should().NotBeNull(); - result!.FullName.Should().Be(tempFile); - consoleOutput.ToString().Should().Contain("File path cannot be empty."); - } - finally - { - if (File.Exists(tempFile)) - File.Delete(tempFile); - } - } - - [TestMethod] - public void PromptForInputFile_WithExitCommand_ShouldReturnNull() - { - // Arrange - SetConsoleInput("exit"); - - // Act - FileInfo? result = CliInputUtilities.PromptForInputFile(); - - // Assert - result.Should().BeNull(); - } - - [TestMethod] - public void PromptForInputFile_WithXCommand_ShouldReturnNull() - { - // Arrange - SetConsoleInput("x"); - - // Act - FileInfo? result = CliInputUtilities.PromptForInputFile(); - - // Assert - result.Should().BeNull(); - } - - [TestMethod] - public void PromptForInputFile_WithQCommand_ShouldReturnNull() - { - // Arrange - SetConsoleInput("q"); - - // Act - FileInfo? result = CliInputUtilities.PromptForInputFile(); - - // Assert - result.Should().BeNull(); - } - - [TestMethod] - public void PromptForInputFile_WithUppercaseExitCommand_ShouldReturnNull() - { - // Arrange - SetConsoleInput("EXIT"); - - // Act - FileInfo? result = CliInputUtilities.PromptForInputFile(); - - // Assert - result.Should().BeNull(); - } - - [TestMethod] - public void PromptForInputFolder_WithValidFolderPath_ShouldReturnDirectoryInfo() - { - // Arrange - string tempFolder = Path.GetTempPath(); - SetConsoleInput(tempFolder); - - // Act - DirectoryInfo? result = CliInputUtilities.PromptForInputFolder(); - - // Assert - result.Should().NotBeNull(); - result!.FullName.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) - .Should().Be(tempFolder.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); - consoleOutput.ToString().Should().Contain("Please enter the path to folder (or x|q|exit to return to the previous menu):"); - } - - [TestMethod] - public void PromptForInputFolder_WithNonExistentFolder_ShouldShowErrorAndRetry() - { - // Arrange - string tempFolder = Path.GetTempPath(); - string nonExistentFolder = Path.Combine(Path.GetTempPath(), "nonexistent"); - SetConsoleInput(nonExistentFolder, tempFolder); - - // Act - DirectoryInfo? result = CliInputUtilities.PromptForInputFolder(); - - // Assert - result.Should().NotBeNull(); - consoleOutput.ToString().Should().Contain("Folder does not exist."); - } - - [TestMethod] - public void PromptForInputFolder_WithEmptyInput_ShouldShowErrorAndRetry() - { - // Arrange - string tempFolder = Path.GetTempPath(); - SetConsoleInput("", tempFolder); - - // Act - DirectoryInfo? result = CliInputUtilities.PromptForInputFolder(); - - // Assert - result.Should().NotBeNull(); - consoleOutput.ToString().Should().Contain("Input Error: Input cannot be empty."); - } - - [TestMethod] - public void PromptForInputFolder_WithExitCommand_ShouldReturnNull() - { - // Arrange - SetConsoleInput("exit"); - - // Act - DirectoryInfo? result = CliInputUtilities.PromptForInputFolder(); - - // Assert - result.Should().BeNull(); - } - - [TestMethod] - public void PromptForInputFolder_WithXCommand_ShouldReturnNull() - { - // Arrange - SetConsoleInput("x"); - - // Act - DirectoryInfo? result = CliInputUtilities.PromptForInputFolder(); - - // Assert - result.Should().BeNull(); - } - - [TestMethod] - public void PromptForInputFolder_WithQCommand_ShouldReturnNull() - { - // Arrange - SetConsoleInput("q"); - - // Act - DirectoryInfo? result = CliInputUtilities.PromptForInputFolder(); - - // Assert - result.Should().BeNull(); - } - - [TestMethod] - public void PromptForInputFolder_WithUppercaseExitCommand_ShouldReturnNull() - { - // Arrange - SetConsoleInput("EXIT"); - - // Act - DirectoryInfo? result = CliInputUtilities.PromptForInputFolder(); - - // Assert - result.Should().BeNull(); - } - - [TestMethod] - public void PromptForInputFolder_WithWhitespaceAroundPath_ShouldTrimAndValidate() - { - // Arrange - string tempFolder = Path.GetTempPath(); - SetConsoleInput($" {tempFolder} "); - - // Act - DirectoryInfo? result = CliInputUtilities.PromptForInputFolder(); - - // Assert - result.Should().NotBeNull(); - result!.FullName.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) - .Should().Be(tempFolder.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); - } - - // Testing edge cases for decimal parsing - [TestMethod] - public void GetDecimalInput_WithMaxValue_ShouldReturnMaxDecimal() - { - // Arrange - SetConsoleInput(decimal.MaxValue.ToString()); - - // Act - decimal result = CliInputUtilities.GetDecimalInput(); - - // Assert - result.Should().Be(decimal.MaxValue); - } - - [TestMethod] - public void GetDecimalInput_WithMinValue_ShouldReturnMinDecimal() - { - // Arrange - SetConsoleInput(decimal.MinValue.ToString()); - - // Act - decimal result = CliInputUtilities.GetDecimalInput(); - - // Assert - result.Should().Be(decimal.MinValue); - } - - // Testing edge cases for integer parsing - [TestMethod] - public void GetIntegerInput_WithMaxValue_ShouldReturnMaxInteger() - { - // Arrange - SetConsoleInput(int.MaxValue.ToString()); - - // Act - int result = CliInputUtilities.GetIntegerInput(); - - // Assert - result.Should().Be(int.MaxValue); - } - - [TestMethod] - public void GetIntegerInput_WithMinValue_ShouldReturnMinInteger() - { - // Arrange - SetConsoleInput(int.MinValue.ToString()); - - // Act - int result = CliInputUtilities.GetIntegerInput(); - - // Assert - result.Should().Be(int.MinValue); - } - - [TestMethod] - public void GetStringInput_WithSpecialCharacters_ShouldReturnUppercaseString() - { - // Arrange - SetConsoleInput("hello@world!123"); - - // Act - string result = CliInputUtilities.GetStringInput(); - - // Assert - result.Should().Be("HELLO@WORLD!123"); - } - - [TestMethod] - public void GetStringInput_WithUnicodeCharacters_ShouldReturnUppercaseString() - { - // Arrange - SetConsoleInput("hΓ©llo wΓΆrld"); - - // Act - string result = CliInputUtilities.GetStringInput(); - - // Assert - result.Should().Be("HΓ‰LLO WΓ–RLD"); - } -} diff --git a/tests/VisionaryCoder.Framework.Tests/Extensions/CollectionExtensionsTests.cs b/tests/VisionaryCoder.Framework.Tests/Extensions/CollectionExtensionsTests.cs index 1f47b41..c9ffa28 100644 --- a/tests/VisionaryCoder.Framework.Tests/Extensions/CollectionExtensionsTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Extensions/CollectionExtensionsTests.cs @@ -153,7 +153,7 @@ public void AddRange_WithValidItems_ShouldAddAllItems() { // Arrange var collection = new List { "existing" }; - string[] itemsToAdd = new[] { "item1", "item2", "item3" }; + string[] itemsToAdd = ["item1", "item2", "item3"]; // Act collection.AddRange(itemsToAdd); @@ -171,7 +171,7 @@ public void AddRange_WithEmptyEnumerable_ShouldNotAddAnyItems() { // Arrange var collection = new List { "existing" }; - string[] itemsToAdd = Array.Empty(); + string[] itemsToAdd = []; // Act collection.AddRange(itemsToAdd); @@ -186,7 +186,7 @@ public void AddRange_WithNullCollection_ShouldThrowArgumentNullException() { // Arrange ICollection? collection = null; - string[] itemsToAdd = new[] { "item1" }; + string[] itemsToAdd = ["item1"]; // Act & Assert Action action = () => collection!.AddRange(itemsToAdd); @@ -198,7 +198,7 @@ public void AddRange_WithDuplicateItems_ShouldAddAllDuplicates() { // Arrange var collection = new List(); - string[] itemsToAdd = new[] { "item", "item", "item" }; + string[] itemsToAdd = ["item", "item", "item"]; // Act collection.AddRange(itemsToAdd); @@ -469,7 +469,7 @@ public void CollectionExtensions_ChainedOperations_ShouldWorkCorrectly() var collection = new List { 1, 2, 3, 4, 5 }; // Act - collection.AddRange(new[] { 6, 7, 8 }); + collection.AddRange([6, 7, 8]); int evenRemoved = collection.RemoveWhere(x => x % 2 == 0); bool added = collection.AddIf(9, x => x % 2 != 0); @@ -486,7 +486,7 @@ public void CollectionExtensions_WithDifferentCollectionTypes_ShouldWork() { // Test with HashSet var hashSet = new HashSet { "a", "b" }; - hashSet.AddRange(new[] { "c", "d" }); + hashSet.AddRange(["c", "d"]); hashSet.Should().HaveCount(4); // Test with List diff --git a/tests/VisionaryCoder.Framework.Tests/Extensions/DictionaryExtensionsTests.cs b/tests/VisionaryCoder.Framework.Tests/Extensions/DictionaryExtensionsTests.cs index 1a0d803..d90ce7c 100644 --- a/tests/VisionaryCoder.Framework.Tests/Extensions/DictionaryExtensionsTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Extensions/DictionaryExtensionsTests.cs @@ -822,7 +822,7 @@ public void AddToList_WithNewKey_ShouldCreateListAndAddItem() public void AddToList_WithExistingKey_ShouldAddToExistingList() { // Arrange - var dictionary = new Dictionary> { ["items"] = new List { 1, 2 } }; + var dictionary = new Dictionary> { ["items"] = [1, 2] }; // Act dictionary.AddToList("items", 3); diff --git a/tests/VisionaryCoder.Framework.Tests/Extensions/EnumerableExtensionsTests.cs b/tests/VisionaryCoder.Framework.Tests/Extensions/EnumerableExtensionsTests.cs index f7188e0..c1522c6 100644 --- a/tests/VisionaryCoder.Framework.Tests/Extensions/EnumerableExtensionsTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Extensions/EnumerableExtensionsTests.cs @@ -521,7 +521,7 @@ public void TryLast_WithList_ShouldReturnTrueAndLastElement() public void TryLast_WithArray_ShouldReturnTrueAndLastElement() { // Arrange - int[] source = new int[] { 1, 2, 3 }; + int[] source = [1, 2, 3]; // Act bool result = source.TryLast(out int value); @@ -687,7 +687,7 @@ public void ToDictionary_WithValidKeyValuePairs_ShouldReturnDictionary() }; // Act - var result = EnumerableExtensions.ToDictionary(((IEnumerable>)source)); + var result = EnumerableExtensions.ToDictionary(source); // Assert result.Should().BeOfType>(); diff --git a/tests/VisionaryCoder.Framework.Tests/Extensions/HashSetExtensionsTests.cs b/tests/VisionaryCoder.Framework.Tests/Extensions/HashSetExtensionsTests.cs index e6c44b1..9a1ded1 100644 --- a/tests/VisionaryCoder.Framework.Tests/Extensions/HashSetExtensionsTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Extensions/HashSetExtensionsTests.cs @@ -43,7 +43,7 @@ public void AddRange_WithValidInputs_ShouldAddAllElements() // Assert target.Should().HaveCount(5); - target.Should().Contain(new[] { 1, 2, 3, 4, 5 }); + target.Should().Contain([1, 2, 3, 4, 5]); } [TestMethod] @@ -58,7 +58,7 @@ public void AddRange_WithDuplicates_ShouldNotAddDuplicates() // Assert target.Should().HaveCount(4); - target.Should().Contain(new[] { 1, 2, 3, 4 }); + target.Should().Contain([1, 2, 3, 4]); } [TestMethod] @@ -73,7 +73,7 @@ public void AddRange_WithEmptyCollection_ShouldNotChangeTarget() // Assert target.Should().HaveCount(2); - target.Should().Contain(new[] { 1, 2 }); + target.Should().Contain([1, 2]); } [TestMethod] @@ -88,7 +88,7 @@ public void AddRange_WithEmptyTarget_ShouldAddAllElements() // Assert target.Should().HaveCount(3); - target.Should().Contain(new[] { 1, 2, 3 }); + target.Should().Contain([1, 2, 3]); } #endregion @@ -131,8 +131,8 @@ public void RemoveRange_WithValidInputs_ShouldRemoveMatchingElements() // Assert target.Should().HaveCount(3); - target.Should().Contain(new[] { 1, 3, 5 }); - target.Should().NotContain(new[] { 2, 4 }); + target.Should().Contain([1, 3, 5]); + target.Should().NotContain([2, 4]); } [TestMethod] @@ -147,7 +147,7 @@ public void RemoveRange_WithNonExistentElements_ShouldNotChangeTarget() // Assert target.Should().HaveCount(3); - target.Should().Contain(new[] { 1, 2, 3 }); + target.Should().Contain([1, 2, 3]); } [TestMethod] @@ -162,7 +162,7 @@ public void RemoveRange_WithEmptyCollection_ShouldNotChangeTarget() // Assert target.Should().HaveCount(3); - target.Should().Contain(new[] { 1, 2, 3 }); + target.Should().Contain([1, 2, 3]); } [TestMethod] @@ -423,7 +423,7 @@ public void HashSetExtensions_CombinedOperations_ShouldWorkCorrectly() bool containsAny = target.ContainsAny(new List { 7, 8, 1 }); // Assert - target.Should().Contain(new[] { 1, 3, 4, 5, 6 }); + target.Should().Contain([1, 3, 4, 5, 6]); target.Should().NotContain(2); target.Should().HaveCount(5); containsAll.Should().BeTrue(); // Contains both 1 and 4 @@ -444,7 +444,7 @@ public void HashSetExtensions_WithStrings_ShouldWorkCorrectly() // Assert target.Should().HaveCount(4); // No duplicate apple - target.Should().Contain(new[] { "apple", "banana", "cherry", "date" }); + target.Should().Contain(["apple", "banana", "cherry", "date"]); hasCommonFruits.Should().BeTrue(); // Contains cherry hasAllCitrus.Should().BeFalse(); // Missing lemon and lime } diff --git a/tests/VisionaryCoder.Framework.Tests/Extensions/DivideByZeroExtensionsTests.cs b/tests/VisionaryCoder.Framework.Tests/Extensions/MathExtensionsTests.cs similarity index 81% rename from tests/VisionaryCoder.Framework.Tests/Extensions/DivideByZeroExtensionsTests.cs rename to tests/VisionaryCoder.Framework.Tests/Extensions/MathExtensionsTests.cs index 76f152d..ad3d7ae 100644 --- a/tests/VisionaryCoder.Framework.Tests/Extensions/DivideByZeroExtensionsTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Extensions/MathExtensionsTests.cs @@ -3,7 +3,7 @@ namespace VisionaryCoder.Framework.Tests.Extensions; [TestClass] -public class DivideByZeroExtensionsTests +public class MathExtensionsTests { #region ThrowIfZero Tests @@ -14,7 +14,7 @@ public void ThrowIfZero_WithZeroInt_ShouldThrowDivideByZeroException() int value = 0; // Act & Assert - DivideByZeroException? exception = Assert.ThrowsExactly(() => DivideByZeroExtensions.ThrowIfZero(value)); + DivideByZeroException? exception = Assert.ThrowsExactly(() => MathExtensions.ThrowIfZero(value)); exception.Message.Should().Contain("Division by zero would occur"); } @@ -26,7 +26,7 @@ public void ThrowIfZero_WithZeroIntAndParamName_ShouldThrowWithParamName() string paramName = "divisor"; // Act & Assert - DivideByZeroException? exception = Assert.ThrowsExactly(() => DivideByZeroExtensions.ThrowIfZero(value, paramName)); + DivideByZeroException? exception = Assert.ThrowsExactly(() => MathExtensions.ThrowIfZero(value, paramName)); exception.Message.Should().Contain("Division by zero would occur with parameter 'divisor'"); } @@ -37,7 +37,7 @@ public void ThrowIfZero_WithNonZeroInt_ShouldNotThrow() int value = 5; // Act & Assert - Action action = () => DivideByZeroExtensions.ThrowIfZero(value); + Action action = () => MathExtensions.ThrowIfZero(value); action.Should().NotThrow(); } @@ -48,7 +48,7 @@ public void ThrowIfZero_WithZeroDouble_ShouldThrowDivideByZeroException() double value = 0.0; // Act & Assert - DivideByZeroException? exception = Assert.ThrowsExactly(() => DivideByZeroExtensions.ThrowIfZero(value)); + DivideByZeroException? exception = Assert.ThrowsExactly(() => MathExtensions.ThrowIfZero(value)); exception.Message.Should().Contain("Division by zero would occur"); } @@ -59,7 +59,7 @@ public void ThrowIfZero_WithNonZeroDouble_ShouldNotThrow() double value = 3.14; // Act & Assert - Action action = () => DivideByZeroExtensions.ThrowIfZero(value); + Action action = () => MathExtensions.ThrowIfZero(value); action.Should().NotThrow(); } @@ -70,7 +70,7 @@ public void ThrowIfZero_WithZeroDecimal_ShouldThrowDivideByZeroException() decimal value = 0m; // Act & Assert - DivideByZeroException? exception = Assert.ThrowsExactly(() => DivideByZeroExtensions.ThrowIfZero(value)); + DivideByZeroException? exception = Assert.ThrowsExactly(() => MathExtensions.ThrowIfZero(value)); exception.Message.Should().Contain("Division by zero would occur"); } @@ -81,7 +81,7 @@ public void ThrowIfZero_WithNonZeroDecimal_ShouldNotThrow() decimal value = 1.5m; // Act & Assert - Action action = () => DivideByZeroExtensions.ThrowIfZero(value); + Action action = () => MathExtensions.ThrowIfZero(value); action.Should().NotThrow(); } @@ -206,7 +206,7 @@ public void SafeDivide_WithNonZeroDenominator_ShouldReturnQuotient() int defaultValue = 999; // Act - int result = DivideByZeroExtensions.SafeDivide(numerator, denominator, defaultValue); + int result = MathExtensions.SafeDivide(numerator, denominator, defaultValue); // Assert result.Should().Be(5); @@ -221,7 +221,7 @@ public void SafeDivide_WithZeroDenominator_ShouldReturnDefaultValue() int defaultValue = 999; // Act - int result = DivideByZeroExtensions.SafeDivide(numerator, denominator, defaultValue); + int result = MathExtensions.SafeDivide(numerator, denominator, defaultValue); // Assert result.Should().Be(999); @@ -236,7 +236,7 @@ public void SafeDivide_WithDoubles_ShouldWorkCorrectly() double defaultValue = -1.0; // Act - double result = DivideByZeroExtensions.SafeDivide(numerator, denominator, defaultValue); + double result = MathExtensions.SafeDivide(numerator, denominator, defaultValue); // Assert result.Should().Be(5.0); @@ -251,7 +251,7 @@ public void SafeDivide_WithZeroDoublesDenominator_ShouldReturnDefaultValue() double defaultValue = -1.0; // Act - double result = DivideByZeroExtensions.SafeDivide(numerator, denominator, defaultValue); + double result = MathExtensions.SafeDivide(numerator, denominator, defaultValue); // Assert result.Should().Be(-1.0); @@ -266,7 +266,7 @@ public void SafeDivide_WithDecimals_ShouldWorkCorrectly() decimal defaultValue = 0m; // Act - decimal result = DivideByZeroExtensions.SafeDivide(numerator, denominator, defaultValue); + decimal result = MathExtensions.SafeDivide(numerator, denominator, defaultValue); // Assert result.Should().Be(5m); @@ -284,7 +284,7 @@ public void SafeDivide_WithoutDefault_WithNonZeroDenominator_ShouldReturnQuotien int denominator = 4; // Act - int result = DivideByZeroExtensions.SafeDivide(numerator, denominator); + int result = MathExtensions.SafeDivide(numerator, denominator); // Assert result.Should().Be(5); @@ -298,7 +298,7 @@ public void SafeDivide_WithoutDefault_WithZeroDenominator_ShouldReturnZero() int denominator = 0; // Act - int result = DivideByZeroExtensions.SafeDivide(numerator, denominator); + int result = MathExtensions.SafeDivide(numerator, denominator); // Assert result.Should().Be(0); @@ -312,7 +312,7 @@ public void SafeDivide_WithoutDefault_WithDoubles_ShouldWorkCorrectly() double denominator = 0.0; // Act - double result = DivideByZeroExtensions.SafeDivide(numerator, denominator); + double result = MathExtensions.SafeDivide(numerator, denominator); // Assert result.Should().Be(0.0); @@ -330,7 +330,7 @@ public void TryDivide_WithNonZeroDenominator_ShouldReturnTrueAndCorrectResult() int denominator = 3; // Act - bool success = DivideByZeroExtensions.TryDivide(numerator, denominator, out int result); + bool success = MathExtensions.TryDivide(numerator, denominator, out int result); // Assert success.Should().BeTrue(); @@ -345,7 +345,7 @@ public void TryDivide_WithZeroDenominator_ShouldReturnFalseAndDefaultResult() int denominator = 0; // Act - bool success = DivideByZeroExtensions.TryDivide(numerator, denominator, out int result); + bool success = MathExtensions.TryDivide(numerator, denominator, out int result); // Assert success.Should().BeFalse(); @@ -360,7 +360,7 @@ public void TryDivide_WithDoubles_ShouldWorkCorrectly() double denominator = 7.0; // Act - bool success = DivideByZeroExtensions.TryDivide(numerator, denominator, out double result); + bool success = MathExtensions.TryDivide(numerator, denominator, out double result); // Assert success.Should().BeTrue(); @@ -375,7 +375,7 @@ public void TryDivide_WithZeroDoubleDenominator_ShouldReturnFalse() double denominator = 0.0; // Act - bool success = DivideByZeroExtensions.TryDivide(numerator, denominator, out double result); + bool success = MathExtensions.TryDivide(numerator, denominator, out double result); // Assert success.Should().BeFalse(); @@ -390,7 +390,7 @@ public void TryDivide_WithDecimals_ShouldWorkCorrectly() decimal denominator = 6.15m; // Act - bool success = DivideByZeroExtensions.TryDivide(numerator, denominator, out decimal result); + bool success = MathExtensions.TryDivide(numerator, denominator, out decimal result); // Assert success.Should().BeTrue(); @@ -493,7 +493,7 @@ public void DefaultIfZero_WithNonZeroDecimal_ShouldReturnOriginalValue() public void DivideByZeroExtensions_ComplexScenario_ShouldWorkCorrectly() { // Arrange - int[] values = new[] { 10, 0, 5, 20 }; + int[] values = [10, 0, 5, 20]; int divisor = 2; var results = new List(); @@ -502,8 +502,8 @@ public void DivideByZeroExtensions_ComplexScenario_ShouldWorkCorrectly() { if (!value.IsZero()) { - DivideByZeroExtensions.ThrowIfZero(divisor); // This should not throw for divisor = 2 - int result = DivideByZeroExtensions.SafeDivide(value, divisor); + MathExtensions.ThrowIfZero(divisor); // This should not throw for divisor = 2 + int result = MathExtensions.SafeDivide(value, divisor); results.Add(result); } else @@ -520,19 +520,19 @@ public void DivideByZeroExtensions_ComplexScenario_ShouldWorkCorrectly() public void DivideByZeroExtensions_WithDifferentNumericTypes_ShouldWorkConsistently() { // Test with int - int intResult = DivideByZeroExtensions.SafeDivide(10, 0, -1); + int intResult = MathExtensions.SafeDivide(10, 0, -1); intResult.Should().Be(-1); // Test with double - double doubleResult = DivideByZeroExtensions.SafeDivide(10.0, 0.0, -1.0); + double doubleResult = MathExtensions.SafeDivide(10.0, 0.0, -1.0); doubleResult.Should().Be(-1.0); // Test with decimal - decimal decimalResult = DivideByZeroExtensions.SafeDivide(10m, 0m, -1m); + decimal decimalResult = MathExtensions.SafeDivide(10m, 0m, -1m); decimalResult.Should().Be(-1m); // Test with float - float floatResult = DivideByZeroExtensions.SafeDivide(10f, 0f, -1f); + float floatResult = MathExtensions.SafeDivide(10f, 0f, -1f); floatResult.Should().Be(-1f); // All should consistently return the default value when dividing by zero @@ -546,17 +546,17 @@ public void DivideByZeroExtensions_WithDifferentNumericTypes_ShouldWorkConsisten public void DivideByZeroExtensions_TryDividePattern_ShouldHandleEdgeCases() { // Test successful division - bool success1 = DivideByZeroExtensions.TryDivide(100, 25, out int result1); + bool success1 = MathExtensions.TryDivide(100, 25, out int result1); success1.Should().BeTrue(); result1.Should().Be(4); // Test zero division - bool success2 = DivideByZeroExtensions.TryDivide(100, 0, out int result2); + bool success2 = MathExtensions.TryDivide(100, 0, out int result2); success2.Should().BeFalse(); result2.Should().Be(0); // Test zero numerator (valid division) - bool success3 = DivideByZeroExtensions.TryDivide(0, 5, out int result3); + bool success3 = MathExtensions.TryDivide(0, 5, out int result3); success3.Should().BeTrue(); result3.Should().Be(0); } diff --git a/tests/VisionaryCoder.Framework.Tests/Extensions/MenuHelperTests.cs b/tests/VisionaryCoder.Framework.Tests/Extensions/MenuHelperTests.cs deleted file mode 100644 index 79b438a..0000000 --- a/tests/VisionaryCoder.Framework.Tests/Extensions/MenuHelperTests.cs +++ /dev/null @@ -1,375 +0,0 @@ -namespace VisionaryCoder.Framework.Tests.Extensions; - -[TestClass] -public class MenuHelperTests -{ - private StringWriter consoleOutput = null!; - private StringReader? consoleInput; - - [TestInitialize] - public void Setup() - { - consoleOutput = new StringWriter(); - Console.SetOut(consoleOutput); - } - - [TestCleanup] - public void Cleanup() - { - consoleOutput?.Dispose(); - consoleInput?.Dispose(); - - // Restore original console - Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); - Console.SetIn(new StreamReader(Console.OpenStandardInput())); - } - - private void SetConsoleInput(params string[] inputs) - { - string inputString = string.Join(Environment.NewLine, inputs); - consoleInput = new StringReader(inputString); - Console.SetIn(consoleInput); - } - - [TestMethod] - public void ShowIntroduction_WithAppName_ShouldDisplayFormattedIntroduction() - { - // Arrange - string appName = "Test Application"; - int expectedWidth = 72; - - // Act - MenuHelper.ShowIntroduction(appName); - - // Assert - string output = consoleOutput.ToString(); - string[] lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - - lines.Should().HaveCount(5); - lines[0].Should().Be(new string('-', expectedWidth)); - lines[1].Should().Be("--"); - lines[2].Should().Be($"-- {appName}"); - lines[3].Should().Be("--"); - lines[4].Should().Be(new string('-', expectedWidth)); - } - - [TestMethod] - public void ShowIntroduction_WithCustomWidth_ShouldDisplayIntroductionWithCustomWidth() - { - // Arrange - string appName = "Custom App"; - int customWidth = 50; - - // Act - MenuHelper.ShowIntroduction(appName, customWidth); - - // Assert - string output = consoleOutput.ToString(); - string[] lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - - lines.Should().HaveCount(5); - lines[0].Should().Be(new string('-', customWidth)); - lines[1].Should().Be("--"); - lines[2].Should().Be($"-- {appName}"); - lines[3].Should().Be("--"); - lines[4].Should().Be(new string('-', customWidth)); - } - - [TestMethod] - public void ShowIntroduction_WithEmptyAppName_ShouldDisplayIntroductionWithEmptyName() - { - // Arrange - string appName = ""; - - // Act - MenuHelper.ShowIntroduction(appName); - - // Assert - string output = consoleOutput.ToString(); - string[] lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - - lines.Should().HaveCount(5); - lines[1].Should().Be("--"); - lines[2].Should().Be("-- "); - lines[3].Should().Be("--"); - } - - [TestMethod] - public void ShowIntroduction_WithVeryLongAppName_ShouldDisplayIntroductionWithLongName() - { - // Arrange - string appName = "This is a very long application name that exceeds normal length"; - - // Act - MenuHelper.ShowIntroduction(appName); - - // Assert - string output = consoleOutput.ToString(); - string[] lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - - lines.Should().HaveCount(5); - lines[2].Should().Be($"-- {appName}"); - } - - [TestMethod] - public void ShowIntroduction_WithSpecialCharactersInAppName_ShouldDisplayIntroductionWithSpecialChars() - { - // Arrange - string appName = "App@Name!123#$%"; - - // Act - MenuHelper.ShowIntroduction(appName); - - // Assert - string output = consoleOutput.ToString(); - string[] lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - - lines.Should().HaveCount(5); - lines[2].Should().Be($"-- {appName}"); - } - - [TestMethod] - public void ShowIntroduction_WithZeroWidth_ShouldDisplayIntroductionWithNoSeparator() - { - // Arrange - string appName = "Test App"; - int zeroWidth = 0; - - // Act - MenuHelper.ShowIntroduction(appName, zeroWidth); - - // Assert - string output = consoleOutput.ToString(); - string[] lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - - lines.Should().HaveCount(3); // Only the -- lines, no separators - lines[0].Should().Be("--"); - lines[1].Should().Be($"-- {appName}"); - lines[2].Should().Be("--"); - } - - [TestMethod] - public void ShowIntroduction_WithMinimalWidth_ShouldDisplayIntroductionWithMinimalSeparator() - { - // Arrange - string appName = "App"; - int minimalWidth = 5; - - // Act - MenuHelper.ShowIntroduction(appName, minimalWidth); - - // Assert - string output = consoleOutput.ToString(); - string[] lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - - lines.Should().HaveCount(5); - lines[0].Should().Be(new string('-', minimalWidth)); - lines[4].Should().Be(new string('-', minimalWidth)); - } - - [TestMethod] - public void ShowExit_WithDefaultWidth_ShouldDisplayExitMessageAndWaitForInput() - { - // Arrange - SetConsoleInput(""); // Simulate pressing ENTER - - // Act - MenuHelper.ShowExit(); - - // Assert - string output = consoleOutput.ToString(); - string[] lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - - lines.Should().HaveCount(3); - lines[0].Should().Be(new string('-', 72)); - lines[1].Should().Be("Hit [ENTER] to exit."); - lines[2].Should().Be(new string('-', 72)); - } - - [TestMethod] - public void ShowExit_WithCustomWidth_ShouldDisplayExitMessageWithDefaultWidthSeparator() - { - // Arrange - ShowExit ignores the separateWidth parameter and uses default width for separators - int customWidth = 40; - SetConsoleInput(""); // Simulate pressing ENTER - - // Act - MenuHelper.ShowExit(customWidth); - - // Assert - string output = consoleOutput.ToString(); - string[] lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - - lines.Should().HaveCount(3); - lines[0].Should().Be(new string('-', 72)); // ShowExit always uses default width - lines[1].Should().Be("Hit [ENTER] to exit."); - lines[2].Should().Be(new string('-', 72)); // ShowExit always uses default width - } - - [TestMethod] - public void ShowExit_ParameterIgnored_DocumentsBugInImplementation() - { - // Arrange - This test documents a bug: separateWidth parameter is ignored - int expectedWidth = 100; - int actualWidth = 72; // Default width that's actually used - SetConsoleInput(""); // Simulate pressing ENTER - - // Act - MenuHelper.ShowExit(expectedWidth); - - // Assert - The parameter is ignored, method uses default width - string output = consoleOutput.ToString(); - string[] lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - - lines.Should().HaveCount(3); - lines[0].Should().Be(new string('-', actualWidth)); // Bug: ignores expectedWidth - lines[1].Should().Be("Hit [ENTER] to exit."); - lines[2].Should().Be(new string('-', actualWidth)); // Bug: ignores expectedWidth - - // This test documents that ShowExit.separateWidth parameter is not used - // The method calls ShowSeparator() without parameters, defaulting to width=72 - } - - [TestMethod] - public void ShowExit_WithZeroWidth_ShouldDisplayExitMessageWithDefaultWidthSeparator() - { - // Arrange - ShowExit ignores the separateWidth parameter and uses default width for separators - SetConsoleInput(""); // Simulate pressing ENTER - - // Act - MenuHelper.ShowExit(0); - - // Assert - string output = consoleOutput.ToString(); - string[] lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - - lines.Should().HaveCount(3); // ShowExit always displays separators with default width - lines[0].Should().Be(new string('-', 72)); - lines[1].Should().Be("Hit [ENTER] to exit."); - lines[2].Should().Be(new string('-', 72)); - } - - [TestMethod] - public void ShowSeparator_WithDefaultWidth_ShouldDisplayDefaultSeparator() - { - // Act - MenuHelper.ShowSeparator(); - - // Assert - string output = consoleOutput.ToString().Trim(); - output.Should().Be(new string('-', 72)); - } - - [TestMethod] - public void ShowSeparator_WithCustomWidth_ShouldDisplayCustomSeparator() - { - // Arrange - int customWidth = 50; - - // Act - MenuHelper.ShowSeparator(customWidth); - - // Assert - string output = consoleOutput.ToString().Trim(); - output.Should().Be(new string('-', customWidth)); - } - - [TestMethod] - public void ShowSeparator_WithZeroWidth_ShouldDisplayEmptySeparator() - { - // Act - MenuHelper.ShowSeparator(0); - - // Assert - string output = consoleOutput.ToString().Trim(); - output.Should().Be(""); - } - - [TestMethod] - public void ShowSeparator_WithNegativeWidth_ShouldThrowArgumentOutOfRangeException() - { - // Act & Assert - PadRight throws exception for negative values - Action act = () => MenuHelper.ShowSeparator(-5); - act.Should().Throw() - .WithParameterName("totalWidth"); - } - - [TestMethod] - public void ShowSeparator_WithLargeWidth_ShouldDisplayLargeSeparator() - { - // Arrange - int largeWidth = 200; - - // Act - MenuHelper.ShowSeparator(largeWidth); - - // Assert - string output = consoleOutput.ToString().Trim(); - output.Should().Be(new string('-', largeWidth)); - output.Length.Should().Be(largeWidth); - } - - [TestMethod] - public void ShowSeparator_CalledMultipleTimes_ShouldDisplayMultipleSeparators() - { - // Act - MenuHelper.ShowSeparator(10); - MenuHelper.ShowSeparator(20); - MenuHelper.ShowSeparator(5); - - // Assert - string output = consoleOutput.ToString(); - string[] lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - - lines.Should().HaveCount(3); - lines[0].Should().Be(new string('-', 10)); - lines[1].Should().Be(new string('-', 20)); - lines[2].Should().Be(new string('-', 5)); - } - - // Integration tests - [TestMethod] - public void MenuHelper_IntegrationTest_ShouldDisplayCompleteMenuFlow() - { - // Arrange - string appName = "Integration Test App"; - SetConsoleInput(""); // For ShowExit - - // Act - MenuHelper.ShowIntroduction(appName, 50); - MenuHelper.ShowSeparator(30); - MenuHelper.ShowExit(50); - - // Assert - string output = consoleOutput.ToString(); - output.Should().Contain($"-- {appName}"); - output.Should().Contain(new string('-', 50)); - output.Should().Contain(new string('-', 30)); - output.Should().Contain("Hit [ENTER] to exit."); - } - - [TestMethod] - public void MenuHelper_ConsistentWidthUsage_ShouldMaintainConsistentFormatting() - { - // Arrange - int width = 80; - string appName = "Consistent Width Test"; - SetConsoleInput(""); // For ShowExit - - // Act - MenuHelper.ShowIntroduction(appName, width); - MenuHelper.ShowSeparator(width); - MenuHelper.ShowExit(width); // Note: ShowExit ignores the width parameter for separators - - // Assert - string output = consoleOutput.ToString(); - string[] lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - - // Count lines with the specified width (80) and default width (72) - var width80Lines = lines.Where(line => line.Length == 80 && line.All(c => c == '-')).ToList(); - var width72Lines = lines.Where(line => line.Length == 72 && line.All(c => c == '-')).ToList(); - - width80Lines.Should().HaveCount(3); // 2 from ShowIntroduction, 1 from ShowSeparator - width72Lines.Should().HaveCount(2); // 2 from ShowExit (which ignores the width parameter) - } -} diff --git a/tests/VisionaryCoder.Framework.Tests/Extensions/ReflectionExtensionsTests.cs b/tests/VisionaryCoder.Framework.Tests/Extensions/ReflectionExtensionsTests.cs index 0cb56b2..e7afb5b 100644 --- a/tests/VisionaryCoder.Framework.Tests/Extensions/ReflectionExtensionsTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Extensions/ReflectionExtensionsTests.cs @@ -244,7 +244,7 @@ public void InvokeMethod_WithValidMethodAndParameters_ShouldThrowAmbiguousMatchE // Arrange string obj = "Hello World"; string methodName = "IndexOf"; - object[] parameters = new object[] { "World" }; + object[] parameters = ["World"]; // Act & Assert - IndexOf has overloads causing AmbiguousMatchException Func act = () => obj.InvokeMethod(methodName, parameters); @@ -257,7 +257,7 @@ public void InvokeMethod_WithMultipleParameters_ShouldThrowAmbiguousMatchExcepti // Arrange string obj = "Hello World"; string methodName = "Replace"; - object[] parameters = new object[] { "World", "Universe" }; + object[] parameters = ["World", "Universe"]; // Act & Assert - Replace has overloads causing AmbiguousMatchException Func act = () => obj.InvokeMethod(methodName, parameters); @@ -270,7 +270,7 @@ public void InvokeMethod_WithVoidMethod_ShouldReturnNull() // Arrange var list = new List(); string methodName = "Add"; - object[] parameters = new object[] { "test" }; + object[] parameters = ["test"]; // Act object? result = list.InvokeMethod(methodName, parameters); @@ -302,7 +302,7 @@ public void InvokeMethod_WithMethodThatThrows_ShouldPropagateException() string methodName = "ThrowException"; // Act & Assert - TargetInvocationException? exception = Assert.ThrowsExactly(() => obj.InvokeMethod(methodName)); + TargetInvocationException? exception = Assert.ThrowsExactly(() => obj.InvokeMethod(methodName)); exception.InnerException.Should().BeOfType(); } @@ -348,7 +348,7 @@ public void InvokeMethod_WithWrongParameterTypes_ShouldThrowAmbiguousMatchExcept // Arrange string obj = "Hello World"; string methodName = "IndexOf"; - object[] parameters = new object[] { 123, "extra param" }; // Wrong parameter types/count + object[] parameters = [123, "extra param"]; // Wrong parameter types/count // Act & Assert // Note: The implementation has a flaw - it doesn't handle overloaded methods properly @@ -422,4 +422,7 @@ public void ReflectionExtensions_RealWorldScenario_ShouldHandleComplexTypes() #endregion } -// Helper classes for testing +public class TestClass +{ + +} diff --git a/tests/VisionaryCoder.Framework.Tests/Extensions/TestClass.cs b/tests/VisionaryCoder.Framework.Tests/Extensions/TestClass.cs deleted file mode 100644 index 5b3f8fd..0000000 --- a/tests/VisionaryCoder.Framework.Tests/Extensions/TestClass.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace VisionaryCoder.Framework.Tests.Extensions; - -public class TestClass : IDisposable -{ - public string GetValue() - { - return "TestValue"; - } - - public void ThrowException() - { - throw new InvalidOperationException("Test exception"); - } - - public string OverloadedMethod() - { - return "NoParam"; - } - - public string OverloadedMethod(string param) - { - return $"WithParam:{param}"; - } - - public void Dispose() - { - // Test implementation - } -} diff --git a/tests/VisionaryCoder.Framework.Tests/Extensions/TypeExtensionTests.cs b/tests/VisionaryCoder.Framework.Tests/Extensions/TypeExtensionTests.cs index 8354c3e..67db308 100644 --- a/tests/VisionaryCoder.Framework.Tests/Extensions/TypeExtensionTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Extensions/TypeExtensionTests.cs @@ -193,7 +193,7 @@ public void AsBoolean_WithZeroDecimal_ShouldReturnFalse() public void AsBoolean_WithUnsupportedType_ShouldReturnFalse() { // Arrange - object value = new object(); + object value = new(); // Act bool result = value.AsBoolean(); @@ -340,7 +340,7 @@ public void AsInteger_WithBooleanFalse_ShouldReturnZero() public void AsInteger_WithUnsupportedType_ShouldReturnDefaultValue() { // Arrange - object value = new object(); + object value = new(); // Act int result = value.AsInteger(77); diff --git a/tests/VisionaryCoder.Framework.Tests/FrameworkInfoProviderTests.cs b/tests/VisionaryCoder.Framework.Tests/FrameworkInfoProviderTests.cs index 17f3367..ad34b60 100644 --- a/tests/VisionaryCoder.Framework.Tests/FrameworkInfoProviderTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/FrameworkInfoProviderTests.cs @@ -1,5 +1,4 @@ using System.Reflection; - using VisionaryCoder.Framework.Providers; namespace VisionaryCoder.Framework.Tests; diff --git a/tests/VisionaryCoder.Framework.Tests/FrameworkResultTests.cs b/tests/VisionaryCoder.Framework.Tests/FrameworkResultTests.cs index 88a0945..16a3f4b 100644 --- a/tests/VisionaryCoder.Framework.Tests/FrameworkResultTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/FrameworkResultTests.cs @@ -289,7 +289,7 @@ public void Map_WithSuccessfulResultButNullValue_ShouldReturnOriginalFailure() // Assert mappedResult.IsSuccess.Should().BeFalse(); - mappedResult.ErrorMessage.Should().Be("Unknown error"); + mappedResult.ErrorMessage.Should().Be("Value is null"); } [TestMethod] @@ -457,8 +457,8 @@ public void Match_WithFailedResultWithNullErrorMessage_ShouldUseUnknownError() // This tests the "Unknown error" fallback in Match method // We need to create a result through reflection to test this edge case Type resultType = typeof(ServiceResult); - ConstructorInfo constructor = resultType.GetConstructors(System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)[0]; - var result = (ServiceResult)constructor.Invoke(new object?[] { false, null, null }); + ConstructorInfo constructor = resultType.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance)[0]; + var result = (ServiceResult)constructor.Invoke([false, null, null]); string? capturedError = null; diff --git a/tests/VisionaryCoder.Framework.Tests/IFrameworkInfoProviderTests.cs b/tests/VisionaryCoder.Framework.Tests/IFrameworkInfoProviderTests.cs index f59d8f1..3fa2944 100644 --- a/tests/VisionaryCoder.Framework.Tests/IFrameworkInfoProviderTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/IFrameworkInfoProviderTests.cs @@ -1,8 +1,8 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Moq; using System.Reflection; - using VisionaryCoder.Framework.Providers; namespace VisionaryCoder.Framework.Tests; diff --git a/tests/VisionaryCoder.Framework.Tests/IRequestIdProviderTests.cs b/tests/VisionaryCoder.Framework.Tests/IRequestIdProviderTests.cs index f693b9b..f263e9a 100644 --- a/tests/VisionaryCoder.Framework.Tests/IRequestIdProviderTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/IRequestIdProviderTests.cs @@ -1,8 +1,8 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Moq; using System.Reflection; - using VisionaryCoder.Framework.Providers; namespace VisionaryCoder.Framework.Tests; diff --git a/tests/VisionaryCoder.Framework.Tests/Logging/LogDelegatesTests.cs b/tests/VisionaryCoder.Framework.Tests/Logging/LogDelegatesTests.cs index 85028f0..933f7fe 100644 --- a/tests/VisionaryCoder.Framework.Tests/Logging/LogDelegatesTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Logging/LogDelegatesTests.cs @@ -377,7 +377,7 @@ public void LogDelegate_WithVeryLongMessage_ShouldNotTruncate() // Arrange string? captured = null; LogCritical logCritical = (message, args) => captured = message; - string longMessage = new string('A', 10000); + string longMessage = new('A', 10000); // Act logCritical(longMessage); diff --git a/tests/VisionaryCoder.Framework.Tests/Logging/LogHelperTests.cs b/tests/VisionaryCoder.Framework.Tests/Logging/LogHelperTests.cs index a16e447..96edc0f 100644 --- a/tests/VisionaryCoder.Framework.Tests/Logging/LogHelperTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Logging/LogHelperTests.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; +using Moq; using VisionaryCoder.Framework.Logging; namespace VisionaryCoder.Framework.Tests.Logging; diff --git a/tests/VisionaryCoder.Framework.Tests/FrameworkOptionsTests.cs b/tests/VisionaryCoder.Framework.Tests/OptionsTests.cs similarity index 90% rename from tests/VisionaryCoder.Framework.Tests/FrameworkOptionsTests.cs rename to tests/VisionaryCoder.Framework.Tests/OptionsTests.cs index 464e735..06baa7c 100644 --- a/tests/VisionaryCoder.Framework.Tests/FrameworkOptionsTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/OptionsTests.cs @@ -1,10 +1,10 @@ namespace VisionaryCoder.Framework.Tests; /// -/// Unit tests for FrameworkOptions to ensure 100% code coverage. +/// Unit tests for Options to ensure 100% code coverage. /// [TestClass] -public class FrameworkOptionsTests +public class OptionsTests { #region Constructor and Default Values Tests @@ -12,7 +12,7 @@ public class FrameworkOptionsTests public void DefaultConstructor_ShouldSetCorrectDefaultValues() { // Act - var options = new FrameworkOptions(); + var options = new Options(); // Assert options.EnableCorrelationId.Should().BeTrue(); @@ -26,7 +26,7 @@ public void DefaultConstructor_ShouldSetCorrectDefaultValues() public void DefaultValues_ShouldMatchFrameworkConstants() { // Act - var options = new FrameworkOptions(); + var options = new Options(); // Assert options.DefaultHttpTimeoutSeconds.Should().Be(30); @@ -41,7 +41,7 @@ public void DefaultValues_ShouldMatchFrameworkConstants() public void EnableCorrelationId_CanBeSetAndRetrieved() { // Arrange - var options = new FrameworkOptions(); + var options = new Options(); // Act & Assert - Set to false options.EnableCorrelationId = false; @@ -56,7 +56,7 @@ public void EnableCorrelationId_CanBeSetAndRetrieved() public void EnableRequestId_CanBeSetAndRetrieved() { // Arrange - var options = new FrameworkOptions(); + var options = new Options(); // Act & Assert - Set to false options.EnableRequestId = false; @@ -71,7 +71,7 @@ public void EnableRequestId_CanBeSetAndRetrieved() public void EnableStructuredLogging_CanBeSetAndRetrieved() { // Arrange - var options = new FrameworkOptions(); + var options = new Options(); // Act & Assert - Set to false options.EnableStructuredLogging = false; @@ -86,7 +86,7 @@ public void EnableStructuredLogging_CanBeSetAndRetrieved() public void DefaultHttpTimeoutSeconds_CanBeSetAndRetrieved() { // Arrange - var options = new FrameworkOptions(); + var options = new Options(); // Act & Assert - Set custom value options.DefaultHttpTimeoutSeconds = 60; @@ -105,7 +105,7 @@ public void DefaultHttpTimeoutSeconds_CanBeSetAndRetrieved() public void DefaultCacheExpirationMinutes_CanBeSetAndRetrieved() { // Arrange - var options = new FrameworkOptions(); + var options = new Options(); // Act & Assert - Set custom value options.DefaultCacheExpirationMinutes = 30; @@ -128,7 +128,7 @@ public void DefaultCacheExpirationMinutes_CanBeSetAndRetrieved() public void AllProperties_CanBeSetToExtremeValues() { // Arrange - var options = new FrameworkOptions(); + var options = new Options(); // Act - Set all to minimum values options.EnableCorrelationId = false; @@ -167,7 +167,7 @@ public void AllProperties_CanBeSetToExtremeValues() public void Options_ShouldSupportTypicalConfigurationScenarios() { // Scenario 1: Minimal logging configuration - var minimalOptions = new FrameworkOptions + var minimalOptions = new Options { EnableCorrelationId = false, EnableRequestId = false, @@ -183,7 +183,7 @@ public void Options_ShouldSupportTypicalConfigurationScenarios() minimalOptions.DefaultCacheExpirationMinutes.Should().Be(5); // Scenario 2: High performance configuration - var performanceOptions = new FrameworkOptions + var performanceOptions = new Options { EnableCorrelationId = true, EnableRequestId = true, @@ -203,7 +203,7 @@ public void Options_ShouldSupportTypicalConfigurationScenarios() public void Properties_ShouldBeIndependent() { // Arrange - var options = new FrameworkOptions(); + var options = new Options(); // Act - Modify one property at a time and verify others remain unchanged int originalHttpTimeout = options.DefaultHttpTimeoutSeconds; @@ -235,8 +235,8 @@ public void Properties_ShouldBeIndependent() public void Options_ShouldBeReferenceType() { // Arrange - var options1 = new FrameworkOptions(); - FrameworkOptions options2 = options1; + var options1 = new Options(); + Options options2 = options1; // Act options2.EnableCorrelationId = false; @@ -250,8 +250,8 @@ public void Options_ShouldBeReferenceType() public void MultipleInstances_ShouldBeIndependent() { // Arrange - var options1 = new FrameworkOptions(); - var options2 = new FrameworkOptions(); + var options1 = new Options(); + var options2 = new Options(); // Act options1.EnableCorrelationId = false; diff --git a/tests/VisionaryCoder.Framework.Tests/Pagination/PageExtensionsTests.cs b/tests/VisionaryCoder.Framework.Tests/Pagination/PageExtensionsTests.cs index a80677a..b588c99 100644 --- a/tests/VisionaryCoder.Framework.Tests/Pagination/PageExtensionsTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Pagination/PageExtensionsTests.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore; using VisionaryCoder.Framework.Pagination; namespace VisionaryCoder.Framework.Tests.Pagination; @@ -287,7 +288,7 @@ public async Task ToPageWithTokenAsync_WithEmptyResult_ShouldReturnEmptyPage() async (query, token, pageSize, ct) => { List items = await query.Take(pageSize).ToListAsync(ct); - return (items, (string?)null); + return (items, null); }); // Assert diff --git a/tests/VisionaryCoder.Framework.Tests/Pagination/PageTests.cs b/tests/VisionaryCoder.Framework.Tests/Pagination/PageTests.cs index d860fb3..442dfef 100644 --- a/tests/VisionaryCoder.Framework.Tests/Pagination/PageTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Pagination/PageTests.cs @@ -99,9 +99,9 @@ public void Items_ShouldReturnReadOnlyList() public void Items_WithDifferentTypes_ShouldWork() { // Arrange & Act - var stringPage = new Page(new[] { "a", "b" }, 2, 1, 10); - var intPage = new Page(new[] { 1, 2, 3 }, 3, 1, 10); - var objectPage = new Page(new object[] { 1, "test", 3.14 }, 3, 1, 10); + var stringPage = new Page(["a", "b"], 2, 1, 10); + var intPage = new Page([1, 2, 3], 3, 1, 10); + var objectPage = new Page([1, "test", 3.14], 3, 1, 10); // Assert stringPage.Items.Should().AllBeOfType(); @@ -357,9 +357,9 @@ public void Constructor_WithNegativeValues_ShouldAccept() public void Page_WithValueTypes_ShouldWork() { // Act - var intPage = new Page(new[] { 1, 2, 3 }, 3, 1, 10); - var doublePage = new Page(new[] { 1.1, 2.2 }, 2, 1, 10); - var boolPage = new Page(new[] { true, false, true }, 3, 1, 10); + var intPage = new Page([1, 2, 3], 3, 1, 10); + var doublePage = new Page([1.1, 2.2], 2, 1, 10); + var boolPage = new Page([true, false, true], 3, 1, 10); // Assert intPage.Items.Should().AllBeOfType(); @@ -371,8 +371,8 @@ public void Page_WithValueTypes_ShouldWork() public void Page_WithReferenceTypes_ShouldWork() { // Act - var stringPage = new Page(new[] { "a", "b" }, 2, 1, 10); - var objectPage = new Page(new object[] { new(), new() }, 2, 1, 10); + var stringPage = new Page(["a", "b"], 2, 1, 10); + var objectPage = new Page([new(), new()], 2, 1, 10); // Assert stringPage.Items.Should().AllBeOfType(); diff --git a/tests/VisionaryCoder.Framework.Tests/Primitives/EntityIdJsonConverterFactoryTests.cs b/tests/VisionaryCoder.Framework.Tests/Primitives/EntityIdJsonConverterFactoryTests.cs index 3ea8698..98b01e2 100644 --- a/tests/VisionaryCoder.Framework.Tests/Primitives/EntityIdJsonConverterFactoryTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Primitives/EntityIdJsonConverterFactoryTests.cs @@ -333,12 +333,12 @@ public void Serialize_ObjectWithEntityIdProperty_ShouldSerializeCorrectly() public void Serialize_ArrayOfEntityIds_ShouldSerializeCorrectly() { // Arrange - EntityId[] ids = new[] - { + EntityId[] ids = + [ new EntityId(1), new EntityId(2), new EntityId(3) - }; + ]; // Act string json = JsonSerializer.Serialize(ids, options); diff --git a/tests/VisionaryCoder.Framework.Tests/Primitives/EntityIdTests.cs b/tests/VisionaryCoder.Framework.Tests/Primitives/EntityIdTests.cs index dc55d23..6dbfbd6 100644 --- a/tests/VisionaryCoder.Framework.Tests/Primitives/EntityIdTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Primitives/EntityIdTests.cs @@ -614,7 +614,7 @@ public void Equality_WithDifferentEntities_ShouldNotBeEqual() public void Parse_WithVeryLongString_ShouldSucceed() { // Arrange - string longString = new string('a', 10000); + string longString = new('a', 10000); // Act var id = EntityId.Parse(longString); diff --git a/tests/VisionaryCoder.Framework.Tests/Providers/CorrelationIdProviderTests.cs b/tests/VisionaryCoder.Framework.Tests/Providers/CorrelationIdProviderTests.cs index 924bac9..b0d388f 100644 --- a/tests/VisionaryCoder.Framework.Tests/Providers/CorrelationIdProviderTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Providers/CorrelationIdProviderTests.cs @@ -104,7 +104,7 @@ public void SetCorrelationId_CalledMultipleTimes_ShouldUpdateEachTime() { // Arrange var provider = new CorrelationIdProvider(); - string[] ids = new[] { "corr-1", "corr-2", "corr-3", "corr-4" }; + string[] ids = ["corr-1", "corr-2", "corr-3", "corr-4"]; // Act & Assert foreach (string id in ids) diff --git a/tests/VisionaryCoder.Framework.Tests/Providers/FrameworkInfoProviderTests.cs b/tests/VisionaryCoder.Framework.Tests/Providers/FrameworkInfoProviderTests.cs index 2c8e71e..33272bb 100644 --- a/tests/VisionaryCoder.Framework.Tests/Providers/FrameworkInfoProviderTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Providers/FrameworkInfoProviderTests.cs @@ -184,7 +184,7 @@ public void CompiledAt_Year_ShouldBeReasonable() { // Arrange var provider = new FrameworkInfoProvider(); - int[] reasonableYears = new[] { 2024, 2025, 2026, 2027 }; + int[] reasonableYears = [2024, 2025, 2026, 2027]; // Act DateTimeOffset compiledAt = provider.CompiledAt; diff --git a/tests/VisionaryCoder.Framework.Tests/Providers/RequestIdProviderTests.cs b/tests/VisionaryCoder.Framework.Tests/Providers/RequestIdProviderTests.cs index b9d7371..537ad98 100644 --- a/tests/VisionaryCoder.Framework.Tests/Providers/RequestIdProviderTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Providers/RequestIdProviderTests.cs @@ -117,7 +117,7 @@ public void SetRequestId_CalledMultipleTimes_ShouldUpdateEachTime() { // Arrange var provider = new RequestIdProvider(); - string[] ids = new[] { "id-1", "id-2", "id-3" }; + string[] ids = ["id-1", "id-2", "id-3"]; // Act & Assert foreach (string id in ids) diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/AuditRecordTests.cs b/tests/VisionaryCoder.Framework.Tests/Proxy/AuditRecordTests.cs index f4799f5..766d3ee 100644 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/AuditRecordTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Proxy/AuditRecordTests.cs @@ -235,7 +235,7 @@ public void UserAgent_WithLongString_ShouldStore() { // Arrange var record = new AuditRecord(); - string longUserAgent = new string('A', 10000); + string longUserAgent = new('A', 10000); // Act record.UserAgent = longUserAgent; diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/DefaultProxyPipelineTests.cs b/tests/VisionaryCoder.Framework.Tests/Proxy/DefaultProxyPipelineTests.cs index a09b536..f26d9aa 100644 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/DefaultProxyPipelineTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Proxy/DefaultProxyPipelineTests.cs @@ -1,6 +1,7 @@ // Copyright (c) 2025 VisionaryCoder. All rights reserved. // Licensed under the MIT License. See LICENSE file in the project root for license information. +using Moq; using VisionaryCoder.Framework.Proxy; using VisionaryCoder.Framework.Proxy.Attributes; diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/ExceptionTests.cs b/tests/VisionaryCoder.Framework.Tests/Proxy/ExceptionTests.cs index dec8e9b..3629f5e 100644 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/ExceptionTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Proxy/ExceptionTests.cs @@ -1,5 +1,4 @@ using System.Net.Sockets; - using VisionaryCoder.Framework.Proxy.Exceptions; using VisionaryCoder.Framework.Proxy.Interceptors.Retries; diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Auditing/AuditingInterceptorTests.cs b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Auditing/AuditingInterceptorTests.cs index 91d21b3..0cf0f29 100644 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Auditing/AuditingInterceptorTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Auditing/AuditingInterceptorTests.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; +using Moq; using VisionaryCoder.Framework.Proxy; using VisionaryCoder.Framework.Proxy.Interceptors.Auditing; @@ -15,7 +17,7 @@ public void Setup() { mockLogger = new Mock>(); mockAuditSink = new Mock(); - interceptor = new AuditingInterceptor(mockLogger.Object, new[] { mockAuditSink.Object }); + interceptor = new AuditingInterceptor(mockLogger.Object, [mockAuditSink.Object]); } [TestMethod] @@ -23,7 +25,7 @@ public void Constructor_WithNullLogger_ShouldThrowArgumentNullException() { // Act & Assert Assert.ThrowsExactly(() => - new AuditingInterceptor(null!, new[] { mockAuditSink.Object })); + new AuditingInterceptor(null!, [mockAuditSink.Object])); } [TestMethod] @@ -150,7 +152,7 @@ public async Task InvokeAsync_WithMultipleAuditSinks_ShouldEmitToAll() var mockSink2 = new Mock(); var multiSinkInterceptor = new AuditingInterceptor( mockLogger.Object, - new[] { mockSink1.Object, mockSink2.Object }); + [mockSink1.Object, mockSink2.Object]); var context = new ProxyContext { Request = new { Id = 1 } }; diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/CachePolicyTests.cs b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/CachePolicyTests.cs index edb097a..fb2bf1f 100644 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/CachePolicyTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/CachePolicyTests.cs @@ -1,4 +1,5 @@ -using VisionaryCoder.Framework.Proxy.Caching; +using Microsoft.Extensions.Caching.Memory; +using VisionaryCoder.Framework.Proxy.Interceptors.Caching; namespace VisionaryCoder.Framework.Tests.Proxy.Interceptors.Caching; diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/CachingInterceptorTests.cs b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/CachingInterceptorTests.cs index 78ae58a..24fb5a6 100644 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/CachingInterceptorTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/CachingInterceptorTests.cs @@ -1,5 +1,7 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Moq; using VisionaryCoder.Framework.Proxy; -using VisionaryCoder.Framework.Proxy.Caching; using VisionaryCoder.Framework.Proxy.Interceptors.Caching; namespace VisionaryCoder.Framework.Tests.Proxy.Interceptors.Caching; diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/CachingOptionsTests.cs b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/CachingOptionsTests.cs index 20f1d4a..c84701b 100644 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/CachingOptionsTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/CachingOptionsTests.cs @@ -1,5 +1,6 @@ +using Microsoft.Extensions.Caching.Memory; using VisionaryCoder.Framework.Proxy; -using VisionaryCoder.Framework.Proxy.Caching; +using VisionaryCoder.Framework.Proxy.Interceptors.Caching; namespace VisionaryCoder.Framework.Tests.Proxy.Interceptors.Caching; diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/DefaultCacheKeyProviderTests.cs b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/DefaultCacheKeyProviderTests.cs index 82dd801..352b5a2 100644 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/DefaultCacheKeyProviderTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/DefaultCacheKeyProviderTests.cs @@ -602,6 +602,6 @@ public void GenerateKey_WithComplexContext_ShouldIncludeAllRelevantParts() private class SearchResult { public int TotalCount { get; set; } - public List Items { get; set; } = new(); + public List Items { get; set; } = []; } } diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/NullCachingInterceptorTests.cs b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/NullCachingInterceptorTests.cs index 2389499..36197b6 100644 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/NullCachingInterceptorTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Caching/NullCachingInterceptorTests.cs @@ -1,5 +1,5 @@ using VisionaryCoder.Framework.Proxy; -using VisionaryCoder.Framework.Proxy.Caching; +using VisionaryCoder.Framework.Proxy.Interceptors.Caching; namespace VisionaryCoder.Framework.Tests.Proxy.Interceptors.Caching; @@ -178,7 +178,7 @@ public async Task InvokeAsync_WithComplexResponseType_ShouldPassThrough() { Id = 123, Name = "Test", - Items = new List { "A", "B", "C" } + Items = ["A", "B", "C"] }; var expectedResponse = ProxyResponse.Success(expectedData); @@ -258,6 +258,6 @@ private class ComplexType { public int Id { get; set; } public string Name { get; set; } = string.Empty; - public List Items { get; set; } = new(); + public List Items { get; set; } = []; } } diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Correlation/CorrelationInterceptorTests.cs b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Correlation/CorrelationInterceptorTests.cs index a32bd5f..c4abb70 100644 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Correlation/CorrelationInterceptorTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Correlation/CorrelationInterceptorTests.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; +using Moq; using VisionaryCoder.Framework.Proxy; using VisionaryCoder.Framework.Proxy.Interceptors.Correlation; diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Logging/LoggingInterceptorTests.cs b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Logging/LoggingInterceptorTests.cs index 2054e02..a181c41 100644 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Logging/LoggingInterceptorTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Logging/LoggingInterceptorTests.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; +using Moq; using VisionaryCoder.Framework.Proxy; using VisionaryCoder.Framework.Proxy.Exceptions; using VisionaryCoder.Framework.Proxy.Interceptors.Logging; diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Logging/TimingInterceptorTests.cs b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Logging/TimingInterceptorTests.cs index 661e392..9562a81 100644 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Logging/TimingInterceptorTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Logging/TimingInterceptorTests.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; +using Moq; using VisionaryCoder.Framework.Proxy; using VisionaryCoder.Framework.Proxy.Interceptors.Logging; diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/OrderedProxyInterceptorTests.cs b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/OrderedProxyInterceptorTests.cs index 20554f7..dfb7918 100644 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/OrderedProxyInterceptorTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/OrderedProxyInterceptorTests.cs @@ -1,3 +1,4 @@ +using Moq; using VisionaryCoder.Framework.Proxy; using VisionaryCoder.Framework.Proxy.Interceptors; diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/QueryFiltering/QueryFilterPipelineTests.cs.bak b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/QueryFiltering/QueryFilterPipelineTests.cs.bak deleted file mode 100644 index dec8264..0000000 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/QueryFiltering/QueryFilterPipelineTests.cs.bak +++ /dev/null @@ -1,91 +0,0 @@ -using System.Reflection; - -using Microsoft.Extensions.DependencyInjection; - -using VisionaryCoder.Framework.Proxy; -using VisionaryCoder.Framework.Proxy.Interceptors.QueryFiltering; -using VisionaryCoder.Framework.Querying; -using VisionaryCoder.Framework.Querying.Serialization; - -namespace VisionaryCoder.Framework.Tests.Proxy.Interceptors.QueryFiltering; - -[TestClass] -public sealed class QueryFilterPipelinePositiveTests -{ - private record User(int Id, string Name, string Email); - - [DataTestMethod] - [DataRow(@"{ ""operator"": ""Contains"", ""property"": ""Name"", ""value"": ""Ann"", ""ignoreCase"": true }", 2, DisplayName = "ContainsIgnoreCase on Name")] - [DataRow(@"{ ""operator"": ""StartsWith"", ""property"": ""Email"", ""value"": ""jo"", ""ignoreCase"": true }", 1, DisplayName = "StartsWithIgnoreCase on Email")] - [DataRow(@"{ ""operator"": ""EndsWith"", ""property"": ""Email"", ""value"": "".org"", ""ignoreCase"": true }", 2, DisplayName = "EndsWithIgnoreCase on Email")] - [DataRow(@" - { - ""operator"": ""And"", - ""children"": [ - { ""operator"": ""Contains"", ""property"": ""Name"", ""value"": ""Ann"", ""ignoreCase"": true }, - { ""operator"": ""EndsWith"", ""property"": ""Email"", ""value"": "".org"", ""ignoreCase"": true } - ] - }", 2, DisplayName = "Composite And filter")] - public async Task ValidPayloads_ShouldRoundTripAndFilter(string validJson, int expectedCount) - { - // Arrange - var context = new ProxyContext - { - Url = "http://localhost/fake", - Method = "POST", - Body = validJson, - Headers = new Dictionary() - }; - - var services = new ServiceCollection() - .AddProxyPipeline() - .AddProxyInterceptor() - .AddProxyTransport() // stub transport - .BuildServiceProvider(); - - var pipeline = services.GetRequiredService(); - - // Act - ProxyResponse> proxyResponse = await pipeline.SendAsync>(context); - - // Assert - Assert.IsTrue(proxyResponse.IsSuccess, "ProxyResponse should be successful"); - - var filter = proxyResponse.Data!; - IQueryable users = new List - { - new(1, "Ann Smith", "ann@ngo.org"), - new(2, "Bob", "bob@gmail.com"), - new(3, "Joanne", "joanne@company.org") - }.AsQueryable(); - - var result = users.Apply(filter).ToList(); - - Assert.AreEqual(expectedCount, result.Count, "Filtered result count mismatch"); - } - - // Fake transport echoes back the rehydrated filter - private sealed class FakeTransport : IProxyTransport - { - public Task> SendCoreAsync(ProxyContext context, CancellationToken cancellationToken = default) - { - FilterNode node = QueryFilterSerializer.Deserialize((string)context.Body!)!; - - // Check if T is QueryFilter - if (typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(QueryFilter<>)) - { - Type innerType = typeof(T).GetGenericArguments()[0]; - MethodInfo method = typeof(QueryFilterRehydrator) - .GetMethod(nameof(QueryFilterRehydrator.ToQueryFilter))! - .MakeGenericMethod(innerType); - - object rehydrated = method.Invoke(null, new object[] { node })!; - return Task.FromResult(ProxyResponse.Success((T)rehydrated, 200)); - } - - // For non-QueryFilter types, just return the node as T - return Task.FromResult(ProxyResponse.Success((T)(object)node, 200)); - } - } - } -} diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Retries/RetryInterceptorTests.cs b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Retries/RetryInterceptorTests.cs index ba3b82f..2f087b0 100644 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Retries/RetryInterceptorTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Retries/RetryInterceptorTests.cs @@ -1,3 +1,6 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; using VisionaryCoder.Framework.Proxy; using VisionaryCoder.Framework.Proxy.Exceptions; diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Security/AuthorizationResultTests.cs b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Security/AuthorizationResultTests.cs index e345974..6a3da43 100644 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Security/AuthorizationResultTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Security/AuthorizationResultTests.cs @@ -182,7 +182,7 @@ public void Failure_WithNullReason_ShouldAllowNull() public void Failure_WithVeryLongReason_ShouldStoreCompletely() { // Arrange - string longReason = new string('A', 10000); + string longReason = new('A', 10000); // Act var result = AuthorizationResult.Failure(longReason); diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Security/TenantContextTests.cs b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Security/TenantContextTests.cs index e4d15eb..8d85a1c 100644 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Security/TenantContextTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Proxy/Interceptors/Security/TenantContextTests.cs @@ -94,7 +94,7 @@ public void TenantId_WithGuid_ShouldStore() public void TenantName_WithVeryLongName_ShouldStoreCompletely() { // Arrange - string longName = new string('A', 10000); + string longName = new('A', 10000); var context = new TenantContext(); // Act diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/ProxyResponseTests.cs b/tests/VisionaryCoder.Framework.Tests/Proxy/ProxyResponseTests.cs index b1caa9c..cfcc9a3 100644 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/ProxyResponseTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Proxy/ProxyResponseTests.cs @@ -113,7 +113,7 @@ public void Success_WithVariousStatusCodes_ShouldStoreStatusCode(int statusCode, public void Failure_WithLongErrorMessage_ShouldPreserve() { // Arrange - string longError = new string('E', 10000); + string longError = new('E', 10000); // Act var response = ProxyResponse.Failure(longError); diff --git a/tests/VisionaryCoder.Framework.Tests/Proxy/ProxyTestsPlaceholder.cs b/tests/VisionaryCoder.Framework.Tests/Proxy/ProxyTestsPlaceholder.cs index d19c300..9911808 100644 --- a/tests/VisionaryCoder.Framework.Tests/Proxy/ProxyTestsPlaceholder.cs +++ b/tests/VisionaryCoder.Framework.Tests/Proxy/ProxyTestsPlaceholder.cs @@ -13,6 +13,8 @@ public void Placeholder_ShouldPass() { // This is a placeholder test to verify the test project structure // Once Proxy project compilation errors are fixed, comprehensive tests can be added +#pragma warning disable MSTEST0032 // Assertion condition is always true Assert.IsTrue(true, "Placeholder test should always pass"); +#pragma warning restore MSTEST0032 // Assertion condition is always true } } diff --git a/tests/VisionaryCoder.Framework.Tests/Querying/QueryFilterExtensionsTests.cs b/tests/VisionaryCoder.Framework.Tests/Querying/QueryFilterExtensionsTests.cs index 36c9baa..ff10648 100644 --- a/tests/VisionaryCoder.Framework.Tests/Querying/QueryFilterExtensionsTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Querying/QueryFilterExtensionsTests.cs @@ -1,5 +1,5 @@ -using VisionaryCoder.Framework.Querying; using System.Linq.Expressions; +using VisionaryCoder.Framework.Querying; namespace VisionaryCoder.Framework.Tests.Querying; @@ -473,12 +473,12 @@ public void EndsWithIgnoreCase_WithNullSelector_ShouldThrowArgumentNullException public void Join_WithMultipleFiltersAndTrue_ShouldCombineWithAnd() { // Arrange - QueryFilter[] filters = new[] - { + QueryFilter[] filters = + [ new QueryFilter(e => e.Id > 0), new QueryFilter(e => e.Id < 100), new QueryFilter(e => e.Name != null) - }; + ]; // Act QueryFilter combined = filters.Join(useAnd: true); @@ -493,11 +493,11 @@ public void Join_WithMultipleFiltersAndTrue_ShouldCombineWithAnd() public void Join_WithMultipleFiltersAndFalse_ShouldCombineWithOr() { // Arrange - QueryFilter[] filters = new[] - { + QueryFilter[] filters = + [ new QueryFilter(e => e.Id < 10), new QueryFilter(e => e.Id > 90) - }; + ]; // Act QueryFilter combined = filters.Join(useAnd: false); @@ -513,7 +513,7 @@ public void Join_WithMultipleFiltersAndFalse_ShouldCombineWithOr() public void Join_WithEmptySequence_ShouldReturnAlwaysTrueFilter() { // Arrange - QueryFilter[] filters = Array.Empty>(); + QueryFilter[] filters = []; // Act QueryFilter combined = filters.Join(); @@ -633,11 +633,11 @@ public void ApplyAll_WithMultipleFilters_ShouldApplyAllSequentially() new TestEntity(4, "David", "david@test.com") }.AsQueryable(); - QueryFilter[] filters = new[] - { + QueryFilter[] filters = + [ new QueryFilter(e => e.Id > 1), new QueryFilter(e => e.Id < 4) - }; + ]; // Act var result = data.ApplyAll(filters).ToList(); @@ -658,12 +658,12 @@ public void ApplyAll_WithNullFiltersInSequence_ShouldSkipNulls() new TestEntity(2, "Bob", "bob@test.com") }.AsQueryable(); - QueryFilter?[] filters = new[] - { + QueryFilter?[] filters = + [ new QueryFilter(e => e.Id > 0), null, new QueryFilter(e => e.Id < 10) - }; + ]; // Act var result = data.ApplyAll(filters!).ToList(); @@ -677,7 +677,7 @@ public void ApplyAll_WithNullSource_ShouldThrowArgumentNullException() { // Arrange IQueryable source = null!; - QueryFilter[] filters = new[] { new QueryFilter(e => e.Id > 0) }; + QueryFilter[] filters = [new QueryFilter(e => e.Id > 0)]; // Act Action act = () => source.ApplyAll(filters); diff --git a/tests/VisionaryCoder.Framework.Tests/Querying/Serialization/Class1.cs b/tests/VisionaryCoder.Framework.Tests/Querying/Serialization/Class1.cs index a7c5f60..14f5924 100644 --- a/tests/VisionaryCoder.Framework.Tests/Querying/Serialization/Class1.cs +++ b/tests/VisionaryCoder.Framework.Tests/Querying/Serialization/Class1.cs @@ -1,3 +1,4 @@ +using VisionaryCoder.Framework.Querying; using VisionaryCoder.Framework.Querying.Serialization; namespace VisionaryCoder.Framework.Tests.Querying.Serialization; diff --git a/tests/VisionaryCoder.Framework.Tests/RequestIdProviderTests.cs b/tests/VisionaryCoder.Framework.Tests/RequestIdProviderTests.cs index dfb24a4..1222efc 100644 --- a/tests/VisionaryCoder.Framework.Tests/RequestIdProviderTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/RequestIdProviderTests.cs @@ -168,8 +168,8 @@ public void SetRequestId_WithWhitespace_ShouldThrowArgumentException() public void SetRequestId_ShouldAcceptAnyNonEmptyString() { // Arrange - string[] testIds = new[] - { + string[] testIds = + [ "A", "123", "lowercase", @@ -177,7 +177,7 @@ public void SetRequestId_ShouldAcceptAnyNonEmptyString() "Mixed-Case_123", "Special@Characters#!", "Very-Long-Request-Id-With-Many-Characters" - }; + ]; foreach (string testId in testIds) { diff --git a/tests/VisionaryCoder.Framework.Tests/Secrets/LocalSecretProviderTests.cs b/tests/VisionaryCoder.Framework.Tests/Secrets/LocalSecretProviderTests.cs index 546be5f..0c42c00 100644 --- a/tests/VisionaryCoder.Framework.Tests/Secrets/LocalSecretProviderTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Secrets/LocalSecretProviderTests.cs @@ -1,3 +1,6 @@ +using Microsoft.Extensions.Configuration; +using Moq; +using Moq.Language; using VisionaryCoder.Framework.Secrets; using VisionaryCoder.Framework.Secrets.Azure.KeyVault; using VisionaryCoder.Framework.Secrets.Local; diff --git a/tests/VisionaryCoder.Framework.Tests/ServiceBaseTests.cs b/tests/VisionaryCoder.Framework.Tests/ServiceBaseTests.cs index 9054651..711f6c9 100644 --- a/tests/VisionaryCoder.Framework.Tests/ServiceBaseTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/ServiceBaseTests.cs @@ -1,3 +1,6 @@ +using Microsoft.Extensions.Logging; +using Moq; + namespace VisionaryCoder.Framework.Tests; /// @@ -12,12 +15,8 @@ public class ServiceBaseTests /// /// Concrete implementation of ServiceBase for testing purposes. /// - public class TestService : ServiceBase + public class TestService(ILogger logger) : ServiceBase(logger) { - public TestService(ILogger logger) : base(logger) - { - } - /// /// Exposes the protected Logger property for testing. /// @@ -215,12 +214,8 @@ public void Logger_ShouldBeAccessibleFromDerivedClass() /// /// Second concrete implementation to test generic type parameter. /// - public class AnotherTestService : ServiceBase + public class AnotherTestService(ILogger logger) : ServiceBase(logger) { - public AnotherTestService(ILogger logger) : base(logger) - { - } - public ILogger ExposedLogger => Logger; } diff --git a/tests/VisionaryCoder.Framework.Tests/SimpleTest.cs b/tests/VisionaryCoder.Framework.Tests/SimpleTest.cs index 50db23a..f2c5926 100644 --- a/tests/VisionaryCoder.Framework.Tests/SimpleTest.cs +++ b/tests/VisionaryCoder.Framework.Tests/SimpleTest.cs @@ -10,10 +10,10 @@ public class SimpleTest public void SimpleTest_ShouldPass() { // Arrange - var expected = true; + bool expected = true; // Act - var actual = true; + bool actual = true; // Assert Assert.AreEqual(expected, actual); diff --git a/tests/VisionaryCoder.Framework.Tests/Storage/StorageFactoryOptionsTests.cs b/tests/VisionaryCoder.Framework.Tests/Storage/StorageFactoryOptionsTests.cs index dc493bb..ebbc2a4 100644 --- a/tests/VisionaryCoder.Framework.Tests/Storage/StorageFactoryOptionsTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Storage/StorageFactoryOptionsTests.cs @@ -46,10 +46,10 @@ public void Implementations_AfterRegistration_ShouldContainImplementation() // Use reflection to call internal method for testing MethodInfo? method = typeof(StorageFactoryOptions).GetMethod("RegisterImplementation", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + BindingFlags.NonPublic | BindingFlags.Instance); // Act - method?.Invoke(options, new object?[] { "test", implementationType, null }); + method?.Invoke(options, ["test", implementationType, null]); // Assert options.Implementations.Should().ContainKey("test"); @@ -67,10 +67,10 @@ public void RegisterImplementation_WithValidParameters_ShouldAddImplementation() var options = new StorageFactoryOptions(); Type implementationType = typeof(TestStorageProvider); MethodInfo? method = typeof(StorageFactoryOptions).GetMethod("RegisterImplementation", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + BindingFlags.NonPublic | BindingFlags.Instance); // Act - method?.Invoke(options, new object?[] { "local", implementationType, null }); + method?.Invoke(options, ["local", implementationType, null]); // Assert options.Implementations.Should().HaveCount(1); @@ -87,10 +87,10 @@ public void RegisterImplementation_WithOptions_ShouldStoreOptions() Type implementationType = typeof(TestStorageProvider); var testOptions = new TestOptions { Setting = "value" }; MethodInfo? method = typeof(StorageFactoryOptions).GetMethod("RegisterImplementation", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + BindingFlags.NonPublic | BindingFlags.Instance); // Act - method?.Invoke(options, new object?[] { "ftp", implementationType, testOptions }); + method?.Invoke(options, ["ftp", implementationType, testOptions]); // Assert options.Implementations["ftp"].Options.Should().BeSameAs(testOptions); @@ -104,11 +104,11 @@ public void RegisterImplementation_WithMultipleImplementations_ShouldStoreAll() Type type1 = typeof(TestStorageProvider); Type type2 = typeof(AnotherTestProvider); MethodInfo? method = typeof(StorageFactoryOptions).GetMethod("RegisterImplementation", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + BindingFlags.NonPublic | BindingFlags.Instance); // Act - method?.Invoke(options, new object?[] { "local", type1, null }); - method?.Invoke(options, new object?[] { "ftp", type2, null }); + method?.Invoke(options, ["local", type1, null]); + method?.Invoke(options, ["ftp", type2, null]); // Assert options.Implementations.Should().HaveCount(2); @@ -124,11 +124,11 @@ public void RegisterImplementation_WithDuplicateName_ShouldOverwrite() Type type1 = typeof(TestStorageProvider); Type type2 = typeof(AnotherTestProvider); MethodInfo? method = typeof(StorageFactoryOptions).GetMethod("RegisterImplementation", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + BindingFlags.NonPublic | BindingFlags.Instance); // Act - method?.Invoke(options, new object?[] { "provider", type1, null }); - method?.Invoke(options, new object?[] { "provider", type2, null }); + method?.Invoke(options, ["provider", type1, null]); + method?.Invoke(options, ["provider", type2, null]); // Assert options.Implementations.Should().HaveCount(1); @@ -145,12 +145,12 @@ public void RegisterImplementation_WithDifferentOptionTypes_ShouldWork() int intOptions = 42; var objectOptions = new { Key = "Value" }; MethodInfo? method = typeof(StorageFactoryOptions).GetMethod("RegisterImplementation", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + BindingFlags.NonPublic | BindingFlags.Instance); // Act - method?.Invoke(options, new object?[] { "string", implementationType, stringOptions }); - method?.Invoke(options, new object?[] { "int", implementationType, intOptions }); - method?.Invoke(options, new object?[] { "object", implementationType, objectOptions }); + method?.Invoke(options, ["string", implementationType, stringOptions]); + method?.Invoke(options, ["int", implementationType, intOptions]); + method?.Invoke(options, ["object", implementationType, objectOptions]); // Assert options.Implementations["string"].Options.Should().Be(stringOptions); @@ -168,9 +168,9 @@ public void Implementations_ShouldSupportKeyEnumeration() // Arrange var options = new StorageFactoryOptions(); MethodInfo? method = typeof(StorageFactoryOptions).GetMethod("RegisterImplementation", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - method?.Invoke(options, new object?[] { "local", typeof(TestStorageProvider), null }); - method?.Invoke(options, new object?[] { "ftp", typeof(AnotherTestProvider), null }); + BindingFlags.NonPublic | BindingFlags.Instance); + method?.Invoke(options, ["local", typeof(TestStorageProvider), null]); + method?.Invoke(options, ["ftp", typeof(AnotherTestProvider), null]); // Act var keys = options.Implementations.Keys.ToList(); @@ -188,8 +188,8 @@ public void Implementations_ShouldSupportValueEnumeration() var options = new StorageFactoryOptions(); Type type1 = typeof(TestStorageProvider); MethodInfo? method = typeof(StorageFactoryOptions).GetMethod("RegisterImplementation", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - method?.Invoke(options, new object?[] { "local", type1, null }); + BindingFlags.NonPublic | BindingFlags.Instance); + method?.Invoke(options, ["local", type1, null]); // Act var values = options.Implementations.Values.ToList(); @@ -206,8 +206,8 @@ public void Implementations_TryGetValue_ShouldWorkCorrectly() var options = new StorageFactoryOptions(); Type implementationType = typeof(TestStorageProvider); MethodInfo? method = typeof(StorageFactoryOptions).GetMethod("RegisterImplementation", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - method?.Invoke(options, new object?[] { "test", implementationType, null }); + BindingFlags.NonPublic | BindingFlags.Instance); + method?.Invoke(options, ["test", implementationType, null]); // Act bool exists = options.Implementations.TryGetValue("test", out StorageImplementation? implementation); @@ -227,8 +227,8 @@ public void Implementations_ContainsKey_ShouldWorkCorrectly() // Arrange var options = new StorageFactoryOptions(); MethodInfo? method = typeof(StorageFactoryOptions).GetMethod("RegisterImplementation", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - method?.Invoke(options, new object?[] { "exists", typeof(TestStorageProvider), null }); + BindingFlags.NonPublic | BindingFlags.Instance); + method?.Invoke(options, ["exists", typeof(TestStorageProvider), null]); // Act & Assert options.Implementations.ContainsKey("exists").Should().BeTrue(); @@ -245,10 +245,10 @@ public void RegisterImplementation_WithEmptyName_ShouldStore() // Arrange var options = new StorageFactoryOptions(); MethodInfo? method = typeof(StorageFactoryOptions).GetMethod("RegisterImplementation", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + BindingFlags.NonPublic | BindingFlags.Instance); // Act - method?.Invoke(options, new object?[] { "", typeof(TestStorageProvider), null }); + method?.Invoke(options, ["", typeof(TestStorageProvider), null]); // Assert options.Implementations.Should().ContainKey(""); @@ -260,10 +260,10 @@ public void RegisterImplementation_WithWhitespaceName_ShouldStore() // Arrange var options = new StorageFactoryOptions(); MethodInfo? method = typeof(StorageFactoryOptions).GetMethod("RegisterImplementation", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + BindingFlags.NonPublic | BindingFlags.Instance); // Act - method?.Invoke(options, new object?[] { " ", typeof(TestStorageProvider), null }); + method?.Invoke(options, [" ", typeof(TestStorageProvider), null]); // Assert options.Implementations.Should().ContainKey(" "); @@ -275,11 +275,11 @@ public void RegisterImplementation_WithCaseSensitiveNames_ShouldStoreSeparately( // Arrange var options = new StorageFactoryOptions(); MethodInfo? method = typeof(StorageFactoryOptions).GetMethod("RegisterImplementation", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + BindingFlags.NonPublic | BindingFlags.Instance); // Act - method?.Invoke(options, new object?[] { "Provider", typeof(TestStorageProvider), null }); - method?.Invoke(options, new object?[] { "provider", typeof(AnotherTestProvider), null }); + method?.Invoke(options, ["Provider", typeof(TestStorageProvider), null]); + method?.Invoke(options, ["provider", typeof(AnotherTestProvider), null]); // Assert options.Implementations.Should().HaveCount(2); @@ -293,10 +293,10 @@ public void RegisterImplementation_WithNullOptions_ShouldAccept() // Arrange var options = new StorageFactoryOptions(); MethodInfo? method = typeof(StorageFactoryOptions).GetMethod("RegisterImplementation", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + BindingFlags.NonPublic | BindingFlags.Instance); // Act - method?.Invoke(options, new object?[] { "test", typeof(TestStorageProvider), null }); + method?.Invoke(options, ["test", typeof(TestStorageProvider), null]); // Assert options.Implementations["test"].Options.Should().BeNull(); @@ -309,11 +309,11 @@ public void MultipleInstances_ShouldBeIndependent() var options1 = new StorageFactoryOptions(); var options2 = new StorageFactoryOptions(); MethodInfo? method = typeof(StorageFactoryOptions).GetMethod("RegisterImplementation", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + BindingFlags.NonPublic | BindingFlags.Instance); // Act - method?.Invoke(options1, new object?[] { "test", typeof(TestStorageProvider), null }); - method?.Invoke(options2, new object?[] { "other", typeof(AnotherTestProvider), null }); + method?.Invoke(options1, ["test", typeof(TestStorageProvider), null]); + method?.Invoke(options2, ["other", typeof(AnotherTestProvider), null]); // Assert options1.Implementations.Should().HaveCount(1); @@ -341,7 +341,7 @@ public void RegisterImplementation_ShouldBeInternal() { // Arrange & Act MethodInfo? method = typeof(StorageFactoryOptions).GetMethod("RegisterImplementation", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + BindingFlags.NonPublic | BindingFlags.Instance); // Assert method.Should().NotBeNull(); diff --git a/tests/VisionaryCoder.Framework.Tests/Storage/StorageServiceTests.cs b/tests/VisionaryCoder.Framework.Tests/Storage/StorageServiceTests.cs index d9554fe..14d70b4 100644 --- a/tests/VisionaryCoder.Framework.Tests/Storage/StorageServiceTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Storage/StorageServiceTests.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; +using Moq; using VisionaryCoder.Framework.Storage; namespace VisionaryCoder.Framework.Tests.Storage; @@ -57,7 +59,7 @@ public void Constructor_WithValidLogger_ShouldInitializeSuccessfully() public void Constructor_WithNullLogger_ShouldThrowArgumentNullException() { // Arrange & Act - var action = () => new StorageService(null!); + Func action = () => new StorageService(null!); // Assert action.Should().Throw() @@ -77,7 +79,7 @@ public void FileExists_FileInfo_WithExistingFile_ShouldReturnTrue() var fileInfo = new FileInfo(filePath); // Act - var result = service!.FileExists(fileInfo); + bool result = service!.FileExists(fileInfo); // Assert result.Should().BeTrue(); @@ -91,7 +93,7 @@ public void FileExists_FileInfo_WithNonExistingFile_ShouldReturnFalse() var fileInfo = new FileInfo(filePath); // Act - var result = service!.FileExists(fileInfo); + bool result = service!.FileExists(fileInfo); // Assert result.Should().BeFalse(); @@ -101,7 +103,7 @@ public void FileExists_FileInfo_WithNonExistingFile_ShouldReturnFalse() public void FileExists_FileInfo_WithNullFileInfo_ShouldThrowArgumentNullException() { // Arrange & Act - var action = () => service!.FileExists((FileInfo)null!); + Func action = () => service!.FileExists((FileInfo)null!); // Assert action.Should().Throw() @@ -128,7 +130,7 @@ public void FileExists_String_WithExistingFile_ShouldReturnTrue(string relativeP File.WriteAllText(filePath, "test content"); // Act - var result = service!.FileExists(filePath); + bool result = service!.FileExists(filePath); // Assert result.Should().BeTrue(); @@ -141,7 +143,7 @@ public void FileExists_String_WithNonExistingFile_ShouldReturnFalse() string filePath = Path.Combine(testDirectory!, "nonexistent.txt"); // Act - var result = service!.FileExists(filePath); + bool result = service!.FileExists(filePath); // Assert result.Should().BeFalse(); @@ -154,7 +156,7 @@ public void FileExists_String_WithNonExistingFile_ShouldReturnFalse() public void FileExists_String_WithInvalidPath_ShouldThrowArgumentException(string? path) { // Arrange & Act - var action = () => service!.FileExists(path!); + Func action = () => service!.FileExists(path!); // Assert action.Should().Throw(); @@ -176,7 +178,7 @@ public void ReadAllText_WithValidFile_ShouldReturnContent(string content) File.WriteAllText(filePath, content); // Act - var result = service!.ReadAllText(filePath); + string result = service!.ReadAllText(filePath); // Assert result.Should().Be(content); @@ -189,7 +191,7 @@ public void ReadAllText_WithNonExistentFile_ShouldThrowFileNotFoundException() string filePath = Path.Combine(testDirectory!, "nonexistent.txt"); // Act - var action = () => service!.ReadAllText(filePath); + Func action = () => service!.ReadAllText(filePath); // Assert action.Should().Throw(); @@ -202,7 +204,7 @@ public void ReadAllText_WithNonExistentFile_ShouldThrowFileNotFoundException() public void ReadAllText_WithInvalidPath_ShouldThrowArgumentException(string? path) { // Arrange & Act - var action = () => service!.ReadAllText(path!); + Func action = () => service!.ReadAllText(path!); // Assert action.Should().Throw(); @@ -223,7 +225,7 @@ public async Task ReadAllTextAsync_WithValidFile_ShouldReturnContent(string cont await File.WriteAllTextAsync(filePath, content); // Act - var result = await service!.ReadAllTextAsync(filePath); + string result = await service!.ReadAllTextAsync(filePath); // Assert result.Should().Be(content); @@ -236,7 +238,7 @@ public async Task ReadAllTextAsync_WithNonExistentFile_ShouldThrowFileNotFoundEx string filePath = Path.Combine(testDirectory!, "nonexistent.txt"); // Act - var action = async () => await service!.ReadAllTextAsync(filePath); + Func> action = async () => await service!.ReadAllTextAsync(filePath); // Assert await action.Should().ThrowAsync(); @@ -252,7 +254,7 @@ public async Task ReadAllTextAsync_WithCancellation_ShouldRespectCancellationTok cts.Cancel(); // Act - var action = async () => await service!.ReadAllTextAsync(filePath, cts.Token); + Func> action = async () => await service!.ReadAllTextAsync(filePath, cts.Token); // Assert await action.Should().ThrowAsync(); @@ -273,7 +275,7 @@ public void ReadAllBytes_WithValidFile_ShouldReturnBytes(byte[] bytes) File.WriteAllBytes(filePath, bytes); // Act - var result = service!.ReadAllBytes(filePath); + byte[] result = service!.ReadAllBytes(filePath); // Assert result.Should().Equal(bytes); @@ -286,7 +288,7 @@ public void ReadAllBytes_WithNonExistentFile_ShouldThrowFileNotFoundException() string filePath = Path.Combine(testDirectory!, "nonexistent.bin"); // Act - var action = () => service!.ReadAllBytes(filePath); + Func action = () => service!.ReadAllBytes(filePath); // Assert action.Should().Throw(); @@ -300,12 +302,12 @@ public void ReadAllBytes_WithNonExistentFile_ShouldThrowFileNotFoundException() public async Task ReadAllBytesAsync_WithValidFile_ShouldReturnBytes() { // Arrange - byte[] bytes = new byte[] { 10, 20, 30, 40, 50 }; + byte[] bytes = [10, 20, 30, 40, 50]; string filePath = Path.Combine(testDirectory!, "async_bytes.bin"); await File.WriteAllBytesAsync(filePath, bytes); // Act - var result = await service!.ReadAllBytesAsync(filePath); + byte[] result = await service!.ReadAllBytesAsync(filePath); // Assert result.Should().Equal(bytes); @@ -339,7 +341,7 @@ public void WriteAllText_WithNullContent_ShouldThrowArgumentNullException() string filePath = Path.Combine(testDirectory!, "null_content.txt"); // Act - var action = () => service!.WriteAllText(filePath, null!); + Action action = () => service!.WriteAllText(filePath, null!); // Assert action.Should().Throw() @@ -353,7 +355,7 @@ public void WriteAllText_WithNullContent_ShouldThrowArgumentNullException() public void WriteAllText_WithInvalidPath_ShouldThrowArgumentException(string? path) { // Arrange & Act - var action = () => service!.WriteAllText(path!, "content"); + Action action = () => service!.WriteAllText(path!, "content"); // Assert action.Should().Throw(); @@ -387,7 +389,7 @@ public void WriteAllBytes_WithValidPath_ShouldWriteBytes() { // Arrange string filePath = Path.Combine(testDirectory!, "write_bytes.bin"); - byte[] bytes = new byte[] { 100, 200, 50 }; + byte[] bytes = [100, 200, 50]; // Act service!.WriteAllBytes(filePath, bytes); @@ -404,7 +406,7 @@ public void WriteAllBytes_WithNullBytes_ShouldThrowArgumentNullException() string filePath = Path.Combine(testDirectory!, "null_bytes.bin"); // Act - var action = () => service!.WriteAllBytes(filePath, null!); + Action action = () => service!.WriteAllBytes(filePath, null!); // Assert action.Should().Throw() @@ -420,7 +422,7 @@ public async Task WriteAllBytesAsync_WithValidPath_ShouldWriteBytes() { // Arrange string filePath = Path.Combine(testDirectory!, "async_write_bytes.bin"); - byte[] bytes = new byte[] { 11, 22, 33, 44 }; + byte[] bytes = [11, 22, 33, 44]; // Act await service!.WriteAllBytesAsync(filePath, bytes); @@ -455,7 +457,7 @@ public void DeleteFile_WithNonExistentFile_ShouldNotThrow() string filePath = Path.Combine(testDirectory!, "nonexistent_delete.txt"); // Act - var action = () => service!.DeleteFile(filePath); + Action action = () => service!.DeleteFile(filePath); // Assert action.Should().NotThrow(); @@ -468,7 +470,7 @@ public void DeleteFile_WithNonExistentFile_ShouldNotThrow() public void DeleteFile_WithInvalidPath_ShouldThrowArgumentException(string? path) { // Arrange & Act - var action = () => service!.DeleteFile(path!); + Action action = () => service!.DeleteFile(path!); // Assert action.Should().Throw(); @@ -504,7 +506,7 @@ public void DirectoryExists_WithExistingDirectory_ShouldReturnTrue() Directory.CreateDirectory(dirPath); // Act - var result = service!.DirectoryExists(dirPath); + bool result = service!.DirectoryExists(dirPath); // Assert result.Should().BeTrue(); @@ -517,7 +519,7 @@ public void DirectoryExists_WithNonExistentDirectory_ShouldReturnFalse() string dirPath = Path.Combine(testDirectory!, "nonexistent_dir"); // Act - var result = service!.DirectoryExists(dirPath); + bool result = service!.DirectoryExists(dirPath); // Assert result.Should().BeFalse(); @@ -530,7 +532,7 @@ public void DirectoryExists_WithNonExistentDirectory_ShouldReturnFalse() public void DirectoryExists_WithInvalidPath_ShouldThrowArgumentException(string? path) { // Arrange & Act - var action = () => service!.DirectoryExists(path!); + Func action = () => service!.DirectoryExists(path!); // Assert action.Should().Throw(); @@ -547,7 +549,7 @@ public void CreateDirectory_WithValidPath_ShouldCreateDirectory() string dirPath = Path.Combine(testDirectory!, "new_directory"); // Act - var result = service!.CreateDirectory(dirPath); + DirectoryInfo result = service!.CreateDirectory(dirPath); // Assert Directory.Exists(dirPath).Should().BeTrue(); @@ -562,7 +564,7 @@ public void CreateDirectory_WithNestedPath_ShouldCreateAllDirectories() string dirPath = Path.Combine(testDirectory!, "level1", "level2", "level3"); // Act - var result = service!.CreateDirectory(dirPath); + DirectoryInfo result = service!.CreateDirectory(dirPath); // Assert Directory.Exists(dirPath).Should().BeTrue(); @@ -576,7 +578,7 @@ public void CreateDirectory_WithExistingDirectory_ShouldNotThrow() Directory.CreateDirectory(dirPath); // Act - var action = () => service!.CreateDirectory(dirPath); + Func action = () => service!.CreateDirectory(dirPath); // Assert action.Should().NotThrow(); @@ -593,7 +595,7 @@ public async Task CreateDirectoryAsync_WithValidPath_ShouldCreateDirectory() string dirPath = Path.Combine(testDirectory!, "async_new_directory"); // Act - var result = await service!.CreateDirectoryAsync(dirPath); + DirectoryInfo result = await service!.CreateDirectoryAsync(dirPath); // Assert Directory.Exists(dirPath).Should().BeTrue(); @@ -644,7 +646,7 @@ public void DeleteDirectory_WithFilesAndRecursiveFalse_ShouldThrowIOException() File.WriteAllText(Path.Combine(dirPath, "file.txt"), "content"); // Act - var action = () => service!.DeleteDirectory(dirPath, recursive: false); + Action action = () => service!.DeleteDirectory(dirPath, recursive: false); // Assert action.Should().Throw(); @@ -657,7 +659,7 @@ public void DeleteDirectory_WithNonExistentDirectory_ShouldNotThrow() string dirPath = Path.Combine(testDirectory!, "nonexistent_delete_dir"); // Act - var action = () => service!.DeleteDirectory(dirPath); + Action action = () => service!.DeleteDirectory(dirPath); // Assert action.Should().NotThrow(); @@ -699,7 +701,7 @@ public void GetFiles_WithPattern_ShouldReturnMatchingFiles(string pattern) File.WriteAllText(Path.Combine(dirPath, "other.doc"), ""); // Act - var result = service!.GetFiles(dirPath, pattern); + string[] result = service!.GetFiles(dirPath, pattern); // Assert result.Should().NotBeNull(); @@ -721,7 +723,7 @@ public void GetFiles_WithEmptyDirectory_ShouldReturnEmptyArray() Directory.CreateDirectory(dirPath); // Act - var result = service!.GetFiles(dirPath); + string[] result = service!.GetFiles(dirPath); // Assert result.Should().BeEmpty(); @@ -737,7 +739,7 @@ public void GetFiles_WithEmptyDirectory_ShouldReturnEmptyArray() public void GetFiles_WithInvalidParameters_ShouldThrowArgumentException(string? path, string? pattern) { // Arrange & Act - var action = () => service!.GetFiles(path!, pattern!); + Func action = () => service!.GetFiles(path!, pattern!); // Assert action.Should().Throw(); @@ -757,7 +759,7 @@ public void GetDirectories_WithExistingSubdirectories_ShouldReturnDirectories() Directory.CreateDirectory(Path.Combine(dirPath, "sub3")); // Act - var result = service!.GetDirectories(dirPath); + string[] result = service!.GetDirectories(dirPath); // Assert result.Should().HaveCount(3); @@ -773,7 +775,7 @@ public void GetDirectories_WithPattern_ShouldReturnMatchingDirectories() Directory.CreateDirectory(Path.Combine(dirPath, "other")); // Act - var result = service!.GetDirectories(dirPath, "test*"); + string[] result = service!.GetDirectories(dirPath, "test*"); // Assert result.Should().HaveCount(2); @@ -795,7 +797,7 @@ public async Task EnumerateFilesAsync_WithFiles_ShouldEnumerateAllFiles() // Act var files = new List(); - await foreach (var file in service!.EnumerateFilesAsync(dirPath)) + await foreach (string file in service!.EnumerateFilesAsync(dirPath)) { files.Add(file); } @@ -820,7 +822,7 @@ public async Task EnumerateFilesAsync_WithCancellation_ShouldStopEnumeration() var files = new List(); Func action = async () => { - await foreach (var file in service!.EnumerateFilesAsync(dirPath, "*", cts.Token)) + await foreach (string file in service!.EnumerateFilesAsync(dirPath, "*", cts.Token)) { files.Add(file); if (files.Count == 5) @@ -846,7 +848,7 @@ public async Task EnumerateFilesAsync_WithCancellation_ShouldStopEnumeration() public void GetFullPath_WithRelativePath_ShouldReturnAbsolutePath(string relativePath) { // Act - var result = service!.GetFullPath(relativePath); + string result = service!.GetFullPath(relativePath); // Assert result.Should().NotBeNullOrWhiteSpace(); @@ -860,7 +862,7 @@ public void GetFullPath_WithRelativePath_ShouldReturnAbsolutePath(string relativ public void GetFullPath_WithInvalidPath_ShouldThrowArgumentException(string? path) { // Arrange & Act - var action = () => service!.GetFullPath(path!); + Func action = () => service!.GetFullPath(path!); // Assert action.Should().Throw(); @@ -876,7 +878,7 @@ public void GetFullPath_WithInvalidPath_ShouldThrowArgumentException(string? pat public void GetDirectoryName_WithValidPath_ShouldReturnDirectoryName(string path, string expected) { // Act - var result = service!.GetDirectoryName(path); + string? result = service!.GetDirectoryName(path); // Assert result.Should().Be(expected); @@ -887,7 +889,7 @@ public void GetDirectoryName_WithValidPath_ShouldReturnDirectoryName(string path public void GetDirectoryName_WithRootPath_ShouldReturnNull(string path) { // Act - var result = service!.GetDirectoryName(path); + string? result = service!.GetDirectoryName(path); // Assert result.Should().BeNull(); @@ -904,7 +906,7 @@ public void GetDirectoryName_WithRootPath_ShouldReturnNull(string path) public void GetFileName_WithValidPath_ShouldReturnFileName(string path, string expected) { // Act - var result = service!.GetFileName(path); + string? result = service!.GetFileName(path); // Assert result.Should().Be(expected); @@ -917,7 +919,7 @@ public void GetFileName_WithValidPath_ShouldReturnFileName(string path, string e public void GetFileName_WithInvalidPath_ShouldThrowArgumentException(string? path) { // Arrange & Act - var action = () => service!.GetFileName(path!); + Func action = () => service!.GetFileName(path!); // Assert action.Should().Throw(); @@ -939,7 +941,7 @@ public void Integration_WriteReadDeleteFile_ShouldWorkEndToEnd() service.FileExists(filePath).Should().BeTrue(); // Act & Assert - Read - var readContent = service.ReadAllText(filePath); + string readContent = service.ReadAllText(filePath); readContent.Should().Be(content); // Act & Assert - Delete @@ -959,7 +961,7 @@ public async Task Integration_AsyncOperations_ShouldWorkEndToEnd() service.FileExists(filePath).Should().BeTrue(); // Act & Assert - Read - var readContent = await service.ReadAllTextAsync(filePath); + string readContent = await service.ReadAllTextAsync(filePath); readContent.Should().Be(content); // Act & Assert - Delete @@ -982,7 +984,7 @@ public void Integration_DirectoryOperations_ShouldWorkEndToEnd() File.WriteAllText(Path.Combine(dirPath, "file2.txt"), "content2"); // Act & Assert - Get Files - var files = service.GetFiles(dirPath); + string[] files = service.GetFiles(dirPath); files.Should().HaveCount(2); // Act & Assert - Delete diff --git a/tests/VisionaryCoder.Framework.Tests/Usings.cs b/tests/VisionaryCoder.Framework.Tests/Usings.cs new file mode 100644 index 0000000..6f971bb --- /dev/null +++ b/tests/VisionaryCoder.Framework.Tests/Usings.cs @@ -0,0 +1,2 @@ +global using FluentAssertions; +global using Microsoft.VisualStudio.TestTools.UnitTesting; diff --git a/tests/VisionaryCoder.Framework.Tests/VisionaryCoder.Framework.Tests.csproj b/tests/VisionaryCoder.Framework.Tests/VisionaryCoder.Framework.Tests.csproj index cb48963..8765b1b 100644 --- a/tests/VisionaryCoder.Framework.Tests/VisionaryCoder.Framework.Tests.csproj +++ b/tests/VisionaryCoder.Framework.Tests/VisionaryCoder.Framework.Tests.csproj @@ -6,14 +6,38 @@ enable true VisionaryCoder.Framework.Tests + true + + + false + false + false + false + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + - \ No newline at end of file + + + + + + diff --git a/version.json b/version.json deleted file mode 100644 index 96fad7a..0000000 --- a/version.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "1.0-alpha.{height}", - "publicReleaseRefSpec": [ - "^refs/tags/v\\d+\\.\\d+\\.\\d+$" - ], - "cloudBuild": { - "setVersionVariables": true, - "buildNumber": { - "enabled": true - } - }, - "branches": { - "main": { - "version": "1.0-preview.{height}" - }, - "release/v\\d+\\.\\d+": { - "version": "1.0-rc.{height}" - }, - "feature/.*": { - "version": "1.0-alpha.{height}" - } - } -} From aa761deee9797392560c4b19840c2a2f663500e0 Mon Sep 17 00:00:00 2001 From: Ivan Jones Date: Mon, 17 Nov 2025 22:51:31 -0800 Subject: [PATCH 2/3] Refactor filters and add Azure storage integration Refactored filter operations by replacing `FilterOperator` with `FilterOperation` for unified handling and introduced specific enums for clarity. Enhanced collection filtering with support for `Any`, `All`, `HasElements`, and `IN` operations. Improved `FilterNode` extensibility and added XML documentation. Introduced `PocoFilterExecutionStrategy` and `EfFilterExecutionStrategy` for applying filters to in-memory collections and EF Core queries. Added `ServiceResultBase` for cleaner success/failure handling with functional-style methods. Integrated Azure Table and Queue Storage with `AzureTableStorageProvider`, `AzureQueueStorageProvider`, and their respective options and interfaces. Implemented CRUD, batch, and messaging operations. Enhanced dependency injection with named registrations for storage providers. Added `QueryFilter` for reusable predicates and demonstrated usage with POCOs and EF Core. Modernized code with improved null handling, structured logging, and updated syntax. Updated tests for new filter operations and added `IN` operation tests. Removed legacy Azure storage code and unused files. Improved documentation with detailed XML comments. --- docs/filtering/collection-operations.md | 26 +- .../Azure/Table/AzureTableStorageOptions.cs | 2 +- .../Azure/Table/AzureTableStorageProvider.cs | 37 +- .../Data/Azure/Table/ITableStorageProvider.cs | 40 ++ .../Abstractions/CollectionOperator.cs | 11 + .../Abstractions/ComparisonOperator.cs | 14 + .../FilterCollectionCondition.cs | 6 +- .../Abstractions/FilterCombination.cs | 7 + .../Filtering/Abstractions/FilterCondition.cs | 9 + .../{ => Abstractions}/FilterGroup.cs | 2 +- .../Filtering/Abstractions/FilterNode.cs | 3 + .../Filtering/Abstractions/FilterOperation.cs | 33 ++ .../Filtering/Abstractions/StringOperator.cs | 11 + .../Filtering/CollectionOperator.cs | 11 + .../Filtering/ComparisonOperator.cs | 14 + .../EFCore/EfFilterExpressionBuilder.cs | 79 +++- .../Filtering/ExpressionToFilterNode.cs | 350 ++++++++++++------ .../Filtering/Filter.cs | 2 + .../Filtering/FilterBuilder.cs | 5 +- .../Filtering/FilterCombination.cs | 7 - .../Filtering/FilterCondition.cs | 3 - .../Filtering/FilterNode.cs | 3 - .../Filtering/FilterOperator.cs | 17 - .../Poco/PocoFilterExpressionBuilder.cs | 78 +++- .../Filtering/Sample/AppDbContext.cs | 11 + .../Filtering/Sample/Demo.cs | 90 +++++ .../Filtering/Sample/Order.cs | 4 + .../Filtering/Sample/User.cs | 9 + .../Filtering/Sample/UserService.cs | 14 + .../Filtering/StringOperator.cs | 11 + .../Logging/LoggingExtensions.cs | 9 +- .../Azure/Queue/AzureQueueStorageOptions.cs | 2 +- .../Azure/Queue/AzureQueueStorageProvider.cs | 12 +- .../Azure/Queue/IQueueStorageProvider.cs | 36 ++ src/VisionaryCoder.Framework/Options.cs | 4 + .../Pipeline/Dispatch/GenericGrpcClient.cs | 2 + .../Interceptors/ResilienceInterceptor.cs | 10 +- .../Providers/CorrelationIdProvider.cs | 21 ++ .../Providers/FrameworkInfoProvider.cs | 34 +- .../Providers/ICorrelationIdProvider.cs | 5 + .../Providers/IFrameworkInfoProvider.cs | 4 + .../Providers/IRequestIdProvider.cs | 3 + .../Providers/RequestIdProvider.cs | 25 ++ .../Authorization/AuthorizationExtensions.cs | 2 - .../Interceptors/Caching/CachingExtensions.cs | 82 ++-- .../Caching/ICachePolicyProvider.cs | 3 + .../Interceptors/CachingInterceptor.cs | 45 +-- .../Caching/Providers/NullCacheKeyProvider.cs | 4 +- .../Providers/NullCachePolicyProvider.cs | 2 +- .../Azure/AzureConfigurationProvider.cs | 2 +- .../Local/LocalConfigurationProvider.cs | 3 +- .../Querying/QueryFilterSchemaValidator.cs | 2 +- .../Querying/Sample/AppDbContext.cs | 11 + .../Querying/Sample/Order.cs | 11 + .../Querying/Sample/QueryFilterDemo.cs | 45 +++ .../Querying/Sample/User.cs | 9 + .../Querying/Sample/UserQueryService.cs | 19 + src/VisionaryCoder.Framework/ServiceBase.cs | 3 +- src/VisionaryCoder.Framework/ServiceResult.cs | 104 ++++-- .../ServiceResultBase.cs | 27 ++ .../Storage/StorageExtensions.cs | 71 +++- .../Storage/StorageRegistrationBuilder.cs | 72 +++- .../VisionaryCoder.Framework.csproj | 11 +- ...CachingServiceCollectionExtensionsTests.cs | 2 +- .../Filtering/ExpressionToFilterNodeTests.cs | 41 +- 65 files changed, 1269 insertions(+), 378 deletions(-) rename src/VisionaryCoder.Framework/{Storage => Data}/Azure/Table/AzureTableStorageOptions.cs (98%) rename src/VisionaryCoder.Framework/{Storage => Data}/Azure/Table/AzureTableStorageProvider.cs (94%) create mode 100644 src/VisionaryCoder.Framework/Data/Azure/Table/ITableStorageProvider.cs create mode 100644 src/VisionaryCoder.Framework/Filtering/Abstractions/CollectionOperator.cs create mode 100644 src/VisionaryCoder.Framework/Filtering/Abstractions/ComparisonOperator.cs rename src/VisionaryCoder.Framework/Filtering/{ => Abstractions}/FilterCollectionCondition.cs (73%) create mode 100644 src/VisionaryCoder.Framework/Filtering/Abstractions/FilterCombination.cs create mode 100644 src/VisionaryCoder.Framework/Filtering/Abstractions/FilterCondition.cs rename src/VisionaryCoder.Framework/Filtering/{ => Abstractions}/FilterGroup.cs (66%) create mode 100644 src/VisionaryCoder.Framework/Filtering/Abstractions/FilterNode.cs create mode 100644 src/VisionaryCoder.Framework/Filtering/Abstractions/FilterOperation.cs create mode 100644 src/VisionaryCoder.Framework/Filtering/Abstractions/StringOperator.cs create mode 100644 src/VisionaryCoder.Framework/Filtering/CollectionOperator.cs create mode 100644 src/VisionaryCoder.Framework/Filtering/ComparisonOperator.cs delete mode 100644 src/VisionaryCoder.Framework/Filtering/FilterCombination.cs delete mode 100644 src/VisionaryCoder.Framework/Filtering/FilterCondition.cs delete mode 100644 src/VisionaryCoder.Framework/Filtering/FilterNode.cs delete mode 100644 src/VisionaryCoder.Framework/Filtering/FilterOperator.cs create mode 100644 src/VisionaryCoder.Framework/Filtering/Sample/AppDbContext.cs create mode 100644 src/VisionaryCoder.Framework/Filtering/Sample/Demo.cs create mode 100644 src/VisionaryCoder.Framework/Filtering/Sample/Order.cs create mode 100644 src/VisionaryCoder.Framework/Filtering/Sample/User.cs create mode 100644 src/VisionaryCoder.Framework/Filtering/Sample/UserService.cs create mode 100644 src/VisionaryCoder.Framework/Filtering/StringOperator.cs rename src/VisionaryCoder.Framework/{Storage => Messaging}/Azure/Queue/AzureQueueStorageOptions.cs (98%) rename src/VisionaryCoder.Framework/{Storage => Messaging}/Azure/Queue/AzureQueueStorageProvider.cs (97%) create mode 100644 src/VisionaryCoder.Framework/Messaging/Azure/Queue/IQueueStorageProvider.cs create mode 100644 src/VisionaryCoder.Framework/Querying/Sample/AppDbContext.cs create mode 100644 src/VisionaryCoder.Framework/Querying/Sample/Order.cs create mode 100644 src/VisionaryCoder.Framework/Querying/Sample/QueryFilterDemo.cs create mode 100644 src/VisionaryCoder.Framework/Querying/Sample/User.cs create mode 100644 src/VisionaryCoder.Framework/Querying/Sample/UserQueryService.cs create mode 100644 src/VisionaryCoder.Framework/ServiceResultBase.cs diff --git a/docs/filtering/collection-operations.md b/docs/filtering/collection-operations.md index 2c8ced8..a65b67d 100644 --- a/docs/filtering/collection-operations.md +++ b/docs/filtering/collection-operations.md @@ -17,7 +17,7 @@ Expression> expr = c => c.Orders.Any(); // Translates to FilterCollectionCondition( Path: "Orders", - Operator: FilterOperator.HasElements, + Operator: FilterOperation.HasElements, Predicate: null ) ``` @@ -33,10 +33,10 @@ Expression> expr = c => c.Orders.Any(o => o.Total > 1000); // Translates to FilterCollectionCondition( Path: "Orders", - Operator: FilterOperator.Any, + Operator: FilterOperation.Any, Predicate: FilterCondition( Path: "Total", - Operator: FilterOperator.GreaterThan, + Operator: FilterOperation.GreaterThan, Value: "1000" ) ) @@ -53,10 +53,10 @@ Expression> expr = c => c.Orders.All(o => o.IsPaid); // Translates to FilterCollectionCondition( Path: "Orders", - Operator: FilterOperator.All, + Operator: FilterOperation.All, Predicate: FilterCondition( Path: "IsPaid", - Operator: FilterOperator.Equals, + Operator: FilterOperation.Equals, Value: "True" ) ) @@ -73,7 +73,7 @@ Expression> expr = p => p.Tags.Contains("electronics"); // Translates to FilterCondition( Path: "Tags", - Operator: FilterOperator.Contains, + Operator: FilterOperation.Contains, Value: "electronics" ) ``` @@ -90,7 +90,7 @@ Expression> expr = c => // Translates to FilterCollectionCondition( Path: "Orders", - Operator: FilterOperator.Any, + Operator: FilterOperation.Any, Predicate: FilterGroup( Combination: FilterCombination.And, Children: [ @@ -131,18 +131,18 @@ A new `FilterNode` type that represents operations on collection properties. ```csharp public sealed record FilterCollectionCondition( string Path, // Collection property path - FilterOperator Operator, // Any, All, or HasElements + FilterOperation Operator, // Any, All, or HasElements FilterNode? Predicate // Nested filter for collection elements ) : FilterNode; ``` -### New FilterOperator Values +### New FilterOperation Values Three new operators have been added to support collection operations: -- `FilterOperator.Any` - At least one element matches the predicate -- `FilterOperator.All` - All elements match the predicate -- `FilterOperator.HasElements` - Collection is not empty +- `FilterOperation.Any` - At least one element matches the predicate +- `FilterOperation.All` - All elements match the predicate +- `FilterOperation.HasElements` - Collection is not empty ## Extensibility for Custom Methods @@ -160,7 +160,7 @@ static FilterNode? TranslateMethodCall(MethodCallExpression call) var targetMember = GetMember(call.Object); var path = GetMemberPath(targetMember); // Create appropriate FilterNode based on method semantics - return new FilterCondition(path, FilterOperator.Equals, "special"); + return new FilterCondition(path, FilterOperation.Equals, "special"); } return null; diff --git a/src/VisionaryCoder.Framework/Storage/Azure/Table/AzureTableStorageOptions.cs b/src/VisionaryCoder.Framework/Data/Azure/Table/AzureTableStorageOptions.cs similarity index 98% rename from src/VisionaryCoder.Framework/Storage/Azure/Table/AzureTableStorageOptions.cs rename to src/VisionaryCoder.Framework/Data/Azure/Table/AzureTableStorageOptions.cs index 33186b9..604845d 100644 --- a/src/VisionaryCoder.Framework/Storage/Azure/Table/AzureTableStorageOptions.cs +++ b/src/VisionaryCoder.Framework/Data/Azure/Table/AzureTableStorageOptions.cs @@ -1,4 +1,4 @@ -namespace VisionaryCoder.Framework.Storage.Azure.Table; +namespace VisionaryCoder.Framework.Data.Azure.Table; /// /// Configuration options for Azure Table Storage operations. diff --git a/src/VisionaryCoder.Framework/Storage/Azure/Table/AzureTableStorageProvider.cs b/src/VisionaryCoder.Framework/Data/Azure/Table/AzureTableStorageProvider.cs similarity index 94% rename from src/VisionaryCoder.Framework/Storage/Azure/Table/AzureTableStorageProvider.cs rename to src/VisionaryCoder.Framework/Data/Azure/Table/AzureTableStorageProvider.cs index fc1c3fa..cea9bda 100644 --- a/src/VisionaryCoder.Framework/Storage/Azure/Table/AzureTableStorageProvider.cs +++ b/src/VisionaryCoder.Framework/Data/Azure/Table/AzureTableStorageProvider.cs @@ -5,14 +5,14 @@ using Microsoft.Extensions.Logging; using System.Runtime.CompilerServices; -namespace VisionaryCoder.Framework.Storage.Azure.Table; +namespace VisionaryCoder.Framework.Data.Azure.Table; /// /// Provides Azure Table Storage-based NoSQL table operations implementation. /// This service wraps Azure Table Storage operations with logging, error handling, and async support. /// Supports both connection string and managed identity authentication. /// -public sealed class AzureTableStorageProvider : ServiceBase +public sealed class AzureTableStorageProvider : ServiceBase, ITableStorageProvider { private readonly AzureTableStorageOptions options; private readonly TableServiceClient tableServiceClient; @@ -168,8 +168,7 @@ public void UpdateEntity(T entity, ETag etag = default, TableUpdateMode mode /// /// Updates an existing entity in the table asynchronously. /// - public async Task UpdateEntityAsync(T entity, ETag etag = default, TableUpdateMode mode = TableUpdateMode.Replace, - CancellationToken cancellationToken = default) where T : class, ITableEntity + public async Task UpdateEntityAsync(T entity, ETag etag = default, TableUpdateMode mode = TableUpdateMode.Replace, CancellationToken cancellationToken = default) where T : class, ITableEntity { ArgumentNullException.ThrowIfNull(entity); @@ -213,8 +212,7 @@ public void UpsertEntity(T entity, TableUpdateMode mode = TableUpdateMode.Rep /// /// Upserts an entity (insert or replace) in the table asynchronously. /// - public async Task UpsertEntityAsync(T entity, TableUpdateMode mode = TableUpdateMode.Replace, - CancellationToken cancellationToken = default) where T : class, ITableEntity + public async Task UpsertEntityAsync(T entity, TableUpdateMode mode = TableUpdateMode.Replace, CancellationToken cancellationToken = default) where T : class, ITableEntity { ArgumentNullException.ThrowIfNull(entity); @@ -259,8 +257,7 @@ public void DeleteEntity(string partitionKey, string rowKey, ETag etag = default /// /// Deletes an entity from the table asynchronously. /// - public async Task DeleteEntityAsync(string partitionKey, string rowKey, ETag etag = default, - CancellationToken cancellationToken = default) + public async Task DeleteEntityAsync(string partitionKey, string rowKey, ETag etag = default, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(partitionKey); ArgumentException.ThrowIfNullOrWhiteSpace(rowKey); @@ -283,7 +280,8 @@ public async Task DeleteEntityAsync(string partitionKey, string rowKey, ETag eta /// /// Gets an entity by partition key and row key. /// - public T? GetEntity(string partitionKey, string rowKey) where T : class, ITableEntity + public T? GetEntity(string partitionKey, string rowKey) + where T : class, ITableEntity { ArgumentException.ThrowIfNullOrWhiteSpace(partitionKey); ArgumentException.ThrowIfNullOrWhiteSpace(rowKey); @@ -344,7 +342,8 @@ public async Task DeleteEntityAsync(string partitionKey, string rowKey, ETag eta /// /// Queries entities from the table with an optional filter. /// - public List QueryEntities(string? filter = null, int? maxPerPage = null) where T : class, ITableEntity + public List QueryEntities(string? filter = null, int? maxPerPage = null) + where T : class, ITableEntity { try { @@ -353,7 +352,7 @@ public List QueryEntities(string? filter = null, int? maxPerPage = null) w int pageSize = maxPerPage ?? options.MaxEntitiesPerQuery; Pageable? query = tableClient.Query(filter, pageSize); - List results = query.ToList(); + var results = query.ToList(); Logger.LogTrace("Successfully queried {Count} entities from table '{TableName}'", results.Count, options.TableName); return results; } @@ -367,8 +366,8 @@ public List QueryEntities(string? filter = null, int? maxPerPage = null) w /// /// Queries entities from the table with an optional filter asynchronously. /// - public async Task> QueryEntitiesAsync(string? filter = null, int? maxPerPage = null, - CancellationToken cancellationToken = default) where T : class, ITableEntity + public async Task> QueryEntitiesAsync(string? filter = null, int? maxPerPage = null, CancellationToken cancellationToken = default) + where T : class, ITableEntity { try { @@ -396,8 +395,8 @@ public async Task> QueryEntitiesAsync(string? filter = null, int? max /// /// Enumerates entities from the table with an optional filter asynchronously. /// - public async IAsyncEnumerable EnumerateEntitiesAsync(string? filter = null, int? maxPerPage = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : class, ITableEntity + public async IAsyncEnumerable EnumerateEntitiesAsync(string? filter = null, int? maxPerPage = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + where T : class, ITableEntity { Logger.LogDebug("Enumerating entities async from table '{TableName}' with filter '{Filter}'", options.TableName, filter ?? "none"); @@ -419,7 +418,7 @@ public void SubmitBatch(IEnumerable actions) try { - List? actionList = actions.ToList(); + var actionList = actions.ToList(); Logger.LogDebug("Submitting batch transaction to table '{TableName}' with {Count} actions", options.TableName, actionList.Count); @@ -447,7 +446,7 @@ public async Task SubmitBatchAsync(IEnumerable actions, try { - List? actionList = actions.ToList(); + var actionList = actions.ToList(); Logger.LogDebug("Submitting batch transaction async to table '{TableName}' with {Count} actions", options.TableName, actionList.Count); @@ -469,7 +468,8 @@ public async Task SubmitBatchAsync(IEnumerable actions, /// /// Gets entities by partition key. /// - public List GetEntitiesByPartitionKey(string partitionKey) where T : class, ITableEntity + public List GetEntitiesByPartitionKey(string partitionKey) + where T : class, ITableEntity { ArgumentException.ThrowIfNullOrWhiteSpace(partitionKey); @@ -488,4 +488,5 @@ public async Task> GetEntitiesByPartitionKeyAsync(string partitionKey string filter = $"PartitionKey eq '{partitionKey}'"; return await QueryEntitiesAsync(filter, cancellationToken: cancellationToken); } + } diff --git a/src/VisionaryCoder.Framework/Data/Azure/Table/ITableStorageProvider.cs b/src/VisionaryCoder.Framework/Data/Azure/Table/ITableStorageProvider.cs new file mode 100644 index 0000000..f97f177 --- /dev/null +++ b/src/VisionaryCoder.Framework/Data/Azure/Table/ITableStorageProvider.cs @@ -0,0 +1,40 @@ +using Azure; +using Azure.Data.Tables; + +namespace VisionaryCoder.Framework.Data.Azure.Table; + +/// +/// Defines NoSQL table-oriented storage operations for CRUD, queries and batch operations. +/// This interface separates table semantics from file/directory storage concerns. +/// +public interface ITableStorageProvider +{ + bool TableExists(); + Task TableExistsAsync(CancellationToken cancellationToken = default); + + void AddEntity(T entity) where T : class, ITableEntity; + Task AddEntityAsync(T entity, CancellationToken cancellationToken = default) where T : class, ITableEntity; + + void UpdateEntity(T entity, ETag etag = default, TableUpdateMode mode = TableUpdateMode.Replace) where T : class, ITableEntity; + Task UpdateEntityAsync(T entity, ETag etag = default, TableUpdateMode mode = TableUpdateMode.Replace, CancellationToken cancellationToken = default) where T : class, ITableEntity; + + void UpsertEntity(T entity, TableUpdateMode mode = TableUpdateMode.Replace) where T : class, ITableEntity; + Task UpsertEntityAsync(T entity, TableUpdateMode mode = TableUpdateMode.Replace, CancellationToken cancellationToken = default) where T : class, ITableEntity; + + void DeleteEntity(string partitionKey, string rowKey, ETag etag = default); + Task DeleteEntityAsync(string partitionKey, string rowKey, ETag etag = default, CancellationToken cancellationToken = default); + + T? GetEntity(string partitionKey, string rowKey) where T : class, ITableEntity; + Task GetEntityAsync(string partitionKey, string rowKey, CancellationToken cancellationToken = default) where T : class, ITableEntity; + + List QueryEntities(string? filter = null, int? maxPerPage = null) where T : class, ITableEntity; + Task> QueryEntitiesAsync(string? filter = null, int? maxPerPage = null, CancellationToken cancellationToken = default) where T : class, ITableEntity; + + IAsyncEnumerable EnumerateEntitiesAsync(string? filter = null, int? maxPerPage = null, CancellationToken cancellationToken = default) where T : class, ITableEntity; + + void SubmitBatch(IEnumerable actions); + Task SubmitBatchAsync(IEnumerable actions, CancellationToken cancellationToken = default); + + List GetEntitiesByPartitionKey(string partitionKey) where T : class, ITableEntity; + Task> GetEntitiesByPartitionKeyAsync(string partitionKey, CancellationToken cancellationToken = default) where T : class, ITableEntity; +} diff --git a/src/VisionaryCoder.Framework/Filtering/Abstractions/CollectionOperator.cs b/src/VisionaryCoder.Framework/Filtering/Abstractions/CollectionOperator.cs new file mode 100644 index 0000000..1304de1 --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/Abstractions/CollectionOperator.cs @@ -0,0 +1,11 @@ +namespace VisionaryCoder.Framework.Filtering.Abstractions; + +/// +/// Collection-specific operators. +/// +public enum CollectionOperator +{ + Any, + All, + HasElements +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Filtering/Abstractions/ComparisonOperator.cs b/src/VisionaryCoder.Framework/Filtering/Abstractions/ComparisonOperator.cs new file mode 100644 index 0000000..0188c56 --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/Abstractions/ComparisonOperator.cs @@ -0,0 +1,14 @@ +namespace VisionaryCoder.Framework.Filtering.Abstractions; + +/// +/// Specific comparison operators. Provided for consumers that prefer a strongly-typed category. +/// +public enum ComparisonOperator +{ + Equals, + NotEquals, + GreaterThan, + GreaterOrEqual, + LessThan, + LessOrEqual +} diff --git a/src/VisionaryCoder.Framework/Filtering/FilterCollectionCondition.cs b/src/VisionaryCoder.Framework/Filtering/Abstractions/FilterCollectionCondition.cs similarity index 73% rename from src/VisionaryCoder.Framework/Filtering/FilterCollectionCondition.cs rename to src/VisionaryCoder.Framework/Filtering/Abstractions/FilterCollectionCondition.cs index 50c41ef..13fa69c 100644 --- a/src/VisionaryCoder.Framework/Filtering/FilterCollectionCondition.cs +++ b/src/VisionaryCoder.Framework/Filtering/Abstractions/FilterCollectionCondition.cs @@ -1,9 +1,9 @@ -namespace VisionaryCoder.Framework.Filtering; +namespace VisionaryCoder.Framework.Filtering.Abstractions; /// /// Represents a filter condition that operates on a collection property. /// /// The path to the collection property (e.g., "Children" or "Orders.Items"). -/// The collection operator (Any, All, HasElements). +/// The collection operator (Any, All, HasElements) from . /// Optional nested filter to apply to collection elements. Required for Any/All, null for HasElements. -public sealed record FilterCollectionCondition(string Path, FilterOperator Operator, FilterNode? Predicate) : FilterNode; +public sealed record FilterCollectionCondition(string Path, FilterOperation Operator, FilterNode? Predicate) : FilterNode; diff --git a/src/VisionaryCoder.Framework/Filtering/Abstractions/FilterCombination.cs b/src/VisionaryCoder.Framework/Filtering/Abstractions/FilterCombination.cs new file mode 100644 index 0000000..64a12b7 --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/Abstractions/FilterCombination.cs @@ -0,0 +1,7 @@ +namespace VisionaryCoder.Framework.Filtering.Abstractions; + +public enum FilterCombination +{ + And, + Or +} diff --git a/src/VisionaryCoder.Framework/Filtering/Abstractions/FilterCondition.cs b/src/VisionaryCoder.Framework/Filtering/Abstractions/FilterCondition.cs new file mode 100644 index 0000000..1ea5d5c --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/Abstractions/FilterCondition.cs @@ -0,0 +1,9 @@ +namespace VisionaryCoder.Framework.Filtering.Abstractions; + +/// +/// Represents a simple property filter condition (e.g. "Age > 18" or "Name Contains 'x'"). +/// +/// Dotted property path (e.g. "Address.City"). +/// The operation to apply for the condition. Uses . +/// The value to compare against, represented as a string (may be null for certain operators). +public sealed record FilterCondition(string Path, FilterOperation Operator, string? Value) : FilterNode; diff --git a/src/VisionaryCoder.Framework/Filtering/FilterGroup.cs b/src/VisionaryCoder.Framework/Filtering/Abstractions/FilterGroup.cs similarity index 66% rename from src/VisionaryCoder.Framework/Filtering/FilterGroup.cs rename to src/VisionaryCoder.Framework/Filtering/Abstractions/FilterGroup.cs index b90899b..ae816ae 100644 --- a/src/VisionaryCoder.Framework/Filtering/FilterGroup.cs +++ b/src/VisionaryCoder.Framework/Filtering/Abstractions/FilterGroup.cs @@ -1,3 +1,3 @@ -namespace VisionaryCoder.Framework.Filtering; +namespace VisionaryCoder.Framework.Filtering.Abstractions; public sealed record FilterGroup(FilterCombination Combination, IReadOnlyList Children) : FilterNode; diff --git a/src/VisionaryCoder.Framework/Filtering/Abstractions/FilterNode.cs b/src/VisionaryCoder.Framework/Filtering/Abstractions/FilterNode.cs new file mode 100644 index 0000000..eae95b7 --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/Abstractions/FilterNode.cs @@ -0,0 +1,3 @@ +namespace VisionaryCoder.Framework.Filtering.Abstractions; + +public abstract record FilterNode; diff --git a/src/VisionaryCoder.Framework/Filtering/Abstractions/FilterOperation.cs b/src/VisionaryCoder.Framework/Filtering/Abstractions/FilterOperation.cs new file mode 100644 index 0000000..e46dca3 --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/Abstractions/FilterOperation.cs @@ -0,0 +1,33 @@ +namespace VisionaryCoder.Framework.Filtering.Abstractions; + +/// +/// Represents an operation used in filter conditions. +/// +/// +/// This enum is a consolidated operation type used by and +/// . For clarity, more specific operator enums are +/// also provided (, , ). +/// +public enum FilterOperation +{ + // Comparison operators + Equals, + NotEquals, + GreaterThan, + GreaterOrEqual, + LessThan, + LessOrEqual, + + // String operators + Contains, + StartsWith, + EndsWith, + + // Collection operators + Any, + All, + HasElements, + + // Membership + In +} diff --git a/src/VisionaryCoder.Framework/Filtering/Abstractions/StringOperator.cs b/src/VisionaryCoder.Framework/Filtering/Abstractions/StringOperator.cs new file mode 100644 index 0000000..ec8ea55 --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/Abstractions/StringOperator.cs @@ -0,0 +1,11 @@ +namespace VisionaryCoder.Framework.Filtering.Abstractions; + +/// +/// String-specific operators. +/// +public enum StringOperator +{ + Contains, + StartsWith, + EndsWith +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Filtering/CollectionOperator.cs b/src/VisionaryCoder.Framework/Filtering/CollectionOperator.cs new file mode 100644 index 0000000..862a49f --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/CollectionOperator.cs @@ -0,0 +1,11 @@ +namespace VisionaryCoder.Framework.Filtering; + +/// +/// Collection-specific operators. +/// +public enum CollectionOperator +{ + Any, + All, + HasElements +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Filtering/ComparisonOperator.cs b/src/VisionaryCoder.Framework/Filtering/ComparisonOperator.cs new file mode 100644 index 0000000..3c3c690 --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/ComparisonOperator.cs @@ -0,0 +1,14 @@ +namespace VisionaryCoder.Framework.Filtering; + +/// +/// Specific comparison operators. Provided for consumers that prefer a strongly-typed category. +/// +public enum ComparisonOperator +{ + Equals, + NotEquals, + GreaterThan, + GreaterOrEqual, + LessThan, + LessOrEqual +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Filtering/EFCore/EfFilterExpressionBuilder.cs b/src/VisionaryCoder.Framework/Filtering/EFCore/EfFilterExpressionBuilder.cs index c24da36..672e085 100644 --- a/src/VisionaryCoder.Framework/Filtering/EFCore/EfFilterExpressionBuilder.cs +++ b/src/VisionaryCoder.Framework/Filtering/EFCore/EfFilterExpressionBuilder.cs @@ -2,6 +2,8 @@ using System.Globalization; using System.Linq.Expressions; using System.Reflection; +using System.Text.Json; +using VisionaryCoder.Framework.Filtering.Abstractions; namespace VisionaryCoder.Framework.Filtering.EFCore; @@ -44,6 +46,51 @@ internal static class EfFilterExpressionBuilder MemberExpression? member = BuildMemberAccess(parameter, condition.Path); if (member is null) return null; + // Handle IN - optimized for EF Core: create a typed constant array and call Enumerable.Contains(array, member) + if (condition.Operator == FilterOperation.In) + { + if (string.IsNullOrEmpty(condition.Value)) return null; + try + { + var items = JsonSerializer.Deserialize>(condition.Value) ?? new(); + if (items.Count == 0) return null; + + Type elementClrType = Nullable.GetUnderlyingType(member.Type) ?? member.Type; + Array arr = Array.CreateInstance(elementClrType, items.Count); + + int idx = 0; + foreach (string? s in items) + { + object? parsed = ConvertFromString(s, elementClrType); + if (parsed is null && elementClrType.IsValueType && elementClrType != typeof(string)) + { + // skip invalid + idx++; + continue; + } + arr.SetValue(parsed, idx); + idx++; + } + + // If arr contains default entries and parsed were skipped, we might trim, but EF can handle empties. If no valid elements, null. + // Build constant expression for the array + Expression arrayConst = Expression.Constant(arr, elementClrType.MakeArrayType()); + + // Ensure member is of the same type as array element + Expression memberExpr = member.Type == elementClrType ? (Expression)member : Expression.Convert(member, elementClrType); + + MethodInfo containsMi = typeof(Enumerable).GetMethods(BindingFlags.Static | BindingFlags.Public) + .First(m => m.Name == "Contains" && m.GetParameters().Length == 2) + .MakeGenericMethod(elementClrType); + + return Expression.Call(containsMi, arrayConst, memberExpr); + } + catch + { + return null; + } + } + Type targetType = Nullable.GetUnderlyingType(member.Type) ?? member.Type; object? constantValue = ConvertFromString(condition.Value, targetType); if (constantValue is null && targetType.IsValueType && targetType != typeof(string)) @@ -66,15 +113,15 @@ internal static class EfFilterExpressionBuilder return condition.Operator switch { - FilterOperator.Equals => Expression.Equal(left, PromoteNull(constant, left.Type)), - FilterOperator.NotEquals => Expression.NotEqual(left, PromoteNull(constant, left.Type)), - FilterOperator.GreaterThan => Expression.GreaterThan(left, constant), - FilterOperator.GreaterOrEqual => Expression.GreaterThanOrEqual(left, constant), - FilterOperator.LessThan => Expression.LessThan(left, constant), - FilterOperator.LessOrEqual => Expression.LessThanOrEqual(left, constant), - FilterOperator.Contains => StringMethod(member, nameof(string.Contains), condition.Value), - FilterOperator.StartsWith => StringMethod(member, nameof(string.StartsWith), condition.Value), - FilterOperator.EndsWith => StringMethod(member, nameof(string.EndsWith), condition.Value), + FilterOperation.Equals => Expression.Equal(left, PromoteNull(constant, left.Type)), + FilterOperation.NotEquals => Expression.NotEqual(left, PromoteNull(constant, left.Type)), + FilterOperation.GreaterThan => Expression.GreaterThan(left, constant), + FilterOperation.GreaterOrEqual => Expression.GreaterThanOrEqual(left, constant), + FilterOperation.LessThan => Expression.LessThan(left, constant), + FilterOperation.LessOrEqual => Expression.LessThanOrEqual(left, constant), + FilterOperation.Contains => StringMethod(member, nameof(string.Contains), condition.Value), + FilterOperation.StartsWith => StringMethod(member, nameof(string.StartsWith), condition.Value), + FilterOperation.EndsWith => StringMethod(member, nameof(string.EndsWith), condition.Value), _ => null }; } @@ -89,17 +136,17 @@ internal static class EfFilterExpressionBuilder string? anyAllMethodName = condition.Operator switch { - FilterOperator.HasElements => nameof(Enumerable.Any), - FilterOperator.Any => nameof(Enumerable.Any), - FilterOperator.All => nameof(Enumerable.All), + FilterOperation.HasElements => nameof(Enumerable.Any), + FilterOperation.Any => nameof(Enumerable.Any), + FilterOperation.All => nameof(Enumerable.All), _ => null }; if (anyAllMethodName is null) return null; - if (condition.Operator == FilterOperator.HasElements) + if (condition.Operator == FilterOperation.HasElements) { return Expression.Call( - typeof(Enumerable), anyAllMethodName, [elementType], collection); + typeof(Enumerable), anyAllMethodName, new[] { elementType }, collection); } if (condition.Predicate is null) return null; @@ -108,13 +155,13 @@ internal static class EfFilterExpressionBuilder if (inner is null) return null; LambdaExpression lambda = Expression.Lambda(inner, elemParam); return Expression.Call( - typeof(Enumerable), anyAllMethodName, [elementType], collection, lambda); + typeof(Enumerable), anyAllMethodName, new[] { elementType }, collection, lambda); } private static Expression? StringMethod(Expression member, string method, string? arg) { if (member.Type != typeof(string)) return null; - MethodInfo mi = typeof(string).GetMethod(method, [typeof(string)])!; + MethodInfo mi = typeof(string).GetMethod(method, new[] { typeof(string) })!; return Expression.Call(member, mi, Expression.Constant(arg ?? string.Empty)); } diff --git a/src/VisionaryCoder.Framework/Filtering/ExpressionToFilterNode.cs b/src/VisionaryCoder.Framework/Filtering/ExpressionToFilterNode.cs index 738d33c..dd085ae 100644 --- a/src/VisionaryCoder.Framework/Filtering/ExpressionToFilterNode.cs +++ b/src/VisionaryCoder.Framework/Filtering/ExpressionToFilterNode.cs @@ -1,13 +1,45 @@ +using System.Diagnostics; using System.Linq.Expressions; +using System.Text.Json; +using VisionaryCoder.Framework.Filtering.Abstractions; namespace VisionaryCoder.Framework.Filtering; +/// +/// Converts LINQ expression trees into the framework's FilterNode representation. +/// +/// +/// This translator supports a subset of expression syntax sufficient for typical +/// filtering scenarios: boolean combinations (&&, ||), comparisons (==, !=, <, >, etc.), +/// simple unary negation (!), string operations (Contains/StartsWith/EndsWith) and +/// common Enumerable methods such as Any/All/Contains used in collection predicates. +/// +/// The translator intentionally returns null for unsupported nodes; public +/// entry points raise when translation fails. +/// public static class ExpressionToFilterNode { + /// + /// Translate a strongly-typed predicate expression into a . + /// + /// The parameter type used in the expression (e.g. entity type). + /// The predicate expression to translate. + /// A representing the predicate. + /// Thrown when the expression contains unsupported constructs. public static FilterNode Translate(Expression> expression) => TranslateNode(expression.Body) ?? throw new NotSupportedException($"Expression '{expression}' is not supported."); + /// + /// Translate a general expression into a . + /// + /// The expression to translate. + /// A representing the expression. + /// Thrown when the expression contains unsupported constructs. public static FilterNode Translate(Expression expression) => TranslateNode(expression) ?? throw new NotSupportedException($"Expression '{expression}' is not supported."); + /// + /// Internal recursive dispatcher that maps expression node types to translator methods. + /// Returns null when the node is not supported by the translator. + /// private static FilterNode? TranslateNode(Expression expression) => expression switch { @@ -17,6 +49,11 @@ public static class ExpressionToFilterNode _ => null }; + /// + /// Translates binary expressions. Handles logical groups (AndAlso/OrElse) by + /// creating nodes and comparison operators by delegating + /// to . + /// private static FilterNode? TranslateBinary(BinaryExpression binary) { // Comparison: ==, !=, <, <=, >, >= @@ -26,7 +63,9 @@ public static class ExpressionToFilterNode } // Logical group: && / || - FilterCombination combination = binary.NodeType == ExpressionType.AndAlso ? FilterCombination.And : FilterCombination.Or; + FilterCombination combination = binary.NodeType == ExpressionType.AndAlso + ? FilterCombination.And + : FilterCombination.Or; FilterNode? left = TranslateNode(binary.Left); FilterNode? right = TranslateNode(binary.Right); @@ -38,6 +77,10 @@ public static class ExpressionToFilterNode } + /// + /// Helper that flattens nested groups of the same combination type to avoid + /// deeply nested group trees (e.g. (A && B) && C becomes A && B && C). + /// private static IEnumerable FlattenIfSameGroup(FilterNode node, FilterCombination combination) { if (node is FilterGroup group && group.Combination == combination) @@ -51,10 +94,14 @@ private static IEnumerable FlattenIfSameGroup(FilterNode node, Filte yield return node; } + /// + /// Handles comparison expressions by normalizing member/constant positions and + /// mapping the expression to a . + /// private static FilterNode? TranslateComparison(BinaryExpression binary) { - (MemberExpression? memberExpr, Expression? constantExpr, FilterOperator? op) = NormalizeBinary(binary); + (MemberExpression? memberExpr, Expression? constantExpr, FilterOperation? op) = NormalizeBinary(binary); if (memberExpr is null || constantExpr is null || op is null) { return null; @@ -72,10 +119,11 @@ private static IEnumerable FlattenIfSameGroup(FilterNode node, Filte } /// - /// Normalizes a binary expression so that the member is on the left - /// and the constant/value is on the right. Adjusts the operator if needed. + /// Normalizes a binary expression so that the member expression is on the left + /// and the constant-like expression is on the right. If operands are reversed the + /// comparison operator will be inverted accordingly. /// - private static (MemberExpression? member, Expression? constant, FilterOperator?) NormalizeBinary(BinaryExpression binary) + private static (MemberExpression? member, Expression? constant, FilterOperation?) NormalizeBinary(BinaryExpression binary) { MemberExpression? leftMember = GetMember(binary.Left); @@ -87,52 +135,59 @@ private static (MemberExpression? member, Expression? constant, FilterOperator?) // member op constant if (leftMember is not null && rightIsConstLike) { - FilterOperator? op = MapComparisonOperator(binary.NodeType, invert: false); + FilterOperation? op = MapComparisonOperator(binary.NodeType, invert: false); return (leftMember, binary.Right, op); } // constant op member -> invert operator if (rightMember is not null && leftIsConstLike) { - FilterOperator? op = MapComparisonOperator(binary.NodeType, invert: true); + FilterOperation? op = MapComparisonOperator(binary.NodeType, invert: true); return (rightMember, binary.Left, op); } return (null, null, null); } - private static FilterOperator? MapComparisonOperator(ExpressionType nodeType, bool invert) + /// + /// Maps ExpressionType comparison nodes to framework , + /// optionally inverting the operator when the constant appears on the left. + /// + private static FilterOperation? MapComparisonOperator(ExpressionType nodeType, bool invert) { return (nodeType, invert) switch { - (ExpressionType.Equal, _) => FilterOperator.Equals, - (ExpressionType.NotEqual, _) => FilterOperator.NotEquals, + (ExpressionType.Equal, _) => FilterOperation.Equals, + (ExpressionType.NotEqual, _) => FilterOperation.NotEquals, - (ExpressionType.GreaterThan, false) => FilterOperator.GreaterThan, - (ExpressionType.GreaterThan, true) => FilterOperator.LessThan, + (ExpressionType.GreaterThan, false) => FilterOperation.GreaterThan, + (ExpressionType.GreaterThan, true) => FilterOperation.LessThan, - (ExpressionType.GreaterThanOrEqual, false) => FilterOperator.GreaterOrEqual, - (ExpressionType.GreaterThanOrEqual, true) => FilterOperator.LessOrEqual, + (ExpressionType.GreaterThanOrEqual, false) => FilterOperation.GreaterOrEqual, + (ExpressionType.GreaterThanOrEqual, true) => FilterOperation.LessOrEqual, - (ExpressionType.LessThan, false) => FilterOperator.LessThan, - (ExpressionType.LessThan, true) => FilterOperator.GreaterThan, + (ExpressionType.LessThan, false) => FilterOperation.LessThan, + (ExpressionType.LessThan, true) => FilterOperation.GreaterThan, - (ExpressionType.LessThanOrEqual, false) => FilterOperator.LessOrEqual, - (ExpressionType.LessThanOrEqual, true) => FilterOperator.GreaterOrEqual, + (ExpressionType.LessThanOrEqual, false) => FilterOperation.LessOrEqual, + (ExpressionType.LessThanOrEqual, true) => FilterOperation.GreaterOrEqual, _ => null }; } + /// + /// Translates supported method calls into FilterNode forms. + /// Supported patterns include string methods (Contains/StartsWith/EndsWith), + /// Enumerable static methods (Any/All/Contains) and instance collection Contains. + /// private static FilterNode? TranslateMethodCall(MethodCallExpression call) { // string.Contains / StartsWith / EndsWith - if (call.Object is not null && - call.Object.Type == typeof(string) && - call.Arguments.Count == 1) + if (call.Object is not null && call.Object.Type == typeof(string) && call.Arguments.Count == 1) { MemberExpression? targetMember = GetMember(call.Object); if (targetMember is null) @@ -149,11 +204,11 @@ private static (MemberExpression? member, Expression? constant, FilterOperator?) Expression arg = call.Arguments[0]; string? value = EvaluateToString(arg); - FilterOperator? op = call.Method.Name switch + FilterOperation? op = call.Method.Name switch { - nameof(string.Contains) => FilterOperator.Contains, - nameof(string.StartsWith) => FilterOperator.StartsWith, - nameof(string.EndsWith) => FilterOperator.EndsWith, + nameof(string.Contains) => FilterOperation.Contains, + nameof(string.StartsWith) => FilterOperation.StartsWith, + nameof(string.EndsWith) => FilterOperation.EndsWith, _ => null }; @@ -188,7 +243,7 @@ private static (MemberExpression? member, Expression? constant, FilterOperator?) } string? value = EvaluateToString(call.Arguments[0]); - return new FilterCondition(path, FilterOperator.Contains, value); + return new FilterCondition(path, FilterOperation.Contains, value); } // Custom methods can be added here by checking call.Method.DeclaringType and Method.Name @@ -201,61 +256,69 @@ private static (MemberExpression? member, Expression? constant, FilterOperator?) } + /// + /// Translates a simple logical negation. Only supports negation of a comparison + /// expression (e.g. !x.IsActive or ! (x.Value > 4)). + /// private static FilterNode? TranslateNot(UnaryExpression unary) { // Only handle simple negation of a comparison or method call for now // e.g. !c.IsActive or !c.Name.Contains("x") - if (unary.Operand is BinaryExpression binary) + if (unary.Operand is not BinaryExpression binary) { - // Flip operator if possible - (MemberExpression? memberExpr, Expression? constantExpr, FilterOperator? op) = NormalizeBinary(binary); - if (memberExpr is null || constantExpr is null || op is null) - { - return null; - } - - FilterOperator negated = NegateOperator(op.Value); - string? path = GetMemberPath(memberExpr); - string? value = EvaluateToString(constantExpr); - - return new FilterCondition(path!, negated, value); + return null; } - if (unary.Operand is MethodCallExpression call) + // Flip operator if possible + (MemberExpression? memberExpr, Expression? constantExpr, FilterOperation? op) = NormalizeBinary(binary); + if (memberExpr is null || constantExpr is null || op is null) { - // e.g. !c.Name.Contains("x") => NotContains (or NotEquals on Contains semantics) - // For now, treat as NotEquals with Contains semantics if you like, - // or just not support and return null. return null; } - return null; + FilterOperation negated = NegateOperator(op.Value); + string? path = GetMemberPath(memberExpr); + string? value = EvaluateToString(constantExpr); + return new FilterCondition(path!, negated, value); + } - private static FilterOperator NegateOperator(FilterOperator op) => + /// + /// Negates a filter operator when a logical NOT is applied to a condition. + /// + private static FilterOperation NegateOperator(FilterOperation op) => op switch { - FilterOperator.Equals => FilterOperator.NotEquals, - FilterOperator.NotEquals => FilterOperator.Equals, - FilterOperator.GreaterThan => FilterOperator.LessOrEqual, - FilterOperator.GreaterOrEqual => FilterOperator.LessThan, - FilterOperator.LessThan => FilterOperator.GreaterOrEqual, - FilterOperator.LessOrEqual => FilterOperator.GreaterThan, + FilterOperation.Equals => FilterOperation.NotEquals, + FilterOperation.NotEquals => FilterOperation.Equals, + FilterOperation.GreaterThan => FilterOperation.LessOrEqual, + FilterOperation.GreaterOrEqual => FilterOperation.LessThan, + FilterOperation.LessThan => FilterOperation.GreaterOrEqual, + FilterOperation.LessOrEqual => FilterOperation.GreaterThan, _ => throw new NotSupportedException($"Cannot negate operator '{op}'.") }; + /// + /// Attempts to obtain a from an expression. + /// Handles trivial conversions (e.g. boxing/unboxing) by unwrapping unary convert nodes. + /// private static MemberExpression? GetMember(Expression expression) => expression switch { MemberExpression m => m, - UnaryExpression u when u.NodeType == ExpressionType.Convert && u.Operand is MemberExpression inner - => inner, + UnaryExpression { NodeType: ExpressionType.Convert, Operand: MemberExpression inner } => inner, _ => null }; - private static bool IsConstantLike(Expression expression) => - expression.NodeType is ExpressionType.Constant || expression is MemberExpression { Expression: ConstantExpression } || expression is UnaryExpression u && IsConstantLike(u.Operand); + /// + /// Determines whether an expression is "constant-like" (literal, captured closure, or nested constant conversion). + /// + private static bool IsConstantLike(Expression expression) => expression.NodeType is ExpressionType.Constant || expression is MemberExpression { Expression: ConstantExpression } || (expression is UnaryExpression u && IsConstantLike(u.Operand)); + /// + /// Builds a dotted member path for nested member expressions (e.g. x.Address.City -> "Address.City"). + /// Returns null if path cannot be determined. + /// private static string? GetMemberPath(MemberExpression member) { var parts = new Stack(); @@ -273,6 +336,7 @@ private static bool IsConstantLike(Expression expression) => /// /// Translates LINQ Enumerable method calls (Any, All, Contains) to FilterNode structures. + /// Supports: Any(), Any(predicate), All(predicate), Enumerable.Contains(source, value). /// /// The method call expression representing a LINQ Enumerable method. /// A FilterNode representing the collection operation, or null if translation is not supported. @@ -286,84 +350,158 @@ private static bool IsConstantLike(Expression expression) => Expression collectionExpr = call.Arguments[0]; MemberExpression? collectionMember = GetMember(collectionExpr); - if (collectionMember is null) + if (collectionMember is not null) { - return null; - } + string? path = GetMemberPath(collectionMember); + if (path is null) + { + return null; + } + + switch (call.Method.Name) + { + case nameof(Enumerable.Any): + switch (call.Arguments.Count) + { + + // Any() without predicate - just check if collection has elements + case 1: + return new FilterCollectionCondition(path, FilterOperation.HasElements, null); + + // Any(predicate) - check if any element matches the predicate + case 2 when call.Arguments[1] is UnaryExpression { Operand: LambdaExpression anyLambdaPredicate }: + { + FilterNode? predicateFilter = TranslateNode(anyLambdaPredicate.Body); + return predicateFilter is null + ? null + : new FilterCollectionCondition(path, FilterOperation.Any, predicateFilter); + } + } + + break; + + case nameof(Enumerable.All): + + // All(predicate) - check if all elements match the predicate + if (call.Arguments.Count == 2 && call.Arguments[1] is UnaryExpression { Operand: LambdaExpression allLambdaPredicate }) + { + FilterNode? predicateFilter = TranslateNode(allLambdaPredicate.Body); + return predicateFilter is null + ? null + : new FilterCollectionCondition(path, FilterOperation.All, predicateFilter); + } + break; + + case nameof(Enumerable.Contains): + // Contains(value) - check if collection contains a specific value + if (call.Arguments.Count == 2) + { + string? value = EvaluateToString(call.Arguments[1]); + return new FilterCondition(path, FilterOperation.Contains, value); + } + break; + } - string? path = GetMemberPath(collectionMember); - if (path is null) - { return null; } - switch (call.Method.Name) + // collectionExpr is not a member (likely a constant or complex expression) + // Support patterns like: new[] { "A", "B" }.Contains(x.Prop) -> translates to IN + if (call.Method.Name == nameof(Enumerable.Contains) && call.Arguments.Count == 2) { - case nameof(Enumerable.Any): - switch (call.Arguments.Count) - { - - // Any() without predicate - just check if collection has elements - case 1: - return new FilterCollectionCondition(path, FilterOperator.HasElements, null); - - // Any(predicate) - check if any element matches the predicate - case 2 when call.Arguments[1] is UnaryExpression { Operand: LambdaExpression anyLambdaPredicate }: - { - FilterNode? predicateFilter = TranslateNode(anyLambdaPredicate.Body); - return predicateFilter is null - ? null - : new FilterCollectionCondition(path, FilterOperator.Any, predicateFilter); - } - } - - break; - - case nameof(Enumerable.All): - - // All(predicate) - check if all elements match the predicate - if (call.Arguments.Count == 2 && call.Arguments[1] is UnaryExpression { Operand: LambdaExpression allLambdaPredicate }) + // If the first arg is constant-like (collection) and the second arg is a member, create an IN + if (IsConstantLike(collectionExpr)) + { + // evaluate collection + object? raw = EvaluateExpression(collectionExpr); + if (raw is System.Collections.IEnumerable items) { - FilterNode? predicateFilter = TranslateNode(allLambdaPredicate.Body); - return predicateFilter is null - ? null - : new FilterCollectionCondition(path, FilterOperator.All, predicateFilter); + // collect string forms + var list = new List(); + foreach (object? it in items) + { + list.Add(it?.ToString()); + } + + // second argument should be member expression representing the property + MemberExpression? memberExpr = GetMember(call.Arguments[1]); + if (memberExpr is null) return null; + string? path = GetMemberPath(memberExpr); + if (path is null) return null; + + string json = JsonSerializer.Serialize(list); + return new FilterCondition(path, FilterOperation.In, json); } - break; + } - case nameof(Enumerable.Contains): - // Contains(value) - check if collection contains a specific value - if (call.Arguments.Count == 2) + // Also support instance-method form: (new[] {"A"}).Contains(x.Prop) + if (call.Object is not null && IsConstantLike(call.Object)) + { + object? raw = EvaluateExpression(call.Object); + if (raw is System.Collections.IEnumerable items) { - string? value = EvaluateToString(call.Arguments[1]); - return new FilterCondition(path, FilterOperator.Contains, value); + var list = new List(); + foreach (object? it in items) + { + list.Add(it?.ToString()); + } + + // argument[0] is the element (member) + MemberExpression? memberExpr = GetMember(call.Arguments[0]); + if (memberExpr is null) return null; + string? path = GetMemberPath(memberExpr); + if (path is null) return null; + + string json = JsonSerializer.Serialize(list); + return new FilterCondition(path, FilterOperation.In, json); } - break; + } } return null; } + private static object? EvaluateExpression(Expression expression) + { + Expression expr = expression; + while (expr is UnaryExpression u && expr.NodeType == ExpressionType.Convert) + { + expr = u.Operand; + } + + if (expr is ConstantExpression constant) + { + return constant.Value; + } + + try + { + LambdaExpression lambda = Expression.Lambda(expr); + return lambda.Compile().DynamicInvoke(); + } + catch + { + return null; + } + } + /// /// Checks if a type is a collection type (array or implements IEnumerable<T>, ICollection<T>, or IList<T>). + /// Strings are explicitly excluded. /// - /// The type to check. - /// True if the type is a collection type (excluding string); otherwise, false. private static bool IsCollectionType(Type type) { if (type == typeof(string)) { return false; } - - return type.IsArray || - type.GetInterfaces().Any(i => - i.IsGenericType && - (i.GetGenericTypeDefinition() == typeof(IEnumerable<>) || - i.GetGenericTypeDefinition() == typeof(ICollection<>) || - i.GetGenericTypeDefinition() == typeof(IList<>))); + return type.IsArray || type.GetInterfaces().Any(i => i.IsGenericType && (i.GetGenericTypeDefinition() == typeof(IEnumerable<>) || i.GetGenericTypeDefinition() == typeof(ICollection<>) || i.GetGenericTypeDefinition() == typeof(IList<>))); } + /// + /// Evaluates an expression to its string representation. Handles constants, captured closures + /// and simple expressions by compiling and invoking the expression where necessary. + /// private static string? EvaluateToString(Expression expression) { // Normalize to underlying expression diff --git a/src/VisionaryCoder.Framework/Filtering/Filter.cs b/src/VisionaryCoder.Framework/Filtering/Filter.cs index dfb1124..4a0ff9c 100644 --- a/src/VisionaryCoder.Framework/Filtering/Filter.cs +++ b/src/VisionaryCoder.Framework/Filtering/Filter.cs @@ -1,3 +1,5 @@ +using VisionaryCoder.Framework.Filtering.Abstractions; + namespace VisionaryCoder.Framework.Filtering; public static class Filter diff --git a/src/VisionaryCoder.Framework/Filtering/FilterBuilder.cs b/src/VisionaryCoder.Framework/Filtering/FilterBuilder.cs index f94df7c..68ca96a 100644 --- a/src/VisionaryCoder.Framework/Filtering/FilterBuilder.cs +++ b/src/VisionaryCoder.Framework/Filtering/FilterBuilder.cs @@ -1,10 +1,11 @@ using System.Linq.Expressions; +using VisionaryCoder.Framework.Filtering.Abstractions; namespace VisionaryCoder.Framework.Filtering; public sealed class FilterBuilder { - private readonly List roots = []; + private readonly List roots = new(); public FilterBuilder Where(Expression> predicate) { @@ -17,7 +18,7 @@ public FilterNode Build() { return roots.Count switch { - 0 => new FilterGroup(FilterCombination.And, []), + 0 => new FilterGroup(FilterCombination.And, new List()), 1 => roots[0], _ => new FilterGroup(FilterCombination.And, roots) }; diff --git a/src/VisionaryCoder.Framework/Filtering/FilterCombination.cs b/src/VisionaryCoder.Framework/Filtering/FilterCombination.cs deleted file mode 100644 index dcf54da..0000000 --- a/src/VisionaryCoder.Framework/Filtering/FilterCombination.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace VisionaryCoder.Framework.Filtering; - -public enum FilterCombination -{ - And, - Or -} diff --git a/src/VisionaryCoder.Framework/Filtering/FilterCondition.cs b/src/VisionaryCoder.Framework/Filtering/FilterCondition.cs deleted file mode 100644 index f271dae..0000000 --- a/src/VisionaryCoder.Framework/Filtering/FilterCondition.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace VisionaryCoder.Framework.Filtering; - -public sealed record FilterCondition(string Path, FilterOperator Operator, string? Value) : FilterNode; diff --git a/src/VisionaryCoder.Framework/Filtering/FilterNode.cs b/src/VisionaryCoder.Framework/Filtering/FilterNode.cs deleted file mode 100644 index 1fb244d..0000000 --- a/src/VisionaryCoder.Framework/Filtering/FilterNode.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace VisionaryCoder.Framework.Filtering; - -public abstract record FilterNode; diff --git a/src/VisionaryCoder.Framework/Filtering/FilterOperator.cs b/src/VisionaryCoder.Framework/Filtering/FilterOperator.cs deleted file mode 100644 index 3d7e035..0000000 --- a/src/VisionaryCoder.Framework/Filtering/FilterOperator.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace VisionaryCoder.Framework.Filtering; - -public enum FilterOperator -{ - Equals, - NotEquals, - GreaterThan, - GreaterOrEqual, - LessThan, - LessOrEqual, - Contains, - StartsWith, - EndsWith, - Any, - All, - HasElements -} diff --git a/src/VisionaryCoder.Framework/Filtering/Poco/PocoFilterExpressionBuilder.cs b/src/VisionaryCoder.Framework/Filtering/Poco/PocoFilterExpressionBuilder.cs index df310f4..26e6fa0 100644 --- a/src/VisionaryCoder.Framework/Filtering/Poco/PocoFilterExpressionBuilder.cs +++ b/src/VisionaryCoder.Framework/Filtering/Poco/PocoFilterExpressionBuilder.cs @@ -1,6 +1,8 @@ using System.Globalization; using System.Linq.Expressions; using System.Reflection; +using System.Text.Json; +using VisionaryCoder.Framework.Filtering.Abstractions; namespace VisionaryCoder.Framework.Filtering.Poco; @@ -42,6 +44,50 @@ internal static class PocoFilterExpressionBuilder MemberExpression? member = BuildMemberAccess(parameter, condition.Path); if (member is null) return null; + // Special handling for IN + if (condition.Operator == FilterOperation.In) + { + // condition.Value holds JSON array of string values + if (string.IsNullOrEmpty(condition.Value)) return null; + try + { + var items = JsonSerializer.Deserialize>(condition.Value) ?? new(); + if (items.Count == 0) return null; + + // Build OR equals: (member == v1) || (member == v2) ... + Expression? combined = null; + foreach (string? s in items) + { + object? parsed = ConvertFromString(s, Nullable.GetUnderlyingType(member.Type) ?? member.Type); + if (parsed is null && (Nullable.GetUnderlyingType(member.Type) ?? member.Type).IsValueType && (Nullable.GetUnderlyingType(member.Type) ?? member.Type) != typeof(string)) + continue; + + ConstantExpression c = Expression.Constant(parsed, parsed?.GetType() ?? typeof(string)); + Expression leftExpr = member; + if (member.Type != c.Type) + { + if (Nullable.GetUnderlyingType(member.Type) == c.Type) + { + // ok + } + else + { + leftExpr = Expression.Convert(member, c.Type); + } + } + + Expression eq = Expression.Equal(leftExpr, PromoteNull(c, leftExpr.Type)); + combined = combined is null ? eq : Expression.OrElse(combined, eq); + } + + return combined; + } + catch + { + return null; + } + } + Type targetType = Nullable.GetUnderlyingType(member.Type) ?? member.Type; object? constantValue = ConvertFromString(condition.Value, targetType); if (constantValue is null && targetType.IsValueType && targetType != typeof(string)) @@ -63,15 +109,15 @@ internal static class PocoFilterExpressionBuilder return condition.Operator switch { - FilterOperator.Equals => Expression.Equal(left, PromoteNull(constant, left.Type)), - FilterOperator.NotEquals => Expression.NotEqual(left, PromoteNull(constant, left.Type)), - FilterOperator.GreaterThan => Expression.GreaterThan(left, constant), - FilterOperator.GreaterOrEqual => Expression.GreaterThanOrEqual(left, constant), - FilterOperator.LessThan => Expression.LessThan(left, constant), - FilterOperator.LessOrEqual => Expression.LessThanOrEqual(left, constant), - FilterOperator.Contains => StringMethod(member, nameof(string.Contains), condition.Value), - FilterOperator.StartsWith => StringMethod(member, nameof(string.StartsWith), condition.Value), - FilterOperator.EndsWith => StringMethod(member, nameof(string.EndsWith), condition.Value), + FilterOperation.Equals => Expression.Equal(left, PromoteNull(constant, left.Type)), + FilterOperation.NotEquals => Expression.NotEqual(left, PromoteNull(constant, left.Type)), + FilterOperation.GreaterThan => Expression.GreaterThan(left, constant), + FilterOperation.GreaterOrEqual => Expression.GreaterThanOrEqual(left, constant), + FilterOperation.LessThan => Expression.LessThan(left, constant), + FilterOperation.LessOrEqual => Expression.LessThanOrEqual(left, constant), + FilterOperation.Contains => StringMethod(member, nameof(string.Contains), condition.Value), + FilterOperation.StartsWith => StringMethod(member, nameof(string.StartsWith), condition.Value), + FilterOperation.EndsWith => StringMethod(member, nameof(string.EndsWith), condition.Value), _ => null }; } @@ -85,17 +131,17 @@ internal static class PocoFilterExpressionBuilder string? anyAllMethodName = condition.Operator switch { - FilterOperator.HasElements => nameof(Enumerable.Any), - FilterOperator.Any => nameof(Enumerable.Any), - FilterOperator.All => nameof(Enumerable.All), + FilterOperation.HasElements => nameof(Enumerable.Any), + FilterOperation.Any => nameof(Enumerable.Any), + FilterOperation.All => nameof(Enumerable.All), _ => null }; if (anyAllMethodName is null) return null; - if (condition.Operator == FilterOperator.HasElements) + if (condition.Operator == FilterOperation.HasElements) { return Expression.Call( - typeof(Enumerable), anyAllMethodName, [elementType], collection); + typeof(Enumerable), anyAllMethodName, new[] { elementType }, collection); } if (condition.Predicate is null) return null; @@ -104,13 +150,13 @@ internal static class PocoFilterExpressionBuilder if (inner is null) return null; LambdaExpression lambda = Expression.Lambda(inner, elemParam); return Expression.Call( - typeof(Enumerable), anyAllMethodName, [elementType], collection, lambda); + typeof(Enumerable), anyAllMethodName, new[] { elementType }, collection, lambda); } private static Expression? StringMethod(Expression member, string method, string? arg) { if (member.Type != typeof(string)) return null; - MethodInfo mi = typeof(string).GetMethod(method, [typeof(string)])!; + MethodInfo mi = typeof(string).GetMethod(method, new[] { typeof(string) })!; return Expression.Call(member, mi, Expression.Constant(arg ?? string.Empty)); } diff --git a/src/VisionaryCoder.Framework/Filtering/Sample/AppDbContext.cs b/src/VisionaryCoder.Framework/Filtering/Sample/AppDbContext.cs new file mode 100644 index 0000000..d91c909 --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/Sample/AppDbContext.cs @@ -0,0 +1,11 @@ +using Microsoft.EntityFrameworkCore; + +namespace VisionaryCoder.Framework.Filtering.Sample; + +public class AppDbContext : DbContext +{ + public DbSet Users { get; set; } = null!; + public DbSet Orders { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder opts) => opts.UseInMemoryDatabase("DemoDb"); +} diff --git a/src/VisionaryCoder.Framework/Filtering/Sample/Demo.cs b/src/VisionaryCoder.Framework/Filtering/Sample/Demo.cs new file mode 100644 index 0000000..2a97547 --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/Sample/Demo.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore; +using VisionaryCoder.Framework.Filtering.EFCore; +using VisionaryCoder.Framework.Filtering.Poco; +using VisionaryCoder.Framework.Filtering.Abstractions; + +namespace VisionaryCoder.Framework.Filtering.Sample; + +public static class Demo +{ + public static async Task Run() + { + // 1) Build a reusable filter: + // Users whose name contains "Smith" AND have at least one Order with Total > 1000 + FilterNode filter = Filter.For() + .Where(u => u.Name.Contains("Smith")) + .Where(u => u.Orders.Any(o => o.Total > 1000)) + .Build(); + + // 2) Use with in-memory POCOs via PocoFilterExecutionStrategy + var users = new List + { + new User { Id = 1, Name = "John Smith", Age = 40, Orders = new List { new Order { Id = 1, Total = 1500 } } }, + new User { Id = 2, Name = "Ann Smith", Age = 30, Orders = new List { new Order { Id = 2, Total = 200 } } }, + new User { Id = 3, Name = "Bob Brown", Age = 50, Orders = new List { new Order { Id = 3, Total = 2000 } } } + }; + + var pocoStrategy = new PocoFilterExecutionStrategy(); + var pocoService = new UserService(pocoStrategy); + IEnumerable matchedPoco = pocoService.Query(users, filter); + Console.WriteLine("POCO matches:"); + foreach (var u in matchedPoco) + Console.WriteLine($" - {u.Name} (Id={u.Id})"); + + // 3) Use with EF Core via EfFilterExecutionStrategy + // (the same FilterNode is applied to an EF queryable) + using var db = new AppDbContext(); + // seed + if (!db.Users.Any()) + { + db.Users.AddRange(users); + await db.SaveChangesAsync(); + } + + var efStrategy = new EfFilterExecutionStrategy(db); + var efService = new UserService(efStrategy); + + IQueryable efQuery = efService.Query(db.Users.AsQueryable(), filter); + List matchedEf = await efQuery.ToListAsync(); + + Console.WriteLine("EF Core matches:"); + foreach (var u in matchedEf) + Console.WriteLine($" - {u.Name} (Id={u.Id})"); + + // --- NEW: Examples showing IN support --- + + // Example A: constant collection variable used as left-side .Contains -> translated to IN + var allowedNames = new[] { "John Smith", "Bob Brown" }; + FilterNode inFilterVariable = Filter.For() + .Where(u => allowedNames.Contains(u.Name)) + .Build(); + + var pocoMatchesInVar = pocoService.Query(users, inFilterVariable); + Console.WriteLine("POCO IN (variable) matches:"); + foreach (var u in pocoMatchesInVar) + Console.WriteLine($" - {u.Name} (Id={u.Id})"); + + IQueryable efInQueryVar = efService.Query(db.Users.AsQueryable(), inFilterVariable); + var efInVarMatches = await efInQueryVar.ToListAsync(); + Console.WriteLine("EF IN (variable) matches:"); + foreach (var u in efInVarMatches) + Console.WriteLine($" - {u.Name} (Id={u.Id})"); + + // Example B: array literal left-side .Contains -> also translated to IN + FilterNode inFilterLiteral = Filter.For() + .Where(u => new[] { "Ann Smith", "Bob Brown" }.Contains(u.Name)) + .Build(); + + var pocoMatchesInLit = pocoService.Query(users, inFilterLiteral); + Console.WriteLine("POCO IN (literal) matches:"); + foreach (var u in pocoMatchesInLit) + Console.WriteLine($" - {u.Name} (Id={u.Id})"); + + IQueryable efInQueryLit = efService.Query(db.Users.AsQueryable(), inFilterLiteral); + var efInLitMatches = await efInQueryLit.ToListAsync(); + Console.WriteLine("EF IN (literal) matches:"); + foreach (var u in efInLitMatches) + Console.WriteLine($" - {u.Name} (Id={u.Id})"); + } +} + diff --git a/src/VisionaryCoder.Framework/Filtering/Sample/Order.cs b/src/VisionaryCoder.Framework/Filtering/Sample/Order.cs new file mode 100644 index 0000000..ee1218f --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/Sample/Order.cs @@ -0,0 +1,4 @@ +namespace VisionaryCoder.Framework.Filtering.Sample; + +public class Order { public int Id { get; set; } public decimal Total { get; set; } } + diff --git a/src/VisionaryCoder.Framework/Filtering/Sample/User.cs b/src/VisionaryCoder.Framework/Filtering/Sample/User.cs new file mode 100644 index 0000000..cc7329a --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/Sample/User.cs @@ -0,0 +1,9 @@ +namespace VisionaryCoder.Framework.Filtering.Sample; + +public class User +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public int Age { get; set; } + public List Orders { get; set; } = new(); +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Filtering/Sample/UserService.cs b/src/VisionaryCoder.Framework/Filtering/Sample/UserService.cs new file mode 100644 index 0000000..71c33d1 --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/Sample/UserService.cs @@ -0,0 +1,14 @@ +using VisionaryCoder.Framework.Filtering.Abstractions; + +namespace VisionaryCoder.Framework.Filtering.Sample; + +public sealed class UserService(IFilterExecutionStrategy strategy) +{ + private readonly IFilterExecutionStrategy strategy = strategy ?? throw new ArgumentNullException(nameof(strategy)); + + // Apply to an IQueryable (works for both POCO and EF Core queries) + public IQueryable Query(IQueryable source, FilterNode? filter) => strategy.Apply(source, filter); + + // Convenience: apply to IEnumerable (in-memory) + public IEnumerable Query(IEnumerable source, FilterNode? filter) => strategy.Apply(source, filter); +} diff --git a/src/VisionaryCoder.Framework/Filtering/StringOperator.cs b/src/VisionaryCoder.Framework/Filtering/StringOperator.cs new file mode 100644 index 0000000..3db468e --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/StringOperator.cs @@ -0,0 +1,11 @@ +namespace VisionaryCoder.Framework.Filtering; + +/// +/// String-specific operators. +/// +public enum StringOperator +{ + Contains, + StartsWith, + EndsWith +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Logging/LoggingExtensions.cs b/src/VisionaryCoder.Framework/Logging/LoggingExtensions.cs index 2480651..0babed6 100644 --- a/src/VisionaryCoder.Framework/Logging/LoggingExtensions.cs +++ b/src/VisionaryCoder.Framework/Logging/LoggingExtensions.cs @@ -50,10 +50,7 @@ public static IServiceCollection AddLoggingInterceptor(this IServiceCollection s /// Threshold in milliseconds for slow operation warnings. /// Threshold in milliseconds for critical operation errors. /// The service collection for chaining. - public static IServiceCollection AddTimingInterceptor( - this IServiceCollection services, - long slowThresholdMs = 1000, - long criticalThresholdMs = 5000) + public static IServiceCollection AddTimingInterceptor(this IServiceCollection services, long slowThresholdMs = 1000, long criticalThresholdMs = 5000) { services.TryAddSingleton(provider => { @@ -99,9 +96,7 @@ public static IServiceCollection AddLogging(this IServiceCo /// The service collection. /// Action to configure logging behavior. /// The service collection for chaining. - public static IServiceCollection AddLogging( - this IServiceCollection services, - Action configureOptions) + public static IServiceCollection AddLogging(this IServiceCollection services, Action configureOptions) { var options = new LoggingOptions(); configureOptions(options); diff --git a/src/VisionaryCoder.Framework/Storage/Azure/Queue/AzureQueueStorageOptions.cs b/src/VisionaryCoder.Framework/Messaging/Azure/Queue/AzureQueueStorageOptions.cs similarity index 98% rename from src/VisionaryCoder.Framework/Storage/Azure/Queue/AzureQueueStorageOptions.cs rename to src/VisionaryCoder.Framework/Messaging/Azure/Queue/AzureQueueStorageOptions.cs index ad73fd9..afee826 100644 --- a/src/VisionaryCoder.Framework/Storage/Azure/Queue/AzureQueueStorageOptions.cs +++ b/src/VisionaryCoder.Framework/Messaging/Azure/Queue/AzureQueueStorageOptions.cs @@ -1,4 +1,4 @@ -namespace VisionaryCoder.Framework.Storage.Azure.Queue; +namespace VisionaryCoder.Framework.Messaging.Azure.Queue; /// /// Configuration options for Azure Queue Storage operations. diff --git a/src/VisionaryCoder.Framework/Storage/Azure/Queue/AzureQueueStorageProvider.cs b/src/VisionaryCoder.Framework/Messaging/Azure/Queue/AzureQueueStorageProvider.cs similarity index 97% rename from src/VisionaryCoder.Framework/Storage/Azure/Queue/AzureQueueStorageProvider.cs rename to src/VisionaryCoder.Framework/Messaging/Azure/Queue/AzureQueueStorageProvider.cs index 958f7c1..70acd64 100644 --- a/src/VisionaryCoder.Framework/Storage/Azure/Queue/AzureQueueStorageProvider.cs +++ b/src/VisionaryCoder.Framework/Messaging/Azure/Queue/AzureQueueStorageProvider.cs @@ -6,14 +6,14 @@ using System.Text; using System.Text.Json; -namespace VisionaryCoder.Framework.Storage.Azure.Queue; +namespace VisionaryCoder.Framework.Messaging.Azure.Queue; /// /// Provides Azure Queue Storage-based message queue operations implementation. /// This service wraps Azure Queue Storage operations with logging, error handling, and async support. /// Supports both connection string and managed identity authentication. /// -public sealed class AzureQueueStorageProvider : ServiceBase +public sealed class AzureQueueStorageProvider : ServiceBase, IQueueStorageProvider { private static readonly Encoding defaultEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); @@ -191,7 +191,7 @@ public QueueMessage[] ReceiveMessages(int? maxMessages = null) try { int messageCount = maxMessages ?? options.MaxMessagesToRetrieve; - TimeSpan visibilityTimeout = TimeSpan.FromSeconds(options.VisibilityTimeoutSeconds); + var visibilityTimeout = TimeSpan.FromSeconds(options.VisibilityTimeoutSeconds); Logger.LogDebug("Receiving up to {MaxMessages} messages from queue '{QueueName}'", messageCount, options.QueueName); @@ -215,7 +215,7 @@ public async Task ReceiveMessagesAsync(int? maxMessages = null, try { int messageCount = maxMessages ?? options.MaxMessagesToRetrieve; - TimeSpan visibilityTimeout = TimeSpan.FromSeconds(options.VisibilityTimeoutSeconds); + var visibilityTimeout = TimeSpan.FromSeconds(options.VisibilityTimeoutSeconds); Logger.LogDebug("Receiving up to {MaxMessages} messages async from queue '{QueueName}'", messageCount, options.QueueName); @@ -343,8 +343,7 @@ public void UpdateMessage(string messageId, string popReceipt, string? messageTe /// /// Updates the visibility timeout of a message asynchronously. /// - public async Task UpdateMessageAsync(string messageId, string popReceipt, string? messageText = null, - TimeSpan? visibilityTimeout = null, CancellationToken cancellationToken = default) + public async Task UpdateMessageAsync(string messageId, string popReceipt, string? messageText = null, TimeSpan? visibilityTimeout = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(messageId); ArgumentException.ThrowIfNullOrWhiteSpace(popReceipt); @@ -441,4 +440,5 @@ public async Task ClearMessagesAsync(CancellationToken cancellationToken = defau throw; } } + } diff --git a/src/VisionaryCoder.Framework/Messaging/Azure/Queue/IQueueStorageProvider.cs b/src/VisionaryCoder.Framework/Messaging/Azure/Queue/IQueueStorageProvider.cs new file mode 100644 index 0000000..1f6555a --- /dev/null +++ b/src/VisionaryCoder.Framework/Messaging/Azure/Queue/IQueueStorageProvider.cs @@ -0,0 +1,36 @@ +using Azure.Storage.Queues.Models; + +namespace VisionaryCoder.Framework.Messaging.Azure.Queue; + +/// +/// Defines queue-oriented storage operations (enqueue, dequeue, peek, ack, etc.). +/// This interface separates messaging semantics from file/directory storage concerns. +/// +public interface IQueueStorageProvider +{ + bool QueueExists(); + Task QueueExistsAsync(CancellationToken cancellationToken = default); + + void SendMessage(string messageText); + Task SendMessageAsync(string messageText, CancellationToken cancellationToken = default); + void SendMessage(T messageObject) where T : class; + Task SendMessageAsync(T messageObject, CancellationToken cancellationToken = default) where T : class; + + QueueMessage[] ReceiveMessages(int? maxMessages = null); + Task ReceiveMessagesAsync(int? maxMessages = null, CancellationToken cancellationToken = default); + + PeekedMessage[] PeekMessages(int? maxMessages = null); + Task PeekMessagesAsync(int? maxMessages = null, CancellationToken cancellationToken = default); + + void DeleteMessage(string messageId, string popReceipt); + Task DeleteMessageAsync(string messageId, string popReceipt, CancellationToken cancellationToken = default); + + void UpdateMessage(string messageId, string popReceipt, string? messageText = null, TimeSpan? visibilityTimeout = null); + Task UpdateMessageAsync(string messageId, string popReceipt, string? messageText = null, TimeSpan? visibilityTimeout = null, CancellationToken cancellationToken = default); + + int GetMessageCount(); + Task GetMessageCountAsync(CancellationToken cancellationToken = default); + + void ClearMessages(); + Task ClearMessagesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/VisionaryCoder.Framework/Options.cs b/src/VisionaryCoder.Framework/Options.cs index 1593dbf..5b86e63 100644 --- a/src/VisionaryCoder.Framework/Options.cs +++ b/src/VisionaryCoder.Framework/Options.cs @@ -9,12 +9,16 @@ public sealed class Options /// Gets or sets whether correlation ID generation is enabled. /// public bool EnableCorrelationId { get; set; } = true; + /// Gets or sets whether request ID generation is enabled. public bool EnableRequestId { get; set; } = true; + /// Gets or sets whether structured logging is enabled. public bool EnableStructuredLogging { get; set; } = true; + /// Gets or sets the default HTTP timeout in seconds. public int DefaultHttpTimeoutSeconds { get; set; } = Constants.Timeouts.DefaultHttpTimeoutSeconds; + /// Gets or sets the default cache expiration in minutes. public int DefaultCacheExpirationMinutes { get; set; } = Constants.Timeouts.DefaultCacheExpirationMinutes; } diff --git a/src/VisionaryCoder.Framework/Pipeline/Dispatch/GenericGrpcClient.cs b/src/VisionaryCoder.Framework/Pipeline/Dispatch/GenericGrpcClient.cs index 4b1d80f..5ce6efd 100644 --- a/src/VisionaryCoder.Framework/Pipeline/Dispatch/GenericGrpcClient.cs +++ b/src/VisionaryCoder.Framework/Pipeline/Dispatch/GenericGrpcClient.cs @@ -1,3 +1,5 @@ +using Grpc.Net.Client; + namespace VisionaryCoder.Framework.Pipeline.Dispatch; public sealed class GenericGrpcClient diff --git a/src/VisionaryCoder.Framework/Pipeline/Interceptors/ResilienceInterceptor.cs b/src/VisionaryCoder.Framework/Pipeline/Interceptors/ResilienceInterceptor.cs index 6b85ff6..ff11d86 100644 --- a/src/VisionaryCoder.Framework/Pipeline/Interceptors/ResilienceInterceptor.cs +++ b/src/VisionaryCoder.Framework/Pipeline/Interceptors/ResilienceInterceptor.cs @@ -1,20 +1,20 @@ +using Polly; using VisionaryCoder.Framework.Pipeline.Abstractions; namespace VisionaryCoder.Framework.Pipeline.Interceptors; public sealed class ResilienceInterceptor(AsyncPolicy policy) : IInterceptor { - private readonly AsyncPolicy policy = policy; - public Task InvokeAsync( - TRequest request, Func> next) + public Task InvokeAsync(TRequest request, Func> next) where TRequest : IRequest { return policy.ExecuteAsync(() => next(request)); } public static AsyncPolicy DefaultPolicy() => - Policy.WrapAsync(Policy.Handle().CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)), Policy.Handle().WaitAndRetryAsync( - [TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(300), TimeSpan.FromMilliseconds(1000)]) + Policy.WrapAsync( + Policy.Handle().CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)), + Policy.Handle().WaitAndRetryAsync([TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(300), TimeSpan.FromMilliseconds(1000)]) ); } diff --git a/src/VisionaryCoder.Framework/Providers/CorrelationIdProvider.cs b/src/VisionaryCoder.Framework/Providers/CorrelationIdProvider.cs index ad861b6..cbb35df 100644 --- a/src/VisionaryCoder.Framework/Providers/CorrelationIdProvider.cs +++ b/src/VisionaryCoder.Framework/Providers/CorrelationIdProvider.cs @@ -3,13 +3,29 @@ namespace VisionaryCoder.Framework.Providers; /// /// Default implementation of . /// +/// +/// Provides a lightweight, per-async-context correlation identifier. The value is stored +/// in an so it flows with async/await and +/// preserves logical operation context without leaking between logical flows. +/// public sealed class CorrelationIdProvider : ICorrelationIdProvider { + /// + /// Stores the current correlation id in the ambient async context. + /// private static readonly AsyncLocal currentCorrelationId = new(); /// + /// + /// If no correlation id has been set on the current context, a new id will be generated + /// and returned. Generated ids are 12-character uppercase strings derived from a GUID. + /// public string CorrelationId => currentCorrelationId.Value ?? GenerateNew(); + /// + /// Generates and sets a new correlation id for the current async context. + /// + /// The newly generated correlation id. public string GenerateNew() { string newId = Guid.NewGuid().ToString("N")[..12].ToUpperInvariant(); @@ -17,6 +33,11 @@ public string GenerateNew() return newId; } + /// + /// Explicitly sets the correlation id for the current async context. + /// + /// The correlation id to set. Must not be null or whitespace. + /// Thrown when is null or whitespace. public void SetCorrelationId(string correlationId) { if (string.IsNullOrWhiteSpace(correlationId)) diff --git a/src/VisionaryCoder.Framework/Providers/FrameworkInfoProvider.cs b/src/VisionaryCoder.Framework/Providers/FrameworkInfoProvider.cs index bbdf3b2..aa3af91 100644 --- a/src/VisionaryCoder.Framework/Providers/FrameworkInfoProvider.cs +++ b/src/VisionaryCoder.Framework/Providers/FrameworkInfoProvider.cs @@ -5,27 +5,59 @@ namespace VisionaryCoder.Framework.Providers; /// /// Default implementation of . /// +/// +/// Provides build- and assembly-level information about the VisionaryCoder Framework +/// such as the informational version, human-readable name and description, and an +/// approximation of the compilation timestamp. This implementation reads metadata +/// from the executing assembly and falls back to conservative defaults when metadata +/// is not present. +/// public sealed class FrameworkInfoProvider : IFrameworkInfoProvider { /// + /// + /// Attempts to return the assembly value + /// if available. If not present, falls back to the assembly version and then + /// to a string literal "0.0.0" as the final fallback. + /// public string Version { get { var assembly = Assembly.GetExecutingAssembly(); return assembly.GetCustomAttribute()?.InformationalVersion - ?? assembly.GetName().Version?.ToString() ?? "0.0.0"; + ?? assembly.GetName().Version?.ToString() + ?? "0.0.0"; } } + /// + /// Friendly name of the framework. + /// public string Name => "VisionaryCoder Framework"; + + /// + /// Short description of the framework's purpose. + /// public string Description => "A comprehensive framework for building enterprise-grade applications with proxy interceptor architecture."; + + /// + /// + /// The compilation timestamp is derived from the executing assembly file's + /// creation time. This provides an approximation useful for diagnostics but + /// is not guaranteed to be the exact build-time in all CI/CD environments. + /// public DateTimeOffset CompiledAt { get; } = GetCompilationTime(); + /// + /// Attempts to determine an approximate compilation timestamp for the executing assembly. + /// + /// An approximation of the assembly compilation time based on the assembly file information. private static DateTimeOffset GetCompilationTime() { var assembly = Assembly.GetExecutingAssembly(); var fileInfo = new FileInfo(assembly.Location); return fileInfo.CreationTime; } + } diff --git a/src/VisionaryCoder.Framework/Providers/ICorrelationIdProvider.cs b/src/VisionaryCoder.Framework/Providers/ICorrelationIdProvider.cs index 445c454..7babeb5 100644 --- a/src/VisionaryCoder.Framework/Providers/ICorrelationIdProvider.cs +++ b/src/VisionaryCoder.Framework/Providers/ICorrelationIdProvider.cs @@ -2,19 +2,24 @@ // Licensed under the MIT License. See LICENSE file in the project root for license information. namespace VisionaryCoder.Framework.Providers; + /// /// Provides correlation ID generation and management. /// public interface ICorrelationIdProvider { + /// /// Gets the current correlation ID. /// string CorrelationId { get; } + /// Generates a new correlation ID. /// A new correlation ID. string GenerateNew(); + /// Sets the current correlation ID. /// The correlation ID to set. void SetCorrelationId(string correlationId); + } diff --git a/src/VisionaryCoder.Framework/Providers/IFrameworkInfoProvider.cs b/src/VisionaryCoder.Framework/Providers/IFrameworkInfoProvider.cs index 067fd2b..66b7f92 100644 --- a/src/VisionaryCoder.Framework/Providers/IFrameworkInfoProvider.cs +++ b/src/VisionaryCoder.Framework/Providers/IFrameworkInfoProvider.cs @@ -7,14 +7,18 @@ namespace VisionaryCoder.Framework.Providers; /// public interface IFrameworkInfoProvider { + /// /// Gets the current framework version. /// string Version { get; } + /// Gets the framework name. string Name { get; } + /// Gets the framework description. string Description { get; } + /// Gets when the framework was compiled. DateTimeOffset CompiledAt { get; } } diff --git a/src/VisionaryCoder.Framework/Providers/IRequestIdProvider.cs b/src/VisionaryCoder.Framework/Providers/IRequestIdProvider.cs index a7d0f65..5f51e22 100644 --- a/src/VisionaryCoder.Framework/Providers/IRequestIdProvider.cs +++ b/src/VisionaryCoder.Framework/Providers/IRequestIdProvider.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. See LICENSE file in the project root for license information. namespace VisionaryCoder.Framework.Providers; + /// /// Provides request ID generation and management. /// @@ -11,9 +12,11 @@ public interface IRequestIdProvider /// Gets the current request ID. /// string RequestId { get; } + /// Generates a new request ID. /// A new request ID. string GenerateNew(); + /// Sets the current request ID. /// The request ID to set. void SetRequestId(string requestId); diff --git a/src/VisionaryCoder.Framework/Providers/RequestIdProvider.cs b/src/VisionaryCoder.Framework/Providers/RequestIdProvider.cs index a22e22c..c80d7dd 100644 --- a/src/VisionaryCoder.Framework/Providers/RequestIdProvider.cs +++ b/src/VisionaryCoder.Framework/Providers/RequestIdProvider.cs @@ -2,17 +2,42 @@ namespace VisionaryCoder.Framework.Providers; /// /// Default implementation of . /// +/// +/// Provides a per-logical-operation request identifier that flows with async/await. +/// The identifier is stored in an +/// so it does not leak between unrelated logical execution contexts. +/// Generated request IDs are 8-character uppercase strings derived from a GUID. +/// public sealed class RequestIdProvider : IRequestIdProvider { + /// + /// Stores the current request id in the ambient async context. + /// private static readonly AsyncLocal currentRequestId = new(); + /// + /// + /// If no request id has been set on the current context, a new id will be generated + /// and returned by . + /// public string RequestId => currentRequestId.Value ?? GenerateNew(); + + /// + /// Generates and sets a new request id for the current async context. + /// + /// The newly generated request id. public string GenerateNew() { string newId = Guid.NewGuid().ToString("N")[..8].ToUpperInvariant(); currentRequestId.Value = newId; return newId; } + + /// + /// Explicitly sets the request id for the current async context. + /// + /// The request id to set. Must not be null or whitespace. + /// Thrown when is null or whitespace. public void SetRequestId(string requestId) { ArgumentException.ThrowIfNullOrWhiteSpace(requestId); diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/AuthorizationExtensions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/AuthorizationExtensions.cs index 9bc56f9..d41bd37 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/AuthorizationExtensions.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/AuthorizationExtensions.cs @@ -3,8 +3,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using VisionaryCoder.Framework.Authorization.Policies; -using VisionaryCoder.Framework.Proxy.Authorization.Policies; using VisionaryCoder.Framework.Proxy.Interceptors.Authorization.Policies; namespace VisionaryCoder.Framework.Proxy.Interceptors.Authorization; diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingExtensions.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingExtensions.cs index 6b8b042..090e6a3 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingExtensions.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingExtensions.cs @@ -20,9 +20,7 @@ public static class CachingExtensions /// The service collection to add caching services to. /// Optional configuration action for caching options. /// The service collection for chaining. - public static IServiceCollection AddCaching( - this IServiceCollection services, - Action? configure = null) + public static IServiceCollection AddCaching(this IServiceCollection services, Action? configure = null) { // Add memory cache for infrastructure services.AddMemoryCache(); @@ -46,16 +44,13 @@ public static IServiceCollection AddCaching( } /// - /// Adds caching services with a specific cache implementation. + /// Adds caching services by registering a single implementation type which can be either + /// an IProxyCache, ICachePolicyProvider, or ICacheKeyProvider. This single generic overload + /// allows concise registration in tests and consumers (e.g. AddCaching() + /// or AddCaching()). /// - /// The type of cache implementation. - /// The service collection. - /// Optional configuration for caching options. - /// The service collection for chaining. - public static IServiceCollection AddCaching( - this IServiceCollection services, - Action? configure = null) - where TCache : class, IProxyCache + public static IServiceCollection AddCaching(this IServiceCollection services, Action? configure = null) + where T : class { services.AddMemoryCache(); @@ -64,38 +59,31 @@ public static IServiceCollection AddCaching( services.Configure(configure); } - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - - return services; - } + Type t = typeof(T); - /// - /// Adds caching with custom providers for fine-grained control. - /// - /// The cache key provider implementation. - /// The cache policy provider implementation. - /// The service collection. - /// Optional configuration for caching options. - /// The service collection for chaining. - public static IServiceCollection AddCaching( - this IServiceCollection services, - Action? configure = null) - where TKeyProvider : class, ICacheKeyProvider - where TPolicyProvider : class, ICachePolicyProvider - { - services.AddMemoryCache(); - - if (configure != null) + if (typeof(IProxyCache).IsAssignableFrom(t)) { - services.Configure(configure); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(typeof(IProxyCache), t); + } + else if (typeof(ICachePolicyProvider).IsAssignableFrom(t)) + { + services.TryAddSingleton(); + services.TryAddSingleton(typeof(ICachePolicyProvider), t); + services.TryAddSingleton(); + } + else if (typeof(ICacheKeyProvider).IsAssignableFrom(t)) + { + services.TryAddSingleton(typeof(ICacheKeyProvider), t); + services.TryAddSingleton(); + services.TryAddSingleton(); + } + else + { + throw new ArgumentException($"Type parameter {t.FullName} must implement IProxyCache, ICachePolicyProvider or ICacheKeyProvider.", nameof(T)); } - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); services.TryAddSingleton(); return services; @@ -109,11 +97,7 @@ public static IServiceCollection AddCaching( /// Optional maximum cache size in entries. /// Whether to enable eviction logging. /// The service collection for chaining. - public static IServiceCollection AddCaching( - this IServiceCollection services, - TimeSpan defaultDuration, - int? maxCacheSize = null, - bool enableEvictionLogging = false) + public static IServiceCollection AddCaching(this IServiceCollection services, TimeSpan defaultDuration, int? maxCacheSize = null, bool enableEvictionLogging = false) { return services.AddCaching(options => { @@ -129,9 +113,7 @@ public static IServiceCollection AddCaching( /// The service collection. /// Configuration for caching options. /// The service collection for chaining. - public static IServiceCollection AddDistributedCaching( - this IServiceCollection services, - Action configure) + public static IServiceCollection AddDistributedCaching(this IServiceCollection services, Action configure) { // This would be extended to support distributed caching providers like Redis // For now, falls back to memory cache with a warning in configuration @@ -217,8 +199,8 @@ public static IServiceCollection UseDefaultCachingProviders(this IServiceCollect { ArgumentNullException.ThrowIfNull(services); - services.ReplaceCacheKeyProvider(); - services.ReplaceCachePolicyProvider(); + services.ReplaceCacheKeyProvider(); + services.ReplaceCachePolicyProvider(); services.ReplaceProxyCache(); return services; diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/ICachePolicyProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/ICachePolicyProvider.cs index 5d4323f..effa51f 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/ICachePolicyProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/ICachePolicyProvider.cs @@ -14,4 +14,7 @@ public interface ICachePolicyProvider /// Determines whether the operation should be cached. /// True if the operation should be cached; otherwise, false. bool ShouldCache(ProxyContext context); + + CachePolicy GetPolicy(ProxyContext context); + } diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Interceptors/CachingInterceptor.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Interceptors/CachingInterceptor.cs index 7f61d7b..afed24f 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Interceptors/CachingInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Interceptors/CachingInterceptor.cs @@ -12,6 +12,7 @@ namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching.Interceptors; public sealed class CachingInterceptor(ILogger logger, IProxyCache proxyCache, ICacheKeyProvider keyProvider, ICachePolicyProvider policyProvider) : IOrderedProxyInterceptor { + /// public int Order => 50; // Caching typically runs in the middle of the pipeline @@ -52,20 +53,12 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe } // Generate and try to retrieve from cache - string? cacheKey = keyProvider.GenerateKey(context); - if (cacheKey == null) - { - logger.LogDebug("No cache key generated for operation '{OperationName}', bypassing cache. Correlation ID: '{CorrelationId}'", - operationName, correlationId); - return await next(context, cancellationToken); - } - + string cacheKey = keyProvider.GenerateKey(context); ProxyResponse? cachedResponse = await proxyCache.GetAsync(cacheKey, cancellationToken); if (cachedResponse != null) { - logger.LogDebug("Cache hit for operation '{OperationName}' with key '{CacheKey}'. Correlation ID: '{CorrelationId}'", - operationName, cacheKey, correlationId); + logger.LogDebug("Cache hit for operation '{OperationName}' with key '{CacheKey}'. Correlation ID: '{CorrelationId}'", operationName, cacheKey, correlationId); context.Metadata["CacheHit"] = true; context.Metadata["CacheKey"] = cacheKey; @@ -74,8 +67,7 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe } // Cache miss - execute the operation - logger.LogDebug("Cache miss for operation '{OperationName}' with key '{CacheKey}'. Correlation ID: '{CorrelationId}'", - operationName, cacheKey, correlationId); + logger.LogDebug("Cache miss for operation '{OperationName}' with key '{CacheKey}'. Correlation ID: '{CorrelationId}'", operationName, cacheKey, correlationId); ProxyResponse response = await next(context, cancellationToken); @@ -86,8 +78,7 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe await proxyCache.SetAsync(cacheKey, response, expiration, cancellationToken); - logger.LogDebug("Cached successful response for operation '{OperationName}' with key '{CacheKey}' for {Duration}. Correlation ID: '{CorrelationId}'", - operationName, cacheKey, expiration, correlationId); + logger.LogDebug("Cached successful response for operation '{OperationName}' with key '{CacheKey}' for {Duration}. Correlation ID: '{CorrelationId}'", operationName, cacheKey, expiration, correlationId); } context.Metadata["CacheHit"] = false; @@ -103,25 +94,26 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe /// True if caching should be disabled for this request. private static bool IsCachingDisabled(ProxyContext context) { + // Check for explicit cache disable flag - if (context.Metadata.TryGetValue("DisableCache", out object? disableCache) && - disableCache is bool disabled && disabled) + if (context.Metadata.TryGetValue("DisableCache", out object? disableCache) && disableCache is bool and true) { return true; } // Check for cache-control headers that indicate no-cache - if (context.Headers.TryGetValue("Cache-Control", out string? cacheControl)) + if (!context.Headers.TryGetValue("Cache-Control", out string? cacheControl)) { - string? cacheControlValue = cacheControl?.ToString()?.ToLowerInvariant(); - if (cacheControlValue?.Contains("no-cache") == true || - cacheControlValue?.Contains("no-store") == true) - { - return true; - } + return false; } + string? cacheControlValue = cacheControl.ToLowerInvariant(); + if (cacheControlValue?.Contains("no-cache") == true || cacheControlValue?.Contains("no-store") == true) + { + return true; + } return false; + } /// @@ -140,11 +132,6 @@ private static bool ShouldCacheResponse(ProxyResponse response, CachePolic } // Apply policy-specific caching decision - if (policy.ShouldCache != null && response.Data != null) - { - return policy.ShouldCache(response.Data); - } - - return true; + return response.Data == null || policy.ShouldCache(response.Data); } } diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullCacheKeyProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullCacheKeyProvider.cs index 0d1c241..a042c7b 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullCacheKeyProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullCacheKeyProvider.cs @@ -8,7 +8,7 @@ namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers; /// Returns consistent but non-functional cache keys when no explicit provider is registered. /// Follows SOLID principles by ensuring safe operation without implicit defaults. /// -public sealed class NullCacheKeyProvider : ICacheKeyProvider +public sealed class NullCacheKeyProvider : ICacheKeyProvider, Caching.ICacheKeyProvider { /// /// Returns a null cache key to indicate caching should be bypassed. @@ -33,4 +33,4 @@ public bool CanGenerateKey(ProxyContext context) { return false; } -} \ No newline at end of file +} diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullCachePolicyProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullCachePolicyProvider.cs index d56e632..8f48112 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullCachePolicyProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullCachePolicyProvider.cs @@ -8,7 +8,7 @@ namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers; /// Returns no-cache policies when no explicit provider is registered. /// Follows SOLID principles by ensuring safe operation without implicit defaults. /// -public sealed class NullCachePolicyProvider : ICachePolicyProvider +public sealed class NullCachePolicyProvider : ICachePolicyProvider, Caching.ICachePolicyProvider { /// /// Returns a cache policy that disables caching entirely. diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Azure/AzureConfigurationProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Azure/AzureConfigurationProvider.cs index ab7ef3b..ab7f010 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Azure/AzureConfigurationProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Azure/AzureConfigurationProvider.cs @@ -14,7 +14,7 @@ public sealed class AzureConfigurationProvider : ConfigurationProvider, IConfigurationProvider { - private readonly AzureConfigurationProviderOptions options; + private new readonly AzureConfigurationProviderOptions options; public AzureConfigurationProvider(AzureConfigurationProviderOptions options, ILogger logger) : base(options, logger) diff --git a/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Local/LocalConfigurationProvider.cs b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Local/LocalConfigurationProvider.cs index 01d7e44..5c87b9a 100644 --- a/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Local/LocalConfigurationProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Local/LocalConfigurationProvider.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using VisionaryCoder.Framework.Proxy.Interceptors.Configuration; namespace VisionaryCoder.Framework.Proxy.Interceptors.Configuration.Local; @@ -13,7 +12,7 @@ public sealed class LocalConfigurationProvider : ConfigurationProvider, IConfigurationProvider { - private readonly LocalConfigurationProviderOptions options; + private new readonly LocalConfigurationProviderOptions options; private readonly FileSystemWatcher? fileWatcher; public LocalConfigurationProvider(LocalConfigurationProviderOptions options, ILogger logger) diff --git a/src/VisionaryCoder.Framework/Querying/QueryFilterSchemaValidator.cs b/src/VisionaryCoder.Framework/Querying/QueryFilterSchemaValidator.cs index 0bc7fac..88a33c3 100644 --- a/src/VisionaryCoder.Framework/Querying/QueryFilterSchemaValidator.cs +++ b/src/VisionaryCoder.Framework/Querying/QueryFilterSchemaValidator.cs @@ -18,7 +18,7 @@ public static IReadOnlyList Validate(string json) { try { - using JsonDocument doc = JsonDocument.Parse(json); + using var doc = JsonDocument.Parse(json); return ValidateElement(doc.RootElement); } catch (JsonException je) diff --git a/src/VisionaryCoder.Framework/Querying/Sample/AppDbContext.cs b/src/VisionaryCoder.Framework/Querying/Sample/AppDbContext.cs new file mode 100644 index 0000000..841c5e0 --- /dev/null +++ b/src/VisionaryCoder.Framework/Querying/Sample/AppDbContext.cs @@ -0,0 +1,11 @@ +using Microsoft.EntityFrameworkCore; + +namespace VisionaryCoder.Framework.Querying.Sample; + +public class AppDbContext : DbContext +{ + public DbSet Users { get; set; } = null!; + public DbSet Orders { get; set; } = null!; + protected override void OnConfiguring(DbContextOptionsBuilder opts) => + opts.UseInMemoryDatabase("QueryFilterDemoDb"); +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Querying/Sample/Order.cs b/src/VisionaryCoder.Framework/Querying/Sample/Order.cs new file mode 100644 index 0000000..4afa5ec --- /dev/null +++ b/src/VisionaryCoder.Framework/Querying/Sample/Order.cs @@ -0,0 +1,11 @@ +// QueryFilterDemo.cs + +namespace VisionaryCoder.Framework.Querying.Sample; +// QueryFilter, QueryFilterExtensions + +// POCO models +public class Order { public int Id { get; set; } public decimal Total { get; set; } } + +// Simple EF Core context (InMemory for demo) + +// A consumer receiving a QueryFilter diff --git a/src/VisionaryCoder.Framework/Querying/Sample/QueryFilterDemo.cs b/src/VisionaryCoder.Framework/Querying/Sample/QueryFilterDemo.cs new file mode 100644 index 0000000..3857bfb --- /dev/null +++ b/src/VisionaryCoder.Framework/Querying/Sample/QueryFilterDemo.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; + +namespace VisionaryCoder.Framework.Querying.Sample; + +public static class QueryFilterDemo +{ + public static async Task Run() + { + // Build two reusable QueryFilter instances + var nameContainsSmith = QueryFilterExtensions.ContainsIgnoreCase(u => u.Name, "smith"); + var highValueOrder = new QueryFilter(u => u.Orders.Any(o => o.Total > 1000m)); + + // Combine filters: name contains "smith" AND has a high-value order + var combined = nameContainsSmith.And(highValueOrder); + + // Sample POCO list + var users = new List + { + new User { Id = 1, Name = "John Smith", Orders = new List { new Order { Id = 1, Total = 1500 } } }, + new User { Id = 2, Name = "Ann Smith", Orders = new List { new Order { Id = 2, Total = 200 } } }, + new User { Id = 3, Name = "Bob Brown", Orders = new List { new Order { Id = 3, Total = 2000 } } }, + }; + + var service = new UserQueryService(); + + // Apply to POCO (in-memory) + IEnumerable pocoMatches = service.ApplyToEnumerable(users, combined); + Console.WriteLine("POCO matches:"); + foreach (var u in pocoMatches) Console.WriteLine($" - {u.Name} (Id={u.Id})"); + + // Apply to EF Core + using var db = new AppDbContext(); + if (!db.Users.Any()) + { + db.Users.AddRange(users); + await db.SaveChangesAsync(); + } + + IQueryable efQuery = service.ApplyToQueryable(db.Users.AsQueryable(), combined); + List efMatches = await efQuery.ToListAsync(); + + Console.WriteLine("EF Core matches:"); + foreach (var u in efMatches) Console.WriteLine($" - {u.Name} (Id={u.Id})"); + } +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Querying/Sample/User.cs b/src/VisionaryCoder.Framework/Querying/Sample/User.cs new file mode 100644 index 0000000..04001e1 --- /dev/null +++ b/src/VisionaryCoder.Framework/Querying/Sample/User.cs @@ -0,0 +1,9 @@ +namespace VisionaryCoder.Framework.Querying.Sample; + +public class User +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public int Age { get; set; } + public List Orders { get; set; } = new(); +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Querying/Sample/UserQueryService.cs b/src/VisionaryCoder.Framework/Querying/Sample/UserQueryService.cs new file mode 100644 index 0000000..036e0c2 --- /dev/null +++ b/src/VisionaryCoder.Framework/Querying/Sample/UserQueryService.cs @@ -0,0 +1,19 @@ +namespace VisionaryCoder.Framework.Querying.Sample; + +public sealed class UserQueryService +{ + // Queryable consumer (EF Core) + public IQueryable ApplyToQueryable(IQueryable source, QueryFilter filter) + { + // Extension method in QueryFilterExtensions: source.Apply(filter) + return source.Apply(filter); + } + + // Enumerable consumer (POCO in-memory) + public IEnumerable ApplyToEnumerable(IEnumerable source, QueryFilter filter) + { + // Compile the expression and use it for in-memory filtering + var predicate = filter.Predicate.Compile(); + return source.Where(predicate); + } +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/ServiceBase.cs b/src/VisionaryCoder.Framework/ServiceBase.cs index f8f1ccf..d2f3d41 100644 --- a/src/VisionaryCoder.Framework/ServiceBase.cs +++ b/src/VisionaryCoder.Framework/ServiceBase.cs @@ -40,6 +40,7 @@ public void Dispose() /// true to release both managed and unmanaged resources; false to release only unmanaged resources. protected virtual void Dispose(bool disposing) { + if (!disposed) { if (disposing) @@ -47,9 +48,9 @@ protected virtual void Dispose(bool disposing) // Dispose managed resources here // Derived classes can override this method to dispose their resources } - disposed = true; } + } /// diff --git a/src/VisionaryCoder.Framework/ServiceResult.cs b/src/VisionaryCoder.Framework/ServiceResult.cs index f40c801..0e59d99 100644 --- a/src/VisionaryCoder.Framework/ServiceResult.cs +++ b/src/VisionaryCoder.Framework/ServiceResult.cs @@ -1,46 +1,52 @@ namespace VisionaryCoder.Framework; /// -/// Base result class for all operation outcomes. +/// Result for operations that don't return a value. /// -public abstract class ServiceResultBase(bool isSuccess, string? errorMessage, Exception? exception) +/// +/// Use this type to represent success/failure of operations that do not produce +/// a value. Factory helpers are provided for creating success and failure results. +/// The method provides a convenient way to branch on the +/// result without throwing exceptions. +/// +public sealed class ServiceResult : ServiceResultBase { + private ServiceResult(bool isSuccess, string? errorMessage, Exception? exception) + : base(isSuccess, errorMessage, exception) + { + } + /// - /// Gets a value indicating whether the operation was successful. + /// Creates a successful result. /// - public bool IsSuccess { get; } = isSuccess; + public static ServiceResult Success() => new(true, null, null); /// - /// Gets a value indicating whether the operation failed. + /// Creates a failure result with an error message. /// - public bool IsFailure => !IsSuccess; + /// Human-readable error message describing the failure. + public static ServiceResult Failure(string errorMessage) => new(false, errorMessage, null); /// - /// Gets the error message if the operation failed. + /// Creates a failure result from an exception. The exception's message is used + /// as the . /// - public string? ErrorMessage { get; } = errorMessage; + /// The exception that caused the failure. + public static ServiceResult Failure(Exception exception) => new(false, exception.Message, exception); /// - /// Gets the exception if the operation failed with an exception. + /// Creates a failure result with both a custom message and the originating exception. /// - public Exception? Exception { get; } = exception; -} - -/// -/// Result for operations that don't return a value. -/// -public sealed class ServiceResult : ServiceResultBase -{ - private ServiceResult(bool isSuccess, string? errorMessage, Exception? exception) - : base(isSuccess, errorMessage, exception) - { - } - - public static ServiceResult Success() => new(true, null, null); - public static ServiceResult Failure(string errorMessage) => new(false, errorMessage, null); - public static ServiceResult Failure(Exception exception) => new(false, exception.Message, exception); + /// Human-readable error message describing the failure. + /// The exception that caused the failure. public static ServiceResult Failure(string errorMessage, Exception exception) => new(false, errorMessage, exception); + /// + /// Pattern-match the result: executes when successful, + /// otherwise executes with the error message and optional exception. + /// + /// Action to execute when the result is successful. + /// Action to execute when the result is a failure. Receives the error message and optional exception. public void Match(Action onSuccess, Action onFailure) { if (IsSuccess) @@ -54,6 +60,11 @@ public void Match(Action onSuccess, Action onFailure) /// Result for operations that return a value. /// /// The type of the result value. +/// +/// Encapsulates the success/failure state and, when successful, the resulting value. +/// Provides helpers for mapping and transforming values in a safe manner that preserves +/// failure metadata. +/// public sealed class ServiceResult : ServiceResultBase { private ServiceResult(bool isSuccess, T? value, string? errorMessage, Exception? exception) @@ -63,15 +74,41 @@ private ServiceResult(bool isSuccess, T? value, string? errorMessage, Exception? } /// - /// Gets the result value if the operation was successful. + /// Gets the result value if the operation was successful; otherwise the default value for . /// public T? Value { get; } + /// + /// Creates a successful result containing the given . + /// + /// The successful result value. public static ServiceResult Success(T value) => new(true, value, null, null); + + /// + /// Creates a failure result with an error message. + /// + /// Human-readable error message describing the failure. public static ServiceResult Failure(string errorMessage) => new(false, default, errorMessage, null); + + /// + /// Creates a failure result from an exception. + /// + /// The exception that caused the failure. public static ServiceResult Failure(Exception exception) => new(false, default, exception.Message, exception); + + /// + /// Creates a failure result with both a custom message and the originating exception. + /// + /// Human-readable error message describing the failure. + /// The exception that caused the failure. public static ServiceResult Failure(string errorMessage, Exception exception) => new(false, default, errorMessage, exception); + /// + /// Pattern-match the result: executes when successful, + /// otherwise executes with the error message and optional exception. + /// + /// Action to execute when the result is successful. Receives the successful value. + /// Action to execute when the result is a failure. Receives the error message and optional exception. public void Match(Action onSuccess, Action onFailure) { if (IsSuccess && Value is not null) @@ -80,6 +117,13 @@ public void Match(Action onSuccess, Action onFailure) onFailure(ErrorMessage ?? "Unknown error", Exception); } + /// + /// Transforms the successful result value using into a new . + /// If the current result is a failure, the failure is propagated. + /// + /// The type of the mapped value. + /// Function to transform the value. + /// A new containing the mapped value or a propagated failure. public ServiceResult Map(Func mapper) { if (!IsSuccess || Value is null) @@ -95,6 +139,13 @@ public ServiceResult Map(Func mapper) } } + /// + /// Asynchronously transforms the successful result value using into a new . + /// If the current result is a failure, the failure is propagated. + /// + /// The type of the mapped value. + /// Asynchronous function to transform the value. + /// A task that produces a containing the mapped value or a propagated failure. public async Task> MapAsync(Func> mapper) { if (!IsSuccess || Value is null) @@ -111,3 +162,4 @@ public async Task> MapAsync(Func> mapper } } } + diff --git a/src/VisionaryCoder.Framework/ServiceResultBase.cs b/src/VisionaryCoder.Framework/ServiceResultBase.cs new file mode 100644 index 0000000..cd9efe1 --- /dev/null +++ b/src/VisionaryCoder.Framework/ServiceResultBase.cs @@ -0,0 +1,27 @@ +namespace VisionaryCoder.Framework; + +/// +/// Base result class for all operation outcomes. +/// +public abstract class ServiceResultBase(bool isSuccess, string? errorMessage, Exception? exception) +{ + /// + /// Gets a value indicating whether the operation was successful. + /// + public bool IsSuccess { get; } = isSuccess; + + /// + /// Gets a value indicating whether the operation failed. + /// + public bool IsFailure => !IsSuccess; + + /// + /// Gets the error message if the operation failed. + /// + public string? ErrorMessage { get; } = errorMessage; + + /// + /// Gets the exception if the operation failed with an exception. + /// + public Exception? Exception { get; } = exception; +} \ No newline at end of file diff --git a/src/VisionaryCoder.Framework/Storage/StorageExtensions.cs b/src/VisionaryCoder.Framework/Storage/StorageExtensions.cs index 461d067..6968cf1 100644 --- a/src/VisionaryCoder.Framework/Storage/StorageExtensions.cs +++ b/src/VisionaryCoder.Framework/Storage/StorageExtensions.cs @@ -1,5 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using VisionaryCoder.Framework.Data.Azure.Table; +using VisionaryCoder.Framework.Messaging.Azure.Queue; using VisionaryCoder.Framework.Storage.Azure.Blob; using VisionaryCoder.Framework.Storage.Ftp; using VisionaryCoder.Framework.Storage.Local; @@ -15,10 +17,25 @@ public static class StorageExtensions /// /// Registers the local storage implementation. /// - public static IServiceCollection AddLocalStorage(this IServiceCollection services) + public static IServiceCollection AddLocalStorage(this IServiceCollection services, LocalStorageOptions options) { ArgumentNullException.ThrowIfNull(services); - services.AddTransient(); + ArgumentNullException.ThrowIfNull(options); + services.TryAddSingleton(options); + services.TryAddTransient(); + return services; + } + + /// + /// Registers the local storage implementation. + /// + public static IServiceCollection AddNamedLocalStorage(this IServiceCollection services, string name, LocalStorageOptions options) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(options); + services.TryAddSingleton(options); + services.TryAddKeyedTransient(name); return services; } @@ -29,7 +46,7 @@ public static IServiceCollection AddFtpStorage(this IServiceCollection services, { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(options); - services.AddSingleton(options); + services.TryAddSingleton(options); services.TryAddTransient(); return services; } @@ -42,7 +59,7 @@ public static IServiceCollection AddNamedFtpStorage(this IServiceCollection serv ArgumentNullException.ThrowIfNull(services); ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentNullException.ThrowIfNull(options); - services.AddSingleton(options); + services.TryAddSingleton(options); services.TryAddKeyedTransient(name); return services; } @@ -52,7 +69,7 @@ public static IServiceCollection AddNamedFtpStorage(this IServiceCollection serv /// public static IServiceCollection AddAzureBlobStorage(this IServiceCollection services, AzureBlobStorageOptions options) { - services.AddSingleton(options); + services.TryAddSingleton(options); services.TryAddTransient(); return services; } @@ -60,11 +77,51 @@ public static IServiceCollection AddAzureBlobStorage(this IServiceCollection ser /// /// Registers the Azure Blob storage provider implementation. /// - public static IServiceCollection AddAzureBlobStorage(this IServiceCollection services, string name, AzureBlobStorageOptions options) + public static IServiceCollection AddNamedAzureBlobStorage(this IServiceCollection services, string name, AzureBlobStorageOptions options) { - services.AddSingleton(options); + services.TryAddSingleton(options); services.TryAddKeyedTransient(name); return services; } + /// + /// Registers the Azure Queue storage provider implementation. + /// + public static IServiceCollection AddAzureQueueStorage(this IServiceCollection services, AzureQueueStorageOptions options) + { + services.TryAddSingleton(options); + services.TryAddTransient(); + return services; + } + + /// + /// Registers the Azure Queue storage provider implementation. + /// + public static IServiceCollection AddNamedAzureQueueStorage(this IServiceCollection services, string name, AzureQueueStorageOptions options) + { + services.TryAddSingleton(options); + services.TryAddKeyedTransient(name); + return services; + } + + /// + /// Registers the Azure Table storage provider implementation. + /// + public static IServiceCollection AddAzureTableStorage(this IServiceCollection services, AzureTableStorageOptions options) + { + services.TryAddSingleton(options); + services.TryAddTransient(); + return services; + } + + /// + /// Registers the Azure Table storage provider implementation. + /// + public static IServiceCollection AddNamedAzureTableStorage(this IServiceCollection services, string name, AzureTableStorageOptions options) + { + services.TryAddSingleton(options); + services.TryAddKeyedTransient(name); + return services; + } + } diff --git a/src/VisionaryCoder.Framework/Storage/StorageRegistrationBuilder.cs b/src/VisionaryCoder.Framework/Storage/StorageRegistrationBuilder.cs index 0c9b645..608f4e7 100644 --- a/src/VisionaryCoder.Framework/Storage/StorageRegistrationBuilder.cs +++ b/src/VisionaryCoder.Framework/Storage/StorageRegistrationBuilder.cs @@ -1,21 +1,31 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using VisionaryCoder.Framework.Data.Azure.Table; +using VisionaryCoder.Framework.Messaging.Azure.Queue; +using VisionaryCoder.Framework.Storage.Azure.Blob; using VisionaryCoder.Framework.Storage.Ftp; using VisionaryCoder.Framework.Storage.Local; namespace VisionaryCoder.Framework.Storage; /// -/// Builder for configuring multiple storage implementations. +/// Builder used to register multiple storage implementations with the DI container and +/// to configure factory options for each named implementation. /// +/// +/// This builder records registration information onto +/// and registers provider implementations into the provided . +/// Use this class from extension methods that expose a fluent registration API. +/// public sealed class StorageRegistrationBuilder(IServiceCollection services) { /// - /// Adds a local storage implementation to the factory. + /// Adds a local file system storage implementation to the factory. /// - /// The unique name for this storage implementation. - /// The builder for method chaining. + /// The unique logical name for this storage implementation. Must not be null or whitespace. + /// The builder instance to allow fluent chaining. + /// Thrown when is null or whitespace. public StorageRegistrationBuilder AddLocal(string name = "local") { ArgumentException.ThrowIfNullOrWhiteSpace(name); @@ -25,10 +35,12 @@ public StorageRegistrationBuilder AddLocal(string name = "local") } /// - /// Adds an FTP/FTPS storage implementation to the factory using FluentFTP. + /// Adds an FTP/FTPS storage implementation to the factory using FluentFTP-based provider. /// - /// The unique name for this storage implementation. - /// The FTP configuration options. + /// The unique logical name for this storage implementation. Must not be null or whitespace. + /// Configuration options for the FTP provider. Caller is responsible for validating options. + /// The builder instance to allow fluent chaining. + /// Thrown when is null or whitespace. public StorageRegistrationBuilder AddFtp(string name, FtpStorageOptions options) { ArgumentException.ThrowIfNullOrWhiteSpace(name); @@ -37,4 +49,50 @@ public StorageRegistrationBuilder AddFtp(string name, FtpStorageOptions options) return this; } + /// + /// Adds an Azure Blob storage implementation to the factory. + /// + /// The unique logical name for this storage implementation. Must not be null or whitespace. + /// Azure Blob storage options (connection string, container name, etc.). Options should be validated before calling. + /// The builder instance to allow fluent chaining. + /// Thrown when is null or whitespace. + public StorageRegistrationBuilder AddBlob(string name, AzureBlobStorageOptions options) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + services.Configure(factoryOptions => factoryOptions.RegisterImplementation(name, typeof(AzureBlobStorageProvider), options)); + services.TryAddTransient(); + return this; + } + + /// + /// Adds an Azure Queue storage implementation to the factory. + /// + /// The unique logical name for this storage implementation. Must not be null or whitespace. + /// Azure Queue storage options (connection string, queue name, TTL, etc.). Options should be validated before calling. + /// The builder instance to allow fluent chaining. + /// Thrown when is null or whitespace. + public StorageRegistrationBuilder AddQueue(string name, AzureQueueStorageOptions options) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + services.Configure(factoryOptions => factoryOptions.RegisterImplementation(name, typeof(AzureQueueStorageProvider), options)); + services.TryAddTransient(); + services.TryAddTransient(); + return this; + } + + /// + /// Adds an Azure Table storage implementation to the factory. + /// + /// The unique logical name for this storage implementation. Must not be null or whitespace. + /// Azure Table storage options (connection string, table name, retry settings, etc.). Options should be validated before calling. + /// The builder instance to allow fluent chaining. + /// Thrown when is null or whitespace. + public StorageRegistrationBuilder AddTable(string name, AzureTableStorageOptions options) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + services.Configure(factoryOptions => factoryOptions.RegisterImplementation(name, typeof(AzureTableStorageProvider), options)); + services.TryAddTransient(); + services.TryAddTransient(); + return this; + } } diff --git a/src/VisionaryCoder.Framework/VisionaryCoder.Framework.csproj b/src/VisionaryCoder.Framework/VisionaryCoder.Framework.csproj index 4e22db2..ca618d0 100644 --- a/src/VisionaryCoder.Framework/VisionaryCoder.Framework.csproj +++ b/src/VisionaryCoder.Framework/VisionaryCoder.Framework.csproj @@ -26,13 +26,18 @@ git main + + + + + - Schemas\queryfilter.schema.json - VisionaryCoder.Framework.Schemas.queryfilter.schema.json + Querying\Schemas\queryfilter.schema.json + VisionaryCoder.Framework.Querying.Schemas.queryfilter.schema.json @@ -48,6 +53,7 @@ + @@ -81,6 +87,7 @@ + diff --git a/tests/VisionaryCoder.Framework.Tests/Caching/CachingServiceCollectionExtensionsTests.cs b/tests/VisionaryCoder.Framework.Tests/Caching/CachingServiceCollectionExtensionsTests.cs index 505062a..43e770c 100644 --- a/tests/VisionaryCoder.Framework.Tests/Caching/CachingServiceCollectionExtensionsTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Caching/CachingServiceCollectionExtensionsTests.cs @@ -107,7 +107,7 @@ public void AddCaching_WithGenericCache_ShouldRegisterSpecifiedCache() public void AddCaching_WithGenericProviders_ShouldRegisterSpecifiedProviders() { // Act - services.AddCaching(); + services.AddCaching(); // Assert ServiceProvider serviceProvider = services.BuildServiceProvider(); diff --git a/tests/VisionaryCoder.Framework.Tests/Filtering/ExpressionToFilterNodeTests.cs b/tests/VisionaryCoder.Framework.Tests/Filtering/ExpressionToFilterNodeTests.cs index 34b0419..fd2e616 100644 --- a/tests/VisionaryCoder.Framework.Tests/Filtering/ExpressionToFilterNodeTests.cs +++ b/tests/VisionaryCoder.Framework.Tests/Filtering/ExpressionToFilterNodeTests.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using VisionaryCoder.Framework.Filtering; +using VisionaryCoder.Framework.Filtering.Abstractions; namespace VisionaryCoder.Framework.Tests.Filtering; @@ -41,7 +42,7 @@ public void Translate_WithEqualsExpression_ShouldCreateFilterCondition() result.Should().BeOfType(); var condition = (FilterCondition)result; condition.Path.Should().Be("Name"); - condition.Operator.Should().Be(FilterOperator.Equals); + condition.Operator.Should().Be(FilterOperation.Equals); condition.Value.Should().Be("test"); } @@ -58,7 +59,7 @@ public void Translate_WithNotEqualsExpression_ShouldCreateFilterCondition() result.Should().BeOfType(); var condition = (FilterCondition)result; condition.Path.Should().Be("Age"); - condition.Operator.Should().Be(FilterOperator.NotEquals); + condition.Operator.Should().Be(FilterOperation.NotEquals); condition.Value.Should().Be("25"); } @@ -75,7 +76,7 @@ public void Translate_WithGreaterThanExpression_ShouldCreateFilterCondition() result.Should().BeOfType(); var condition = (FilterCondition)result; condition.Path.Should().Be("Age"); - condition.Operator.Should().Be(FilterOperator.GreaterThan); + condition.Operator.Should().Be(FilterOperation.GreaterThan); condition.Value.Should().Be("18"); } @@ -92,7 +93,7 @@ public void Translate_WithLessThanOrEqualExpression_ShouldCreateFilterCondition( result.Should().BeOfType(); var condition = (FilterCondition)result; condition.Path.Should().Be("Age"); - condition.Operator.Should().Be(FilterOperator.LessOrEqual); + condition.Operator.Should().Be(FilterOperation.LessOrEqual); condition.Value.Should().Be("65"); } @@ -113,7 +114,7 @@ public void Translate_WithStringContainsExpression_ShouldCreateFilterCondition() result.Should().BeOfType(); var condition = (FilterCondition)result; condition.Path.Should().Be("Name"); - condition.Operator.Should().Be(FilterOperator.Contains); + condition.Operator.Should().Be(FilterOperation.Contains); condition.Value.Should().Be("test"); } @@ -130,7 +131,7 @@ public void Translate_WithStringStartsWithExpression_ShouldCreateFilterCondition result.Should().BeOfType(); var condition = (FilterCondition)result; condition.Path.Should().Be("Email"); - condition.Operator.Should().Be(FilterOperator.StartsWith); + condition.Operator.Should().Be(FilterOperation.StartsWith); condition.Value.Should().Be("admin"); } @@ -147,7 +148,7 @@ public void Translate_WithStringEndsWithExpression_ShouldCreateFilterCondition() result.Should().BeOfType(); var condition = (FilterCondition)result; condition.Path.Should().Be("Email"); - condition.Operator.Should().Be(FilterOperator.EndsWith); + condition.Operator.Should().Be(FilterOperation.EndsWith); condition.Value.Should().Be(".com"); } @@ -200,7 +201,7 @@ public void Translate_WithNotExpression_ShouldNegateOperator() result.Should().BeOfType(); var condition = (FilterCondition)result; condition.Path.Should().Be("Age"); - condition.Operator.Should().Be(FilterOperator.LessOrEqual); // Negation of > + condition.Operator.Should().Be(FilterOperation.LessOrEqual); // Negation of > condition.Value.Should().Be("18"); } @@ -221,7 +222,7 @@ public void Translate_WithAnyWithoutPredicate_ShouldCreateCollectionCondition() result.Should().BeOfType(); var condition = (FilterCollectionCondition)result; condition.Path.Should().Be("Tags"); - condition.Operator.Should().Be(FilterOperator.HasElements); + condition.Operator.Should().Be(FilterOperation.HasElements); condition.Predicate.Should().BeNull(); } @@ -238,13 +239,13 @@ public void Translate_WithAnyWithSimplePredicate_ShouldCreateCollectionCondition result.Should().BeOfType(); var condition = (FilterCollectionCondition)result; condition.Path.Should().Be("Children"); - condition.Operator.Should().Be(FilterOperator.Any); + condition.Operator.Should().Be(FilterOperation.Any); condition.Predicate.Should().NotBeNull(); condition.Predicate.Should().BeOfType(); var predicateCondition = (FilterCondition)condition.Predicate!; predicateCondition.Path.Should().Be("Value"); - predicateCondition.Operator.Should().Be(FilterOperator.GreaterThan); + predicateCondition.Operator.Should().Be(FilterOperation.GreaterThan); predicateCondition.Value.Should().Be("10"); } @@ -261,13 +262,13 @@ public void Translate_WithAnyWithStringPredicate_ShouldCreateCollectionCondition result.Should().BeOfType(); var condition = (FilterCollectionCondition)result; condition.Path.Should().Be("Children"); - condition.Operator.Should().Be(FilterOperator.Any); + condition.Operator.Should().Be(FilterOperation.Any); condition.Predicate.Should().NotBeNull(); condition.Predicate.Should().BeOfType(); var predicateCondition = (FilterCondition)condition.Predicate!; predicateCondition.Path.Should().Be("Name"); - predicateCondition.Operator.Should().Be(FilterOperator.Contains); + predicateCondition.Operator.Should().Be(FilterOperation.Contains); predicateCondition.Value.Should().Be("test"); } @@ -284,7 +285,7 @@ public void Translate_WithAnyWithComplexPredicate_ShouldCreateCollectionConditio result.Should().BeOfType(); var condition = (FilterCollectionCondition)result; condition.Path.Should().Be("Children"); - condition.Operator.Should().Be(FilterOperator.Any); + condition.Operator.Should().Be(FilterOperation.Any); condition.Predicate.Should().NotBeNull(); condition.Predicate.Should().BeOfType(); @@ -310,13 +311,13 @@ public void Translate_WithAllWithPredicate_ShouldCreateCollectionCondition() result.Should().BeOfType(); var condition = (FilterCollectionCondition)result; condition.Path.Should().Be("Children"); - condition.Operator.Should().Be(FilterOperator.All); + condition.Operator.Should().Be(FilterOperation.All); condition.Predicate.Should().NotBeNull(); condition.Predicate.Should().BeOfType(); var predicateCondition = (FilterCondition)condition.Predicate!; predicateCondition.Path.Should().Be("Value"); - predicateCondition.Operator.Should().Be(FilterOperator.GreaterThan); + predicateCondition.Operator.Should().Be(FilterOperation.GreaterThan); predicateCondition.Value.Should().Be("0"); } @@ -333,7 +334,7 @@ public void Translate_WithAllWithComplexPredicate_ShouldCreateCollectionConditio result.Should().BeOfType(); var condition = (FilterCollectionCondition)result; condition.Path.Should().Be("Children"); - condition.Operator.Should().Be(FilterOperator.All); + condition.Operator.Should().Be(FilterOperation.All); condition.Predicate.Should().NotBeNull(); condition.Predicate.Should().BeOfType(); } @@ -355,7 +356,7 @@ public void Translate_WithEnumerableContains_ShouldCreateFilterCondition() result.Should().BeOfType(); var condition = (FilterCondition)result; condition.Path.Should().Be("Tags"); - condition.Operator.Should().Be(FilterOperator.Contains); + condition.Operator.Should().Be(FilterOperation.Contains); condition.Value.Should().Be("important"); } @@ -468,7 +469,7 @@ public void Translate_WithInvertedComparison_ShouldNormalizeCorrectly() result.Should().BeOfType(); var condition = (FilterCondition)result; condition.Path.Should().Be("Age"); - condition.Operator.Should().Be(FilterOperator.GreaterThan); + condition.Operator.Should().Be(FilterOperation.GreaterThan); condition.Value.Should().Be("18"); } @@ -485,7 +486,7 @@ public void Translate_WithInvertedEquality_ShouldNormalizeCorrectly() result.Should().BeOfType(); var condition = (FilterCondition)result; condition.Path.Should().Be("Name"); - condition.Operator.Should().Be(FilterOperator.Equals); + condition.Operator.Should().Be(FilterOperation.Equals); condition.Value.Should().Be("test"); } From a8d27738f68e4c28654dbaa11a6bd650910bf711 Mon Sep 17 00:00:00 2001 From: Ivan Jones Date: Tue, 18 Nov 2025 12:26:51 -0800 Subject: [PATCH 3/3] Improve documentation and add `IN` operator support Updated README.md to enhance clarity on repository structure, modularity, and future decomposition plans. Added examples for the new `IN` operator in the filtering subsystem. Introduced ADR-0005 to document the rationale and implementation details for the `IN` operator, including its translation for EF Core and POCO execution. Added new READMEs for the filtering subsystem and querying helpers, detailing their components, usage, and guidance for splitting them into standalone packages. These updates aim to improve developer onboarding, maintainability, and modular development practices. --- README.md | 81 ++++++------ docs/adr/adr-0005.md | 77 +++++++++++ .../Filtering/README.md | 23 ++++ .../Querying/README.md | 16 +++ src/VisionaryCoder.Framework/README.md | 122 ++++++++++-------- 5 files changed, 222 insertions(+), 97 deletions(-) create mode 100644 docs/adr/adr-0005.md create mode 100644 src/VisionaryCoder.Framework/Filtering/README.md create mode 100644 src/VisionaryCoder.Framework/Querying/README.md diff --git a/README.md b/README.md index dc6f2c2..fb6331d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![NuGet](https://img.shields.io/nuget/v/VisionaryCoder.Framework.Core.svg)](https://www.nuget.org/packages/VisionaryCoder.Framework.Core) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -A modular, enterprise-grade framework starting with a single foundational library (`VisionaryCoder.Framework`) and accompanying test project. The repository emphasizes **clean, reproducible, and automated development** with .NET 8 (ready for forward compatibility to .NET 10). Future packages will evolve incrementally via ADRs. +A modular, enterprise-grade framework starting from a single foundational library. This repository contains the source, documentation, samples and tests for the VisionaryCoder Framework ecosystem. --- @@ -13,7 +13,7 @@ A modular, enterprise-grade framework starting with a single foundational librar ```bash # Clone git clone https://github.com/visionarycoder/Framework.git -cd vc +cd Framework # Restore dotnet restore VisionaryCoder.Framework.sln @@ -25,64 +25,59 @@ dotnet test VisionaryCoder.Framework.sln --configuration Release --- -## πŸ“¦ Current Solution Contents +## πŸ“¦ Solution Overview -| Project | Type | Description | -|----------------------------------|--------------|---------------------------------------------------------------------------------------------| -| `VisionaryCoder.Framework` | Library | Core framework primitives (configuration, results, options, providers, proxy abstractions). | -| `VisionaryCoder.Framework.Tests` | Test Project | Unit tests validating core behaviors (results, request/correlation IDs, options). | +This repository currently contains a single main library that aggregates foundational capabilities. The intent is to progressively decompose this monolith into smaller packages (see ADRs and roadmap in `docs/`), and this README serves as the top-level index linking to module-level READMEs and developer guidance to make that process easier. -Planned future packages (tracked via ADRs) will be introduced gradually rather than pre-listed. See ADR index for roadmap context. +### Projects -## πŸ—ƒοΈ Repository Structure (High-Level) +- `src/VisionaryCoder.Framework` β€” Core library and shared utilities (see `src/VisionaryCoder.Framework/README.md`) +- `tests/VisionaryCoder.Framework.Tests` β€” Unit tests validating framework behaviors -```text -/.copilot # Modular AI assistant instruction set (base + C# + patterns + standards) -/docs # Documentation (ADRs, best-practices capsules, diagrams, reviews, onboarding) -/src/VisionaryCoder.Framework # Core library source -/tests/VisionaryCoder.Framework.Tests # Unit tests -/.github # Global Copilot instructions & workflows -``` +### Module READMEs (entry points) ---- +- Core project README: `src/VisionaryCoder.Framework/README.md` +- Filtering subsystem: `src/VisionaryCoder.Framework/Filtering/README.md` +- Querying serialization & helpers: `src/VisionaryCoder.Framework/Querying/README.md` +- Documentation and architecture decisions: `docs/` (ADRs, best-practices, diagrams) -## πŸ—οΈ Architecture Overview +Use these module READMEs as the canonical documentation when splitting the project into multiple packages. -The framework follows **Volatility-Based Decomposition (VBD)** principles. While the current library aggregates foundational concerns, future decomposition will create distinct Manager, Engine, and Accessor component packages as volatility boundaries emerge. +## πŸ—ƒοΈ Repository Structure (High-Level) -Core library already enforces: +```text +/.copilot +/docs +/src/VisionaryCoder.Framework + β”œβ”€ Filtering/ + β”œβ”€ Querying/ + └─ VisionaryCoder.Framework.csproj +/tests/VisionaryCoder.Framework.Tests +/.github +``` -- Contract-first abstractions for providers & proxies -- Structured result + request/correlation context handling -- Dependency injection integration & options binding -- Early extensibility points for caching, security, querying +## πŸ“š Documentation & Roadmap ---- +- Architectural Decision Records (ADRs): `docs/adr/index.md` +- Design diagrams and best-practice capsules: `docs/*` +- Roadmap notes: `docs/reviews/*` -## πŸ“š Documentation & Guidance +## 🧭 How to subdivide this repo (next steps) -- **ADRs**: `docs/adr/index.md` (recent: ADR-0004 modular Copilot instructions) -- **Best Practices Capsules**: `docs/best-practices/*/readme.md` (architecture, security, observability, etc.) -- **Copilot Instructions**: `.github/copilot-instructions.md` (enterprise baseline) and `.copilot/copilot-instructions.md` (modular hub) -- **Design Patterns Guidance**: `.copilot/design-patterns.instructions.md` -- **C# Generation Heuristics**: `.copilot/csharp.instructions.md` -- **Repository Standards**: `.copilot/repo-standards.md` +If you plan to split this repository into multiple packages, follow these high-level steps: -## 🀝 Contributing +1. Identify volatility boundaries using VBD (Volatility-Based Decomposition). Good candidates: Filtering, Querying/Serialization, Execution Strategies, POCO helpers, EFCore adapters. +2. Create new projects under `src/` for each package and move code with one-class-per-file, preserving namespaces (e.g., `VisionaryCoder.Framework.Filtering.Abstractions`). +3. Keep `IFilterExecutionStrategy` and other small provider-agnostic interfaces in their own `*.Abstractions` package to avoid circular references. +4. Introduce `VisionaryCoder.Framework.*.csproj` projects with clear dependencies and update solution file. +5. Add module README files (use those in this repo as templates) and ADRs to justify the split. -Contributions are welcomeβ€”please open an issue or ADR proposal before large architectural changes. Align new code with: +## 🀝 Contributing -1. Naming & layering rules (see global Copilot instructions) -2. Volatility boundaries (introduce new packages only when volatility justifies extraction) -3. Modular instruction consistency (update domain index + ADR when extending guidance) +Contributions are welcome. Please open an issue or ADR proposal for large architectural changes. Keep PRs focused and update module READMEs when moving code. --- -## πŸ“„ License +This document is the canonical solution-level index. See module READMEs for implementation details and examples. -MIT License – see [LICENSE](LICENSE). - -Copyright (c) 2025 VisionaryCoder - ---- Last synchronized with solution structure: 2025-11-14 diff --git a/docs/adr/adr-0005.md b/docs/adr/adr-0005.md new file mode 100644 index 0000000..f6046ef --- /dev/null +++ b/docs/adr/adr-0005.md @@ -0,0 +1,77 @@ +# ADR-0005: Add `IN` Membership Operator to Filtering Model and EF Core Translation + +## Status + +Accepted + +## Date + +2025-11-18 + +## Context + +The filtering subsystem uses a portable, provider-agnostic AST (`FilterNode` and related records/enums) to represent predicate logic that can be serialized and applied across different execution strategies (POCO in-memory and EF Core query translation). + +Common query patterns include membership tests (SQL `IN`) expressed in code as constant-collection `.Contains(member)` or array-literal `.Contains(member)`. Previously these were translated into OR-chains or not recognized uniformly across strategies which reduced portability and caused inefficient SQL generation. + +Goals: +- Provide an explicit membership (`IN`) operator in the filter model so serialized filters are unambiguous. +- Ensure EF Core translation emits expressions that providers translate into efficient SQL `IN (...)` clauses. +- Keep POCO execution semantics equivalent (evaluate membership in-memory). + +## Decision + +1. Extend the filter model with a membership operator: + - Add `FilterOperation.In` to `VisionaryCoder.Framework.Filtering.Abstractions.FilterOperation`. + +2. Expression translation: + - Detect patterns of constant-collection `Contains` (both static and instance forms) and translate them to `FilterCondition` with `Operator = FilterOperation.In` and `Value` set to a compact JSON array of element string representations. + +3. EF Core execution optimization: + - When translating `FilterCondition` with `Operator.In` for EF Core, deserialize the JSON array, convert items to the CLR element type, construct a typed array constant (e.g., `new int[] { 1, 2 }`) and generate `Enumerable.Contains(array, member)` in the expression tree. This expression is recognized and translated by EF Core providers into SQL `IN` clauses. + +4. POCO execution: + - For in-memory/POCO execution, deserialize the JSON array and evaluate membership via an OR-chain of equality comparisons (semantically equivalent) or using `Enumerable.Contains` on the deserialized collection when appropriate. + +5. Samples and documentation: + - Update samples to show variable-backed and literal collection `Contains` usage and confirm behavior for both POCO and EF Core. + - Add ADR and README updates documenting the change and migration guidance. + +## Consequences + +- Positive: + - Filters that express membership become first-class, serializable, and portable across execution strategies. + - EF Core queries are optimized to produce `IN` clauses where supported, improving SQL readability and performance for moderate-sized lists. + - Serialized filters are explicit and compact (JSON array payload) and can be stored or transmitted. + +- Negative / Tradeoffs: + - Using JSON payload inside `FilterCondition.Value` is a pragmatic serialization approach; it couples the value string format to a JSON array representation. + - Very large IN lists may not be optimal as array constants; future work should consider parameterization, TVPs, or provider-specific optimizations for large membership sets. + +## Alternatives Considered + +- Represent membership with a dedicated `FilterCollectionCondition` subtype for membership instead of serializing to JSON in `FilterCondition.Value`. + - Rejected for now because it would require larger API surface changes and more migration surface for existing serialization. + +- Always translate to OR-chains for EF Core and POCO. + - Rejected because EF Core can translate `Enumerable.Contains(array, member)` into `IN`, which produces better SQL and parameters handling in many providers. + +- Provider-specific extension methods or annotations to signal `IN`. + - Deferred: prefer a simple model-first approach that keeps the Abstractions small and portable. + +## Implementation Notes + +- The translator detects both `Enumerable.Contains(collection, value)` and `collection.Contains(value)` where `collection` is a constant-like expression (array, list literal, or captured variable). When found, the translator evaluates the collection at translation time and emits `FilterOperation.In` with JSON-serialized values. +- EF Core builder constructs a typed constant array and emits `Enumerable.Contains(array, member)`. POCO builder deserializes and evaluates in-memory. +- Unit tests should be added to cover translation and execution paths for both POCO and EF Core strategies. Consider tests verifying SQL generation when running against an EF Core provider. + +## References + +- ADR-0001: Architecture playbook and documentation conventions +- docs/filtering/collection-operations.md +- EF Core docs on how `Enumerable.Contains` is translated to `IN` by query providers + +```text +Status: Accepted +Date: 2025-11-18 +``` diff --git a/src/VisionaryCoder.Framework/Filtering/README.md b/src/VisionaryCoder.Framework/Filtering/README.md new file mode 100644 index 0000000..2533bc1 --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/README.md @@ -0,0 +1,23 @@ +# Filtering Subsystem + +This directory contains the filtering model, expression translation and execution strategies used to build and apply portable filters across POCO collections and EF Core queryables. + +Key components + +- `VisionaryCoder.Framework.Filtering.Abstractions` β€” Filter node model and enums (`FilterNode`, `FilterCondition`, `FilterGroup`, `FilterOperation`, etc.) +- `ExpressionToFilterNode` β€” Translate LINQ `Expression>` into `FilterNode` trees +- `Poco` execution strategy β€” Apply `FilterNode` to in-memory `IEnumerable` +- `EFCore` execution strategy β€” Translate `FilterNode` into EF Core expressions (optimized translation for `IN`, Any/All and string ops) + +Samples + +A sample demo demonstrates building filters, applying to POCO lists and EF queryables. See `src/VisionaryCoder.Framework/Filtering/Sample`. + +Splitting guidance + +When extracting the filtering subsystem into a standalone package: + +1. Create `VisionaryCoder.Framework.Filtering.Abstractions` project with the model types and interfaces. +2. Create `VisionaryCoder.Framework.Filtering.Poco` and `VisionaryCoder.Framework.Filtering.EFCore` projects for execution strategies. +3. Keep `ExpressionToFilterNode` close to the Abstractions or in a lightweight `Filtering.Helpers` package if you want to share it between strategies. + diff --git a/src/VisionaryCoder.Framework/Querying/README.md b/src/VisionaryCoder.Framework/Querying/README.md new file mode 100644 index 0000000..fe518f1 --- /dev/null +++ b/src/VisionaryCoder.Framework/Querying/README.md @@ -0,0 +1,16 @@ +# Querying Helpers & Serialization + +This module contains lightweight utilities for serializing filter trees, rehydrating query filters and small helper extensions for `QueryFilter`. + +Key files + +- `QueryFilter` β€” Lightweight wrapper for `Expression>` used to compose and apply predicates +- `QueryFilterSerializer` / `QueryFilterRehydrator` β€” Serialization helpers for persisting filters +- `QueryFilterExtensions` β€” Convenience helpers for building and composing `QueryFilter` instances + +Splitting guidance + +When extracting this module: +- Create `VisionaryCoder.Framework.Querying` project containing `QueryFilter` and serialization helpers +- Keep serialization schema stable (use ADR to track breaking changes) + diff --git a/src/VisionaryCoder.Framework/README.md b/src/VisionaryCoder.Framework/README.md index 1d79624..29384bd 100644 --- a/src/VisionaryCoder.Framework/README.md +++ b/src/VisionaryCoder.Framework/README.md @@ -4,85 +4,99 @@ A comprehensive core framework library providing foundational features for the V ## Overview -The `VisionaryCoder.Framework` project serves as the foundational library for the entire VisionaryCoder Framework ecosystem. It provides core services, utilities, and patterns that are used throughout all other framework components. +`VisionaryCoder.Framework` is the foundational library for the VisionaryCoder Framework ecosystem. It provides core services, utilities and small, focused sub-systems that other framework components and applications can reuse. -## Features +This project contains several concerns, including the filtering model and execution strategies that make it easy to build, serialize and apply query filters across in-memory POCO collections and EF Core queryables. -### Core Services +## Highlights (what's included today) -- **Framework Information Provider**: Provides metadata about the framework version, compilation time, and description -- **Correlation ID Provider**: Manages correlation IDs for distributed request tracking -- **Request ID Provider**: Manages request IDs for individual request tracking -- **Response Wrapper**: Consistent success/failure handling with `Response` and `Response` +- Core framework helpers and DI registration utilities +- A flexible filtering model (portable abstractions) + - `VisionaryCoder.Framework.Filtering.Abstractions` contains the filter node model: `FilterNode`, `FilterCondition`, `FilterGroup`, `FilterOperation`, and related enums + - Expression translation from LINQ predicates via `ExpressionToFilterNode` (produces the portable `FilterNode` tree) +- Pluggable filter execution strategies + - POCO strategy (default) for in-memory `IEnumerable` filtering + - EF Core strategy (sub-library) which translates `FilterNode` into EF expressions and is optimized to produce SQL (including `IN` support) +- Sample code showing usage and new `IN` operator support (constant collection `.Contains(member)` and array literal `.Contains(member)` are translated) -### Configuration +## Filtering and Querying -- **Service Collection Extensions**: Easy registration of framework services via dependency injection -- **Framework Options**: Configurable settings for framework behavior -- **Framework Constants**: Centralized constants for timeouts, headers, and logging +The filtering subsystem is intentionally split into a stable `Abstractions` surface and provider-specific execution code: -### Key Components +- `VisionaryCoder.Framework.Filtering.Abstractions` + - Portable POCO types used to represent filters (`FilterNode`, `FilterCondition`, `FilterGroup`, etc.) + - `IFilterExecutionStrategy` interface to apply a `FilterNode` to `IQueryable` or `IEnumerable` -#### FrameworkConstants +- `VisionaryCoder.Framework.Filtering` (helpers) + - `Filter.For()` builder and `ExpressionToFilterNode` translator used to create `FilterNode` trees from LINQ expressions -Provides framework-wide constants including: +- `VisionaryCoder.Framework.Filtering.Poco` (default) + - In-memory application of `FilterNode` trees; intended as the default execution strategy for POCO collections -- Version information -- Default timeout values -- Common HTTP headers -- Logging configuration +- `VisionaryCoder.Framework.Filtering.EFCore` (optional provider) + - EF Core optimized execution strategy that translates `FilterNode` into expression trees EF can turn into SQL + - Includes optimized handling of `IN` by generating a typed constant array and using `Enumerable.Contains(array, member)`, which EF Core providers translate to `IN (...)` in SQL -#### ServiceCollectionExtensions +## New: IN operator support -Extension methods for easy framework integration: +You can now write filters using constant collections or array literals and have them translated to an `IN` operation for EF Core: ```csharp -services.AddVisionaryCoderFramework(); -services.AddVisionaryCoderFramework(options => -{ - options.EnableCorrelationId = true; - options.DefaultHttpTimeoutSeconds = 60; -}); +// variable-backed collection -> translated to IN for EF +var allowedNames = new[] { "John Smith", "Bob Brown" }; +var filter = Filter.For() + .Where(u => allowedNames.Contains(u.Name)) + .Build(); + +// literal array -> also translated to IN +var filter2 = Filter.For() + .Where(u => new[] { "Ann Smith", "Bob Brown" }.Contains(u.Name)) + .Build(); ``` -#### Response +For POCO execution the framework evaluates the same semantics in-memory; for EF Core the expression is optimized to a SQL `IN` clause where possible. -Consistent result wrapper for operations: +## Samples -```csharp -var response = Response.Success("Hello World"); -response.Match( - onSuccess: value => Console.WriteLine(value), - onFailure: (error, ex) => Console.WriteLine($"Error: {error}") -); -``` +A small sample application is included under `src/VisionaryCoder.Framework/Filtering/Sample` demonstrating: + +- Building a filter with `Filter.For()` and `ExpressionToFilterNode` +- Applying the filter to an in-memory collection via the POCO execution strategy +- Applying the same filter to an EF Core `IQueryable` via the EF Core execution strategy +- Examples for `IN` usage (variable collection and array literal) -## Project Structure +## Project structure (high level) -``` text -VisionaryCoder.Framework/ -β”œβ”€β”€ Abstractions.cs # Core interfaces -β”œβ”€β”€ FrameworkConstants.cs # Framework constants -β”œβ”€β”€ FrameworkResult.cs # Result wrapper types -β”œβ”€β”€ Implementations.cs # Default implementations -β”œβ”€β”€ ServiceCollectionExtensions.cs # DI extensions +```text +src/VisionaryCoder.Framework/ +β”œβ”€β”€ Filtering/ +β”‚ β”œβ”€β”€ Abstractions/ # Filter model (FilterNode, FilterCondition, FilterOperation...) +β”‚ β”œβ”€β”€ EFCore/ # EF Core execution strategy and expression builder +β”‚ β”œβ”€β”€ Poco/ # Default POCO execution strategy +β”‚ └── Sample/ # Samples demonstrating usage +β”œβ”€β”€ Querying/ # Thin query helpers and serialization └── VisionaryCoder.Framework.csproj ``` -## Dependencies +## Dependencies and targets + +- Target: .NET 8 +- C# language level: modern (C# 13+ where applicable) +- Notable NuGet: `Microsoft.EntityFrameworkCore` (for EF strategy / samples) + +## Testing -- **.NET 8.0**: Target framework -- **Microsoft.Extensions.DependencyInjection.Abstractions**: For dependency injection -- **Microsoft.Extensions.Logging.Abstractions**: For logging abstractions -- **Microsoft.Extensions.Options**: For configuration options -- **VisionaryCoder.Framework.Abstractions**: Core framework abstractions +Unit tests for filtering and expression translation exist in the `tests/VisionaryCoder.Framework.Tests` project. They cover: -## Integration +- Expression -> FilterNode translation +- Collection operator translation (Any, All, HasElements) +- `IN` translation cases -This project is automatically included when referencing the VisionaryCoder Framework ecosystem. It provides the foundational services that other framework components depend on. +## Contribution notes -## Version +- The filtering model is intentionally small and provider-agnostic; new execution strategies can be added by implementing `IFilterExecutionStrategy` and wiring it through DI or via helper classes. +- Prefer immutable records for filter model types; keep translation logic focused and testable. -Current Version: **1.0.0** +--- -Built with C# 12 and .NET 8.0, following Microsoft naming conventions and modern C# practices including primary constructors where applicable. +This README reflects the current state of the `VisionaryCoder.Framework` repository and the filtering features available in the codebase.