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..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. --- @@ -12,8 +12,8 @@ A modular, enterprise-grade framework starting with a single foundational librar ```bash # Clone -git clone https://github.com/visionarycoder/vc.git -cd vc +git clone https://github.com/visionarycoder/Framework.git +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/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/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/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/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/Storage/Azure/Table/AzureTableStorageOptions.cs b/src/VisionaryCoder.Framework/Data/Azure/Table/AzureTableStorageOptions.cs similarity index 95% rename from src/VisionaryCoder.Framework/Storage/Azure/Table/AzureTableStorageOptions.cs rename to src/VisionaryCoder.Framework/Data/Azure/Table/AzureTableStorageOptions.cs index b8149ad..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; +namespace VisionaryCoder.Framework.Data.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/Data/Azure/Table/AzureTableStorageProvider.cs similarity index 93% rename from src/VisionaryCoder.Framework/Storage/Azure/Table/AzureTableStorageProvider.cs rename to src/VisionaryCoder.Framework/Data/Azure/Table/AzureTableStorageProvider.cs index a6b1482..cea9bda 100644 --- a/src/VisionaryCoder.Framework/Storage/Azure/Table/AzureTableStorageProvider.cs +++ b/src/VisionaryCoder.Framework/Data/Azure/Table/AzureTableStorageProvider.cs @@ -1,13 +1,18 @@ +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.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; @@ -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; @@ -163,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); @@ -208,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); @@ -254,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); @@ -278,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); @@ -339,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 { @@ -348,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; } @@ -362,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 { @@ -391,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"); @@ -414,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); @@ -442,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); @@ -464,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); @@ -483,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/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/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 65% rename from src/VisionaryCoder.Framework/Filtering/FilterGroup.cs rename to src/VisionaryCoder.Framework/Filtering/Abstractions/FilterGroup.cs index ac5609f..ae816ae 100644 --- a/src/VisionaryCoder.Framework/Filtering/FilterGroup.cs +++ b/src/VisionaryCoder.Framework/Filtering/Abstractions/FilterGroup.cs @@ -1 +1,3 @@ +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/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/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/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..672e085 --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/EFCore/EfFilterExpressionBuilder.cs @@ -0,0 +1,217 @@ +using Microsoft.EntityFrameworkCore; +using System.Globalization; +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json; +using VisionaryCoder.Framework.Filtering.Abstractions; + +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; + + // 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)) + 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 + { + 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 + }; + } + + 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 + { + 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 == FilterOperation.HasElements) + { + return Expression.Call( + typeof(Enumerable), anyAllMethodName, new[] { 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, 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, new[] { 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..dd085ae 100644 --- a/src/VisionaryCoder.Framework/Filtering/ExpressionToFilterNode.cs +++ b/src/VisionaryCoder.Framework/Filtering/ExpressionToFilterNode.cs @@ -1,53 +1,91 @@ +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 { - public static FilterNode Translate(Expression> expression) => - TranslateNode(expression.Body) - ?? throw new NotSupportedException($"Expression '{expression}' is not supported."); + /// + /// 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."); - public static FilterNode Translate(Expression expression) => - TranslateNode(expression) - ?? 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."); - static FilterNode? TranslateNode(Expression expression) => + /// + /// 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 { 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) + /// + /// Translates binary expressions. Handles logical groups (AndAlso/OrElse) by + /// creating nodes and comparison operators by delegating + /// to . + /// + 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) + /// + /// 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) { - foreach (var child in group.Children) + foreach (FilterNode child in group.Children) { yield return child; } @@ -56,104 +94,122 @@ static IEnumerable FlattenIfSameGroup(FilterNode node, FilterCombina yield return node; } - static FilterNode? TranslateComparison(BinaryExpression binary) + /// + /// Handles comparison expressions by normalizing member/constant positions and + /// mapping the expression to a . + /// + private static FilterNode? TranslateComparison(BinaryExpression binary) { - var (memberExpr, constantExpr, op) = NormalizeBinary(binary); + + (MemberExpression? memberExpr, Expression? constantExpr, FilterOperation? 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. + /// 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. /// - static (MemberExpression? member, Expression? constant, FilterOperator?) NormalizeBinary(BinaryExpression binary) + private static (MemberExpression? member, Expression? constant, FilterOperation?) 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); + FilterOperation? 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); + FilterOperation? op = MapComparisonOperator(binary.NodeType, invert: true); return (rightMember, binary.Left, op); } return (null, null, null); } - 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 }; + } - static FilterNode? TranslateMethodCall(MethodCallExpression call) + /// + /// 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) { - 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 + FilterOperation? op = call.Method.Name switch { - nameof(string.Contains) => FilterOperator.Contains, - nameof(string.StartsWith) => FilterOperator.StartsWith, - nameof(string.EndsWith) => FilterOperator.EndsWith, - _ => (FilterOperator?)null + nameof(string.Contains) => FilterOperation.Contains, + nameof(string.StartsWith) => FilterOperation.StartsWith, + nameof(string.EndsWith) => FilterOperation.EndsWith, + _ => null }; return op is null @@ -161,68 +217,109 @@ 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; + } - 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) + MemberExpression? collectionMember = GetMember(call.Object); + if (collectionMember is null) { return null; } - var negated = NegateOperator(op.Value); - var path = GetMemberPath(memberExpr); - var value = EvaluateToString(constantExpr); + string? path = GetMemberPath(collectionMember); + if (path is null) + { + return null; + } - return new FilterCondition(path!, negated, value); + string? value = EvaluateToString(call.Arguments[0]); + return new FilterCondition(path, FilterOperation.Contains, value); } - if (unary.Operand is MethodCallExpression call) + // 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(...); + // } + + } + + /// + /// 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 not BinaryExpression binary) { - // 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; + // Flip operator if possible + (MemberExpression? memberExpr, Expression? constantExpr, FilterOperation? op) = NormalizeBinary(binary); + if (memberExpr is null || constantExpr is null || op is null) + { + return null; + } + + FilterOperation negated = NegateOperator(op.Value); + string? path = GetMemberPath(memberExpr); + string? value = EvaluateToString(constantExpr); + return new FilterCondition(path!, negated, value); + } - 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}'.") }; - static MemberExpression? GetMember(Expression expression) => + /// + /// 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 }; - 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); + /// + /// 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)); - static string? GetMemberPath(MemberExpression member) + /// + /// 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(); Expression? current = member; @@ -237,7 +334,175 @@ 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. + /// 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. + 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 not 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; + } + + return null; + } + + // 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) + { + // 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) + { + // 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); + } + } + + // 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) + { + 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); + } + } + } + + 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. + /// + 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<>))); + } + + /// + /// 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 Expression expr = expression; @@ -254,9 +519,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..4a0ff9c 100644 --- a/src/VisionaryCoder.Framework/Filtering/Filter.cs +++ b/src/VisionaryCoder.Framework/Filtering/Filter.cs @@ -1,3 +1,7 @@ +using VisionaryCoder.Framework.Filtering.Abstractions; + +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..68ca96a 100644 --- a/src/VisionaryCoder.Framework/Filtering/FilterBuilder.cs +++ b/src/VisionaryCoder.Framework/Filtering/FilterBuilder.cs @@ -1,12 +1,15 @@ using System.Linq.Expressions; +using VisionaryCoder.Framework.Filtering.Abstractions; + +namespace VisionaryCoder.Framework.Filtering; public sealed class FilterBuilder { - readonly List roots = new(); + private readonly List roots = new(); public FilterBuilder Where(Expression> predicate) { - var node = ExpressionToFilterNode.Translate(predicate); + FilterNode node = ExpressionToFilterNode.Translate(predicate); roots.Add(node); return this; } @@ -15,7 +18,7 @@ public FilterNode Build() { return roots.Count switch { - 0 => new FilterGroup(FilterCombination.And, Array.Empty()), + 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 7fac1f2..0000000 --- a/src/VisionaryCoder.Framework/Filtering/FilterCombination.cs +++ /dev/null @@ -1,5 +0,0 @@ -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 19a31d0..0000000 --- a/src/VisionaryCoder.Framework/Filtering/FilterCondition.cs +++ /dev/null @@ -1 +0,0 @@ -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 a5ea4e0..0000000 --- a/src/VisionaryCoder.Framework/Filtering/FilterNode.cs +++ /dev/null @@ -1 +0,0 @@ -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 107ab2a..0000000 --- a/src/VisionaryCoder.Framework/Filtering/FilterOperator.cs +++ /dev/null @@ -1,15 +0,0 @@ -public enum FilterOperator -{ - Equals, - NotEquals, - GreaterThan, - GreaterOrEqual, - LessThan, - LessOrEqual, - Contains, - StartsWith, - EndsWith, - Any, - All, - HasElements -} 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..26e6fa0 --- /dev/null +++ b/src/VisionaryCoder.Framework/Filtering/Poco/PocoFilterExpressionBuilder.cs @@ -0,0 +1,212 @@ +using System.Globalization; +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json; +using VisionaryCoder.Framework.Filtering.Abstractions; + +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; + + // 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)) + 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 + { + 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 + }; + } + + 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 + { + 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 == FilterOperation.HasElements) + { + return Expression.Call( + typeof(Enumerable), anyAllMethodName, new[] { 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, 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, new[] { 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/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/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/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/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/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 83% rename from src/VisionaryCoder.Framework/Logging/LoggingServiceCollectionExtensions.cs rename to src/VisionaryCoder.Framework/Logging/LoggingExtensions.cs index 9156a65..0babed6 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). @@ -47,14 +50,11 @@ 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 => { - var logger = provider.GetRequiredService>(); + ILogger logger = provider.GetRequiredService>(); return new TimingInterceptor(logger) { SlowOperationThresholdMs = slowThresholdMs, @@ -96,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); @@ -112,7 +110,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 +160,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 +188,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/Storage/Azure/Queue/AzureQueueStorageOptions.cs b/src/VisionaryCoder.Framework/Messaging/Azure/Queue/AzureQueueStorageOptions.cs similarity index 95% rename from src/VisionaryCoder.Framework/Storage/Azure/Queue/AzureQueueStorageOptions.cs rename to src/VisionaryCoder.Framework/Messaging/Azure/Queue/AzureQueueStorageOptions.cs index 48ee9bd..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; +namespace VisionaryCoder.Framework.Messaging.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/Messaging/Azure/Queue/AzureQueueStorageProvider.cs similarity index 96% rename from src/VisionaryCoder.Framework/Storage/Azure/Queue/AzureQueueStorageProvider.cs rename to src/VisionaryCoder.Framework/Messaging/Azure/Queue/AzureQueueStorageProvider.cs index 173a68f..70acd64 100644 --- a/src/VisionaryCoder.Framework/Storage/Azure/Queue/AzureQueueStorageProvider.cs +++ b/src/VisionaryCoder.Framework/Messaging/Azure/Queue/AzureQueueStorageProvider.cs @@ -1,14 +1,19 @@ +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.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); @@ -186,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); @@ -210,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); @@ -338,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); @@ -436,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/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..5b86e63 100644 --- a/src/VisionaryCoder.Framework/FrameworkOptions.cs +++ b/src/VisionaryCoder.Framework/Options.cs @@ -3,18 +3,22 @@ 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. /// 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/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..5ce6efd --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Dispatch/GenericGrpcClient.cs @@ -0,0 +1,27 @@ +using Grpc.Net.Client; + +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..ff11d86 --- /dev/null +++ b/src/VisionaryCoder.Framework/Pipeline/Interceptors/ResilienceInterceptor.cs @@ -0,0 +1,20 @@ +using Polly; +using VisionaryCoder.Framework.Pipeline.Abstractions; + +namespace VisionaryCoder.Framework.Pipeline.Interceptors; + +public sealed class ResilienceInterceptor(AsyncPolicy policy) : IInterceptor +{ + + 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/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/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..d41bd37 100644 --- a/src/VisionaryCoder.Framework/Authorization/AuthorizationServiceCollectionExtensions.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Authorization/AuthorizationExtensions.cs @@ -1,16 +1,18 @@ // 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 Microsoft.Extensions.DependencyInjection.Extensions; +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 +87,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 70% rename from src/VisionaryCoder.Framework/Caching/CachingServiceCollectionExtensions.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/CachingExtensions.cs index 0ae1636..090e6a3 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). @@ -20,9 +20,7 @@ public static class CachingServiceCollectionExtensions /// 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(); @@ -40,22 +38,19 @@ public static IServiceCollection AddCaching( services.TryAddSingleton(); // Register the caching interceptor - services.TryAddSingleton(); + services.TryAddSingleton(); return services; } /// - /// 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,39 +59,32 @@ public static IServiceCollection AddCaching( services.Configure(configure); } - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - - return services; - } - - /// - /// 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(); + Type t = typeof(T); - 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(); + 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 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 84% rename from src/VisionaryCoder.Framework/Proxy/Caching/ICachePolicyProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/ICachePolicyProvider.cs index 6ee40ed..effa51f 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. @@ -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/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 69% rename from src/VisionaryCoder.Framework/Caching/Interceptors/CachingInterceptor.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Interceptors/CachingInterceptor.cs index fb90c08..afed24f 100644 --- a/src/VisionaryCoder.Framework/Caching/Interceptors/CachingInterceptor.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Interceptors/CachingInterceptor.cs @@ -1,21 +1,18 @@ // 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 +32,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 +44,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,20 +53,12 @@ public async Task> InvokeAsync(ProxyContext context, ProxyDe } // Generate and try to retrieve from cache - var 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); - } - - var cachedResponse = await proxyCache.GetAsync(cacheKey, 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; @@ -78,20 +67,18 @@ 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); - 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); - 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; @@ -107,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 var 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 var cacheControl)) + if (!context.Headers.TryGetValue("Cache-Control", out string? cacheControl)) { - var 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; + } /// @@ -144,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/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 90% rename from src/VisionaryCoder.Framework/Caching/Providers/NullCacheKeyProvider.cs rename to src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullCacheKeyProvider.cs index 93a3fed..a042c7b 100644 --- a/src/VisionaryCoder.Framework/Caching/Providers/NullCacheKeyProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullCacheKeyProvider.cs @@ -1,16 +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 VisionaryCoder.Framework.Proxy; - -namespace VisionaryCoder.Framework.Caching.Providers; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers; /// /// Null Object implementation of that provides safe fallback behavior. /// 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. @@ -35,4 +33,4 @@ public bool CanGenerateKey(ProxyContext context) { return false; } -} \ No newline at end of file +} 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..8f48112 100644 --- a/src/VisionaryCoder.Framework/Caching/Providers/NullCachePolicyProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Caching/Providers/NullCachePolicyProvider.cs @@ -1,16 +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 VisionaryCoder.Framework.Proxy; - -namespace VisionaryCoder.Framework.Caching.Providers; +namespace VisionaryCoder.Framework.Proxy.Interceptors.Caching.Providers; /// /// Null Object implementation of that provides safe fallback behavior. /// 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/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..ab7f010 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 new 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..5c87b9a 100644 --- a/src/VisionaryCoder.Framework/Configuration/Local/LocalAppConfigurationProvider.cs +++ b/src/VisionaryCoder.Framework/Proxy/Interceptors/Configuration/Local/LocalConfigurationProvider.cs @@ -1,51 +1,36 @@ -using System.Collections.Concurrent; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; -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 new 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 +49,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 +165,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 +176,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 +300,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..88a33c3 --- /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 var 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/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/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/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/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. 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..d2f3d41 100644 --- a/src/VisionaryCoder.Framework/ServiceBase.cs +++ b/src/VisionaryCoder.Framework/ServiceBase.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; + namespace VisionaryCoder.Framework; /// @@ -38,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) @@ -45,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 e52c30b..0e59d99 100644 --- a/src/VisionaryCoder.Framework/ServiceResult.cs +++ b/src/VisionaryCoder.Framework/ServiceResult.cs @@ -1,61 +1,165 @@ namespace VisionaryCoder.Framework; -/// Non-generic result wrapper for operations that don't return a value. -public class ServiceResponse +/// +/// Result for operations that don't return a value. +/// +/// +/// 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 { - protected ServiceResponse(bool isSuccess, string? errorMessage, Exception? exception) + private ServiceResult(bool isSuccess, string? errorMessage, Exception? exception) + : base(isSuccess, errorMessage, exception) { - IsSuccess = isSuccess; - ErrorMessage = errorMessage; - Exception = exception; } + /// /// Creates a successful result. - public static ServiceResponse Success() - { - return new(true, null, null); - } + /// + public static ServiceResult Success() => new(true, 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, errorMessage, null); + + /// + /// Creates a failure result from an exception. The exception's message is used + /// as the . + /// + /// The exception that caused the failure. + public static ServiceResult Failure(Exception exception) => new(false, 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, errorMessage, exception); - public static ServiceResponse Failure(string errorMessage) + /// + /// 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) { - 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. +/// +/// 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) + : 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; 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) { - 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) + /// + /// 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) + 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; } + /// + /// 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) + 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/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/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/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/StorageExtensions.cs b/src/VisionaryCoder.Framework/Storage/StorageExtensions.cs new file mode 100644 index 0000000..6968cf1 --- /dev/null +++ b/src/VisionaryCoder.Framework/Storage/StorageExtensions.cs @@ -0,0 +1,127 @@ +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; + +/// +/// Extension methods for registering storage services with dependency injection. +/// +public static class StorageExtensions +{ + + /// + /// Registers the local storage implementation. + /// + public static IServiceCollection AddLocalStorage(this IServiceCollection services, LocalStorageOptions options) + { + ArgumentNullException.ThrowIfNull(services); + 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; + } + + /// + /// Registers the FluentFTP-based storage implementation. + /// + public static IServiceCollection AddFtpStorage(this IServiceCollection services, FtpStorageOptions options) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(options); + services.TryAddSingleton(options); + services.TryAddTransient(); + return services; + } + + /// + /// Registers a named FluentFTP-based storage implementation (requires .NET 8 keyed services). + /// + public static IServiceCollection AddNamedFtpStorage(this IServiceCollection services, string name, FtpStorageOptions options) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(options); + services.TryAddSingleton(options); + services.TryAddKeyedTransient(name); + return services; + } + + /// + /// Registers the Azure Blob storage provider implementation. + /// + public static IServiceCollection AddAzureBlobStorage(this IServiceCollection services, AzureBlobStorageOptions options) + { + services.TryAddSingleton(options); + services.TryAddTransient(); + return services; + } + + /// + /// Registers the Azure Blob storage provider implementation. + /// + public static IServiceCollection AddNamedAzureBlobStorage(this IServiceCollection services, string name, AzureBlobStorageOptions 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 80544e4..608f4e7 100644 --- a/src/VisionaryCoder.Framework/Storage/StorageRegistrationBuilder.cs +++ b/src/VisionaryCoder.Framework/Storage/StorageRegistrationBuilder.cs @@ -1,19 +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); @@ -23,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); @@ -35,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/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/Storage/StorageServiceCollectionExtensions.cs b/src/VisionaryCoder.Framework/Storage/StorageServiceCollectionExtensions.cs deleted file mode 100644 index f52b88f..0000000 --- a/src/VisionaryCoder.Framework/Storage/StorageServiceCollectionExtensions.cs +++ /dev/null @@ -1,68 +0,0 @@ -using VisionaryCoder.Framework.Storage.Azure; -using VisionaryCoder.Framework.Storage.Ftp; -using VisionaryCoder.Framework.Storage.Local; - -namespace VisionaryCoder.Framework.Storage; - -/// -/// Extension methods for registering storage services with dependency injection. -/// -public static class StorageServiceCollectionExtensions -{ - - /// - /// Registers the local storage implementation. - /// - public static IServiceCollection AddLocalStorage(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - services.AddTransient(); - return services; - } - - /// - /// Registers the FluentFTP-based storage implementation. - /// - public static IServiceCollection AddFtpStorage(this IServiceCollection services, FtpStorageOptions options) - { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(options); - services.AddSingleton(options); - services.TryAddTransient(); - return services; - } - - /// - /// Registers a named FluentFTP-based storage implementation (requires .NET 8 keyed services). - /// - public static IServiceCollection AddNamedFtpStorage(this IServiceCollection services, string name, FtpStorageOptions options) - { - ArgumentNullException.ThrowIfNull(services); - ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentNullException.ThrowIfNull(options); - services.AddSingleton(options); - services.TryAddKeyedTransient(name); - return services; - } - - /// - /// Registers the Azure Blob storage provider implementation. - /// - public static IServiceCollection AddAzureBlobStorage(this IServiceCollection services, AzureBlobStorageOptions options) - { - services.AddSingleton(options); - services.TryAddTransient(); - return services; - } - - /// - /// Registers the Azure Blob storage provider implementation. - /// - public static IServiceCollection AddAzureBlobStorage(this IServiceCollection services, string name, AzureBlobStorageOptions options) - { - services.AddSingleton(options); - services.TryAddKeyedTransient(name); - return services; - } - -} diff --git a/src/VisionaryCoder.Framework/VisionaryCoder.Framework.csproj b/src/VisionaryCoder.Framework/VisionaryCoder.Framework.csproj index d9fda87..ca618d0 100644 --- a/src/VisionaryCoder.Framework/VisionaryCoder.Framework.csproj +++ b/src/VisionaryCoder.Framework/VisionaryCoder.Framework.csproj @@ -1,60 +1,95 @@ ο»Ώ - - - 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 + + + + + + + + + + Querying\Schemas\queryfilter.schema.json + VisionaryCoder.Framework.Querying.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..43e770c 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(); } @@ -102,18 +107,18 @@ public void AddCaching_WithGenericCache_ShouldRegisterSpecifiedCache() public void AddCaching_WithGenericProviders_ShouldRegisterSpecifiedProviders() { // Act - services.AddCaching(); + 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/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"); } 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}" - } - } -}