diff --git a/.editorconfig b/.editorconfig index 101a042..1e10e86 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,10 +1,12 @@ [*.cs] csharp_style_prefer_primary_constructors = false +dotnet_style_prefer_collection_expression = false # ReSharper properties resharper_align_multiline_binary_expressions_chain = false resharper_braces_for_foreach = required resharper_braces_for_ifelse = required +resharper_braces_for_while = required resharper_csharp_keep_blank_lines_in_code = 1 resharper_csharp_keep_blank_lines_in_declarations = 1 resharper_csharp_wrap_before_binary_opsign = true diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a7ad19c..46cadf9 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -22,7 +22,7 @@ jobs: run: dotnet test --report-trx --results-directory=".artifacts/test-results" --no-build --no-restore || true - name: Publish Test Report uses: dorny/test-reporter@v3 - if: always() + if: ${{ !cancelled() }} with: name: .NET Core Tests path: ".artifacts/test-results/**/*.trx" diff --git a/CodeMe.sln.DotSettings b/CodeMe.sln.DotSettings index 3c68084..2efc434 100644 --- a/CodeMe.sln.DotSettings +++ b/CodeMe.sln.DotSettings @@ -1,4 +1,5 @@  + HINT HINT SUGGESTION True diff --git a/CodeMe.slnx b/CodeMe.slnx index af69706..82d0539 100644 --- a/CodeMe.slnx +++ b/CodeMe.slnx @@ -1,13 +1,18 @@ + + + + + diff --git a/Directory.Build.props b/Directory.Build.props index 7869a30..2c59d5d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -41,12 +41,14 @@ true snupkg true + README.md + diff --git a/README.md b/README.md index 002235e..f31ab70 100644 --- a/README.md +++ b/README.md @@ -4,49 +4,11 @@ CodeMe is a set of small, focused, reusable libraries aimed to reduce amount of # CodeMe.ServiceErrors -CodeMe.ServiceErrors is a library for describing service-level errors as first-class values and turning them into serializable payloads or exceptions. It is designed for APIs, background services, and distributed systems where you want a stable error contract across app boundaries. +CodeMe.ServiceErrors is a library for describing service-level errors as first-class values and turning them into serializable payloads or exceptions. It is designed for APIs, background services, and distributed systems where you want a stable error contract across app boundaries. Check [documentation](https://github.com/ig-sinicyn/CodeMe/docs/ServiceErrors/README.md) for more details and examples. -## Introduction +## Minimal example -The core model is built around a few simple concepts: - -* `ServiceError` carries well-known error descriptor together with a human-friendly message and optional inner details. -* `ErrorDescriptor` describes an error with a problem type (error URI), HTTP-like status, transience, and severity. -* `ErrorUri` and `ErrorGroupUri` represent problem type URI inspired by [RFC 9457: Problem Details for HTTP APIs](https://datatracker.ietf.org/doc/html/rfc9457). -* `ServiceErrorDto` represents serializable service error format. -* `ServiceException` and `IServiceException` allow to pass service errors as exceptions. -* `IServiceErrorFactory` converts between `ServiceError`, `ServiceErrorDto`, and `IServiceException`. - -Typical usage scenarios include: - -* Enforcing usage of well-known domain errors. -* Exposing a predictable error contract from HTTP APIs or gRPC services. -* Registering error definitions in DI so services can create and rehydrate errors consistently. -* Passing errors across process boundaries using a serializable error payload. -* Use allocation-free typed errors instead of error codes or exceptions in performance-sensitive code. - -### Minimal example - -Handling the errors: -```csharp -using CodeMe.ServiceErrors; - -// Error URI: problem://orders-api/orders/order-not-found, status 404 -var descriptor = ErrorDescriptor.NotFound( - group: ErrorGroupUri.Create("problem", "orders-api", "orders"), - code: "order-not-found"); - -var error = new ServiceError(descriptor, "Order 66 was not found"); - -// ... - -if (error.Matches(descriptor)) -{ - // handle the error -} -``` - -Well-known errors, conversions and DI registration: +Well-known errors, testing for errors, conversions and DI registration: ```csharp using CodeMe.ServiceErrors; using CodeMe.ServiceErrors.DependencyInjection; @@ -54,14 +16,23 @@ using CodeMe.ServiceErrors.Serializable; using Microsoft.Extensions.DependencyInjection; using static WellKnownOrderApiErrors; +// DI registration var services = new ServiceCollection(); services .AddServiceErrors(RootGroup) .Add(typeof(WellKnownOrderApiErrors)); - using var provider = services.BuildServiceProvider(); var errorFactory = provider.GetRequiredService(); +// Error return and handling +var error = new ServiceError(OrderNotFound, "Order 42 was not found"); +// ... +if (error.Matches(OrderNotFound)) +{ + // handle the error +} + +// Error serialization and exception factory ServiceError error = new ServiceError(OrderNotFound, "Order 42 was not found"); ServiceErrorDto dto = errorFactory.CreateDto(error); ServiceError errorFromDto = errorFactory.CreateError(dto); @@ -79,6 +50,7 @@ ServiceError errorFromDto = errorFactory.CreateError(dto); IServiceException exception = errorFactory.CreateException(errorFromDto); ServiceError errorFromException = exception.Error; +// Well-known errors declaration [ServiceErrors] internal static class WellKnownOrderApiErrors { @@ -91,6 +63,7 @@ internal static class WellKnownOrderApiErrors ErrorDescriptor.NotFound(OrdersGroup, "order-not-found"); } +// Typed exceptions internal sealed class OrderNotFoundException : ServiceException { public OrderNotFoundException(ServiceError error) @@ -105,202 +78,6 @@ internal sealed class OrderNotFoundException : ServiceException } ``` -## Advanced usage - -### Checking for well-known errors - -All error-related types do provide `Matches(...)` / `MatchesAny()` methods. Match logic works as follows: -* x matches to StatusCode: exact match. -* x matches to ErrorUri: exact match. -* x matches to ErrorGroupUri: match if x.Group is descendant of specified error group. -* x matches to ErrorDescriptor: match if x.Type and x.StatusCode are equal to descriptor's type and status code. - -Example usage: -```csharp -using CodeMe.ServiceErrors; - -var rootGroup = ErrorGroupUri.Create("problem", "orders-api"); -var otherAppGroup = ErrorGroupUri.Create("problem", "users-api"); -var notFoundErrorUri = ErrorUri.Create(rootGroup.SubGroup("orders"), "order-not-found"); -var notFoundDescriptor = new ErrorDescriptor(notFoundErrorUri, ErrorStatusCode.NotFound); - -var error = new ServiceError(notFoundDescriptor, "Order 404 was not found"); -var exception = new ServiceException(error); - -// Test for error descriptor (checks for type and status code) -if (error.Matches(notFoundDescriptor)) -{ -} - -// Test for error groups (checks if any group do contain descriptor's error group) -if (notFoundDescriptor.MatchesAny(rootGroup, otherAppGroup)) -{ -} - -// Test for problem type URI -try -{ -} -catch (ServiceException ex) when (ex.Matches(notFoundErrorUri)) -{ -} - -// Test for error status code -try -{ -} -catch (Exception ex) - when (ex is IServiceException x && x.Matches(ErrorStatusCode.NotFound)) -{ -} -``` - -### Serialization - -To convert between a `ServiceError` and a serializable `ServiceErrorDto`, use `IServiceErrorFactory`. - -```csharp -using CodeMe.ServiceErrors; -using CodeMe.ServiceErrors.Serializable; -using CodeMe.ServiceErrors.Serializable.Builders; -using static WellKnownOrderApiErrors; - -var factory = new DefaultServiceErrorFactoryBuilder(RootGroup) - .Add(typeof(WellKnownOrderApiErrors)) - .Build(); - -var serviceError = new ServiceError(OrderNotFound, "Order 404 was not found"); - -ServiceErrorDto dto = factory.CreateDto(serviceError); -// DTO content in JSON format: -// { -// "scheme": "problem", -// "application": "orders-api", -// "category": "orders", -// "code": "order-not-found", -// "statusCode": "NotFound", -// "message": "Order 404 was not found" -// } - -ServiceError restored = factory.CreateError(dto); -``` - -### Exception Mapping - -You can attach an exception type to a well-known error descriptor or error group with the `ServiceExceptionAttribute` family. When a matching error is turned into an exception, the factory instantiates the configured exception type. The target exception type must expose a public constructor that accepts `ServiceError` as a single argument. - -```csharp -using CodeMe.ServiceErrors; -using CodeMe.ServiceErrors.Serializable.Builders; -using static WellKnownOrderApiErrors; - -var factory = new DefaultServiceErrorFactoryBuilder(RootGroup) - .Add(typeof(WellKnownOrderApiErrors)) - .Build(); - -ServiceError serviceError = new ServiceError(OrderNotFound, "Order 42 was not found"); -IServiceException exception = factory.CreateException(serviceError); // returns OrderNotFoundException - -// ... - -ServiceError restoredError = factory.CreateError((Exception)exception); -``` - -#### Unknown exceptions - -If exception is not registered, the `IServiceErrorFactory.CreateError()` method will return ServiceException with error code derived from exception's type. - -```csharp -using CodeMe.ServiceErrors; -using CodeMe.ServiceErrors.Serializable.Builders; -using static WellKnownOrderApiErrors; - -var factory = new DefaultServiceErrorFactoryBuilder(RootGroup) - .Add(typeof(WellKnownOrderApiErrors)) - .Build(); - -var ex = new InvalidOperationException("Something strange happened"); -var error = factory.CreateError(ex); -// StatusCode: Internal -// Type: "problem://orders-api/invalid-operation" -// Message: "Something strange happened" -// InnerException: ex -``` - - -### DI registrations of well-known errors - -The DI extensions support three common registration patterns: - -- Register a specific well-known errors type. -- Register all well-known error types from one assembly. -- Register well-known error types from an assembly and its referenced assemblies. - -```csharp -using CodeMe.ServiceErrors; -using CodeMe.ServiceErrors.DependencyInjection; -using Microsoft.Extensions.DependencyInjection; -using static WellKnownOrderApiErrors; - -var services = new ServiceCollection(); - -services - .AddServiceErrors(RootGroup) - .Add(typeof(WellKnownOrderApiErrors)); - -// or -services - .AddServiceErrors(RootGroup) - .AddAssembly(typeof(WellKnownOrderApiErrors).Assembly, filterByServiceErrorsAttribute: false); - -services - .AddServiceErrors(RootGroup) - .AddAssemblyAndDependencies( - typeof(WellKnownOrderApiErrors).Assembly, - referenceNamePrefix: "CodeMe.", - filterByServiceErrorsAttribute: true); -``` - -The `Add` overload registers the static error container type directly. `AddAssembly` scans a single assembly for static classes marked with `[ServiceErrors]` or for classes containing public static fields of well-known error types, depending on the `filterByServiceErrorsAttribute` flag. `AddAssemblyAndDependencies` walks the assembly graph and registers error definitions from matching dependencies. If `referenceNamePrefix` is specified, only root assembly and assemblies whose name starts with the prefix will be scanned. - -#### Typed error factories and per-factory well-known error registration - -In some cases it is useful to have a custom error factory configuration instead of the default one. As example, you may want to have a specialized error factory for client of some external service and do not want external service errors to be used across the rest of your application. Meet the typed factory concept. You have to create a marker interface derived from `IServiceErrorFactory` and use it as a type argument for the `AddServiceErrors()` call. - -```csharp -using CodeMe.ServiceErrors; -using CodeMe.ServiceErrors.DependencyInjection; -using CodeMe.ServiceErrors.Serializable; -using Microsoft.Extensions.DependencyInjection; -using static WellKnownOrderApiErrors; - -var services = new ServiceCollection(); -services - .AddServiceErrors(RootGroup) - .Add(typeof(WellKnownOrderApiErrors)); - -public interface IOrdersErrorFactory : IServiceErrorFactory -{ -} -``` - -With this setup, the container can resolve `IOrdersErrorFactory` as a typed service error factory while still using the same well-known error registration model. - -### DI-free error factory - -For scenarios where you do not want to use DI, you can create a service error factory directly using `DefaultServiceErrorFactoryBuilder`. Same configuration, no DI. +# CodeMe.Basics -```csharp -using CodeMe.ServiceErrors; -using CodeMe.ServiceErrors.Serializable; -using CodeMe.ServiceErrors.Serializable.Builders; -using static WellKnownOrderApiErrors; - -var factory = new DefaultServiceErrorFactoryBuilder(RootGroup) - .Add(typeof(WellKnownOrderApiErrors)) - .Build(); - -var serviceError = new ServiceError(OrderNotFound, "Order 404 was not found"); - -ServiceErrorDto dto = factory.CreateDto(serviceError); -``` +CodeMe.Basics contains BCL-style extensions such as `ScopedAsyncLocal` and others. \ No newline at end of file diff --git a/docs/Basics/AsyncLocal/CustomScope.md b/docs/Basics/AsyncLocal/CustomScope.md new file mode 100644 index 0000000..bcbd8ff --- /dev/null +++ b/docs/Basics/AsyncLocal/CustomScope.md @@ -0,0 +1,131 @@ +```csharp +using System.Data; +using System.Data.Common; +using CodeMe.Basics.Threading; + +var unitOfWorkManager = new UnitOfWorkManager(); +var repository = new UserRepository(unitOfWorkManager); // Usually, this is injected via DI container. + +await using (var unitOfWork = await unitOfWorkManager.BeginUnitOfWorkAsync()) +{ + var user = await repository.GetUserAsync(id: 123); + + // ... + + await unitOfWork.CommitAsync(); +} + +// UserRepository.GetUserAsync() is called within the context of an ambient unit of work. +async ValueTask GetUserAsync(long id) +{ + var unitOfWork = unitOfWorkManager.RequiredCurrent; + + return await unitOfWork.Connection.QueryFirstAsync(...); +} + + +// Simplified version for demonstration purposes. In real-world scenarios, consider using a more robust implementation. +public class UnitOfWorkManager +{ + private readonly Func> _connectionFactory; + + private readonly ScopedAsyncLocal _scopedAsyncLocal = + new ScopedAsyncLocal(validateDisposeOrder: true); + + public UnitOfWork RequiredCurrent => _scopedAsyncLocal.Current + ?? throw new InvalidOperationException("No ambient unit of work."); + + public ValueTask BeginUnitOfWorkAsync(IsolationLevel isolation = IsolationLevel.ReadCommitted) + { + // IMPORTANT: + // The BeginScopeInitialization method SHOULD be called in the synchronous part of the method. + // Otherwise, the new AsyncLocal value will not be stored in the caller's execution context, + var scope = _scopedAsyncLocal.BeginScopeInitialization(); + return BeginUnitOfWorkAsync(scope, isolation); + } + + private async ValueTask BeginUnitOfWorkAsync( + ScopedAsyncLocal.Scope scope, + IsolationLevel isolation) + { + // Asynchronous part. + // Performs step-by-step initialization of the UnitOfWork instance + // and assigns it to the scope. If any exception occurs, UnitOfWork (and all related resources) will be disposed. + var result = new UnitOfWork(); + try + { + result.Initialize(scope); + var connection = await _connectionFactory(); + result.Initialize(connection); + await connection.OpenAsync(); + + var transaction = await connection.BeginTransactionAsync(isolation); + result.Initialize(transaction); + + // Assign fully constructed UnitOfWork to the scope. + // This is the only place where the scope value is set. + scope.Initialize(result); + + return result; + } + catch (Exception) + { + await result.DisposeAsync(); + throw; + } + } +} + +public sealed class UnitOfWork : IAsyncDisposable +{ + private DbConnection? _connection; + private DbTransaction? _transaction; + private IDisposable _asyncScope; + private bool _transactionClosed; + + public DbConnection Connection { get; private set; } + + public DbTransaction Transaction { get; private set; } + + public void Initialize(DbConnection connection) => _connection = connection; + + public void Initialize(DbTransaction transaction) => _transaction = transaction; + + public void Initialize(IDisposable asyncScope) => _asyncScope = asyncScope; + + public async ValueTask CommitAsync() + { + await _transaction.CommitAsync(); + _transactionClosed = true; + } + + public async ValueTask RollbackAsync() + { + await _transaction.CommitAsync(); + _transactionClosed = true; + } + + public ValueTask DisposeAsync() + { + // IMPORTANT: + // For performance-sensitive code + // it is recommended to Dispose scope in synchronous part of DisposeAsync. + // The trick slightly reduces the memory usage + // as it clears AsyncLocal value in the caller's execution context. + _asyncScope?.Dispose(); + return DisposeCoreAsync(); + } + + private async ValueTask DisposeCoreAsync() + { + // Asynchronous part. Nothing special here. + if (!_transactionClosed) + { + await _transaction.RollbackAsync(); + } + + await _transaction.DisposeAsync(); + await _connection.DisposeAsync(); + } +} +``` \ No newline at end of file diff --git a/docs/Basics/AsyncLocal/README.md b/docs/Basics/AsyncLocal/README.md new file mode 100644 index 0000000..5ef91f2 --- /dev/null +++ b/docs/Basics/AsyncLocal/README.md @@ -0,0 +1,93 @@ + +# ScopedAsyncLocal + +`ScopedAsyncLocal` provides ambient context for a logical execution flow. It is useful when a value should be available to all code in the current flow without explicitly passing it through every method call. Typical scenarios include unit of work scopes, request correlation identifiers, tenant information, etc. + +## How it works + +`ScopedAsyncLocal` provides ambient context by keeping a stack of scopes for the current execution flow. Each scope represents a logical boundary such as a request, unit-of-work, or tenant context. When you call `BeginScope` or `BeginScopeAsync`, a new scope is pushed onto the current execution context, and `Current` resolves to the value from the innermost initialized scope. + +Nested scopes override the parent value while they are active. When the child scope is disposed, the previous ambient value is restored automatically, so the value behaves like a flow-scoped context without having to pass it through every method call. + +The implementation uses `AsyncLocal` under the hood, which means the value is captured per async flow. Because async calls create copies of the execution context, initialization is split into two steps: + +- the scope is created synchronously so it can be observed immediately by the caller; +- the scope value is initialized later, and uninitialized scopes are ignored while resolving `Current`. + +This design lets the new scope be available to the caller right away, while still supporting asynchronous value factories. The library also keeps a stack of scopes so it can restore the previous value when a scope is disposed. If you forget to dispose scopes, the stack can grow and hold references longer than intended. When `validateDisposeOrder: true` is used, disposing scopes in the wrong order throws an exception. + +## Scenarios and example of usage + +Use `ScopedAsyncLocal` when a value should be available to the current logical execution path and reverted automatically when the scope ends. The value is visible to nested scopes and is restored to the previous ambient value when the scope is disposed. + +```csharp +using CodeMe.Basics.Threading; + +var context = new ScopedAsyncLocal(); + +using (context.BeginScope("request-1")) +{ + Console.WriteLine(context.Current); // request-1 + + using (context.BeginScope("nested")) + { + Console.WriteLine(context.Current); // nested + } + + Console.WriteLine(context.Current); // request-1 +} + +Console.WriteLine(context.Current); // null +``` + +`ScopedAsyncLocal` also works with asynchronous flows. The value from the current scope is available after `await`; the scope must be disposed to restore the previous value. + +```csharp +var local = new ScopedAsyncLocal(); + +using (await local.BeginScopeAsync(() => new ValueTask("request-2"))) +{ + Console.WriteLine(local.Current); // request-2 +} +``` + +The constructor can be used with `validateDisposeOrder: true` to detect out-of-order scope disposal. + +## Using BeginScopeAsync from helper methods + +`BeginScopeAsync` initializes the scope asynchronously through a value factory. If you call it from a helper method, keep the helper synchronous and avoid `await` in the call. Otherwise new scope will not be propagated back to the caller. The value factory itself may use `await`. + +```csharp + private Task BeginUnitOfWorkAsync( + ScopedAsyncLocal local, + CancellationToken cancellation = default) => + local.BeginScopeAsync( + async () => + { + // The body is simplified for demonstration purposes. + DbConnection? connection = null; + DbTransaction? transaction = null; + try + { + connection = await _connectionFactory.CreateConnectionAsync(cancellation); + await connection.OpenAsync(cancellation); + transaction = await connection.BeginTransactionAsync(cancellation); + + return new UnitOfWork(connection, transaction); + } + catch (Exception ex) + { + if (transaction != null) + { + await transaction.DisposeAsync(); + } + + if (connection != null) + { + await connection.DisposeAsync(); + } + + throw; + } + }); +``` \ No newline at end of file diff --git a/docs/ServiceErrors/README.md b/docs/ServiceErrors/README.md new file mode 100644 index 0000000..65face7 --- /dev/null +++ b/docs/ServiceErrors/README.md @@ -0,0 +1,302 @@ +# CodeMe.ServiceErrors + +CodeMe.ServiceErrors is a library for describing service-level errors as first-class values and turning them into serializable payloads or exceptions. It is designed for APIs, background services, and distributed systems where you want a stable error contract across app boundaries. + +## Introduction + +The core model is built around a few simple concepts: + +* `ServiceError` carries well-known error descriptor together with a human-friendly message and optional inner details. +* `ErrorDescriptor` describes an error with a problem type (error URI), HTTP-like status, transience, and severity. +* `ErrorUri` and `ErrorGroupUri` represent problem type URI inspired by [RFC 9457: Problem Details for HTTP APIs](https://datatracker.ietf.org/doc/html/rfc9457). +* `ServiceErrorDto` represents serializable service error format. +* `ServiceException` and `IServiceException` allow to pass service errors as exceptions. +* `IServiceErrorFactory` converts between `ServiceError`, `ServiceErrorDto`, and `IServiceException`. + +Typical usage scenarios include: + +* Enforcing usage of well-known domain errors. +* Exposing a predictable error contract from HTTP APIs or gRPC services. +* Registering error definitions in DI so services can create and rehydrate errors consistently. +* Passing errors across process boundaries using a serializable error payload. +* Use allocation-free typed errors instead of error codes or exceptions in performance-sensitive code. + +### Minimal example + +Handling the errors: +```csharp +using CodeMe.ServiceErrors; + +// Error URI: problem://orders-api/orders/order-not-found, status 404 +var descriptor = ErrorDescriptor.NotFound( + group: ErrorGroupUri.Create("problem", "orders-api", "orders"), + code: "order-not-found"); + +var error = new ServiceError(descriptor, "Order 66 was not found"); + +// ... + +if (error.Matches(descriptor)) +{ + // handle the error +} +``` + +Well-known errors, conversions and DI registration: +```csharp +using CodeMe.ServiceErrors; +using CodeMe.ServiceErrors.DependencyInjection; +using CodeMe.ServiceErrors.Serializable; +using Microsoft.Extensions.DependencyInjection; +using static WellKnownOrderApiErrors; + +var services = new ServiceCollection(); +services + .AddServiceErrors(RootGroup) + .Add(typeof(WellKnownOrderApiErrors)); + +using var provider = services.BuildServiceProvider(); +var errorFactory = provider.GetRequiredService(); + +ServiceError error = new ServiceError(OrderNotFound, "Order 42 was not found"); +ServiceErrorDto dto = errorFactory.CreateDto(error); +ServiceError errorFromDto = errorFactory.CreateError(dto); +// DTO content in JSON format: +// { +// "scheme": "problem", +// "application": "orders-api", +// "category": "orders", +// "code": "order-not-found", +// "statusCode": "NotFound", +// "message": "Order 42 was not found" +// } + +// returns OrderNotFoundException +IServiceException exception = errorFactory.CreateException(errorFromDto); +ServiceError errorFromException = exception.Error; + +[ServiceErrors] +internal static class WellKnownOrderApiErrors +{ + public static readonly ErrorGroupUri RootGroup = ErrorGroupUri.Create("problem", "orders-api"); + + public static readonly ErrorGroupUri OrdersGroup = RootGroup.SubGroup("orders"); + + [ServiceException] + public static readonly ErrorDescriptor OrderNotFound = + ErrorDescriptor.NotFound(OrdersGroup, "order-not-found"); +} + +internal sealed class OrderNotFoundException : ServiceException +{ + public OrderNotFoundException(ServiceError error) + : base(AssertMatches(OrderNotFound, error)) + { + } + + public OrderNotFoundException(string message, Exception? innerException = null) + : base(OrderNotFound, message, innerException) + { + } +} +``` + +## Advanced usage + +### Checking for well-known errors + +All error-related types do provide `Matches(...)` / `MatchesAny()` methods. Match logic works as follows: +* x matches to StatusCode: exact match. +* x matches to ErrorUri: exact match. +* x matches to ErrorGroupUri: match if x.Group is descendant of specified error group. +* x matches to ErrorDescriptor: match if x.Type and x.StatusCode are equal to descriptor's type and status code. + +Example usage: +```csharp +using CodeMe.ServiceErrors; + +var rootGroup = ErrorGroupUri.Create("problem", "orders-api"); +var otherAppGroup = ErrorGroupUri.Create("problem", "users-api"); +var notFoundErrorUri = ErrorUri.Create(rootGroup.SubGroup("orders"), "order-not-found"); +var notFoundDescriptor = new ErrorDescriptor(notFoundErrorUri, ErrorStatusCode.NotFound); + +var error = new ServiceError(notFoundDescriptor, "Order 404 was not found"); +var exception = new ServiceException(error); + +// Test for error descriptor (checks for type and status code) +if (error.Matches(notFoundDescriptor)) +{ +} + +// Test for error groups (checks if any group do contain descriptor's error group) +if (notFoundDescriptor.MatchesAny(rootGroup, otherAppGroup)) +{ +} + +// Test for problem type URI +try +{ +} +catch (ServiceException ex) when (ex.Matches(notFoundErrorUri)) +{ +} + +// Test for error status code +try +{ +} +catch (Exception ex) + when (ex is IServiceException x && x.Matches(ErrorStatusCode.NotFound)) +{ +} +``` + +### Serialization + +To convert between a `ServiceError` and a serializable `ServiceErrorDto`, use `IServiceErrorFactory`. + +```csharp +using CodeMe.ServiceErrors; +using CodeMe.ServiceErrors.Serializable; +using CodeMe.ServiceErrors.Serializable.Builders; +using static WellKnownOrderApiErrors; + +var factory = new DefaultServiceErrorFactoryBuilder(RootGroup) + .Add(typeof(WellKnownOrderApiErrors)) + .Build(); + +var serviceError = new ServiceError(OrderNotFound, "Order 404 was not found"); + +ServiceErrorDto dto = factory.CreateDto(serviceError); +// DTO content in JSON format: +// { +// "scheme": "problem", +// "application": "orders-api", +// "category": "orders", +// "code": "order-not-found", +// "statusCode": "NotFound", +// "message": "Order 404 was not found" +// } + +ServiceError restored = factory.CreateError(dto); +``` + +### Exception Mapping + +You can attach an exception type to a well-known error descriptor or error group with the `ServiceExceptionAttribute` family. When a matching error is turned into an exception, the factory instantiates the configured exception type. The target exception type must expose a public constructor that accepts `ServiceError` as a single argument. + +```csharp +using CodeMe.ServiceErrors; +using CodeMe.ServiceErrors.Serializable.Builders; +using static WellKnownOrderApiErrors; + +var factory = new DefaultServiceErrorFactoryBuilder(RootGroup) + .Add(typeof(WellKnownOrderApiErrors)) + .Build(); + +ServiceError serviceError = new ServiceError(OrderNotFound, "Order 42 was not found"); +IServiceException exception = factory.CreateException(serviceError); // returns OrderNotFoundException + +// ... + +ServiceError restoredError = factory.CreateError((Exception)exception); +``` + +#### Unknown exceptions + +If exception is not registered, the `IServiceErrorFactory.CreateError()` method will return ServiceException with error code derived from exception's type. + +```csharp +using CodeMe.ServiceErrors; +using CodeMe.ServiceErrors.Serializable.Builders; +using static WellKnownOrderApiErrors; + +var factory = new DefaultServiceErrorFactoryBuilder(RootGroup) + .Add(typeof(WellKnownOrderApiErrors)) + .Build(); + +var ex = new InvalidOperationException("Something strange happened"); +var error = factory.CreateError(ex); +// StatusCode: Internal +// Type: "problem://orders-api/invalid-operation" +// Message: "Something strange happened" +// InnerException: ex +``` + + +### DI registrations of well-known errors + +The DI extensions support three common registration patterns: + +- Register a specific well-known errors type. +- Register all well-known error types from one assembly. +- Register well-known error types from an assembly and its referenced assemblies. + +```csharp +using CodeMe.ServiceErrors; +using CodeMe.ServiceErrors.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using static WellKnownOrderApiErrors; + +var services = new ServiceCollection(); + +services + .AddServiceErrors(RootGroup) + .Add(typeof(WellKnownOrderApiErrors)); + +// or +services + .AddServiceErrors(RootGroup) + .AddAssembly(typeof(WellKnownOrderApiErrors).Assembly, filterByServiceErrorsAttribute: false); + +services + .AddServiceErrors(RootGroup) + .AddAssemblyAndDependencies( + typeof(WellKnownOrderApiErrors).Assembly, + referenceNamePrefix: "CodeMe.", + filterByServiceErrorsAttribute: true); +``` + +The `Add` overload registers the static error container type directly. `AddAssembly` scans a single assembly for static classes marked with `[ServiceErrors]` or for classes containing public static fields of well-known error types, depending on the `filterByServiceErrorsAttribute` flag. `AddAssemblyAndDependencies` walks the assembly graph and registers error definitions from matching dependencies. If `referenceNamePrefix` is specified, only root assembly and assemblies whose name starts with the prefix will be scanned. + +#### Typed error factories and per-factory well-known error registration + +In some cases it is useful to have a custom error factory configuration instead of the default one. As example, you may want to have a specialized error factory for client of some external service and do not want external service errors to be used across the rest of your application. Meet the typed factory concept. You have to create a marker interface derived from `IServiceErrorFactory` and use it as a type argument for the `AddServiceErrors()` call. + +```csharp +using CodeMe.ServiceErrors; +using CodeMe.ServiceErrors.DependencyInjection; +using CodeMe.ServiceErrors.Serializable; +using Microsoft.Extensions.DependencyInjection; +using static WellKnownOrderApiErrors; + +var services = new ServiceCollection(); +services + .AddServiceErrors(RootGroup) + .Add(typeof(WellKnownOrderApiErrors)); + +public interface IOrdersErrorFactory : IServiceErrorFactory +{ +} +``` + +With this setup, the container can resolve `IOrdersErrorFactory` as a typed service error factory while still using the same well-known error registration model. + +### DI-free error factory + +For scenarios where you do not want to use DI, you can create a service error factory directly using `DefaultServiceErrorFactoryBuilder`. Same configuration, no DI. + +```csharp +using CodeMe.ServiceErrors; +using CodeMe.ServiceErrors.Serializable; +using CodeMe.ServiceErrors.Serializable.Builders; +using static WellKnownOrderApiErrors; + +var factory = new DefaultServiceErrorFactoryBuilder(RootGroup) + .Add(typeof(WellKnownOrderApiErrors)) + .Build(); + +var serviceError = new ServiceError(OrderNotFound, "Order 404 was not found"); + +ServiceErrorDto dto = factory.CreateDto(serviceError); +``` diff --git a/docs/docs.csproj b/docs/docs.csproj new file mode 100644 index 0000000..5fba55e --- /dev/null +++ b/docs/docs.csproj @@ -0,0 +1,7 @@ + + + + netstandard2.0 + + + diff --git a/src/CodeMe.Basics/CodeMe.Basics.csproj b/src/CodeMe.Basics/CodeMe.Basics.csproj new file mode 100644 index 0000000..9aff5c0 --- /dev/null +++ b/src/CodeMe.Basics/CodeMe.Basics.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + Library or simple reusable infrastructure types (scoped async local etc). + AsyncLocal;ScopedAsyncLocal + + + \ No newline at end of file diff --git a/src/CodeMe.Basics/README.md b/src/CodeMe.Basics/README.md new file mode 100644 index 0000000..46178a4 --- /dev/null +++ b/src/CodeMe.Basics/README.md @@ -0,0 +1,87 @@ +# CodeMe.Basics + +CodeMe.Basics is a library for simple reusable infrastructure types that are missing in BCL. + +# ScopedAsyncLocal + +`ScopedAsyncLocal` provides ambient context for a logical execution flow. It is useful when a value should be available to all code in the current flow without explicitly passing it through every method call. Typical scenarios include unit of work scopes, request correlation identifiers, tenant information, etc. + +## Scenarios and example of usage + +Use `ScopedAsyncLocal` when a value should be available to the current logical execution path and reverted automatically when the scope ends. The value is visible to nested scopes and is restored to the previous ambient value when the scope is disposed. + +```csharp +using CodeMe.Basics.Threading; + +var context = new ScopedAsyncLocal(); + +using (context.BeginScope("request-1")) +{ + Console.WriteLine(context.Current); // request-1 + + using (context.BeginScope("nested")) + { + Console.WriteLine(context.Current); // nested + } + + Console.WriteLine(context.Current); // request-1 +} + +Console.WriteLine(context.Current); // null +``` +n t +`ScopedAsyncLocal` also works with asynchronous flows. The value from the current scope is available after `await`; the scope must be disposed to restore the previous value. + +```csharp +var local = new ScopedAsyncLocal(); + +using (await local.BeginScopeAsync(() => new ValueTask("request-2"))) +{ + Console.WriteLine(local.Current); // request-2 +} +``` + +The constructor can be used with `validateDisposeOrder: true` to detect out-of-order scope disposal. + +## Using BeginScopeAsync from helper methods + +`BeginScopeAsync` initializes the scope asynchronously through a value factory. If you call it from a helper method, keep the helper synchronous and avoid `await` in the call. Otherwise new scope will not be propagated back to the caller. The value factory itself may use `await`. + +```csharp + private Task BeginUnitOfWorkAsync( + ScopedAsyncLocal local, + CancellationToken cancellation = default) => + local.BeginScopeAsync( + async () => + { + // The body is simplified for demonstration purposes. + DbConnection? connection = null; + DbTransaction? transaction = null; + try + { + connection = await _connectionFactory.CreateConnectionAsync(cancellation); + await connection.OpenAsync(cancellation); + transaction = await connection.BeginTransactionAsync(cancellation); + + return new UnitOfWork(connection, transaction); + } + catch (Exception ex) + { + if (transaction != null) + { + await transaction.DisposeAsync(); + } + + if (connection != null) + { + await connection.DisposeAsync(); + } + + throw; + } + }); +``` + +# Documentation + +Check [documentation](https://github.com/ig-sinicyn/CodeMe/docs/Basics/README.md) for more details and examples. diff --git a/src/CodeMe.Basics/Threading/ScopedAsyncLocal.TestAccessor.cs b/src/CodeMe.Basics/Threading/ScopedAsyncLocal.TestAccessor.cs new file mode 100644 index 0000000..15a8e99 --- /dev/null +++ b/src/CodeMe.Basics/Threading/ScopedAsyncLocal.TestAccessor.cs @@ -0,0 +1,48 @@ +using System.Collections.Immutable; + +namespace CodeMe.Basics.Threading; + +public partial class ScopedAsyncLocal +{ + internal TestAccessor GetTestAccessor() => new(this); + + internal readonly record struct TestSnapshot(T? Value, bool IsInitialized, bool IsDisposed) + { + public static TestSnapshot Uninitialized => new(null, false, false); + public static TestSnapshot Disposed => new(null, false, true); + + public TestSnapshot(Scope scope) + : this( + scope.IsInitialized ? scope.Value : null, + scope.IsInitialized, + scope.IsDisposed) + { + } + + public TestSnapshot(T value) + : this( + value, + true, + false) + { + } + } + + internal readonly struct TestAccessor(ScopedAsyncLocal owner) + { + private ImmutableStack Scopes => + owner._current.Value switch + { + null => [], + { IsEmpty: true } => throw new InvalidOperationException("Inner stack should be null"), + var x => x + }; + + public TestSnapshot? GetSnapshot() => + Scopes.Select(x => (TestSnapshot?)new TestSnapshot(x)).FirstOrDefault(); + + public TestSnapshot GetSingleSnapshot() => Scopes.Select(x => new TestSnapshot(x)).Single(); + + public TestSnapshot[] GetSnapshots() => Scopes.Select(x => new TestSnapshot(x)).ToArray(); + } +} \ No newline at end of file diff --git a/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs b/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs new file mode 100644 index 0000000..f912465 --- /dev/null +++ b/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs @@ -0,0 +1,343 @@ +using System.Collections.Immutable; +using System.ComponentModel; + +namespace CodeMe.Basics.Threading; + +/// +/// Provides an ambient context for a logical execution flow. +/// +/// You SHOULD call on the end of scope lifetime or there may be memory leaks. +/// +/// Please check documentation for using scopes together with async initialization and disposal. +/// +/// The type of the ambient data. +public sealed partial class ScopedAsyncLocal + where T : class +{ + // A stack of scopes is kept to support partial initialization + // and to restore the previous ambient value when a scope is disposed. + // + // Scopes are initialized in two steps because AsyncLocal values are stored in current ExecutionContext. + // Async calls create copies of ExecutionContext and any changes in derived contexts are not passed back to the parent execution context. + // Therefore, we start new scope synchronously in the caller execution context. + // The rest of initialization of a new scope may be performed asynchronously. + // The new scope is available to the caller immediately, but we filter out uninitialized scopes. + private readonly AsyncLocal> _current; + private readonly bool _validateDisposeOrder; + + /// + /// Creates ambient context. + /// + /// Fail for out-of-order scope disposal. + public ScopedAsyncLocal(bool validateDisposeOrder = false) + { + _current = new AsyncLocal>(); + _validateDisposeOrder = validateDisposeOrder; + } + + private Scope? CurrentScope + { + // Returns first initialized scope value or default. + // Check the comment of the _current field for the justification. + get + { + if (_current.Value is not { } stack) + { + return null; + } + + foreach (var scope in stack) + { + if (scope.IsInitialized) + { + return scope; + } + } + + return null; + } + } + + /// + /// The current ambient value. + /// + public T? Current => CurrentScope?.Value; + + /// + /// Starts a new scope with uninitialized value. + /// + /// This method is designed for advanced scenarios and requires some care from the caller. Consider to use + /// or as they are simpler to use. + /// + /// + /// The caller MUST call to set the value of the scope. + /// and on the end of scope lifetime (even for non-initialized scopes) or there may be a memory + /// leak. + /// + /// + /// If you want to pass a new scope back to the caller of your code, you SHOULD NOT use async methods. + /// Scopes are stored as a part of . + /// Async calls create a copy of execution context and do not pass changes back to the parent context. + /// + /// + /// A proper implementation that will pass the new scope back to the caller of the method: + /// + /// ValueTask<IDisposable> BeginCustomScopeAsync() + /// { + /// // sync part + /// var scope = _scopedAsyncLocal.BeginScopeInitialization(); + /// return CompleteCustomScopeAsync(scope); + /// } + /// + /// async ValueTask<IDisposable> CompleteCustomScopeAsync(ScopedAsyncLocal<string>.Scope scope) + /// { + /// // asynchronous part + /// var resource = await GetResourceAsync(); + /// scope.Initialize(resource); + /// return scope; + /// } + /// + /// + /// + /// to complete initialization and to restore the parent scope. + [EditorBrowsable(EditorBrowsableState.Advanced)] + public Scope BeginScopeInitialization() + { + var newScope = new Scope(this); + + PushScope(newScope); + + return newScope; + } + + /// + /// Begins a new scope with new ambient value. + /// + /// The caller MUST call on the end of scope lifetime or there may be a memory leak. + /// + /// + /// If you want to pass a new scope back to the caller of your code, you SHOULD NOT use async methods. + /// Scopes are stored as a part of . + /// Async calls create a copy of execution context and do not pass changes back to the parent context. + /// + /// Consider to use or if you need to initialize the + /// scope asynchronously. + /// + /// The new scope ambient value. Will be replaced with previous one on scope disposal. + /// to restore the parent scope. + public IDisposable BeginScope(T? value) + { + var newScope = new Scope(this, value); + + PushScope(newScope); + + return newScope; + } + + /// + /// Begins a new scope with new ambient value. + /// + /// The caller MUST call on the end of scope lifetime or there may be a memory leak. + /// + /// + /// If you want to pass a new scope back to the caller of your code, you SHOULD NOT use async methods. + /// Scopes are stored as a part of . + /// Async calls create a copy of execution context and do not pass changes back to the parent context. + /// + /// + /// A proper implementation that will pass the new scope back to the caller of the method: + /// + /// ValueTask<IDisposable> BeginResourceScopeAsync() + /// { + /// // sync part + /// return _scopedAsyncLocal.BeginScopeAsync(()=> CreateResourceAsync()); + /// } + /// + /// async ValueTask<Resource> CreateResourceAsync() { ... } + /// + /// + /// + /// + /// Async factory for the new scope ambient value. Value will be replaced with previous one on scope disposal. + /// + /// to restore the parent scope. + public Task BeginScopeAsync(Func> valueFactory) + { + ArgumentNullException.ThrowIfNull(valueFactory); + + var newScope = BeginScopeInitialization(); + return CompleteScopeAsync(newScope, valueFactory); + } + + private static async Task CompleteScopeAsync( + Scope newScope, + Func> valueFactory) + { + try + { + var value = await valueFactory(); + newScope.Initialize(value); + + return newScope; + } + catch (Exception) + { + newScope.Dispose(); + throw; + } + } + + private void AssertIsCurrentScope(Scope expected) + { + if (_validateDisposeOrder && expected is { IsInitialized: true } && !ReferenceEquals(expected, CurrentScope)) + { + throw new InvalidOperationException( + "Scope's Current value mismatch. Please do dispose scopes in reverse order of scope creation."); + } + } + + private void PushScope(Scope newScope) + { + var stack = _current.Value; + _current.Value = stack == null + ? ImmutableStack.Create(newScope) + : PopDisposedScopes(stack).Push(newScope); + } + + private void PopDisposedScopes() + { + if (_current.Value is { } stack) + { + stack = PopDisposedScopes(stack); + _current.Value = stack.IsEmpty ? null! : stack; + } + } + + private static ImmutableStack PopDisposedScopes(ImmutableStack current) + { + var newStack = current; + + while (!newStack.IsEmpty && newStack.Peek().IsDisposed) + { + newStack = newStack.Pop(); + } + + return newStack; + } + + /// + /// Ambient scope instance with partial initialization support. + /// You SHOULD call on the end of scope lifetime or there may be memory leaks. + /// It is especially important if you are using scopes inside a loop or recursive calls + /// as you may end with a very long stack of non-disposed scopes. + /// + /// If you store scope in wrapper, it is recommended to dispose the scope synchronously. + /// This minor optimization will remove reference to the disposed scope from calling execution context. + /// + /// + /// A proper implementation that will clear caller's execution context reference to the disposed scope: + /// + /// ValueTask DisposeAsync() + /// { + /// _scope.Dispose(); + /// return CompleteDisposeAsync(); + /// } + /// + /// async ValueTask CompleteDisposeAsync() { ... } + /// + /// + /// + public sealed class Scope : IDisposable + { + private readonly ScopedAsyncLocal _owner; + private T? _value; + + internal Scope(ScopedAsyncLocal owner) + { + _owner = owner; + } + + internal Scope(ScopedAsyncLocal owner, T? value) + : this(owner) + { + _value = value; + IsInitialized = true; + } + + /// + /// True if the scope was initialized. + /// + public bool IsInitialized { get; private set; } + + internal bool IsDisposed { get; private set; } + + /// + /// Value for the initialized scope. + /// + /// Scope was not initialized. + /// Scope was disposed. + public T? Value + { + get + { + ObjectDisposedException.ThrowIf(IsDisposed, GetType()); + + return IsInitialized + ? _value + : throw new InvalidOperationException("The scope value is not initialized."); + } + } + + /// + /// Initializes the scope with specified value. + /// + /// Scope was already initialized. + /// Scope was disposed. + public void Initialize(T? value) + { + ObjectDisposedException.ThrowIf(IsDisposed, GetType()); + + if (IsInitialized) + { + throw new InvalidOperationException("The scope value is already initialized."); + } + + _value = value; + IsInitialized = true; + } + + /// + /// Closes current scope and restores previous one. + /// You SHOULD call on the end of scope lifetime or there may be memory leaks. + /// It is especially important if you are using scopes inside a loop or recursive calls + /// as you may end with a very long stack of non-disposed scopes. + /// + /// If you store scope in wrapper, it is recommended to dispose the scope synchronously. + /// This minor optimization will remove reference to the disposed scope from calling execution context. + /// + /// + /// A proper implementation that will clear caller's execution context reference to the disposed scope: + /// + /// ValueTask DisposeAsync() + /// { + /// _scope.Dispose(); + /// return CompleteDisposeAsync(); + /// } + /// + /// async ValueTask CompleteDisposeAsync() { ... } + /// + /// + /// + public void Dispose() + { + if (!IsDisposed) + { + _owner.AssertIsCurrentScope(this); + _value = null; + IsInitialized = false; + IsDisposed = true; + _owner.PopDisposedScopes(); + } + } + } +} \ No newline at end of file diff --git a/src/CodeMe.Basics/packages.lock.json b/src/CodeMe.Basics/packages.lock.json new file mode 100644 index 0000000..6afd678 --- /dev/null +++ b/src/CodeMe.Basics/packages.lock.json @@ -0,0 +1,6 @@ +{ + "version": 2, + "dependencies": { + "net10.0": {} + } +} \ No newline at end of file diff --git a/src/CodeMe.ServiceErrors.Abstractions/CodeMe.ServiceErrors.Abstractions.csproj b/src/CodeMe.ServiceErrors.Abstractions/CodeMe.ServiceErrors.Abstractions.csproj index 9199871..b97e967 100644 --- a/src/CodeMe.ServiceErrors.Abstractions/CodeMe.ServiceErrors.Abstractions.csproj +++ b/src/CodeMe.ServiceErrors.Abstractions/CodeMe.ServiceErrors.Abstractions.csproj @@ -4,7 +4,6 @@ netstandard2.1 CodeMe.ServiceErrors latest - Library for typed service errors (Problem Details-alike DTOs) errors;typed errors diff --git a/src/CodeMe.ServiceErrors.Abstractions/README.md b/src/CodeMe.ServiceErrors.Abstractions/README.md new file mode 100644 index 0000000..eb93c09 --- /dev/null +++ b/src/CodeMe.ServiceErrors.Abstractions/README.md @@ -0,0 +1,28 @@ +# CodeMe.ServiceErrors + +CodeMe.ServiceErrors.Abstractions is a library for describing service-level errors as first-class values and turning them into serializable payloads or exceptions. It is designed for APIs, background services, and distributed systems where you want a stable error contract across app boundaries. + +## Minimal example + +Handling the errors: +```csharp +using CodeMe.ServiceErrors; + +// Error URI: problem://orders-api/orders/order-not-found, status 404 +var descriptor = ErrorDescriptor.NotFound( + group: ErrorGroupUri.Create("problem", "orders-api", "orders"), + code: "order-not-found"); + +var error = new ServiceError(descriptor, "Order 66 was not found"); + +// ... + +if (error.Matches(descriptor)) +{ + // handle the error +} +``` + +# Documentation + +Check [documentation](https://github.com/ig-sinicyn/CodeMe/docs/ServiceErrors/README.md) for more details and examples. \ No newline at end of file diff --git a/src/CodeMe.ServiceErrors/CodeMe.ServiceErrors.csproj b/src/CodeMe.ServiceErrors/CodeMe.ServiceErrors.csproj index ba53439..8cd33c4 100644 --- a/src/CodeMe.ServiceErrors/CodeMe.ServiceErrors.csproj +++ b/src/CodeMe.ServiceErrors/CodeMe.ServiceErrors.csproj @@ -2,12 +2,8 @@ net10.0 - Library for typed service errors (Problem Details-alike DTOs) errors;typed errors - - - README.md @@ -18,8 +14,4 @@ - - - - \ No newline at end of file diff --git a/src/CodeMe.ServiceErrors/README.md b/src/CodeMe.ServiceErrors/README.md index 3ad8964..c87228b 100644 --- a/src/CodeMe.ServiceErrors/README.md +++ b/src/CodeMe.ServiceErrors/README.md @@ -2,28 +2,28 @@ CodeMe.ServiceErrors is a library for describing service-level errors as first-class values and turning them into serializable payloads or exceptions. It is designed for APIs, background services, and distributed systems where you want a stable error contract across app boundaries. -### Minimal example +## Introduction -Handling the errors: -```csharp -using CodeMe.ServiceErrors; +The core model is built around a few simple concepts: -// Error URI: problem://orders-api/orders/order-not-found, status 404 -var descriptor = ErrorDescriptor.NotFound( - group: ErrorGroupUri.Create("problem", "orders-api", "orders"), - code: "order-not-found"); +* `ServiceError` carries well-known error descriptor together with a human-friendly message and optional inner details. +* `ErrorDescriptor` describes an error with a problem type (error URI), HTTP-like status, transience, and severity. +* `ErrorUri` and `ErrorGroupUri` represent problem type URI inspired by [RFC 9457: Problem Details for HTTP APIs](https://datatracker.ietf.org/doc/html/rfc9457). +* `ServiceErrorDto` represents serializable service error format. +* `ServiceException` and `IServiceException` allow to pass service errors as exceptions. +* `IServiceErrorFactory` converts between `ServiceError`, `ServiceErrorDto`, and `IServiceException`. -var error = new ServiceError(descriptor, "Order 66 was not found"); +Typical usage scenarios include: -// ... +* Enforcing usage of well-known domain errors. +* Exposing a predictable error contract from HTTP APIs or gRPC services. +* Registering error definitions in DI so services can create and rehydrate errors consistently. +* Passing errors across process boundaries using a serializable error payload. +* Use allocation-free typed errors instead of error codes or exceptions in performance-sensitive code. -if (error.Matches(descriptor)) -{ - // handle the error -} -``` +### Minimal example -Well-known errors, conversions and DI registration: +Well-known errors, testing for errors, conversions and DI registration: ```csharp using CodeMe.ServiceErrors; using CodeMe.ServiceErrors.DependencyInjection; @@ -31,14 +31,23 @@ using CodeMe.ServiceErrors.Serializable; using Microsoft.Extensions.DependencyInjection; using static WellKnownOrderApiErrors; +// DI registration var services = new ServiceCollection(); services .AddServiceErrors(RootGroup) .Add(typeof(WellKnownOrderApiErrors)); - using var provider = services.BuildServiceProvider(); var errorFactory = provider.GetRequiredService(); +// Error return and handling +var error = new ServiceError(OrderNotFound, "Order 42 was not found"); +// ... +if (error.Matches(OrderNotFound)) +{ + // handle the error +} + +// Error serialization and exception factory ServiceError error = new ServiceError(OrderNotFound, "Order 42 was not found"); ServiceErrorDto dto = errorFactory.CreateDto(error); ServiceError errorFromDto = errorFactory.CreateError(dto); @@ -56,6 +65,7 @@ ServiceError errorFromDto = errorFactory.CreateError(dto); IServiceException exception = errorFactory.CreateException(errorFromDto); ServiceError errorFromException = exception.Error; +// Well-known errors declaration [ServiceErrors] internal static class WellKnownOrderApiErrors { @@ -68,6 +78,7 @@ internal static class WellKnownOrderApiErrors ErrorDescriptor.NotFound(OrdersGroup, "order-not-found"); } +// Typed exceptions internal sealed class OrderNotFoundException : ServiceException { public OrderNotFoundException(ServiceError error) @@ -80,4 +91,8 @@ internal sealed class OrderNotFoundException : ServiceException { } } -``` \ No newline at end of file +``` + +# Documentation + +Check [documentation](https://github.com/ig-sinicyn/CodeMe/docs/ServiceErrors/README.md) for more details and examples. \ No newline at end of file diff --git a/tests/CodeMe.Basics.UnitTests/CodeMe.Basics.UnitTests.csproj b/tests/CodeMe.Basics.UnitTests/CodeMe.Basics.UnitTests.csproj new file mode 100644 index 0000000..4077e5c --- /dev/null +++ b/tests/CodeMe.Basics.UnitTests/CodeMe.Basics.UnitTests.csproj @@ -0,0 +1,7 @@ + + + + net10.0 + + + \ No newline at end of file diff --git a/tests/CodeMe.Basics.UnitTests/Threading/CustomScopeTests.cs b/tests/CodeMe.Basics.UnitTests/Threading/CustomScopeTests.cs new file mode 100644 index 0000000..bcd4ea9 --- /dev/null +++ b/tests/CodeMe.Basics.UnitTests/Threading/CustomScopeTests.cs @@ -0,0 +1,84 @@ +using CodeMe.Basics.Threading; + +namespace CodeMe.Basics.UnitTests.Threading; + +public class CustomAsyncLocalScopeTests +{ + [Fact] + public void EmptyScope_ShouldBeNull() + { + // Arrange + var context = new ResourceManager(); + + // Assert + context.Current.Should().BeNull(); + } + + [Fact] + public async Task Scope_BeginDispose_ShouldBeExpected() + { + // Arrange + var context = new ResourceManager(); + + // Act + string? inScope; + var before = context.Current?.Value; + await using (await context.BeginScopeAsync("Hello!")) + { + inScope = context.Current?.Value; + } + + var after = context.Current?.Value; + + // Assert + before.Should().BeNull(); + inScope.Should().Be("Hello!"); + after.Should().BeNull(); + } + + private sealed class ResourceScope : IAsyncDisposable + { + internal IDisposable? Scope { get; set; } + + public string Value { get; set; } = null!; + + public ValueTask DisposeAsync() + { + Scope?.Dispose(); + return DisposeCoreAsync(); + } + + private async ValueTask DisposeCoreAsync() + { + await Task.Delay(1); + Value = null!; + } + } + + private sealed class ResourceManager + { + private readonly ScopedAsyncLocal _context = new(validateDisposeOrder: true); + + public ResourceScope? Current => _context.Current; + + public ValueTask BeginScopeAsync(string value) + { + var scope = _context.BeginScopeInitialization(); + return BeginScopeAsyncCore(scope, value); + } + + private static async ValueTask BeginScopeAsyncCore( + ScopedAsyncLocal.Scope scope, + string value) + { + await Task.Delay(1); + var result = new ResourceScope + { + Scope = scope, + Value = value + }; + scope.Initialize(result); + return result; + } + } +} \ No newline at end of file diff --git a/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalInternalTests.cs b/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalInternalTests.cs new file mode 100644 index 0000000..e37af02 --- /dev/null +++ b/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalInternalTests.cs @@ -0,0 +1,386 @@ +using CodeMe.Basics.Threading; + +namespace CodeMe.Basics.UnitTests.Threading; + +using TestSnapshot = ScopedAsyncLocal.TestSnapshot; + +public class ScopedAsyncLocalInternalTests +{ + [Fact] + public void EmptyScope_ShouldBeNull() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + var current = internals.GetSnapshot(); + + // Assert + local.Current.Should().BeNull(); + current.Should().BeNull(); + } + + [Fact] + public void Scope_BeginInitialize_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + using var scope = local.BeginScopeInitialization(); + var current = internals.GetSingleSnapshot(); + + // Assert + local.Current.Should().BeNull(); + current.Should().Be(TestSnapshot.Uninitialized); + } + + [Fact] + public void Scope_Initialize_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + using var scope = local.BeginScopeInitialization(); + scope.Initialize("Hello!"); + var current = internals.GetSingleSnapshot(); + + // Assert + local.Current.Should().Be("Hello!"); + current.Should().Be(new TestSnapshot("Hello!")); + } + + [Fact] + public void Scope_BeginInitializeDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + var scope = local.BeginScopeInitialization(); + scope.Dispose(); + var current = internals.GetSnapshot(); + + // Assert + local.Current.Should().BeNull(); + current.Should().BeNull(); + } + + [Fact] + public void Scope_InitializeDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + var scope = local.BeginScopeInitialization(); + scope.Initialize("Hello!"); + scope.Dispose(); + var current = internals.GetSnapshot(); + + // Assert + local.Current.Should().BeNull(); + current.Should().BeNull(); + } + + [Fact] + public void Scope_BeginScope_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + using var scope = local.BeginScope("Hello!"); + var current = internals.GetSingleSnapshot(); + + // Assert + local.Current.Should().Be("Hello!"); + current.Should().Be(new TestSnapshot("Hello!")); + } + + [Fact] + public void Scope_BeginScopeDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + var scope = local.BeginScope("Hello!"); + scope.Dispose(); + var current = internals.GetSnapshot(); + + // Assert + local.Current.Should().BeNull(); + current.Should().BeNull(); + } + + [Fact] + public async Task Scope_BeginScopeAsync_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + + using var scope = await local.BeginScopeAsync( + async () => + { + await Task.Delay(1); + return "Hello!"; + }); + var current = internals.GetSingleSnapshot(); + + // Assert + local.Current.Should().Be("Hello!"); + current.Should().Be(new TestSnapshot("Hello!")); + } + + [Fact] + public async Task Scope_BeginScopeAsyncDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + var scope = await local.BeginScopeAsync( + async () => + { + await Task.Delay(1); + return "Hello!"; + }); + scope.Dispose(); + var current = internals.GetSnapshot(); + + // Assert + local.Current.Should().BeNull(); + current.Should().BeNull(); + } + + [Fact] + public void NestedScopes_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + using var scope1 = local.BeginScope("Hello!"); + using var scope2 = local.BeginScope("Hello from nested scope!"); + var current = internals.GetSnapshots(); + + // Assert + local.Current.Should().Be("Hello from nested scope!"); + current.Should().BeEquivalentTo( + [ + new TestSnapshot("Hello!"), + new TestSnapshot("Hello from nested scope!") + ]); + } + + [Fact] + public void NestedScopes_SingleDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + using var scope1 = local.BeginScope("Hello!"); + var scope2 = local.BeginScope("Hello from nested scope!"); + scope2.Dispose(); + var current = internals.GetSnapshots(); + + // Assert + local.Current.Should().Be("Hello!"); + current.Should().BeEquivalentTo( + [ + new TestSnapshot("Hello!") + ]); + } + + [Fact] + public void NestedScopes_OutOfOrderDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + var scope1 = local.BeginScope("Hello!"); + using var scope2 = local.BeginScope("Hello from nested scope!"); + scope1.Dispose(); + var current = internals.GetSnapshots(); + + // Assert + local.Current.Should().Be("Hello from nested scope!"); + current.Should().BeEquivalentTo( + [ + TestSnapshot.Disposed, + new TestSnapshot("Hello from nested scope!") + ]); + } + + [Fact] + public void NestedScopes_FullDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + + // Act + var scope1 = local.BeginScope("Hello!"); + var scope2 = local.BeginScope("Hello from nested scope!"); + scope1.Dispose(); + scope2.Dispose(); + var current = internals.GetSnapshots(); + + // Assert + local.Current.Should().BeNull(); + current.Should().BeEmpty(); + } + + [Fact] + public async Task BeginScopeAsync_FullAsyncPath_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Act + var beforeCall = internals.GetSnapshot(); + + var inInit = TestSnapshot.Disposed; + var task = local.BeginScopeAsync( + async () => + { + await completion.Task; + inInit = internals.GetSingleSnapshot(); + return "Hello!"; + }); + var afterCall = internals.GetSingleSnapshot(); + + completion.SetResult(); + var scope = await task; + var afterAwait = internals.GetSingleSnapshot(); + + scope.Dispose(); + var afterDispose = internals.GetSnapshot(); + + // Assert + local.Current.Should().BeNull(); + beforeCall.Should().BeNull(); + inInit.Should().Be(TestSnapshot.Uninitialized); + afterCall.Should().Be(TestSnapshot.Uninitialized); + afterAwait.Should().Be(new TestSnapshot("Hello!")); + afterDispose.Should().BeNull(); + } + + [Fact] + public async Task BeginScopeAsyncAndFail_FullAsyncPath_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Act + var beforeCall = internals.GetSnapshot(); + + var inInit = TestSnapshot.Disposed; + var task = local.BeginScopeAsync( + async () => + { + await completion.Task; + inInit = internals.GetSingleSnapshot(); + throw new InvalidOperationException(); + }); + var afterCall = internals.GetSingleSnapshot(); + + completion.SetResult(); + await Assert.ThrowsAsync(() => task); + var afterAwait = internals.GetSingleSnapshot(); + + // Assert + local.Current.Should().BeNull(); + beforeCall.Should().BeNull(); + inInit.Should().Be(TestSnapshot.Uninitialized); + afterCall.Should().Be(TestSnapshot.Uninitialized); + afterAwait.Should().Be(TestSnapshot.Disposed); + } + + [Fact] + public async Task DisposeScopeAsync_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Act + var scope = local.BeginScope("Hello!"); + var beforeDispose = internals.GetSingleSnapshot(); + + var inAsyncDispose = TestSnapshot.Uninitialized; + var inAsyncAfterDispose = (TestSnapshot?)TestSnapshot.Uninitialized; + var task = Task.Run( + async () => + { + await completion.Task; + inAsyncDispose = internals.GetSingleSnapshot(); + scope.Dispose(); + inAsyncAfterDispose = internals.GetSnapshot(); + }, + TestContext.Current.CancellationToken); + var afterCall = internals.GetSingleSnapshot(); + + completion.SetResult(); + await task; + var afterAwait = internals.GetSingleSnapshot(); + + // Assert + local.Current.Should().BeNull(); + beforeDispose.Should().Be(new TestSnapshot("Hello!")); + inAsyncDispose.Should().Be(new TestSnapshot("Hello!")); + inAsyncAfterDispose.Should().BeNull(); + afterCall.Should().Be(new TestSnapshot("Hello!")); + afterAwait.Should().Be(TestSnapshot.Disposed); + } + + [Fact] + public async Task DisposedScopes_ShouldBeRemoved() + { + // Arrange + var local = new ScopedAsyncLocal(); + var internals = local.GetTestAccessor(); + var disposedAsyncScope = local.BeginScope("Hello!"); + await Task.Run( + async () => + { + await Task.Delay(1); + disposedAsyncScope.Dispose(); + }, + TestContext.Current.CancellationToken); + + // Act + var beforeNewScope = internals.GetSingleSnapshot(); + var scope = local.BeginScope("Hello-2!"); + var afterNewScope = internals.GetSingleSnapshot(); + scope.Dispose(); + var afterDispose = internals.GetSnapshot(); + + // Assert + local.Current.Should().BeNull(); + beforeNewScope.Should().Be(TestSnapshot.Disposed); + afterNewScope.Should().Be(new TestSnapshot("Hello-2!")); + afterDispose.Should().BeNull(); + } +} \ No newline at end of file diff --git a/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalTests.cs b/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalTests.cs new file mode 100644 index 0000000..7ced292 --- /dev/null +++ b/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalTests.cs @@ -0,0 +1,298 @@ +using CodeMe.Basics.Threading; + +namespace CodeMe.Basics.UnitTests.Threading; + +public class ScopedAsyncLocalTests +{ + [Fact] + public void EmptyScope_ShouldBeNull() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Assert + local.Current.Should().BeNull(); + } + + [Fact] + public void Scope_BeginDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Act + string? inScope; + var before = local.Current; + using (local.BeginScope("Hello!")) + { + inScope = local.Current; + } + + var after = local.Current; + + // Assert + before.Should().BeNull(); + inScope.Should().Be("Hello!"); + after.Should().BeNull(); + } + + [Fact] + public void ScopeBeforeInitialization_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Act + using var scope = local.BeginScopeInitialization(); + + // Assert + local.Current.Should().BeNull(); + scope.IsInitialized.Should().BeFalse(); + Assert.Throws(() => scope.Value); + scope.IsDisposed.Should().BeFalse(); + } + + [Fact] + public void ScopeAfterInitialization_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Act + using var scope = local.BeginScopeInitialization(); + scope.Initialize("Hello!"); + + // Assert + local.Current.Should().Be("Hello!"); + scope.IsInitialized.Should().BeTrue(); + scope.Value.Should().Be("Hello!"); + scope.IsDisposed.Should().BeFalse(); + Assert.Throws(() => scope.Initialize("Hello!")); + } + + [Fact] + public void ScopeAfterInitializationAndDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Act + var scope = local.BeginScopeInitialization(); + scope.Initialize("Hello!"); + scope.Dispose(); + + // Assert + local.Current.Should().BeNull(); + scope.IsInitialized.Should().BeFalse(); + Assert.Throws(() => scope.Value); + scope.IsDisposed.Should().BeTrue(); + Assert.Throws(() => scope.Initialize("Hello!")); + } + + [Fact] + public void ScopeAfterDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Act + var scope = local.BeginScopeInitialization(); + scope.Dispose(); + + // Assert + local.Current.Should().BeNull(); + scope.IsInitialized.Should().BeFalse(); + Assert.Throws(() => scope.Value); + scope.IsDisposed.Should().BeTrue(); + } + + [Fact] + public void Scope_NestedBeginDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Act + var before1 = local.Current; + string? inScope1; + string? inScope2; + string? after2; + using (local.BeginScope("Hello!")) + { + inScope1 = local.Current; + + using (local.BeginScope("Hello from nested scope!")) + { + inScope2 = local.Current; + } + + after2 = local.Current; + } + + var after1 = local.Current; + + // Assert + before1.Should().BeNull(); + inScope1.Should().Be("Hello!"); + inScope2.Should().Be("Hello from nested scope!"); + after2.Should().Be("Hello!"); + after1.Should().BeNull(); + } + + [Fact] + public async Task Scope_NestedBeginAsyncDispose_ShouldBeExpected() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Act + var before1 = local.Current; + string? inScope1; + string? inScope2; + string? after2; + using (await BeginScopeAsync(local, "Hello!")) + { + inScope1 = local.Current; + + using (await BeginScopeAsync(local, "Hello from nested scope!")) + { + inScope2 = local.Current; + } + + after2 = local.Current; + } + + var after1 = local.Current; + + // Assert + before1.Should().BeNull(); + inScope1.Should().Be("Hello!"); + inScope2.Should().Be("Hello from nested scope!"); + after2.Should().Be("Hello!"); + after1.Should().BeNull(); + } + + [Fact] + public async Task Scope_DoesNotReturnFromAsyncCall() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Act + var before = local.Current; + string? inScope = null; + var scope = await Task.Run( + async () => + { + var result = local.BeginScope("Hello!"); + await Task.Delay(1); + inScope = local.Current; + return result; + }, + TestContext.Current.CancellationToken); + + var after = local.Current; + scope.Dispose(); + + // Assert + before.Should().BeNull(); + inScope.Should().Be("Hello!"); + after.Should().BeNull(); + } + + [Fact] + public async Task ScopeDispose_AppliedFromAsyncCall() + { + // Arrange + var local = new ScopedAsyncLocal(); + + // Act + + var scope = local.BeginScope("Hello!"); + var before = local.Current; + await Task.Run( + async () => + { + await Task.Delay(1); + scope.Dispose(); + }, + TestContext.Current.CancellationToken); + + var after = local.Current; + + // Assert + before.Should().Be("Hello!"); + after.Should().BeNull(); + } + + [Fact] + public void ScopeValidation_ShouldThrow() + { + // Arrange + var local = new ScopedAsyncLocal(validateDisposeOrder: true); + var scopes = new List(); + foreach (var value in new[] { "A", "B", "C" }) + { + scopes.Add(local.BeginScope(value)); + } + + // Act & Assert + Assert.Throws(() => scopes[0].Dispose()); + Assert.Throws(() => scopes[1].Dispose()); + local.Current.Should().Be("C"); + scopes[2].Dispose(); + local.Current.Should().Be("B"); + scopes[1].Dispose(); + local.Current.Should().Be("A"); + scopes[0].Dispose(); + local.Current.Should().BeNull(); + } + + [Fact] + public void ScopeWithoutValidation_ShouldNotThrow() + { + // Arrange + var local = new ScopedAsyncLocal(validateDisposeOrder: false); + var scopes = new List(); + foreach (var value in new[] { "A", "B", "C" }) + { + scopes.Add(local.BeginScope(value)); + } + + // Act & Assert + local.Current.Should().Be("C"); + scopes[1].Dispose(); + local.Current.Should().Be("C"); + scopes[2].Dispose(); + local.Current.Should().Be("A"); + scopes[0].Dispose(); + local.Current.Should().BeNull(); + } + + [Fact] + public async Task ScopeValidation_ShouldThrowOnBackpropagate() + { + // Arrange + var local = new ScopedAsyncLocal(validateDisposeOrder: true); + + // Act & Assert + var scope = await Task.Run( + async () => + { + var result = local.BeginScope("Hello!"); + await Task.Delay(1); + return result; + }, + TestContext.Current.CancellationToken); + Assert.Throws(() => scope.Dispose()); + } + + private static Task BeginScopeAsync(ScopedAsyncLocal local, string value) => + local.BeginScopeAsync( + async () => + { + await Task.Delay(1); + + return value; + }); +} \ No newline at end of file diff --git a/tests/CodeMe.Basics.UnitTests/packages.lock.json b/tests/CodeMe.Basics.UnitTests/packages.lock.json new file mode 100644 index 0000000..053f789 --- /dev/null +++ b/tests/CodeMe.Basics.UnitTests/packages.lock.json @@ -0,0 +1,139 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "AwesomeAssertions": { + "type": "Direct", + "requested": "[9.4.0, )", + "resolved": "9.4.0", + "contentHash": "dJxkWiQ8D+xT6Gr2sSL83+Mar+Vpy2JTcUPxFcckpPJ8VYBfSgnk+zqpS6t7kcGnjz8NLyF14qfuoL4bKzzoew==" + }, + "Microsoft.Testing.Extensions.TrxReport": { + "type": "Direct", + "requested": "[2.2.3, )", + "resolved": "2.2.3", + "contentHash": "9Hot3ty5ZVWHrW40k2NPfD0dCaPwIxj7j7VjujNYwpYkYw9AdbejPHjGNkL/gvUWorauJf5IkeDoUeIbS7LuUg==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.2.3", + "Microsoft.Testing.Platform": "2.2.3" + } + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "S0LJpeMIMrmbVLXDCvPVX47OLk28qBYfGU+5SNCbarOEdw8oKLfiVqaACwuYRvLiOqDEB/+VJ8gTSB1ZwheoOQ==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v2": "[3.2.2]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "H580BvHyuADoWzlH9zRk5fqVyGucm6mhph+k40CQc9O4ie+Buxa4Pk9Q92BEClqIICqi25J7fuMII9qFYYgKtw==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.2.3", + "contentHash": "hntvxJEkmUAx6C2xXc/PO38DqEQl4rimzOgSvTR1hAMruMid7R4RcXOrzzF33J66gKaN7jRaQ0TMW/nNfaV9jw==", + "dependencies": { + "Microsoft.Testing.Platform": "2.2.3" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.2.3", + "contentHash": "LhM1/Qoi8Ams5QcD4r3f09CSOono9iQr3NEJQItFtyzWB55nWTgEOsVqXqMWWWIwk3nkPqc+XfnlJmp8xUI5fg==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "2zKkQKaUoaKgb/3AekboWOdLMh4upCo1nLWQnjGzp8r9YjiNOZRrzTsJQ3A4U03AcbH0evlIvFDKYSUqmTVuug==", + "dependencies": { + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "zW82tdCm+T1uUD1JKE+SmhgMq8nCAvcFPRLIVEiRgaxBSjcyJEKopLU3bHGOa416q+N3Dz7m1zLoPR5VJ5OQ+Q==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.0.2", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.0.2", + "Microsoft.Testing.Platform": "2.0.2", + "Microsoft.Testing.Platform.MSBuild": "2.0.2", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "codeme.basics": { + "type": "Project" + } + } + } +} \ No newline at end of file