diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 0cf7b57..cd92afb 100755 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,10 +1,7 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0.100-jammy +FROM mcr.microsoft.com/dotnet/sdk:8.0-jammy # Install make -RUN apt update && apt install -y make +# RUN apt-get update && +# RUN apt-get update && apt-get install -y make -## https://github.com/dotnet/aspire/blob/main/docs/using-latest-daily.md -RUN dotnet workload update --skip-sign-check --source https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet8/nuget/v3/index.json \ - && dotnet workload install aspire --skip-sign-check --source https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet8/nuget/v3/index.json - -# RUN dotnet workload install aspire +RUN dotnet workload install aspire diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d3a250f..410124e 100755 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -17,6 +17,8 @@ "ghcr.io/devcontainers/features/azure-cli:1": {}, "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": {}, "ghcr.io/rio/features/k3d:1": {}, + "ghcr.io/azure/azure-dev/azd:0": { "version": "latest" }, + "ghcr.io/prom3theu5/aspirational-manifests/aspirate:latest": {}, "ghcr.io/devcontainers/features/common-utils:2": { "configureZshAsDefaultShell": true } @@ -37,6 +39,7 @@ "ms-dotnettools.csdevkit", "ms-azuretools.vscode-docker", "dunn.redis", + "ms-azuretools.vscode-bicep", "GitHub.copilot" ] } diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index c754d99..244021a 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -5,10 +5,21 @@ while (! kubectl cluster-info ); do # Docker takes a few seconds to initialize echo "Waiting for Docker to launch..." k3d cluster delete - k3d cluster create -p '8081:80@loadbalancer' --k3s-arg '--disable=traefik@server:0' + k3d registry create myregistry.localhost --port 12345 + k3d cluster create -p '8081:80@loadbalancer' --k3s-arg '--disable=traefik@server:0' --registry-use k3d-myregistry.localhost:12345 sleep 1 done +# docker pull docker.io/library/postgres:16.2 +# docker pull docker.io/library/rabbitmq:3.13-management +# docker pull docker.io/library/redis:7.2 + +# docker tag docker.io/library/postgres:16.2 k3d-myregistry.localhost:12345/postgres:16.2 +# docker tag docker.io/library/rabbitmq:3.13-management k3d-myregistry.localhost:12345/rabbitmq:3.13-management +# docker tag docker.io/library/redis:7.2 k3d-myregistry.localhost:12345/redis:7.2 + +# docker tag product-api k3d-myregistry.localhost:12345/product-api:latest + ## dotnet dotnet restore diff --git a/.editorconfig b/.editorconfig index 4a9952c..276e6b0 100755 --- a/.editorconfig +++ b/.editorconfig @@ -2,7 +2,7 @@ root = true # All files [*] -indent_style = space +indent_style = tab # Xml files [*.xml] @@ -170,6 +170,23 @@ csharp_space_between_square_brackets = false # Wrapping preferences csharp_preserve_single_line_blocks = true csharp_preserve_single_line_statements = true +csharp_style_namespace_declarations = block_scoped:silent +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion +csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent +csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent +csharp_style_prefer_extended_property_pattern = true:suggestion #### Naming styles #### [*.{cs,vb}] @@ -361,4 +378,11 @@ dotnet_naming_style.s_camelcase.required_prefix = s_ dotnet_naming_style.s_camelcase.required_suffix = dotnet_naming_style.s_camelcase.word_separator = dotnet_naming_style.s_camelcase.capitalization = camel_case +tab_width = 4 +indent_size = 4 +end_of_line = crlf +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_allow_multiple_blank_lines_experimental = true:silent +dotnet_style_allow_statement_immediately_after_block_experimental = true:silent diff --git a/.github/workflows/dotnet-code-coverage.yaml b/.github/workflows/dotnet-code-coverage.yaml new file mode 100644 index 0000000..377c8ea --- /dev/null +++ b/.github/workflows/dotnet-code-coverage.yaml @@ -0,0 +1,46 @@ +name: .NET Coverage + +on: + push: + branches: + - main + - feat/* + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.0.x + + - name: Install .NET Aspire workload + run: dotnet workload install aspire + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore + + - name: Test + run: dotnet test --no-build --settings tests.runsettings --results-directory ./coverage + + - name: Publish coverage + uses: irongut/CodeCoverageSummary@v1.3.0 + with: + filename: coverage/**/coverage.cobertura.xml + badge: true + format: markdown + + - name: Add Coverage PR Comment + uses: marocchino/sticky-pull-request-comment@v2 + if: github.event_name == 'pull_request' + with: + recreate: true + path: code-coverage-results.md diff --git a/.gitignore b/.gitignore index 5069b04..59c2d03 100755 --- a/.gitignore +++ b/.gitignore @@ -350,4 +350,5 @@ MigrationBackup/ .ionide/ .idea/ tmp/ -.env \ No newline at end of file +.env +coverage/ \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index fce9cd1..cf78083 100755 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,45 +1,77 @@ - - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + true + 8.0.5 + 8.4.0 + 8.0.4 + 8.0.1 + 0.0.4 + 8.0.1 + 8.1.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/NuGet.config b/NuGet.config index 54af6e8..0e585fb 100755 --- a/NuGet.config +++ b/NuGet.config @@ -2,7 +2,6 @@ - \ No newline at end of file diff --git a/README.md b/README.md index 03bc0d8..0e7f420 100755 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ The coffeeshop apps on .NET Aspire +![Counter API-Code Coverage](https://img.shields.io/badge/Code%20Coverage-73%25-yellow?style=flat) + ## Prerequisites If you run on `Windows 11`: @@ -19,6 +21,21 @@ If you run on `Windows 11`: # http://localhost:5019 ``` +## Generate manifest file (powershell) + +```sh +dotnet run --project app-host\CoffeeShop.AppHost.csproj ` + -- ` + --publisher manifest ` + --output-path ../aspire-manifest.json +``` + +## Deploy to Kubernetes + +```sh +dotnet tool install -g aspirate --prerelease +``` + ## Run with Justfile (cross-platform) ```sh @@ -34,3 +51,8 @@ On Windows 11 - WSL2 Ubuntu 22 integrated, we can use `Podman Desktop` to replac > make run # http://localhost:5019 ``` + +```sh +dotnet publish "/workspaces/coffeeshop-aspire/app-host/../product-api/CoffeeShop.ProductApi.csproj" -p:PublishProfile="DefaultContainer" -p:PublishSingleFile="true" +-p:PublishTrimmed="false" --self-contained "true" --verbosity "quiet" --nologo -r "linux-x64" -p:ContainerRegistry="k3d-myregistry.localhost:12345" -p:ContainerRepository="product-api" -p:ContainerImageTag="latest" +``` diff --git a/app-host/CoffeeShop.AppHost.csproj b/app-host/CoffeeShop.AppHost.csproj new file mode 100644 index 0000000..1a988c0 --- /dev/null +++ b/app-host/CoffeeShop.AppHost.csproj @@ -0,0 +1,37 @@ + + + + Exe + net8.0 + enable + enable + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app-host/HealthCheckExtensions.cs b/app-host/HealthCheckExtensions.cs new file mode 100644 index 0000000..fd0bc88 --- /dev/null +++ b/app-host/HealthCheckExtensions.cs @@ -0,0 +1,65 @@ +using Aspirant.Hosting; + +using HealthChecks.NpgSql; +using HealthChecks.RabbitMQ; +using HealthChecks.Redis; +using HealthChecks.Uris; + +namespace CoffeeShop.AppHost; + +/// +/// Ref: https://github.com/davidfowl/WaitForDependenciesAspire/tree/main/WaitForDependencies.Aspire.Hosting +/// +public static class Extensions +{ + public static IResourceBuilder WithHealthCheck(this IResourceBuilder builder) + { + return builder.WithAnnotation(HealthCheckAnnotation.Create(cs => new RabbitMQHealthCheck(new RabbitMQHealthCheckOptions { ConnectionUri = new(cs) }))); + } + + public static IResourceBuilder WithHealthCheck(this IResourceBuilder builder) + { + return builder.WithAnnotation(HealthCheckAnnotation.Create(cs => new RedisHealthCheck(cs))); + } + + public static IResourceBuilder WithHealthCheck(this IResourceBuilder builder) + { + return builder.WithAnnotation(HealthCheckAnnotation.Create(cs => new NpgSqlHealthCheck(new NpgSqlHealthCheckOptions(cs)))); + } + + public static IResourceBuilder WithHealthCheck( + this IResourceBuilder builder, + string? endpointName = null, + string path = "health", + Action? configure = null) + where T : IResourceWithEndpoints + { + return builder.WithAnnotation(new HealthCheckAnnotation(async (resource, ct) => + { + if (resource is not IResourceWithEndpoints resourceWithEndpoints) + { + return null; + } + + var endpoint = endpointName is null + ? resourceWithEndpoints.GetEndpoints().FirstOrDefault(e => e.Scheme is "http" or "https") + : resourceWithEndpoints.GetEndpoint(endpointName); + + var url = endpoint?.Url; + + if (url is null) + { + return null; + } + + var options = new UriHealthCheckOptions(); + + options.AddUri(new(new(url), path)); + + configure?.Invoke(options); + + var client = new HttpClient(); + return new UriHealthCheck(options, () => client); + })); + } +} \ No newline at end of file diff --git a/app-host/Program.cs b/app-host/Program.cs index f67b5e3..6be3f8d 100755 --- a/app-host/Program.cs +++ b/app-host/Program.cs @@ -1,20 +1,47 @@ +using Aspirant.Hosting; + +using CoffeeShop.AppHost; + var builder = DistributedApplication.CreateBuilder(args); -var rabbitmq = builder.AddRabbitMQContainer("rabbitmq"); +var postgresQL = builder.AddPostgres("postgresQL").WithHealthCheck().WithPgAdmin(); +var postgres = postgresQL.AddDatabase("postgres"); + +var redis = builder.AddRedis("redis").WithHealthCheck(); +var rabbitmq = builder.AddRabbitMQ("rabbitmq").WithHealthCheck().WithManagementPlugin(); + +var productApi = builder.AddProject("product-api") + .WithSwaggerUI(); + +var counterApi = builder.AddProject("counter-api") + .WithReference(productApi) + .WithReference(rabbitmq) + .WaitFor(rabbitmq) + .WithSwaggerUI(); + +builder.AddProject("barista-api") + .WithReference(rabbitmq) + .WaitFor(rabbitmq); -var productApi = builder.AddProject("productapi") - .WithReplicas(2); +builder.AddProject("kitchen-api") + .WithReference(rabbitmq) + .WaitFor(rabbitmq); -builder.AddProject("counterapi") - .WithReference(productApi) - .WithReference(rabbitmq); +var orderSummaryApi = builder.AddProject("order-summary") + .WithReference(postgres) + .WithReference(rabbitmq) + .WaitFor(postgres) + .WaitFor(rabbitmq) + .WithSwaggerUI(); -builder.AddProject("baristaapi") - .WithReference(rabbitmq) - .WithReplicas(2); +var isHttps = builder.Configuration["DOTNET_LAUNCH_PROFILE"] == "https"; +var ingressPort = int.TryParse(builder.Configuration["Ingress:Port"], out var port) ? port : (int?)null; -builder.AddProject("kitchenapi") - .WithReference(rabbitmq) - .WithReplicas(2); +builder.AddYarp("ingress") + .WithEndpoint(scheme: isHttps ? "https" : "http", port: ingressPort) + .WithReference(productApi) + .WithReference(counterApi) + .WithReference(orderSummaryApi) + .LoadFromConfiguration("ReverseProxy"); builder.Build().Run(); diff --git a/app-host/Properties/launchSettings.json b/app-host/Properties/launchSettings.json index 420fa28..e0f80eb 100755 --- a/app-host/Properties/launchSettings.json +++ b/app-host/Properties/launchSettings.json @@ -1,41 +1,30 @@ { - "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:5284", - "sslPort": 44331 - } - }, - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "http://localhost:5019", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "https://localhost:7256;http://localhost:5019", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17092;http://localhost:15158", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21011", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22250" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15158", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19041", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20248", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + } + } +} \ No newline at end of file diff --git a/app-host/SwaggerUi/OpenApiRouteBuilderExtensions.cs b/app-host/SwaggerUi/OpenApiRouteBuilderExtensions.cs new file mode 100644 index 0000000..5cb89b8 --- /dev/null +++ b/app-host/SwaggerUi/OpenApiRouteBuilderExtensions.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +internal static class OpenApiRouteBuilderExtensions +{ + /// + /// Helper method to render Swagger UI view for testing. + /// + public static IEndpointConventionBuilder MapSwaggerUI(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/swagger/{resourceName}/{documentName}", (string resourceName, string documentName) => Results.Content($$""" + + + + OpenAPI -{{resourceName}}- {{documentName}} + + + +
+ + + + + + + + """, "text/html")).ExcludeFromDescription(); + } +} diff --git a/app-host/SwaggerUi/README.md b/app-host/SwaggerUi/README.md new file mode 100644 index 0000000..bb2eb80 --- /dev/null +++ b/app-host/SwaggerUi/README.md @@ -0,0 +1 @@ +Ref: https://github.com/davidfowl/AspireSwaggerUI \ No newline at end of file diff --git a/app-host/SwaggerUi/SwaggerUi.Aspire.Hosting.csproj b/app-host/SwaggerUi/SwaggerUi.Aspire.Hosting.csproj new file mode 100644 index 0000000..387eaf8 --- /dev/null +++ b/app-host/SwaggerUi/SwaggerUi.Aspire.Hosting.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/app-host/SwaggerUi/SwaggerUiAnnotation.cs b/app-host/SwaggerUi/SwaggerUiAnnotation.cs new file mode 100644 index 0000000..8fa590f --- /dev/null +++ b/app-host/SwaggerUi/SwaggerUiAnnotation.cs @@ -0,0 +1,8 @@ +using Aspire.Hosting.ApplicationModel; + +public class SwaggerUIAnnotation(string[] documentNames, string path, EndpointReference endpointReference) : IResourceAnnotation +{ + public string[] DocumentNames { get; } = documentNames; + public string Path { get; } = path; + public EndpointReference EndpointReference { get; } = endpointReference; +} diff --git a/app-host/SwaggerUi/SwaggerUiExtensions.cs b/app-host/SwaggerUi/SwaggerUiExtensions.cs new file mode 100644 index 0000000..f3d1b47 --- /dev/null +++ b/app-host/SwaggerUi/SwaggerUiExtensions.cs @@ -0,0 +1,181 @@ +using System.Collections.Immutable; +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Lifecycle; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Yarp.ReverseProxy.Forwarder; + +public static class SwaggerUIExtensions +{ + /// + /// Maps the swagger ui endpoint to the application. + /// + /// The resource builder. + /// The list of open api documents. Defaults to "v1" if null. + /// The path to the open api document. + /// The endpoint name + public static IResourceBuilder WithSwaggerUI(this IResourceBuilder builder, + string[]? documentNames = null, string path = "swagger/v1/swagger.json", string endpointName = "http") + { + if (!builder.ApplicationBuilder.Resources.OfType().Any()) + { + // Add the swagger ui code hook and resource + builder.ApplicationBuilder.Services.TryAddLifecycleHook(); + builder.ApplicationBuilder.AddResource(new SwaggerUIResource("swagger-ui")) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = "swagger-ui", + Properties = [], + State = "Starting" + }) + .ExcludeFromManifest(); + } + + return builder.WithAnnotation(new SwaggerUIAnnotation(documentNames ?? ["v1"], path, builder.GetEndpoint(endpointName))); + } + + class SwaggerUiHook(ResourceNotificationService notificationService, + ResourceLoggerService resourceLoggerService) : IDistributedApplicationLifecycleHook + { + public async Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default) + { + var openApiResource = appModel.Resources.OfType().SingleOrDefault(); + + if (openApiResource is null) + { + return; + } + + // We host a single webserver that will manage the swagger ui endpoints for all resources + var builder = WebApplication.CreateSlimBuilder(); + + builder.Services.AddHttpForwarder(); + builder.Logging.ClearProviders(); + + builder.Logging.AddProvider(new ResourceLoggerProvider(resourceLoggerService.GetLogger(openApiResource.Name))); + + var app = builder.Build(); + + // openapi/resourcename/documentname.json + app.MapSwaggerUI(); + + var resourceToEndpoint = new Dictionary(); + var portToResourceMap = new Dictionary)>(); + + foreach (var r in appModel.Resources) + { + if (!r.TryGetLastAnnotation(out var annotation)) + { + continue; + } + + // We store the url and path for each resource so we can hit the open api endpoint + resourceToEndpoint[r.Name] = (annotation.EndpointReference.Url, annotation.Path); + + var paths = new List(); + // To avoid cors issues, we expose URLs that send requests to the apphost and then forward them to the actual resource + foreach (var documentName in annotation.DocumentNames) + { + paths.Add($"swagger/{r.Name}/{documentName}"); + } + + // We store the URL for the resource on the host so we can map it back to the actual address once they are allocated + portToResourceMap[app.Urls.Count] = (annotation.EndpointReference.Url, paths); + + // We add a new URL for each resource that has a swagger ui annotation + // This is because swagger ui takes over the entire url space + app.Urls.Add("http://127.0.0.1:0"); + } + + var client = new HttpMessageInvoker(new SocketsHttpHandler()); + + // Swagger UI will make requests to the apphost so we can avoid doing any CORS configuration. + app.Map("/openapi/{resourceName}/{documentName}.json", + async (string resourceName, string documentName, IHttpForwarder forwarder, HttpContext context) => + { + var (endpoint, path) = resourceToEndpoint[resourceName]; + + await forwarder.SendAsync(context, endpoint, client, (c, r) => + { + r.RequestUri = new($"{endpoint}/{path}"); + return ValueTask.CompletedTask; + }); + }); + + app.Map("{*path}", async (HttpContext context, IHttpForwarder forwarder, string? path) => + { + var (endpoint, _) = portToResourceMap[context.Connection.LocalPort]; + + await forwarder.SendAsync(context, endpoint, client, (c, r) => + { + r.RequestUri = path is null ? new(endpoint) : new($"{endpoint}/{path}"); + return ValueTask.CompletedTask; + }); + }); + + await app.StartAsync(cancellationToken); + + var addresses = app.Services.GetRequiredService().Features.GetRequiredFeature().Addresses; + + var urls = ImmutableArray.CreateBuilder(); + + // Map our index back to the actual address + var index = 0; + foreach (var rawAddress in addresses) + { + var address = BindingAddress.Parse(rawAddress); + + // We map the bound port to the resource URL. This lets us forward requests to the correct resource + var (_, paths) = portToResourceMap[address.Port] = portToResourceMap[index++]; + + // We add the swagger ui endpoint for each resource + foreach (var p in paths) + { + urls.Add(new UrlSnapshot(rawAddress, $"{rawAddress}/{p}", IsInternal: false)); + } + } + + await notificationService.PublishUpdateAsync(openApiResource, s => s with + { + State = "Running", + Urls = urls.ToImmutableArray() + }); + } + } + + private class ResourceLoggerProvider(ILogger logger) : ILoggerProvider + { + public ILogger CreateLogger(string categoryName) + { + return new ResourceLogger(logger); + } + + public void Dispose() + { + } + + private class ResourceLogger(ILogger logger) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull + { + return logger.BeginScope(state); + } + + public bool IsEnabled(LogLevel logLevel) + { + return logger.IsEnabled(logLevel); + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + logger.Log(logLevel, eventId, state, exception, formatter); + } + } + } +} \ No newline at end of file diff --git a/app-host/SwaggerUi/SwaggerUiResource.cs b/app-host/SwaggerUi/SwaggerUiResource.cs new file mode 100644 index 0000000..51c02f4 --- /dev/null +++ b/app-host/SwaggerUi/SwaggerUiResource.cs @@ -0,0 +1,6 @@ +using Aspire.Hosting.ApplicationModel; + +public class SwaggerUIResource(string name) : Resource(name) +{ + +} diff --git a/app-host/app-host.csproj b/app-host/app-host.csproj deleted file mode 100755 index 3eeffa6..0000000 --- a/app-host/app-host.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net8.0 - enable - enable - true - - - - - - - - - - - - - - diff --git a/app-host/app1.http b/app-host/app1.http new file mode 100644 index 0000000..08abbf0 --- /dev/null +++ b/app-host/app1.http @@ -0,0 +1,44 @@ +# For more info on HTTP files go to https://aka.ms/vs/httpfile +@hostname=localhost:5000 + +POST https://{{hostname}}/c/api/v1/orders +Content-Type: application/json + +{ + "orderId": "{{$guid}}", + "commandType": 0, + "orderSource": 0, + "location": 0, + "loyaltyMemberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "baristaItems": [ + { + "itemType": {{$randomInt 0 5}} + }, + { + "itemType": {{$randomInt 0 5}} + } + ], + "kitchenItems": [ + { + "itemType": {{$randomInt 6 9}} + } + ], + "timestamp": "{{$datetime iso8601}}" +} + +### +GET https://{{hostname}}/c/api/v1/fulfillment-orders +content-type: application/json + +### +GET https://{{hostname}}/p/api/v1/item-types +content-type: application/json + +### +GET https://{{hostname}}/p/api/v1/items-by-types/1,2,3 +content-type: application/json + +### +@orderId = 8cf20000-8d12-00ff-acd0-08dc7cc27ccd +GET https://{{hostname}}/audit/api/v1/summary?orderId={{orderId}} +content-type: application/json \ No newline at end of file diff --git a/app-host/appsettings.Development.json b/app-host/appsettings.Development.json index ff66ba6..76ad0df 100755 --- a/app-host/appsettings.Development.json +++ b/app-host/appsettings.Development.json @@ -1,8 +1,11 @@ { "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Aspire.Hosting": "Information", + "Aspire.Hosting.Dcp": "Information" + } } } diff --git a/app-host/appsettings.json b/app-host/appsettings.json index b844787..e088c3f 100755 --- a/app-host/appsettings.json +++ b/app-host/appsettings.json @@ -1,10 +1,72 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Aspire.Hosting.Dcp": "Warning", - "Microsoft.AspNetCore.DataProtection": "Information" - } - } + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Ingress": { + "Port": 5000 + }, + "ReverseProxy": { + "Routes": { + "productapi": { + "ClusterId": "productapi", + "Match": { + "Path": "/p/{**remainder}" + }, + "Transforms": [ + { "PathRemovePrefix": "/p" }, + { "PathPrefix": "/" }, + { "RequestHeaderOriginalHost": "true" } + ] + }, + "counterApi": { + "ClusterId": "counterApi", + "Match": { + "Path": "/c/{**remainder}" + }, + "Transforms": [ + { "PathRemovePrefix": "/c" }, + { "PathPrefix": "/" }, + { "RequestHeaderOriginalHost": "true" } + ] + }, + "orderSummaryApi": { + "ClusterId": "orderSummaryApi", + "Match": { + "Path": "/audit/{**remainder}" + }, + "Transforms": [ + { "PathRemovePrefix": "/audit" }, + { "PathPrefix": "/" }, + { "RequestHeaderOriginalHost": "true" } + ] + } + }, + "Clusters": { + "productapi": { + "Destinations": { + "base_destination": { + "Address": "http://product-api" + } + } + }, + "counterApi": { + "Destinations": { + "base_destination": { + "Address": "http://counter-api" + } + } + }, + "orderSummaryApi": { + "Destinations": { + "base_destination": { + "Address": "http://order-summary" + } + } + } + } + } } diff --git a/app-host/aspirate-output/docker-compose.yaml b/app-host/aspirate-output/docker-compose.yaml new file mode 100644 index 0000000..d017657 --- /dev/null +++ b/app-host/aspirate-output/docker-compose.yaml @@ -0,0 +1,108 @@ +services: + postgresQL: + container_name: "postgresQL" + image: "docker.io/library/postgres:16.2" + environment: + POSTGRES_HOST_AUTH_METHOD: "scram-sha-256" + POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256 --auth-local=scram-sha-256" + POSTGRES_USER: "postgres" + POSTGRES_PASSWORD: "2lUmFKentK!fuyjdGIK4ka" + ports: + - target: 5432 + published: 5432 + restart: unless-stopped + redis: + container_name: "redis" + image: "docker.io/library/redis:7.2" + ports: + - target: 6379 + published: 6379 + restart: unless-stopped + rabbitmq: + container_name: "rabbitmq" + image: "docker.io/library/rabbitmq:3.13-management" + environment: + RABBITMQ_DEFAULT_USER: "guest" + RABBITMQ_DEFAULT_PASS: "m7YZc!8nqV6bmTb8VKM318" + ports: + - target: 5672 + published: 5672 + - target: 15672 + published: 15672 + restart: unless-stopped + product-api: + container_name: "product-api" + image: "product-api:latest" + environment: + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "true" + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "true" + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" + ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true" + ports: + - target: 8080 + published: 10000 + - target: 8443 + published: 10001 + restart: unless-stopped + counter-api: + container_name: "counter-api" + image: "counter-api:latest" + environment: + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "true" + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "true" + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" + ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true" + services__product-api__http__0: "http://product-api:8080" + ConnectionStrings__rabbitmq: "amqp://guest:m7YZc!8nqV6bmTb8VKM318@rabbitmq:5672" + ports: + - target: 8080 + published: 10002 + - target: 8443 + published: 10003 + restart: unless-stopped + barista-api: + container_name: "barista-api" + image: "barista-api:latest" + environment: + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "true" + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "true" + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" + ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true" + ConnectionStrings__rabbitmq: "amqp://guest:m7YZc!8nqV6bmTb8VKM318@rabbitmq:5672" + ports: + - target: 8080 + published: 10004 + - target: 8443 + published: 10005 + restart: unless-stopped + kitchen-api: + container_name: "kitchen-api" + image: "kitchen-api:latest" + environment: + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "true" + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "true" + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" + ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true" + ConnectionStrings__rabbitmq: "amqp://guest:m7YZc!8nqV6bmTb8VKM318@rabbitmq:5672" + ports: + - target: 8080 + published: 10006 + - target: 8443 + published: 10007 + restart: unless-stopped + order-summary: + container_name: "order-summary" + image: "order-summary:latest" + environment: + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "true" + OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "true" + OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory" + ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true" + ConnectionStrings__postgres: "Host=postgresQL;Port=5432;Username=postgres;Password=2lUmFKentK!fuyjdGIK4ka;Database=postgres" + ConnectionStrings__rabbitmq: "amqp://guest:m7YZc!8nqV6bmTb8VKM318@rabbitmq:5672" + ports: + - target: 8080 + published: 10008 + - target: 8443 + published: 10009 + restart: unless-stopped diff --git a/app-host/aspirate-state.json b/app-host/aspirate-state.json new file mode 100644 index 0000000..b414403 --- /dev/null +++ b/app-host/aspirate-state.json @@ -0,0 +1,48 @@ +{ + "projectPath": ".", + "namespace": "coffeeshop", + "containerImageTags": [ + "latest" + ], + "imagePullPolicy": "IfNotPresent", + "containerBuilder": "docker", + "kubeContext": "k3d-k3s-default", + "outputFormat": "kustomize", + "privateRegistryEmail": "aspir8@aka.ms", + "includeDashboard": true, + "useCustomNamespace": true, + "secrets": { + "salt": "EBQWuonzWR6ciGDa", + "hash": "qC2Cj\u002Bdvmez\u002BREtX9lWAaHcX1/fCDhy71t/5URBY23k=", + "secrets": { + "postgresQL-password": { + "value": "EBQWuonzWR6ciGDaz\u002BVw/NaSBujWMsclpH2c9tNxYjYh7xom2bblDrRVn6hfrPF9ja0=" + }, + "rabbitmq-password": { + "value": "EBQWuonzWR6ciGDa\u002BxNAFVcRzLbDrscmCJ0eZNA3eQ90zD4G17fxPph8qItzveRZtsU=" + }, + "postgresQL": { + "POSTGRES_PASSWORD": "EBQWuonzWR6ciGDaz\u002BVw/NaSBujWMsclpH2c9tNxYjYh7xom2bblDrRVn6hfrPF9ja0=" + }, + "postgres": {}, + "redis": {}, + "rabbitmq": {}, + "product-api": {}, + "counter-api": { + "ConnectionStrings__rabbitmq": "EBQWuonzWR6ciGDatn7KBVD9q6vYrfaZnAJGPPArfjItrHQuy6DMA8lTjJB8uMRfuv0bfVAkppcrnPSChcBdetazdRv7YX4VaUHLGM4=" + }, + "barista-api": { + "ConnectionStrings__rabbitmq": "EBQWuonzWR6ciGDatn7KBVD9q6vYrfaZnAJGPPArfjItrHQuy6DMA8lTjJB8uMRfuv0bfVAkppcrnPSChcBdetazdRv7YX4VaUHLGM4=" + }, + "kitchen-api": { + "ConnectionStrings__rabbitmq": "EBQWuonzWR6ciGDatn7KBVD9q6vYrfaZnAJGPPArfjItrHQuy6DMA8lTjJB8uMRfuv0bfVAkppcrnPSChcBdetazdRv7YX4VaUHLGM4=" + }, + "order-summary": { + "ConnectionStrings__postgres": "EBQWuonzWR6ciGDaqxc3s7Lbk\u002BUObOfAKkEQtNkpfDYq8zQ6yqLNEoBDsd1htPlOyKFdACt0nbEjrPyMi\u002BYxSsuhYx7gcGBfAxWOXIviTVdgDiy2cWiIyi4J50NQtIXa9ReqyHbyGrZ0M4gBho0YXiPpfe8gyiPw5A==", + "ConnectionStrings__rabbitmq": "EBQWuonzWR6ciGDatn7KBVD9q6vYrfaZnAJGPPArfjItrHQuy6DMA8lTjJB8uMRfuv0bfVAkppcrnPSChcBdetazdRv7YX4VaUHLGM4=" + } + } + }, + "processAllComponents": true, + "isRunning": true +} \ No newline at end of file diff --git a/app-host/manifest.json b/app-host/manifest.json new file mode 100644 index 0000000..a6e2a41 --- /dev/null +++ b/app-host/manifest.json @@ -0,0 +1,211 @@ +{ + "resources": { + "postgresQL": { + "type": "container.v0", + "connectionString": "Host={postgresQL.bindings.tcp.host};Port={postgresQL.bindings.tcp.port};Username=postgres;Password={postgresQL-password.value}", + "image": "docker.io/library/postgres:16.2", + "env": { + "POSTGRES_HOST_AUTH_METHOD": "scram-sha-256", + "POSTGRES_INITDB_ARGS": "--auth-host=scram-sha-256 --auth-local=scram-sha-256", + "POSTGRES_USER": "postgres", + "POSTGRES_PASSWORD": "{postgresQL-password.value}" + }, + "bindings": { + "tcp": { + "scheme": "tcp", + "protocol": "tcp", + "transport": "tcp", + "targetPort": 5432 + } + } + }, + "postgres": { + "type": "value.v0", + "connectionString": "{postgresQL.connectionString};Database=postgres" + }, + "redis": { + "type": "container.v0", + "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port}", + "image": "docker.io/library/redis:7.2", + "bindings": { + "tcp": { + "scheme": "tcp", + "protocol": "tcp", + "transport": "tcp", + "targetPort": 6379 + } + } + }, + "rabbitmq": { + "type": "container.v0", + "connectionString": "amqp://guest:{rabbitmq-password.value}@{rabbitmq.bindings.tcp.host}:{rabbitmq.bindings.tcp.port}", + "image": "docker.io/library/rabbitmq:3.13-management", + "env": { + "RABBITMQ_DEFAULT_USER": "guest", + "RABBITMQ_DEFAULT_PASS": "{rabbitmq-password.value}" + }, + "bindings": { + "tcp": { + "scheme": "tcp", + "protocol": "tcp", + "transport": "tcp", + "targetPort": 5672 + }, + "management": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "targetPort": 15672 + } + } + }, + "product-api": { + "type": "project.v0", + "path": "../product-api/CoffeeShop.ProductApi.csproj", + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", + "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http" + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http" + } + } + }, + "counter-api": { + "type": "project.v0", + "path": "../counter-api/CoffeeShop.CounterApi.csproj", + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", + "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", + "services__product-api__http__0": "{product-api.bindings.http.url}", + "services__product-api__https__0": "{product-api.bindings.https.url}", + "ConnectionStrings__rabbitmq": "{rabbitmq.connectionString}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http" + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http" + } + } + }, + "barista-api": { + "type": "project.v0", + "path": "../barista-api/CoffeeShop.BaristaApi.csproj", + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", + "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", + "ConnectionStrings__rabbitmq": "{rabbitmq.connectionString}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http" + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http" + } + } + }, + "kitchen-api": { + "type": "project.v0", + "path": "../kitchen-api/CoffeeShop.KitchenApi.csproj", + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", + "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", + "ConnectionStrings__rabbitmq": "{rabbitmq.connectionString}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http" + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http" + } + } + }, + "order-summary": { + "type": "project.v0", + "path": "../order-summary/CoffeeShop.OrderSummary.csproj", + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", + "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", + "ConnectionStrings__postgres": "{postgres.connectionString}", + "ConnectionStrings__rabbitmq": "{rabbitmq.connectionString}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http" + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http" + } + } + }, + "postgresQL-password": { + "type": "parameter.v0", + "value": "{postgresQL-password.inputs.value}", + "inputs": { + "value": { + "type": "string", + "secret": true, + "default": { + "generate": { + "minLength": 22 + } + } + } + } + }, + "rabbitmq-password": { + "type": "parameter.v0", + "value": "{rabbitmq-password.inputs.value}", + "inputs": { + "value": { + "type": "string", + "secret": true, + "default": { + "generate": { + "minLength": 22, + "special": false + } + } + } + } + } + } +} \ No newline at end of file diff --git a/barista-api/CoffeeShop.BaristaApi.csproj b/barista-api/CoffeeShop.BaristaApi.csproj new file mode 100644 index 0000000..e96a025 --- /dev/null +++ b/barista-api/CoffeeShop.BaristaApi.csproj @@ -0,0 +1,23 @@ + + + + Exe + BaristaApi + barista-api + latest + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/barista-api/IntegrationEvents/EventHandlers/BaristaOrderedConsumer.cs b/barista-api/IntegrationEvents/EventHandlers/BaristaOrderedConsumer.cs index 1ba8a0c..4010afb 100755 --- a/barista-api/IntegrationEvents/EventHandlers/BaristaOrderedConsumer.cs +++ b/barista-api/IntegrationEvents/EventHandlers/BaristaOrderedConsumer.cs @@ -1,59 +1,64 @@ using BaristaApi.Domain; + using CoffeeShop.MessageContracts; + using MassTransit; namespace BaristaApi.IntegrationEvents.EventHandlers; -internal class BaristaOrderedConsumer(IPublishEndpoint publisher, ILogger logger) - : IConsumer +internal class BaristaOrderedConsumer(IPublishEndpoint publisher, ILogger logger) + : IConsumer { - public async Task Consume(ConsumeContext context) - { - ArgumentNullException.ThrowIfNull(context); - logger.LogInformation("Received an message {name}", nameof(context.Message)); - - var message = context.Message; - - foreach(var item in message.ItemLines) { - await Task.Delay(CalculateDelay(item.ItemType)); - } - - await publisher.Publish(new BaristaOrderUpdated{OrderId = message.OrderId, ItemLines = message.ItemLines}); - } - - private static TimeSpan CalculateDelay(ItemType itemType) - { - return itemType switch - { - ItemType.COFFEE_BLACK => TimeSpan.FromSeconds(5), - ItemType.COFFEE_WITH_ROOM => TimeSpan.FromSeconds(5), - ItemType.ESPRESSO => TimeSpan.FromSeconds(7), - ItemType.ESPRESSO_DOUBLE => TimeSpan.FromSeconds(7), - ItemType.CAPPUCCINO => TimeSpan.FromSeconds(10), - _ => TimeSpan.FromSeconds(3) - }; - } + public async Task Consume(ConsumeContext context) + { + ArgumentNullException.ThrowIfNull(context); + logger.LogInformation("Received an message {name}", nameof(context.Message)); + + var message = context.Message; + + // toto: processing and persist it + + foreach (var item in message.ItemLines) + { + await Task.Delay(CalculateDelay(item.ItemType)); + } + + await publisher.Publish(new BaristaOrderUpdated { OrderId = message.OrderId, ItemLines = message.ItemLines }); + } + + private static TimeSpan CalculateDelay(ItemType itemType) + { + return itemType switch + { + ItemType.COFFEE_BLACK => TimeSpan.FromSeconds(5), + ItemType.COFFEE_WITH_ROOM => TimeSpan.FromSeconds(5), + ItemType.ESPRESSO => TimeSpan.FromSeconds(7), + ItemType.ESPRESSO_DOUBLE => TimeSpan.FromSeconds(7), + ItemType.CAPPUCCINO => TimeSpan.FromSeconds(10), + _ => TimeSpan.FromSeconds(3) + }; + } } internal class BaristaOrderedConsumerDefinition : ConsumerDefinition { - public BaristaOrderedConsumerDefinition() - { - // override the default endpoint name - EndpointName = "barista-service"; - - // limit the number of messages consumed concurrently - // this applies to the consumer only, not the endpoint - ConcurrentMessageLimit = 8; - } - - protected override void ConfigureConsumer(IReceiveEndpointConfigurator endpointConfigurator, - IConsumerConfigurator consumerConfigurator) - { - // configure message retry with millisecond intervals - endpointConfigurator.UseMessageRetry(r => r.Intervals(100, 200, 500, 800, 1000)); - - // use the outbox to prevent duplicate events from being published - endpointConfigurator.UseInMemoryOutbox(); - } + public BaristaOrderedConsumerDefinition() + { + // override the default endpoint name + EndpointName = "barista-service"; + + // limit the number of messages consumed concurrently + // this applies to the consumer only, not the endpoint + ConcurrentMessageLimit = 8; + } + + protected override void ConfigureConsumer(IReceiveEndpointConfigurator endpointConfigurator, + IConsumerConfigurator consumerConfigurator) + { + // configure message retry with millisecond intervals + endpointConfigurator.UseMessageRetry(r => r.Intervals(100, 200, 500, 800, 1000)); + + // use the outbox to prevent duplicate events from being published + endpointConfigurator.UseInMemoryOutbox(); + } } \ No newline at end of file diff --git a/barista-api/IntegrationEvents/Events/BaristaOrderUpdated.cs b/barista-api/IntegrationEvents/Events/BaristaOrderUpdated.cs index afa6e3b..13c6d19 100755 --- a/barista-api/IntegrationEvents/Events/BaristaOrderUpdated.cs +++ b/barista-api/IntegrationEvents/Events/BaristaOrderUpdated.cs @@ -5,5 +5,5 @@ namespace CoffeeShop.MessageContracts; public record BaristaOrderUpdated { public Guid OrderId { get; init; } - public List ItemLines { get; init; } = new(); + public List ItemLines { get; init; } = []; } \ No newline at end of file diff --git a/barista-api/IntegrationEvents/Events/BaristaPlaced.cs b/barista-api/IntegrationEvents/Events/BaristaPlaced.cs index c9182e6..109b210 100755 --- a/barista-api/IntegrationEvents/Events/BaristaPlaced.cs +++ b/barista-api/IntegrationEvents/Events/BaristaPlaced.cs @@ -5,5 +5,5 @@ namespace CoffeeShop.MessageContracts; public record BaristaOrderPlaced { public Guid OrderId { get; init; } - public List ItemLines { get; init; } = new(); + public List ItemLines { get; init; } = []; } \ No newline at end of file diff --git a/barista-api/Program.cs b/barista-api/Program.cs index f0d8bf5..5e98faa 100755 --- a/barista-api/Program.cs +++ b/barista-api/Program.cs @@ -1,50 +1,53 @@ using FluentValidation; using MassTransit; using BaristaApi.IntegrationEvents.EventHandlers; +using CoffeeShop.Shared.OpenTelemetry; +using CoffeeShop.Shared.Exceptions; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); +builder.Services.AddExceptionHandler(); +builder.Services.AddExceptionHandler(); builder.Services.AddProblemDetails(); builder.Services.AddHttpContextAccessor(); -builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining()); -builder.Services.AddValidatorsFromAssemblyContaining(); +builder.Services.AddMediatR(cfg => { + cfg.RegisterServicesFromAssemblyContaining(); + cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); + cfg.AddOpenBehavior(typeof(HandlerBehavior<,>)); +}); +builder.Services.AddValidatorsFromAssemblyContaining(includeInternalTypes: true); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); builder.Services.AddMassTransit(x => { - x.AddConsumer(typeof(BaristaOrderedConsumerDefinition)); + x.AddConsumer(typeof(BaristaOrderedConsumerDefinition)); - x.SetKebabCaseEndpointNameFormatter(); + x.SetKebabCaseEndpointNameFormatter(); - x.UsingRabbitMq((context, cfg) => - { - // cfg.Host(builder.Configuration.GetValue("RabbitMqUrl")!); - cfg.Host(builder.Configuration.GetConnectionString("rabbitmq")!); - cfg.ConfigureEndpoints(context); - }); + x.UsingRabbitMq((context, cfg) => + { + cfg.Host(builder.Configuration.GetConnectionString("rabbitmq")!); + cfg.ConfigureEndpoints(context); + }); }); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + var app = builder.Build(); app.UseExceptionHandler(); -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - app.UseRouting(); app.MapDefaultEndpoints(); -app.Map("/", () => Results.Redirect("/swagger")); +app.Run(); -// _ = app.MapOrderUpApiRoutes(); +public partial class Program; -app.Run(); diff --git a/barista-api/appsettings.Development.json b/barista-api/appsettings.Development.json index a7dbfd2..ff66ba6 100755 --- a/barista-api/appsettings.Development.json +++ b/barista-api/appsettings.Development.json @@ -4,6 +4,5 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } - }, - "RabbitMqUrl": "localhost" + } } diff --git a/barista-api/appsettings.json b/barista-api/appsettings.json index 4d56694..430039c 100755 --- a/barista-api/appsettings.json +++ b/barista-api/appsettings.json @@ -1,9 +1,13 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "postgres": "Server=localhost;Port=5432;Database=postgres;", + "rabbitmq": "amqp://localhost" + } } diff --git a/client.local.http b/client.local.http index 5322fad..53b6efb 100755 --- a/client.local.http +++ b/client.local.http @@ -1,5 +1,6 @@ @product_host = http://localhost:5001 @host = http://localhost:5002 +@order_host = http://localhost:5005 ### GET {{product_host}}/v1/api/item-types HTTP/1.1 @@ -36,4 +37,9 @@ content-type: application/json ### GET {{host}}/v1/api/fulfillment-orders HTTP/1.1 -content-type: application/json \ No newline at end of file +content-type: application/json + +### +@orderId = f0c60000-8d12-00ff-509a-08dc78db471b +GET {{order_host}}/summary?orderId={{orderId}} +content-type: application/json diff --git a/coffeeshop-aspire.sln b/coffeeshop-aspire.sln index 63f1ed4..0d475c2 100755 --- a/coffeeshop-aspire.sln +++ b/coffeeshop-aspire.sln @@ -3,17 +3,33 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.002.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "barista-api", "barista-api\barista-api.csproj", "{1E1B0C85-DB1F-485E-8B5A-CE03E9977029}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoffeeShop.BaristaApi", "barista-api\CoffeeShop.BaristaApi.csproj", "{1E1B0C85-DB1F-485E-8B5A-CE03E9977029}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "service-defaults", "service-defaults\service-defaults.csproj", "{5E32B73F-45E0-4E11-BBCD-397249DF3E82}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoffeeShop.ProductApi", "product-api\CoffeeShop.ProductApi.csproj", "{605A641D-F5E3-4B6F-9092-591F0A7DEE9D}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "product-api", "product-api\product-api.csproj", "{605A641D-F5E3-4B6F-9092-591F0A7DEE9D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoffeeShop.AppHost", "app-host\CoffeeShop.AppHost.csproj", "{EB61218A-CF80-4389-9C6A-D6AA5C205E95}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "app-host", "app-host\app-host.csproj", "{EB61218A-CF80-4389-9C6A-D6AA5C205E95}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoffeeShop.KitchenApi", "kitchen-api\CoffeeShop.KitchenApi.csproj", "{16CC8BE1-3E21-46FB-813D-CF37C7079EC4}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "kitchen-api", "kitchen-api\kitchen-api.csproj", "{16CC8BE1-3E21-46FB-813D-CF37C7079EC4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoffeeShop.CounterApi", "counter-api\CoffeeShop.CounterApi.csproj", "{6A163558-FB94-449A-817A-8D8FD08D5E22}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "counter-api", "counter-api\counter-api.csproj", "{6A163558-FB94-449A-817A-8D8FD08D5E22}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{140D0842-3BBC-4339-9C75-331FD9CB50F8}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + .gitignore = .gitignore + aspire-manifest.json = aspire-manifest.json + Directory.Build.props = Directory.Build.props + Directory.Packages.props = Directory.Packages.props + global.json = global.json + NuGet.config = NuGet.config + README.md = README.md + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoffeeShop.Shared", "shared\CoffeeShop.Shared\CoffeeShop.Shared.csproj", "{46796FD1-9AC2-498D-8535-D9E57286AA2F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoffeeShop.OrderSummary", "order-summary\CoffeeShop.OrderSummary.csproj", "{FE457D2C-ABB3-464C-8C6D-16D09C77FFB0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoffeeShop.CounterApi.IntegrationTests", "counter-api-tests\CoffeeShop.CounterApi.IntegrationTests.csproj", "{5DB71E8E-BF52-4918-895D-CD3BB373D6A0}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -25,10 +41,6 @@ Global {1E1B0C85-DB1F-485E-8B5A-CE03E9977029}.Debug|Any CPU.Build.0 = Debug|Any CPU {1E1B0C85-DB1F-485E-8B5A-CE03E9977029}.Release|Any CPU.ActiveCfg = Release|Any CPU {1E1B0C85-DB1F-485E-8B5A-CE03E9977029}.Release|Any CPU.Build.0 = Release|Any CPU - {5E32B73F-45E0-4E11-BBCD-397249DF3E82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5E32B73F-45E0-4E11-BBCD-397249DF3E82}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5E32B73F-45E0-4E11-BBCD-397249DF3E82}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5E32B73F-45E0-4E11-BBCD-397249DF3E82}.Release|Any CPU.Build.0 = Release|Any CPU {605A641D-F5E3-4B6F-9092-591F0A7DEE9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {605A641D-F5E3-4B6F-9092-591F0A7DEE9D}.Debug|Any CPU.Build.0 = Debug|Any CPU {605A641D-F5E3-4B6F-9092-591F0A7DEE9D}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -45,6 +57,18 @@ Global {6A163558-FB94-449A-817A-8D8FD08D5E22}.Debug|Any CPU.Build.0 = Debug|Any CPU {6A163558-FB94-449A-817A-8D8FD08D5E22}.Release|Any CPU.ActiveCfg = Release|Any CPU {6A163558-FB94-449A-817A-8D8FD08D5E22}.Release|Any CPU.Build.0 = Release|Any CPU + {46796FD1-9AC2-498D-8535-D9E57286AA2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {46796FD1-9AC2-498D-8535-D9E57286AA2F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {46796FD1-9AC2-498D-8535-D9E57286AA2F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {46796FD1-9AC2-498D-8535-D9E57286AA2F}.Release|Any CPU.Build.0 = Release|Any CPU + {FE457D2C-ABB3-464C-8C6D-16D09C77FFB0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FE457D2C-ABB3-464C-8C6D-16D09C77FFB0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FE457D2C-ABB3-464C-8C6D-16D09C77FFB0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FE457D2C-ABB3-464C-8C6D-16D09C77FFB0}.Release|Any CPU.Build.0 = Release|Any CPU + {5DB71E8E-BF52-4918-895D-CD3BB373D6A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5DB71E8E-BF52-4918-895D-CD3BB373D6A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DB71E8E-BF52-4918-895D-CD3BB373D6A0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5DB71E8E-BF52-4918-895D-CD3BB373D6A0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/counter-api-tests/CoffeeShop.CounterApi.IntegrationTests.csproj b/counter-api-tests/CoffeeShop.CounterApi.IntegrationTests.csproj new file mode 100644 index 0000000..b1c931e --- /dev/null +++ b/counter-api-tests/CoffeeShop.CounterApi.IntegrationTests.csproj @@ -0,0 +1,46 @@ + + + + net8.0 + enable + enable + + false + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/counter-api-tests/CounterApiFixture.cs b/counter-api-tests/CounterApiFixture.cs new file mode 100644 index 0000000..cbae8bb --- /dev/null +++ b/counter-api-tests/CounterApiFixture.cs @@ -0,0 +1,131 @@ +using CounterApi.Domain; +using CounterApi.IntegrationEvents.EventHandlers; + +using MassTransit; + +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; + +using WireMock.Client.Builders; + +using Xunit; + +namespace CoffeeShop.CounterApi.IntegrationTests; + +public sealed class CounterApiFixture : WebApplicationFactory, IAsyncLifetime +{ + private readonly IHost _app; + + public IResourceBuilder Postgres { get; private set; } + private string _postgresConnectionString; + + public IResourceBuilder RabbitMq { get; private set; } + private string _rabbitMqConnectionString; + + public IResourceBuilder ProductApi { get; private set; } + + public CounterApiFixture() + { + var options = new DistributedApplicationOptions { AssemblyName = typeof(CounterApiFixture).Assembly.FullName, DisableDashboard = true }; + var appBuilder = DistributedApplication.CreateBuilder(options); + + Postgres = appBuilder.AddPostgres("postgresQL"); + RabbitMq = appBuilder.AddRabbitMQ("rabbitmq").WithHealthCheck(); + ProductApi = appBuilder.AddWireMockNet("product-api") + .WithApiMappingBuilder(ProductApiMock.Build); + + _app = appBuilder.Build(); + } + + protected override IHost CreateHost(IHostBuilder builder) + { + builder.ConfigureHostConfiguration(config => + { + config.AddInMemoryCollection(new Dictionary + { + { $"ConnectionStrings:{Postgres.Resource.Name}", _postgresConnectionString }, + { $"ConnectionStrings:{RabbitMq.Resource.Name}", _rabbitMqConnectionString }, + { "ProductApiUrl", ProductApi.GetEndpoint("http").Url } + }!); + }) + .ConfigureWebHost(builder => + { + builder.UseTestServer() + .ConfigureServices(services => + { + services.RemoveAll(); + }) + .ConfigureTestServices(services => + { + services.AddMassTransitTestHarness(x => + { + x.AddConsumer(); + x.AddConsumer(); + }); + }); + }); + + return base.CreateHost(builder); + } + + public async Task InitializeAsync() + { + await _app.StartAsync(); + + _postgresConnectionString = await Postgres.Resource.GetConnectionStringAsync(); + _rabbitMqConnectionString = await RabbitMq.Resource.ConnectionStringExpression.GetValueAsync(default); + + // if don't waiting then WireMock will be failed + await Task.Delay(TimeSpan.FromSeconds(5)); + } + + public new async Task DisposeAsync() + { + await base.DisposeAsync(); + await _app.StopAsync(); + if (_app is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + else + { + _app.Dispose(); + } + } +} + +internal class ProductApiMock +{ + public static async Task Build(AdminApiMappingBuilder builder) + { + var itemTypes = new List { + new() { + ItemType = ItemType.CAKEPOP + }, + new() { + ItemType = ItemType.CAPPUCCINO + }}; + + builder.Given(builder => builder + .WithRequest(request => request + .UsingGet() + .WithPath("/api/v1/item-types") + ) + .WithResponse(response => response + .WithBodyAsJson(itemTypes) + ) + ); + + await builder.BuildAndPostAsync(); + } +} + +public class ItemTypeDto +{ + public ItemType ItemType { get; set; } + public string Name { get; set; } = null!; +} \ No newline at end of file diff --git a/counter-api-tests/CounterApiTests.cs b/counter-api-tests/CounterApiTests.cs new file mode 100644 index 0000000..1e89d60 --- /dev/null +++ b/counter-api-tests/CounterApiTests.cs @@ -0,0 +1,99 @@ +using System.Net.Http.Json; +using System.Text.Json; + +using Asp.Versioning; +using Asp.Versioning.Http; + +using CoffeeShop.MessageContracts; +using CoffeeShop.Shared.Helpers; + +using CounterApi.Domain; +using CounterApi.Domain.Commands; +using CounterApi.IntegrationEvents.EventHandlers; + +using MassTransit.Testing; + +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +using Xunit; + +namespace CoffeeShop.CounterApi.IntegrationTests; + +internal class RetryHandler : DelegatingHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return base.SendAsync(request, cancellationToken); + } +} + +public sealed class CounterApiTests : IClassFixture +{ + private readonly WebApplicationFactory _webApplicationFactory; + private readonly TestServer _host; + private readonly HttpClient _httpClient; + private readonly JsonSerializerOptions _jsonSerializerOptions = new(JsonSerializerDefaults.Web); + + public CounterApiTests(CounterApiFixture fixture) + { + var handler = new ApiVersionHandler(new QueryStringApiVersionWriter(), new ApiVersion(1.0)); + _webApplicationFactory = fixture; + _host = fixture.Server; + _httpClient = _webApplicationFactory.CreateDefaultClient(handler); + } + + [Fact] + public async Task GetOrder() + { + // Act + var response = await _httpClient.GetAsync("/api/v1/fulfillment-orders"); + + // Assert + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize>(body, _jsonSerializerOptions); + + Assert.NotNull(result); + } + + [Fact] + public async Task SubmitOrder() + { + var json = new PlaceOrderCommand + { + OrderId = GuidHelper.NewGuid(), + CommandType = CommandType.PLACE_ORDER, + OrderSource = OrderSource.WEB, + Location = Location.ATLANTA, + LoyaltyMemberId = GuidHelper.NewGuid(), + Timestamp = DateTime.UtcNow, + BaristaItems = [new CommandItem { ItemType = ItemType.CAPPUCCINO }], + KitchenItems = [new CommandItem { ItemType = ItemType.CAKEPOP }], + }; + + var response = await _httpClient.PostAsJsonAsync("/api/v1/orders", json); + + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadAsStringAsync(); + + Assert.NotNull(body); + + var testHarness = _host.Services?.GetService(); + Assert.NotNull(testHarness); + + await testHarness.Start(); + + var orderId = GuidHelper.NewGuid(); + + await testHarness.Bus.Publish(new BaristaOrderUpdated { OrderId = orderId }); + await testHarness.Bus.Publish(new KitchenOrderUpdated { OrderId = orderId }); + + var consumer1 = testHarness.GetConsumerHarness(); + var consumer2 = testHarness.GetConsumerHarness(); + + Assert.True(await consumer1.Consumed.Any(f => f.Context.Message.OrderId == orderId)); + Assert.True(await consumer2.Consumed.Any(f => f.Context.Message.OrderId == orderId)); + } +} diff --git a/counter-api-tests/HealthCheckExtensions.cs b/counter-api-tests/HealthCheckExtensions.cs new file mode 100644 index 0000000..e3deedc --- /dev/null +++ b/counter-api-tests/HealthCheckExtensions.cs @@ -0,0 +1,65 @@ +using Aspirant.Hosting; + +using HealthChecks.NpgSql; +using HealthChecks.RabbitMQ; +using HealthChecks.Redis; +using HealthChecks.Uris; + +namespace CoffeeShop.CounterApi.IntegrationTests; + +/// +/// Ref: https://github.com/davidfowl/WaitForDependenciesAspire/tree/main/WaitForDependencies.Aspire.Hosting +/// +public static class Extensions +{ + public static IResourceBuilder WithHealthCheck(this IResourceBuilder builder) + { + return builder.WithAnnotation(HealthCheckAnnotation.Create(cs => new RabbitMQHealthCheck(new RabbitMQHealthCheckOptions { ConnectionUri = new(cs) }))); + } + + public static IResourceBuilder WithHealthCheck(this IResourceBuilder builder) + { + return builder.WithAnnotation(HealthCheckAnnotation.Create(cs => new RedisHealthCheck(cs))); + } + + public static IResourceBuilder WithHealthCheck(this IResourceBuilder builder) + { + return builder.WithAnnotation(HealthCheckAnnotation.Create(cs => new NpgSqlHealthCheck(new NpgSqlHealthCheckOptions(cs)))); + } + + public static IResourceBuilder WithHealthCheck( + this IResourceBuilder builder, + string? endpointName = null, + string path = "health", + Action? configure = null) + where T : IResourceWithEndpoints + { + return builder.WithAnnotation(new HealthCheckAnnotation(async (resource, ct) => + { + if (resource is not IResourceWithEndpoints resourceWithEndpoints) + { + return null; + } + + var endpoint = endpointName is null + ? resourceWithEndpoints.GetEndpoints().FirstOrDefault(e => e.Scheme is "http" or "https") + : resourceWithEndpoints.GetEndpoint(endpointName); + + var url = endpoint?.Url; + + if (url is null) + { + return null; + } + + var options = new UriHealthCheckOptions(); + + options.AddUri(new(new(url), path)); + + configure?.Invoke(options); + + var client = new HttpClient(); + return new UriHealthCheck(options, () => client); + })); + } +} \ No newline at end of file diff --git a/counter-api-tests/README.md b/counter-api-tests/README.md new file mode 100644 index 0000000..6a2122b --- /dev/null +++ b/counter-api-tests/README.md @@ -0,0 +1,29 @@ +# Get starting + +## Prerequisite + +```powershell +dotnet tool install -g dotnet-reportgenerator-globaltool +``` + +## Actions + +```powershell +dotnet test --settings tests.runsettings +``` + +```powershell +reportgenerator ` + -reports:".\**\TestResults\**\coverage.cobertura.xml" ` + -targetdir:"coverage" ` + -reporttypes:Html +``` + +```powershell +.\coverage\index.htm +``` + +## Refs + +- https://github.com/thangchung/setup-dotnet-test-projects/tree/main/src/Services/PeopleService/DNP.PeopleService.Tests +- https://knowyourtoolset.com/2024/01/coverage-reports/ diff --git a/counter-api/counter-api.csproj b/counter-api/CoffeeShop.CounterApi.csproj old mode 100755 new mode 100644 similarity index 63% rename from counter-api/counter-api.csproj rename to counter-api/CoffeeShop.CounterApi.csproj index 8ea20e8..f8fe349 --- a/counter-api/counter-api.csproj +++ b/counter-api/CoffeeShop.CounterApi.csproj @@ -1,24 +1,25 @@ - - - - Exe - CounterApi - counter-api - latest - - - - - - - - - - - - - - - - + + + + Exe + CounterApi + counter-api + latest + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/counter-api/Domain/Commands/PlaceOrderCommand.cs b/counter-api/Domain/Commands/PlaceOrderCommand.cs index 61eaf38..46101b5 100755 --- a/counter-api/Domain/Commands/PlaceOrderCommand.cs +++ b/counter-api/Domain/Commands/PlaceOrderCommand.cs @@ -1,25 +1,27 @@ +using CoffeeShop.Shared.Helpers; + using MediatR; namespace CounterApi.Domain.Commands; public class CommandItem { - public ItemType ItemType { get; set; } + public ItemType ItemType { get; set; } } public enum CommandType { - PLACE_ORDER + PLACE_ORDER } public class PlaceOrderCommand : IRequest { - public Guid OrderId { get; set; } - public CommandType CommandType { get; set; } = CommandType.PLACE_ORDER; - public OrderSource OrderSource { get; set; } - public Location Location { get; set; } - public Guid LoyaltyMemberId { get; set; } - public List BaristaItems { get; set; } = new(); - public List KitchenItems { get; set; } = new(); - public DateTime Timestamp { get; set; } = DateTime.UtcNow; + public Guid OrderId { get; set; } = GuidHelper.NewGuid(); + public CommandType CommandType { get; set; } = CommandType.PLACE_ORDER; + public OrderSource OrderSource { get; set; } = OrderSource.COUNTER; + public Location Location { get; set; } = Location.ATLANTA; + public Guid LoyaltyMemberId { get; set; } = GuidHelper.NewGuid(); + public List BaristaItems { get; set; } = []; + public List KitchenItems { get; set; } = []; + public DateTime Timestamp { get; set; } = DateTimeHelper.NewDateTime(); } \ No newline at end of file diff --git a/counter-api/Domain/DomainEvents/OrderIn.cs b/counter-api/Domain/DomainEvents/OrderIn.cs index 66cbead..16f86a6 100755 --- a/counter-api/Domain/DomainEvents/OrderIn.cs +++ b/counter-api/Domain/DomainEvents/OrderIn.cs @@ -1,8 +1,22 @@ -using CounterApi.Domain.SharedKernel; +using CoffeeShop.Shared.Domain; + +using CounterApi.Domain.Dtos; namespace CounterApi.Domain.DomainEvents; -public class OrderIn(Guid orderId, Guid itemLineId, ItemType itemType) : IDomainEvent +public class BaristaOrdersPlacedDomainEvent : EventBase +{ + public Guid? OrderId { get; set; } + public List ItemLines { get; init; } = []; +} + +public class KitchenOrdersPlacedDomainEvent : EventBase +{ + public Guid? OrderId { get; set; } + public List ItemLines { get; init; } = []; +} + +public class OrderIn(Guid orderId, Guid itemLineId, ItemType itemType) : EventBase { public Guid OrderId { get; set; } = orderId; public Guid ItemLineId { get; set; } = itemLineId; diff --git a/counter-api/Domain/DomainEvents/OrderUp.cs b/counter-api/Domain/DomainEvents/OrderUp.cs index f6eba90..94752ea 100755 --- a/counter-api/Domain/DomainEvents/OrderUp.cs +++ b/counter-api/Domain/DomainEvents/OrderUp.cs @@ -1,10 +1,10 @@ -using CounterApi.Domain.SharedKernel; +using CoffeeShop.Shared.Domain; namespace CounterApi.Domain.DomainEvents; -public class OrderUp(Guid itemLineId) : IDomainEvent +public class OrderUp(Guid itemLineId) : EventBase { - public Guid ItemLineId => itemLineId; + public Guid ItemLineId => itemLineId; } public class BaristaOrderUp(Guid itemLineId) : OrderUp(itemLineId) diff --git a/counter-api/Domain/DomainEvents/OrderUpdate.cs b/counter-api/Domain/DomainEvents/OrderUpdate.cs index b2537ef..ef406b7 100755 --- a/counter-api/Domain/DomainEvents/OrderUpdate.cs +++ b/counter-api/Domain/DomainEvents/OrderUpdate.cs @@ -1,30 +1,30 @@ -using CounterApi.Domain.SharedKernel; +//using CoffeeShop.Shared.Domain; -namespace CounterApi.Domain.DomainEvents; +//namespace CounterApi.Domain.DomainEvents; -public class OrderUpdate : IDomainEvent -{ - public Guid OrderId { get; } - public Guid ItemLineId { get; } - public ItemType ItemType { get; } - public OrderStatus OrderStatus { get; } - public string? MadeBy { get; } +//public class OrderUpdate : EventBase +//{ +// public Guid OrderId { get; } +// public Guid ItemLineId { get; } +// public ItemType ItemType { get; } +// public OrderStatus OrderStatus { get; } +// public string? MadeBy { get; } - public OrderUpdate(Guid orderId, Guid itemLineId, ItemType itemType, OrderStatus orderStatus) - { - OrderId = orderId; - ItemLineId = itemLineId; - ItemType = itemType; - OrderStatus = orderStatus; - MadeBy = null; - } +// public OrderUpdate(Guid orderId, Guid itemLineId, ItemType itemType, OrderStatus orderStatus) +// { +// OrderId = orderId; +// ItemLineId = itemLineId; +// ItemType = itemType; +// OrderStatus = orderStatus; +// MadeBy = null; +// } - public OrderUpdate(Guid orderId, Guid itemLineId, ItemType itemType, OrderStatus orderStatus, string madeBy) - { - OrderId = orderId; - ItemLineId = itemLineId; - ItemType = itemType; - OrderStatus = orderStatus; - MadeBy = madeBy; - } -} \ No newline at end of file +// public OrderUpdate(Guid orderId, Guid itemLineId, ItemType itemType, OrderStatus orderStatus, string madeBy) +// { +// OrderId = orderId; +// ItemLineId = itemLineId; +// ItemType = itemType; +// OrderStatus = orderStatus; +// MadeBy = madeBy; +// } +//} \ No newline at end of file diff --git a/counter-api/Domain/Order.cs b/counter-api/Domain/Order.cs index ef4a981..707c66b 100755 --- a/counter-api/Domain/Order.cs +++ b/counter-api/Domain/Order.cs @@ -1,178 +1,161 @@ -using System.Text.Json.Serialization; +using CoffeeShop.Shared.Domain; using CounterApi.Domain.Commands; using CounterApi.Domain.DomainEvents; using CounterApi.Domain.Dtos; -using CounterApi.Domain.SharedKernel; namespace CounterApi.Domain; -public class Order +public class Order(Guid id, OrderSource orderSource, Guid loyaltyMemberId, OrderStatus orderStatus, Location location) + : EntityRootBase(id) { - [JsonIgnore] - public HashSet DomainEvents { get; private set; } = new HashSet(); - - public Guid Id { get; set; } = Guid.NewGuid(); - public OrderSource OrderSource { get; set; } - public Guid LoyaltyMemberId { get; set; } - public OrderStatus OrderStatus { get; set; } - public Location Location { get; set; } - public List ItemLines { get; set; } = new(); - public DateTime Created { get; set; } = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Utc); - public DateTime? Updated { get; set; } - - private Order(OrderSource orderSource, Guid loyaltyMemberId, OrderStatus orderStatus, Location location) - : this(Guid.NewGuid(), orderSource, loyaltyMemberId, orderStatus, location) - { - } - - private Order(Guid id, OrderSource orderSource, Guid loyaltyMemberId, OrderStatus orderStatus, Location location) - { - Id = id; - OrderSource = orderSource; - LoyaltyMemberId = loyaltyMemberId; - OrderStatus = orderStatus; - Location = location; - } - - public void AddDomainEvent(IDomainEvent eventItem) - { - DomainEvents ??= new HashSet(); - DomainEvents.Add(eventItem); - } - - public void RemoveDomainEvent(IDomainEvent eventItem) - { - DomainEvents?.Remove(eventItem); - } - - public static async Task From(PlaceOrderCommand placeOrderCommand, IItemGateway itemGateway) - { - var order = new Order(placeOrderCommand.OrderSource, placeOrderCommand.LoyaltyMemberId, OrderStatus.IN_PROGRESS, placeOrderCommand.Location) - { - Id = placeOrderCommand.OrderId - }; - - if (placeOrderCommand.BaristaItems.Count != 0) - { - var itemTypes = placeOrderCommand.BaristaItems.Select(x => x.ItemType); - var items = await itemGateway.GetItemsByType(itemTypes.ToArray()); - foreach (var baristaItem in placeOrderCommand.BaristaItems) - { - var item = items.FirstOrDefault(x => x.ItemType == baristaItem.ItemType); - var itemLine = new ItemLine(baristaItem.ItemType, item?.ItemType.ToString()!, (decimal)item?.Price!, ItemStatus.IN_PROGRESS, true); - - order.AddDomainEvent(new OrderUpdate(order.Id, itemLine.Id, itemLine.ItemType, OrderStatus.IN_PROGRESS)); - order.AddDomainEvent(new BaristaOrderIn(order.Id, itemLine.Id, itemLine.ItemType)); - - order.ItemLines.Add(itemLine); - } - } - - if (placeOrderCommand.KitchenItems.Count != 0) - { - var itemTypes = placeOrderCommand.KitchenItems.Select(x => x.ItemType); - var items = await itemGateway.GetItemsByType(itemTypes.ToArray()); - foreach (var kitchenItem in placeOrderCommand.KitchenItems) - { - var item = items.FirstOrDefault(x => x.ItemType == kitchenItem.ItemType); - var itemLine = new ItemLine(kitchenItem.ItemType, item?.ItemType.ToString()!, (decimal)item?.Price!, ItemStatus.IN_PROGRESS, false); - - order.AddDomainEvent(new OrderUpdate(order.Id, itemLine.Id, itemLine.ItemType, OrderStatus.IN_PROGRESS)); - order.AddDomainEvent(new KitchenOrderIn(order.Id, itemLine.Id, itemLine.ItemType)); - - order.ItemLines.Add(itemLine); - } - } - - return order; - } - - public Order Apply(OrderUp orderUp) - { - if (ItemLines.Count == 0) return this; - - var item = ItemLines.FirstOrDefault(i => i.Id == orderUp.ItemLineId); - - if (item is not null) - { - item.ItemStatus = ItemStatus.FULFILLED; - // AddDomainEvent(new OrderUpdate(Id, item.Id, item.ItemType, OrderStatus.FULFILLED, orderUp.MadeBy)); - } - - // if there are both barista and kitchen items is fulfilled then checking status and change order to Fulfilled - if (ItemLines.All(i => i.ItemStatus == ItemStatus.FULFILLED)) - { - OrderStatus = OrderStatus.FULFILLED; - } - return this; - } - - public static OrderDto ToDto(Order order) - { - var dto = new OrderDto - { - Id = order.Id, - OrderStatus = order.OrderStatus, - Location = order.Location, - OrderSource = order.OrderSource, - LoyaltyMemberId = order.LoyaltyMemberId - }; - - foreach (var item in order.ItemLines) - { - dto.ItemLines.Add(new OrderItemLineDto(item.Id, item.ItemType, item.ItemStatus)); - } - - return dto; - } - - public static async Task FromDto(OrderDto dto, IItemGateway itemGateway) - { - var order = new Order(dto.Id, dto.OrderSource, dto.LoyaltyMemberId, OrderStatus.IN_PROGRESS, dto.Location) - { - Id = dto.Id - }; - - var itemTypes = dto.ItemLines.Select(x => x.ItemType); - var items = await itemGateway.GetItemsByType(itemTypes.ToArray()); - - foreach (var itemLineDto in dto.ItemLines) - { - var item = items.FirstOrDefault(x => x.ItemType == itemLineDto.ItemType); - var itemLine = new ItemLine(itemLineDto.ItemLineId, itemLineDto.ItemType, item?.ItemType.ToString()!, (decimal)item?.Price!, ItemStatus.IN_PROGRESS, true); - order.ItemLines.Add(itemLine); - } - - return order; - } + public OrderSource OrderSource { get; set; } = orderSource; + public Guid LoyaltyMemberId { get; set; } = loyaltyMemberId; + public OrderStatus OrderStatus { get; set; } = orderStatus; + public Location Location { get; set; } = location; + public List ItemLines { get; set; } = []; + + public static async Task From(PlaceOrderCommand placeOrderCommand, IItemGateway itemGateway) + { + var order = new Order(placeOrderCommand.OrderId, placeOrderCommand.OrderSource, placeOrderCommand.LoyaltyMemberId, OrderStatus.IN_PROGRESS, placeOrderCommand.Location); + + if (placeOrderCommand.BaristaItems.Count != 0) + { + var itemTypes = placeOrderCommand.BaristaItems.Select(x => x.ItemType); + var items = await itemGateway.GetItemsByType(itemTypes.ToArray()); + foreach (var baristaItem in placeOrderCommand.BaristaItems) + { + var item = items.FirstOrDefault(x => x.ItemType == baristaItem.ItemType); + var itemLine = new ItemLine(baristaItem.ItemType, item?.ItemType.ToString()!, (decimal)item?.Price!, ItemStatus.IN_PROGRESS, true); + + // order.AddDomainEvent(new OrderUpdate(order.Id, itemLine.Id, itemLine.ItemType, OrderStatus.IN_PROGRESS)); + order.AddDomainEvent(new BaristaOrderIn(order.Id, itemLine.Id, itemLine.ItemType)); + + order.ItemLines.Add(itemLine); + } + } + + if (placeOrderCommand.KitchenItems.Count != 0) + { + var itemTypes = placeOrderCommand.KitchenItems.Select(x => x.ItemType); + var items = await itemGateway.GetItemsByType(itemTypes.ToArray()); + foreach (var kitchenItem in placeOrderCommand.KitchenItems) + { + var item = items.FirstOrDefault(x => x.ItemType == kitchenItem.ItemType); + var itemLine = new ItemLine(kitchenItem.ItemType, item?.ItemType.ToString()!, (decimal)item?.Price!, ItemStatus.IN_PROGRESS, false); + + // order.AddDomainEvent(new OrderUpdate(order.Id, itemLine.Id, itemLine.ItemType, OrderStatus.IN_PROGRESS)); + order.AddDomainEvent(new KitchenOrderIn(order.Id, itemLine.Id, itemLine.ItemType)); + + order.ItemLines.Add(itemLine); + } + } + + return order; + } + + public Order Apply(OrderUp orderUp) + { + if (ItemLines.Count == 0) return this; + + var item = ItemLines.FirstOrDefault(i => i.Id == orderUp.ItemLineId); + + if (item is not null) + { + item.ItemStatus = ItemStatus.FULFILLED; + // AddDomainEvent(new OrderUpdate(Id, item.Id, item.ItemType, OrderStatus.FULFILLED, orderUp.MadeBy)); + } + + // if there are both barista and kitchen items is fulfilled then checking status and change order to Fulfilled + if (ItemLines.All(i => i.ItemStatus == ItemStatus.FULFILLED)) + { + OrderStatus = OrderStatus.FULFILLED; + } + return this; + } + + public Order DomainEventAggregation() + { + var baristaEvents = new BaristaOrdersPlacedDomainEvent(); + var kitchenEvents = new KitchenOrdersPlacedDomainEvent(); + foreach (var @event in DomainEvents) + { + switch (@event) + { + case BaristaOrderIn baristaOrderInEvent: + baristaEvents.OrderId ??= baristaOrderInEvent.OrderId; + baristaEvents.ItemLines.Add( + new OrderItemLineDto( + baristaOrderInEvent.ItemLineId, + baristaOrderInEvent.ItemType, + ItemStatus.IN_PROGRESS)); + break; + case KitchenOrderIn kitchenOrderInEvent: + kitchenEvents.OrderId ??= kitchenOrderInEvent.OrderId; + kitchenEvents.ItemLines.Add( + new OrderItemLineDto( + kitchenOrderInEvent.ItemLineId, + kitchenOrderInEvent.ItemType, + ItemStatus.IN_PROGRESS)); + break; + } + } + + DomainEvents.Clear(); + DomainEvents.Add(baristaEvents); + DomainEvents.Add(kitchenEvents); + + return this; + } + + public static OrderDto ToDto(Order order) + { + var dto = new OrderDto + { + Id = order.Id, + OrderStatus = order.OrderStatus, + Location = order.Location, + OrderSource = order.OrderSource, + LoyaltyMemberId = order.LoyaltyMemberId + }; + + foreach (var item in order.ItemLines) + { + dto.ItemLines.Add(new OrderItemLineDto(item.Id, item.ItemType, item.ItemStatus)); + } + + return dto; + } + + public static async Task FromDto(OrderDto dto, IItemGateway itemGateway) + { + var order = new Order(dto.Id, dto.OrderSource, dto.LoyaltyMemberId, OrderStatus.IN_PROGRESS, dto.Location); + + var itemTypes = dto.ItemLines.Select(x => x.ItemType); + var items = await itemGateway.GetItemsByType(itemTypes.ToArray()); + + foreach (var itemLineDto in dto.ItemLines) + { + var item = items.FirstOrDefault(x => x.ItemType == itemLineDto.ItemType); + var itemLine = new ItemLine(itemLineDto.ItemLineId, itemLineDto.ItemType, item?.ItemType.ToString()!, (decimal)item?.Price!, ItemStatus.IN_PROGRESS, true); + order.ItemLines.Add(itemLine); + } + + return order; + } } -public class ItemLine +public class ItemLine(Guid id, ItemType itemType, string name, decimal price, ItemStatus itemStatus, bool isBarista) { - public Guid Id { get; set; } = Guid.NewGuid(); - public ItemType ItemType { get; set; } - public string Name { get; set; } - public decimal Price { get; set; } - public ItemStatus ItemStatus { get; set; } - public bool IsBaristaOrder { get; set; } - - public ItemLine() - { - } - - public ItemLine(ItemType itemType, string name, decimal price, ItemStatus itemStatus, bool isBarista) - : this(Guid.NewGuid(), itemType, name, price, itemStatus, isBarista) - { - } - - public ItemLine(Guid id, ItemType itemType, string name, decimal price, ItemStatus itemStatus, bool isBarista) - { - Id = id; - ItemType = itemType; - Name = name; - Price = price; - ItemStatus = itemStatus; - IsBaristaOrder = isBarista; - } + public Guid Id { get; set; } = id; + public ItemType ItemType { get; set; } = itemType; + public string Name { get; set; } = name; + public decimal Price { get; set; } = price; + public ItemStatus ItemStatus { get; set; } = itemStatus; + public bool IsBaristaOrder { get; set; } = isBarista; + + public ItemLine(ItemType itemType, string name, decimal price, ItemStatus itemStatus, bool isBarista) + : this(Guid.NewGuid(), itemType, name, price, itemStatus, isBarista) + { + } } \ No newline at end of file diff --git a/counter-api/Domain/SharedKernel/Events.cs b/counter-api/Domain/SharedKernel/Events.cs deleted file mode 100755 index 327e9ba..0000000 --- a/counter-api/Domain/SharedKernel/Events.cs +++ /dev/null @@ -1,12 +0,0 @@ -using MediatR; - -namespace CounterApi.Domain.SharedKernel; - -public interface IDomainEvent : INotification -{ -} - -public class EventWrapper(IDomainEvent @event) : INotification -{ - public IDomainEvent Event => @event; -} \ No newline at end of file diff --git a/counter-api/GlobalUsings.cs b/counter-api/GlobalUsings.cs new file mode 100644 index 0000000..5fa9510 --- /dev/null +++ b/counter-api/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using FluentValidation; +global using MassTransit; +global using MediatR; +global using Asp.Versioning; +global using System.Text.Json; \ No newline at end of file diff --git a/counter-api/Infrastructure/EventDispatcher.cs b/counter-api/Infrastructure/EventDispatcher.cs new file mode 100644 index 0000000..122482b --- /dev/null +++ b/counter-api/Infrastructure/EventDispatcher.cs @@ -0,0 +1,28 @@ +using CoffeeShop.MessageContracts; +using CoffeeShop.Shared.Domain; +using CounterApi.Domain.DomainEvents; + +namespace CounterApi.Infrastructure; + +public class EventDispatcher(IPublishEndpoint publisher) : INotificationHandler +{ + public virtual async Task Handle(EventWrapper @eventWrapper, CancellationToken cancellationToken) + { + switch (@eventWrapper.Event) + { + case BaristaOrdersPlacedDomainEvent @event: + await publisher.Publish(new { + @event.OrderId, + @event.ItemLines + }, cancellationToken); + break; + case KitchenOrdersPlacedDomainEvent @event: + await publisher.Publish(new + { + @event.OrderId, + @event.ItemLines + }, cancellationToken); + break; + } + } +} diff --git a/counter-api/Infrastructure/ItemHttpGateway.cs b/counter-api/Infrastructure/ItemHttpGateway.cs index c9654f9..6fa92e2 100755 --- a/counter-api/Infrastructure/ItemHttpGateway.cs +++ b/counter-api/Infrastructure/ItemHttpGateway.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using CounterApi.Domain; using CounterApi.Domain.Dtos; @@ -13,7 +12,7 @@ public async Task> GetItemsByType(ItemType[] itemTypes) var httpClient = httpClientFactory.CreateClient(); httpClient.BaseAddress = new Uri(config.GetValue("ProductApiUrl")!); //todo: need truly service discovery - var httpResponseMessage = await httpClient.GetFromJsonAsync>(config.GetValue("GetItemTypesApiRoute", "/v1/api/item-types")); + var httpResponseMessage = await httpClient.GetFromJsonAsync>(config.GetValue("GetItemTypesApiRoute", "/api/v1/item-types")); logger.LogInformation("Can get {Count} items", httpResponseMessage?.Count); logger.LogInformation("JSON: {HttpResponseMessage}", JsonSerializer.Serialize(httpResponseMessage)); diff --git a/counter-api/IntegrationEvents/EventHandlers/BaristaOrderUpdatedConsumer.cs b/counter-api/IntegrationEvents/EventHandlers/BaristaOrderUpdatedConsumer.cs index da6bc7e..a815c65 100755 --- a/counter-api/IntegrationEvents/EventHandlers/BaristaOrderUpdatedConsumer.cs +++ b/counter-api/IntegrationEvents/EventHandlers/BaristaOrderUpdatedConsumer.cs @@ -1,7 +1,5 @@ using CoffeeShop.MessageContracts; -using MassTransit; - namespace CounterApi.IntegrationEvents.EventHandlers; internal class BaristaOrderUpdatedConsumer(IPublishEndpoint publisher, ILogger logger) diff --git a/counter-api/IntegrationEvents/EventHandlers/KitchenOrderUpdatedConsumer.cs b/counter-api/IntegrationEvents/EventHandlers/KitchenOrderUpdatedConsumer.cs index 6d40df0..2eb3523 100755 --- a/counter-api/IntegrationEvents/EventHandlers/KitchenOrderUpdatedConsumer.cs +++ b/counter-api/IntegrationEvents/EventHandlers/KitchenOrderUpdatedConsumer.cs @@ -1,7 +1,5 @@ using CoffeeShop.MessageContracts; -using MassTransit; - namespace CounterApi.IntegrationEvents.EventHandlers; internal class KitchenOrderUpdatedConsumer(IPublishEndpoint publisher, ILogger logger) diff --git a/counter-api/IntegrationEvents/Events/OrderPlaced.cs b/counter-api/IntegrationEvents/Events/OrderPlaced.cs index 6ccd42b..09c15a8 100755 --- a/counter-api/IntegrationEvents/Events/OrderPlaced.cs +++ b/counter-api/IntegrationEvents/Events/OrderPlaced.cs @@ -5,11 +5,11 @@ namespace CoffeeShop.MessageContracts; public record BaristaOrderPlaced { public Guid OrderId { get; init; } - public List ItemLines { get; init; } = new(); + public List ItemLines { get; init; } = []; } public record KitchenOrderPlaced { public Guid OrderId { get; init; } - public List ItemLines { get; init; } = new(); + public List ItemLines { get; init; } = []; } \ No newline at end of file diff --git a/counter-api/IntegrationEvents/Events/OrderUpdated.cs b/counter-api/IntegrationEvents/Events/OrderUpdated.cs index bf4de4f..3bba3b6 100755 --- a/counter-api/IntegrationEvents/Events/OrderUpdated.cs +++ b/counter-api/IntegrationEvents/Events/OrderUpdated.cs @@ -4,12 +4,12 @@ namespace CoffeeShop.MessageContracts; public record BaristaOrderUpdated { - public Guid OrderId { get; init; } - public List ItemLines { get; init; } = new(); + public Guid OrderId { get; init; } + public List ItemLines { get; init; } = new(); } public record KitchenOrderUpdated { - public Guid OrderId { get; init; } - public List ItemLines { get; init; } = new(); + public Guid OrderId { get; init; } + public List ItemLines { get; init; } = new(); } \ No newline at end of file diff --git a/counter-api/InternalsVisibleTo.cs b/counter-api/InternalsVisibleTo.cs new file mode 100644 index 0000000..efd921e --- /dev/null +++ b/counter-api/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CoffeeShop.CounterApi.IntegrationTests")] \ No newline at end of file diff --git a/counter-api/Program.cs b/counter-api/Program.cs index 5840e90..faa3002 100755 --- a/counter-api/Program.cs +++ b/counter-api/Program.cs @@ -1,25 +1,46 @@ -using FluentValidation; -using CounterApi.UseCases; -using MassTransit; using CounterApi.IntegrationEvents.EventHandlers; using CounterApi.Infrastructure.Gateways; using CounterApi.Domain; +using CoffeeShop.Shared.Endpoint; +using CoffeeShop.Shared.Exceptions; +using CoffeeShop.Shared.OpenTelemetry; +using CoffeeShop.Shared.OpenTelemetry.OtelMassTransit; +using System.Diagnostics.CodeAnalysis; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); +builder.Services.AddExceptionHandler(); +builder.Services.AddExceptionHandler(); builder.Services.AddProblemDetails(); builder.Services.AddHttpContextAccessor(); -builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining()); -builder.Services.AddValidatorsFromAssemblyContaining(); +builder.Services.AddMediatR(cfg => { + cfg.RegisterServicesFromAssemblyContaining(); + cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); + cfg.AddOpenBehavior(typeof(HandlerBehavior<,>)); +}); +builder.Services.AddValidatorsFromAssemblyContaining(includeInternalTypes: true); -builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +builder.Services.AddEndpointsApiExplorer(); + +builder.Services.AddApiVersioning(options => +{ + options.DefaultApiVersion = new ApiVersion(1); + options.ApiVersionReader = new UrlSegmentApiVersionReader(); +}).AddApiExplorer(options => +{ + options.GroupNameFormat = "'v'V"; + options.SubstituteApiVersionInUrl = true; +}); + +builder.Services.AddEndpoints(typeof(Program).Assembly); -// builder.Services.AddHttpClient(client => -// client.BaseAddress = new(builder.Configuration.GetValue("ProductApiUrl")!)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddMassTransit(x => @@ -31,36 +52,40 @@ x.UsingRabbitMq((context, cfg) => { - // Console.WriteLine($"RabbitMQ Conn: {builder.Configuration.GetConnectionString("rabbitmq")}"); - // cfg.Host(new Uri(builder.Configuration.GetConnectionString("rabbitmq")!), h => { - // h.Username("guest"); - // h.Password("guest"); - // }); - cfg.Host(builder.Configuration.GetConnectionString("rabbitmq")!); - cfg.ConfigureEndpoints(context); + + cfg.UseSendFilter(typeof(OtelSendFilter<>), context); + cfg.UsePublishFilter(typeof(OtelPublishFilter<>), context); + cfg.UseConsumeFilter(typeof(OTelConsumeFilter<>), context); + + cfg.ConfigureEndpoints(context); }); }); var app = builder.Build(); +var apiVersionSet = app.NewApiVersionSet() + .HasApiVersion(new ApiVersion(1)) + .ReportApiVersions() + .Build(); + +var versionedGroup = app + .MapGroup("api/v{version:apiVersion}") + .WithApiVersionSet(apiVersionSet); + app.UseExceptionHandler(); -if (app.Environment.IsDevelopment()) +if(app.Environment.IsDevelopment()) { - app.UseSwagger(); - app.UseSwaggerUI(); + app.UseSwagger(); } app.UseRouting(); app.MapDefaultEndpoints(); - -app.Map("/", () => Results.Redirect("/swagger")); - -// todo -_ = app.MapOrderInApiRoutes() - // .MapOrderUpApiRoutes() - .MapOrderFulfillmentApiRoutes(); +app.MapEndpoints(versionedGroup); app.Run(); + +[ExcludeFromCodeCoverage] +public partial class Program; diff --git a/counter-api/UseCases/OrderFulfillmentQuery.cs b/counter-api/UseCases/OrderFulfillmentQuery.cs index 44016a5..27f991a 100755 --- a/counter-api/UseCases/OrderFulfillmentQuery.cs +++ b/counter-api/UseCases/OrderFulfillmentQuery.cs @@ -1,17 +1,13 @@ -using CounterApi.Domain.Dtos; - -using FluentValidation; -using MediatR; +using CoffeeShop.Shared.Endpoint; namespace CounterApi.UseCases; -internal static class OrderFulfillmentRouteMapper +public class OrderFulfillmentEndpoint : IEndpoint { - public static IEndpointRouteBuilder MapOrderFulfillmentApiRoutes(this IEndpointRouteBuilder builder) - { - builder.MapGet("/v1/api/fulfillment-orders", async (ISender sender) => await sender.Send(new OrderFulfillmentQuery())); - return builder; - } + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet("fulfillment-orders", async (ISender sender) => await sender.Send(new OrderFulfillmentQuery())); + } } public record OrderFulfillmentQuery : IRequest @@ -20,33 +16,33 @@ public record OrderFulfillmentQuery : IRequest internal class OrderFulfillmentValidator : AbstractValidator { - public OrderFulfillmentValidator() - { - } + public OrderFulfillmentValidator() + { + } } internal class OrderFulfillmentQueryHandler : IRequestHandler { - public async Task Handle(OrderFulfillmentQuery query, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(query); + public async Task Handle(OrderFulfillmentQuery query, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(query); - var orderGuidProcceed = new List(); + var orderGuidProcceed = new List(); - // todo - // var orderGuidList = await daprClient.GetStateAsync>("statestore", "order-list", cancellationToken: cancellationToken); - // if (orderGuidList != null && orderGuidList?.Count > 0) - // { - // foreach (var orderGuid in orderGuidList) - // { - // orderGuidProcceed.Add($"order-{orderGuid}"); - // } + // todo + // var orderGuidList = await daprClient.GetStateAsync>("statestore", "order-list", cancellationToken: cancellationToken); + // if (orderGuidList != null && orderGuidList?.Count > 0) + // { + // foreach (var orderGuid in orderGuidList) + // { + // orderGuidProcceed.Add($"order-{orderGuid}"); + // } - // var mulitpleStateResult = await daprClient.GetBulkStateAsync("statestore", orderGuidProcceed, parallelism: 1, cancellationToken: cancellationToken); + // var mulitpleStateResult = await daprClient.GetBulkStateAsync("statestore", orderGuidProcceed, parallelism: 1, cancellationToken: cancellationToken); - // return Results.Ok(mulitpleStateResult.Select(x => JsonSerializer.Serialize(x.Value)).ToList()); - // } + // return Results.Ok(mulitpleStateResult.Select(x => JsonSerializer.Serialize(x.Value)).ToList()); + // } - return Results.Ok(orderGuidProcceed); - } + return Results.Ok(orderGuidProcceed); + } } \ No newline at end of file diff --git a/counter-api/UseCases/PlaceOrderCommand.cs b/counter-api/UseCases/PlaceOrderCommand.cs index 0f945c9..15596b9 100755 --- a/counter-api/UseCases/PlaceOrderCommand.cs +++ b/counter-api/UseCases/PlaceOrderCommand.cs @@ -1,117 +1,45 @@ -using FluentValidation; -using MediatR; -using CounterApi.Domain.Commands; +using CoffeeShop.Shared.Domain; +using CoffeeShop.Shared.Endpoint; +using CoffeeShop.Shared.OpenTelemetry; + using CounterApi.Domain; -using System.Text.Json; -using MassTransit; -using CounterApi.Domain.SharedKernel; -using CoffeeShop.MessageContracts; -using CounterApi.Domain.DomainEvents; -using CounterApi.Domain.Dtos; +using CounterApi.Domain.Commands; namespace CounterApi.UseCases; -public static class OrderInRouteMapper +public class OrderInEndpoint : IEndpoint { - public static IEndpointRouteBuilder MapOrderInApiRoutes(this IEndpointRouteBuilder builder) - { - builder.MapPost("/v1/api/orders", async (PlaceOrderCommand command, ISender sender) => await sender.Send(command)); - return builder; - } + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapPost("orders", async (PlaceOrderCommand command, ISender sender) => await sender.Send(command)); + } } internal class OrderInValidator : AbstractValidator { + public OrderInValidator() + { + RuleFor(command => command.OrderId) + .NotEmpty().WithMessage("The order identifier can't be empty."); + } } -internal class PlaceOrderHandler(IPublishEndpoint publisher, IItemGateway itemGateway, ILogger logger) - : IRequestHandler +// [IgnoreOTelOnHandler] +internal class PlaceOrderHandler(IPublisher publisher, IItemGateway itemGateway, ILogger logger) + : IRequestHandler { - public async Task Handle(PlaceOrderCommand placeOrderCommand, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(placeOrderCommand); - - var itemTypes = new List { ItemType.ESPRESSO }; - var items = await itemGateway.GetItemsByType(itemTypes.ToArray()); - logger.LogInformation("[ProductAPI] Query: {JsonObject}", JsonSerializer.Serialize(items)); - - var orderId = Guid.NewGuid().ToString(); - - var order = await Order.From(placeOrderCommand, itemGateway); - order.Id = new Guid(orderId); - - // map domain object to dto - var dto = Order.ToDto(order); - dto.OrderStatus = OrderStatus.IN_PROGRESS; - - logger.LogInformation("Got {count} domain events.", order.DomainEvents.Count); - var @events = new IDomainEvent[order.DomainEvents.Count]; - order.DomainEvents.CopyTo(@events); - order.DomainEvents.Clear(); - - var baristaEvents = new Dictionary(); - var kitchenEvents = new Dictionary(); - foreach (var @event in @events) - { - switch (@event) - { - case BaristaOrderIn baristaOrderInEvent: - if (!baristaEvents.TryGetValue(baristaOrderInEvent.OrderId, out _)) - { - baristaEvents.Add(baristaOrderInEvent.OrderId, new BaristaOrderPlaced - { - OrderId = baristaOrderInEvent.OrderId, - ItemLines = - [ - new(baristaOrderInEvent.ItemLineId, baristaOrderInEvent.ItemType, ItemStatus.IN_PROGRESS) - ] - }); - } - else - { - baristaEvents[baristaOrderInEvent.OrderId].ItemLines.Add( - new OrderItemLineDto(baristaOrderInEvent.ItemLineId, baristaOrderInEvent.ItemType, ItemStatus.IN_PROGRESS)); - } - - break; - case KitchenOrderIn kitchenOrderInEvent: - if (!kitchenEvents.TryGetValue(kitchenOrderInEvent.OrderId, out _)) - { - kitchenEvents.Add(kitchenOrderInEvent.OrderId, new KitchenOrderPlaced - { - OrderId = kitchenOrderInEvent.OrderId, - ItemLines = - [ - new(kitchenOrderInEvent.ItemLineId, kitchenOrderInEvent.ItemType, ItemStatus.IN_PROGRESS) - ] - }); - } - else - { - kitchenEvents[kitchenOrderInEvent.OrderId].ItemLines.Add( - new OrderItemLineDto(kitchenOrderInEvent.ItemLineId, kitchenOrderInEvent.ItemType, ItemStatus.IN_PROGRESS)); - } - - break; - } - } + public async Task Handle(PlaceOrderCommand placeOrderCommand, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(placeOrderCommand); - if (baristaEvents.Count > 0) - { - logger.LogInformation("Pushlish barista events."); - foreach(var @event in baristaEvents) { - await publisher.Publish(@event.Value, cancellationToken); - } - } + var itemTypes = new List { ItemType.ESPRESSO }; // todo: remove hard-code + var items = await itemGateway.GetItemsByType(itemTypes.ToArray()); + logger.LogInformation("[ProductAPI] Query: {JsonObject}", JsonSerializer.Serialize(items)); - if (kitchenEvents.Count > 0) - { - logger.LogInformation("Pushlish kitchen events."); - foreach(var @event in kitchenEvents) { - await publisher.Publish(@event.Value, cancellationToken); - } - } + var order = await Order.From(placeOrderCommand, itemGateway); + order.DomainEventAggregation(); + await order.RelayAndPublishEvents(publisher, cancellationToken); - return Results.Ok(); - } + return Results.Ok(); + } } diff --git a/counter-api/appsettings.Development.json b/counter-api/appsettings.Development.json index b844778..5e9fd7f 100755 --- a/counter-api/appsettings.Development.json +++ b/counter-api/appsettings.Development.json @@ -4,7 +4,5 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } - }, - "RabbitMqUrl": "localhost", - "ProductApiUrl": "http://productApi" + } } \ No newline at end of file diff --git a/counter-api/appsettings.json b/counter-api/appsettings.json index 4d56694..e43f94d 100755 --- a/counter-api/appsettings.json +++ b/counter-api/appsettings.json @@ -1,9 +1,14 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "postgres": "Server=localhost;Port=5432;Database=postgres;", + "rabbitmq": "amqp://localhost" + }, + "ProductApiUrl": "http://product-api" } diff --git a/deployment-product-api.yaml b/deployment-product-api.yaml new file mode 100644 index 0000000..7859b8d --- /dev/null +++ b/deployment-product-api.yaml @@ -0,0 +1,69 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployment.kubernetes.io/revision: "1" + creationTimestamp: "2024-05-31T08:04:58Z" + generation: 1 + labels: + app: product-api + name: product-api + namespace: coffeeshop + resourceVersion: "1270" + uid: c29c2b79-f604-4973-a349-a316c82b8680 +spec: + minReadySeconds: 60 + progressDeadlineSeconds: 600 + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app: product-api + strategy: + type: Recreate + template: + metadata: + creationTimestamp: null + labels: + app: product-api + spec: + containers: + - envFrom: + - configMapRef: + name: product-api + image: k3d-myregistry.localhost:12345/product-api:latest + imagePullPolicy: IfNotPresent + name: product-api + ports: + - containerPort: 8080 + name: http + protocol: TCP + - containerPort: 8443 + name: https + protocol: TCP + resources: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + dnsPolicy: ClusterFirst + restartPolicy: Always + schedulerName: default-scheduler + securityContext: {} + terminationGracePeriodSeconds: 180 +status: + conditions: + - lastTransitionTime: "2024-05-31T08:04:58Z" + lastUpdateTime: "2024-05-31T08:04:58Z" + message: Deployment does not have minimum availability. + reason: MinimumReplicasUnavailable + status: "False" + type: Available + - lastTransitionTime: "2024-05-31T08:04:58Z" + lastUpdateTime: "2024-05-31T08:04:58Z" + message: ReplicaSet "product-api-788f967db8" is progressing. + reason: ReplicaSetUpdated + status: "True" + type: Progressing + observedGeneration: 1 + replicas: 1 + unavailableReplicas: 1 + updatedReplicas: 1 diff --git a/global.json b/global.json index fe565a4..abed231 100755 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "8.0.0", - "rollForward": "latestMajor", + "version": "8.0.301", + "rollForward": "major", "allowPrerelease": true } } \ No newline at end of file diff --git a/kitchen-api/kitchen-api.csproj b/kitchen-api/CoffeeShop.KitchenApi.csproj old mode 100755 new mode 100644 similarity index 65% rename from kitchen-api/kitchen-api.csproj rename to kitchen-api/CoffeeShop.KitchenApi.csproj index a8f6b19..42a9c58 --- a/kitchen-api/kitchen-api.csproj +++ b/kitchen-api/CoffeeShop.KitchenApi.csproj @@ -1,24 +1,23 @@ - - - - Exe - KitchenApi - kitchen-api - latest - - - - - - - - - - - - - - - - - + + + + Exe + KitchenApi + kitchen-api + latest + + + + + + + + + + + + + + + + diff --git a/kitchen-api/Program.cs b/kitchen-api/Program.cs index 37b1a21..8c9df8c 100755 --- a/kitchen-api/Program.cs +++ b/kitchen-api/Program.cs @@ -1,19 +1,29 @@ +using CoffeeShop.Shared.Exceptions; +using CoffeeShop.Shared.OpenTelemetry; + using FluentValidation; + using KitchenApi.IntegrationEvents.EventHandlers; + using MassTransit; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); +builder.Services.AddExceptionHandler(); +builder.Services.AddExceptionHandler(); builder.Services.AddProblemDetails(); builder.Services.AddHttpContextAccessor(); -builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining()); -builder.Services.AddValidatorsFromAssemblyContaining(); +builder.Services.AddMediatR(cfg => { + cfg.RegisterServicesFromAssemblyContaining(); + cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); + cfg.AddOpenBehavior(typeof(HandlerBehavior<,>)); +}); +builder.Services.AddValidatorsFromAssemblyContaining(includeInternalTypes: true); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); builder.Services.AddMassTransit(x => { @@ -23,29 +33,23 @@ x.UsingRabbitMq((context, cfg) => { - // cfg.Host(builder.Configuration.GetValue("RabbitMqUrl")!); - cfg.Host(builder.Configuration.GetConnectionString("rabbitmq")!); cfg.ConfigureEndpoints(context); }); }); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + var app = builder.Build(); app.UseExceptionHandler(); -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - app.UseRouting(); app.MapDefaultEndpoints(); -app.Map("/", () => Results.Redirect("/swagger")); - -// _ = app.MapOrderUpApiRoutes(); - app.Run(); + +public partial class Program; diff --git a/kitchen-api/appsettings.json b/kitchen-api/appsettings.json index 4d56694..430039c 100755 --- a/kitchen-api/appsettings.json +++ b/kitchen-api/appsettings.json @@ -1,9 +1,13 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "postgres": "Server=localhost;Port=5432;Database=postgres;", + "rabbitmq": "amqp://localhost" + } } diff --git a/order-summary/CoffeeShop.OrderSummary.csproj b/order-summary/CoffeeShop.OrderSummary.csproj new file mode 100644 index 0000000..64e3f97 --- /dev/null +++ b/order-summary/CoffeeShop.OrderSummary.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + + diff --git a/order-summary/Consumers/OrderConsumer.cs b/order-summary/Consumers/OrderConsumer.cs new file mode 100644 index 0000000..109cf78 --- /dev/null +++ b/order-summary/Consumers/OrderConsumer.cs @@ -0,0 +1,52 @@ +using System.Diagnostics; + +using CoffeeShop.MessageContracts; +using CoffeeShop.OrderSummary.Models; +using CoffeeShop.Shared.Helpers; + +namespace CoffeeShop.OrderSummary.Consumers; + +public class OrderConsumer(IDocumentSession documentSession, ILogger logger) : IConsumer, IConsumer +{ + public async Task Consume(ConsumeContext context) + { + logger.LogInformation("Consumer: {0} with orderId={1}", nameof(BaristaOrderPlaced), context.Message.OrderId); + + using var activity = new ActivitySource("masstransit").StartActivity($"Consumer: {nameof(BaristaOrderPlaced)} with orderId={context.Message.OrderId}"); + try + { + await documentSession.Events.WriteToAggregate( + GuidHelper.NewGuid(), + stream => { stream.AppendOne(context.Message); }); + } + catch (Exception ex) + { + activity?.AddTag("exception.message", ex.Message); + activity?.AddTag("exception.stacktrace", ex.ToString()); + activity?.AddTag("exception.type", ex.GetType().FullName); + activity?.SetStatus(ActivityStatusCode.Error); + throw; + } + } + + public async Task Consume(ConsumeContext context) + { + logger.LogInformation("Consumer: {0} with orderId={1}", nameof(KitchenOrderPlaced), context.Message.OrderId); + + using var activity = new ActivitySource("masstransit").StartActivity($"Consumer: {nameof(BaristaOrderPlaced)} with orderId={context.Message.OrderId}"); + try + { + await documentSession.Events.WriteToAggregate( + GuidHelper.NewGuid(), + stream => { stream.AppendOne(context.Message); }); + } + catch (Exception ex) + { + activity?.AddTag("exception.message", ex.Message); + activity?.AddTag("exception.stacktrace", ex.ToString()); + activity?.AddTag("exception.type", ex.GetType().FullName); + activity?.SetStatus(ActivityStatusCode.Error); + throw; + } + } +} diff --git a/order-summary/Events/Events.cs b/order-summary/Events/Events.cs new file mode 100644 index 0000000..b3b499b --- /dev/null +++ b/order-summary/Events/Events.cs @@ -0,0 +1,50 @@ +namespace CoffeeShop.MessageContracts; + +public enum ItemType +{ + // Beverages + CAPPUCCINO, + COFFEE_BLACK, + COFFEE_WITH_ROOM, + ESPRESSO, + ESPRESSO_DOUBLE, + LATTE, + // Food + CAKEPOP, + CROISSANT, + MUFFIN, + CROISSANT_CHOCOLATE +} + +public enum ItemStatus +{ + PLACED, + IN_PROGRESS, + FULFILLED +} + +public record OrderItemLineDto(Guid ItemLineId, ItemType ItemType, ItemStatus ItemStatus); + +public record BaristaOrderPlaced +{ + public Guid OrderId { get; init; } + public List ItemLines { get; init; } = []; +} + +public record KitchenOrderPlaced +{ + public Guid OrderId { get; init; } + public List ItemLines { get; init; } = []; +} + +public record BaristaOrderUpdated +{ + public Guid OrderId { get; init; } + public List ItemLines { get; init; } = []; +} + +public record KitchenOrderUpdated +{ + public Guid OrderId { get; init; } + public List ItemLines { get; init; } = []; +} diff --git a/order-summary/Features/OrderSummaryQuery.cs b/order-summary/Features/OrderSummaryQuery.cs new file mode 100644 index 0000000..29be632 --- /dev/null +++ b/order-summary/Features/OrderSummaryQuery.cs @@ -0,0 +1,54 @@ +using CoffeeShop.MessageContracts; +using CoffeeShop.Shared.Endpoint; + +namespace CoffeeShop.OrderSummary.Features; + +public class OrderSummaryEndpoint : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet("summary", (HttpContext context, IQuerySession querySession, Guid orderId) => + querySession.Json.WriteById(orderId, context) + ); + } +} + +public class OrderSummaryQuery +{ + public Guid Id { get; set; } + public int NumberOfBaristaProcessed { get; set; } + public int NumberOfKitchenProcessed { get; set; } + public int NumberOfBaristaUpdated { get; set; } + public int NumberOfKitchenUpdated { get; set; } +} + +public class OrderSummaryProjection : MultiStreamProjection +{ + public OrderSummaryProjection() + { + Identity(e => e.OrderId); + Identity(e => e.OrderId); + Identity(e => e.OrderId); + Identity(e => e.OrderId); + } + + public void Apply(BaristaOrderPlaced @event, OrderSummaryQuery current) + { + current.NumberOfBaristaProcessed++; + } + + public void Apply(KitchenOrderPlaced @event, OrderSummaryQuery current) + { + current.NumberOfKitchenProcessed++; + } + + public void Apply(BaristaOrderUpdated @event, OrderSummaryQuery current) + { + current.NumberOfBaristaUpdated++; + } + + public void Apply(KitchenOrderUpdated @event, OrderSummaryQuery current) + { + current.NumberOfKitchenUpdated++; + } +} diff --git a/order-summary/GlobalUsings.cs b/order-summary/GlobalUsings.cs new file mode 100644 index 0000000..2b4364f --- /dev/null +++ b/order-summary/GlobalUsings.cs @@ -0,0 +1,12 @@ +global using FluentValidation; +global using MassTransit; +global using JasperFx.CodeGeneration; +global using Marten; +global using Marten.AspNetCore; +global using Marten.Events.Daemon.Resiliency; +global using Marten.Events.Projections; +global using Weasel.Core; +global using MediatR; +global using Asp.Versioning; +global using Asp.Versioning.Builder; +global using System.Text.Json; \ No newline at end of file diff --git a/order-summary/Internal/Generated/DocumentStorage/OrderProvider60467594.cs b/order-summary/Internal/Generated/DocumentStorage/OrderProvider60467594.cs new file mode 100644 index 0000000..ca822b8 --- /dev/null +++ b/order-summary/Internal/Generated/DocumentStorage/OrderProvider60467594.cs @@ -0,0 +1,1079 @@ +// +#pragma warning disable +using CoffeeShop.OrderSummary.Models; +using Marten.Internal; +using Marten.Internal.Storage; +using Marten.Schema; +using Marten.Schema.Arguments; +using Npgsql; +using System; +using System.Collections.Generic; +using Weasel.Core; +using Weasel.Postgresql; + +namespace Marten.Generated.DocumentStorage +{ + // START: UpsertOrderOperation60467594 + public class UpsertOrderOperation60467594 : Marten.Internal.Operations.StorageOperation + { + private readonly CoffeeShop.OrderSummary.Models.Order _document; + private readonly System.Guid _id; + private readonly System.Collections.Generic.Dictionary _versions; + private readonly Marten.Schema.DocumentMapping _mapping; + + public UpsertOrderOperation60467594(CoffeeShop.OrderSummary.Models.Order document, System.Guid id, System.Collections.Generic.Dictionary versions, Marten.Schema.DocumentMapping mapping) : base(document, id, versions, mapping) + { + _document = document; + _id = id; + _versions = versions; + _mapping = mapping; + } + + + public const string COMMAND_TEXT = "select order_summary.mt_upsert_order(?, ?, ?, ?)"; + + + public override void Postprocess(System.Data.Common.DbDataReader reader, System.Collections.Generic.IList exceptions) + { + if (postprocessRevision(reader, exceptions)) + { + } + + } + + + public override async System.Threading.Tasks.Task PostprocessAsync(System.Data.Common.DbDataReader reader, System.Collections.Generic.IList exceptions, System.Threading.CancellationToken token) + { + if (await postprocessRevisionAsync(reader, exceptions, token)) + { + } + + } + + + public override Marten.Internal.Operations.OperationRole Role() + { + return Marten.Internal.Operations.OperationRole.Upsert; + } + + + public override string CommandText() + { + return COMMAND_TEXT; + } + + + public override NpgsqlTypes.NpgsqlDbType DbType() + { + return NpgsqlTypes.NpgsqlDbType.Uuid; + } + + + public override void ConfigureParameters(Npgsql.NpgsqlParameter[] parameters, CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session) + { + parameters[0].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Jsonb; + parameters[0].Value = session.Serializer.ToJson(_document); + // .Net Class Type + parameters[1].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Varchar; + parameters[1].Value = _document.GetType().FullName; + parameters[2].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Uuid; + parameters[2].Value = document.Id; + setCurrentRevisionParameter(parameters[3]); + } + + } + + // END: UpsertOrderOperation60467594 + + + // START: InsertOrderOperation60467594 + public class InsertOrderOperation60467594 : Marten.Internal.Operations.StorageOperation + { + private readonly CoffeeShop.OrderSummary.Models.Order _document; + private readonly System.Guid _id; + private readonly System.Collections.Generic.Dictionary _versions; + private readonly Marten.Schema.DocumentMapping _mapping; + + public InsertOrderOperation60467594(CoffeeShop.OrderSummary.Models.Order document, System.Guid id, System.Collections.Generic.Dictionary versions, Marten.Schema.DocumentMapping mapping) : base(document, id, versions, mapping) + { + _document = document; + _id = id; + _versions = versions; + _mapping = mapping; + } + + + public const string COMMAND_TEXT = "select order_summary.mt_insert_order(?, ?, ?, ?)"; + + + public override void Postprocess(System.Data.Common.DbDataReader reader, System.Collections.Generic.IList exceptions) + { + if (postprocessRevision(reader, exceptions)) + { + } + + } + + + public override async System.Threading.Tasks.Task PostprocessAsync(System.Data.Common.DbDataReader reader, System.Collections.Generic.IList exceptions, System.Threading.CancellationToken token) + { + if (await postprocessRevisionAsync(reader, exceptions, token)) + { + } + + } + + + public override Marten.Internal.Operations.OperationRole Role() + { + return Marten.Internal.Operations.OperationRole.Insert; + } + + + public override string CommandText() + { + return COMMAND_TEXT; + } + + + public override NpgsqlTypes.NpgsqlDbType DbType() + { + return NpgsqlTypes.NpgsqlDbType.Uuid; + } + + + public override void ConfigureParameters(Npgsql.NpgsqlParameter[] parameters, CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session) + { + parameters[0].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Jsonb; + parameters[0].Value = session.Serializer.ToJson(_document); + // .Net Class Type + parameters[1].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Varchar; + parameters[1].Value = _document.GetType().FullName; + parameters[2].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Uuid; + parameters[2].Value = document.Id; + setCurrentRevisionParameter(parameters[3]); + } + + } + + // END: InsertOrderOperation60467594 + + + // START: UpdateOrderOperation60467594 + public class UpdateOrderOperation60467594 : Marten.Internal.Operations.StorageOperation + { + private readonly CoffeeShop.OrderSummary.Models.Order _document; + private readonly System.Guid _id; + private readonly System.Collections.Generic.Dictionary _versions; + private readonly Marten.Schema.DocumentMapping _mapping; + + public UpdateOrderOperation60467594(CoffeeShop.OrderSummary.Models.Order document, System.Guid id, System.Collections.Generic.Dictionary versions, Marten.Schema.DocumentMapping mapping) : base(document, id, versions, mapping) + { + _document = document; + _id = id; + _versions = versions; + _mapping = mapping; + } + + + public const string COMMAND_TEXT = "select order_summary.mt_update_order(?, ?, ?, ?)"; + + + public override void Postprocess(System.Data.Common.DbDataReader reader, System.Collections.Generic.IList exceptions) + { + if (postprocessRevision(reader, exceptions)) + { + } + + } + + + public override async System.Threading.Tasks.Task PostprocessAsync(System.Data.Common.DbDataReader reader, System.Collections.Generic.IList exceptions, System.Threading.CancellationToken token) + { + if (await postprocessRevisionAsync(reader, exceptions, token)) + { + } + + } + + + public override Marten.Internal.Operations.OperationRole Role() + { + return Marten.Internal.Operations.OperationRole.Update; + } + + + public override string CommandText() + { + return COMMAND_TEXT; + } + + + public override NpgsqlTypes.NpgsqlDbType DbType() + { + return NpgsqlTypes.NpgsqlDbType.Uuid; + } + + + public override void ConfigureParameters(Npgsql.NpgsqlParameter[] parameters, CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session) + { + parameters[0].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Jsonb; + parameters[0].Value = session.Serializer.ToJson(_document); + // .Net Class Type + parameters[1].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Varchar; + parameters[1].Value = _document.GetType().FullName; + parameters[2].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Uuid; + parameters[2].Value = document.Id; + setCurrentRevisionParameter(parameters[3]); + } + + } + + // END: UpdateOrderOperation60467594 + + + // START: QueryOnlyOrderSelector60467594 + public class QueryOnlyOrderSelector60467594 : Marten.Internal.CodeGeneration.DocumentSelectorWithOnlySerializer, Marten.Linq.Selectors.ISelector + { + private readonly Marten.Internal.IMartenSession _session; + private readonly Marten.Schema.DocumentMapping _mapping; + + public QueryOnlyOrderSelector60467594(Marten.Internal.IMartenSession session, Marten.Schema.DocumentMapping mapping) : base(session, mapping) + { + _session = session; + _mapping = mapping; + } + + + + public CoffeeShop.OrderSummary.Models.Order Resolve(System.Data.Common.DbDataReader reader) + { + + CoffeeShop.OrderSummary.Models.Order document; + document = _serializer.FromJson(reader, 0); + return document; + } + + + public async System.Threading.Tasks.Task ResolveAsync(System.Data.Common.DbDataReader reader, System.Threading.CancellationToken token) + { + + CoffeeShop.OrderSummary.Models.Order document; + document = await _serializer.FromJsonAsync(reader, 0, token).ConfigureAwait(false); + return document; + } + + } + + // END: QueryOnlyOrderSelector60467594 + + + // START: LightweightOrderSelector60467594 + public class LightweightOrderSelector60467594 : Marten.Internal.CodeGeneration.DocumentSelectorWithOnlySerializer, Marten.Linq.Selectors.ISelector + { + private readonly Marten.Internal.IMartenSession _session; + private readonly Marten.Schema.DocumentMapping _mapping; + + public LightweightOrderSelector60467594(Marten.Internal.IMartenSession session, Marten.Schema.DocumentMapping mapping) : base(session, mapping) + { + _session = session; + _mapping = mapping; + } + + + + public CoffeeShop.OrderSummary.Models.Order Resolve(System.Data.Common.DbDataReader reader) + { + var id = reader.GetFieldValue(0); + + CoffeeShop.OrderSummary.Models.Order document; + document = _serializer.FromJson(reader, 1); + var version = reader.GetFieldValue(2); + _session.MarkAsDocumentLoaded(id, document); + return document; + } + + + public async System.Threading.Tasks.Task ResolveAsync(System.Data.Common.DbDataReader reader, System.Threading.CancellationToken token) + { + var id = await reader.GetFieldValueAsync(0, token); + + CoffeeShop.OrderSummary.Models.Order document; + document = await _serializer.FromJsonAsync(reader, 1, token).ConfigureAwait(false); + var version = await reader.GetFieldValueAsync(2, token); + _session.MarkAsDocumentLoaded(id, document); + return document; + } + + } + + // END: LightweightOrderSelector60467594 + + + // START: IdentityMapOrderSelector60467594 + public class IdentityMapOrderSelector60467594 : Marten.Internal.CodeGeneration.DocumentSelectorWithIdentityMap, Marten.Linq.Selectors.ISelector + { + private readonly Marten.Internal.IMartenSession _session; + private readonly Marten.Schema.DocumentMapping _mapping; + + public IdentityMapOrderSelector60467594(Marten.Internal.IMartenSession session, Marten.Schema.DocumentMapping mapping) : base(session, mapping) + { + _session = session; + _mapping = mapping; + } + + + + public CoffeeShop.OrderSummary.Models.Order Resolve(System.Data.Common.DbDataReader reader) + { + var id = reader.GetFieldValue(0); + if (_identityMap.TryGetValue(id, out var existing)) return existing; + + CoffeeShop.OrderSummary.Models.Order document; + document = _serializer.FromJson(reader, 1); + var version = reader.GetFieldValue(2); + _session.MarkAsDocumentLoaded(id, document); + _identityMap[id] = document; + return document; + } + + + public async System.Threading.Tasks.Task ResolveAsync(System.Data.Common.DbDataReader reader, System.Threading.CancellationToken token) + { + var id = await reader.GetFieldValueAsync(0, token); + if (_identityMap.TryGetValue(id, out var existing)) return existing; + + CoffeeShop.OrderSummary.Models.Order document; + document = await _serializer.FromJsonAsync(reader, 1, token).ConfigureAwait(false); + var version = await reader.GetFieldValueAsync(2, token); + _session.MarkAsDocumentLoaded(id, document); + _identityMap[id] = document; + return document; + } + + } + + // END: IdentityMapOrderSelector60467594 + + + // START: DirtyTrackingOrderSelector60467594 + public class DirtyTrackingOrderSelector60467594 : Marten.Internal.CodeGeneration.DocumentSelectorWithDirtyChecking, Marten.Linq.Selectors.ISelector + { + private readonly Marten.Internal.IMartenSession _session; + private readonly Marten.Schema.DocumentMapping _mapping; + + public DirtyTrackingOrderSelector60467594(Marten.Internal.IMartenSession session, Marten.Schema.DocumentMapping mapping) : base(session, mapping) + { + _session = session; + _mapping = mapping; + } + + + + public CoffeeShop.OrderSummary.Models.Order Resolve(System.Data.Common.DbDataReader reader) + { + var id = reader.GetFieldValue(0); + if (_identityMap.TryGetValue(id, out var existing)) return existing; + + CoffeeShop.OrderSummary.Models.Order document; + document = _serializer.FromJson(reader, 1); + var version = reader.GetFieldValue(2); + _session.MarkAsDocumentLoaded(id, document); + _identityMap[id] = document; + StoreTracker(_session, document); + return document; + } + + + public async System.Threading.Tasks.Task ResolveAsync(System.Data.Common.DbDataReader reader, System.Threading.CancellationToken token) + { + var id = await reader.GetFieldValueAsync(0, token); + if (_identityMap.TryGetValue(id, out var existing)) return existing; + + CoffeeShop.OrderSummary.Models.Order document; + document = await _serializer.FromJsonAsync(reader, 1, token).ConfigureAwait(false); + var version = await reader.GetFieldValueAsync(2, token); + _session.MarkAsDocumentLoaded(id, document); + _identityMap[id] = document; + StoreTracker(_session, document); + return document; + } + + } + + // END: DirtyTrackingOrderSelector60467594 + + + // START: OverwriteOrderOperation60467594 + public class OverwriteOrderOperation60467594 : Marten.Internal.Operations.StorageOperation + { + private readonly CoffeeShop.OrderSummary.Models.Order _document; + private readonly System.Guid _id; + private readonly System.Collections.Generic.Dictionary _versions; + private readonly Marten.Schema.DocumentMapping _mapping; + + public OverwriteOrderOperation60467594(CoffeeShop.OrderSummary.Models.Order document, System.Guid id, System.Collections.Generic.Dictionary versions, Marten.Schema.DocumentMapping mapping) : base(document, id, versions, mapping) + { + _document = document; + _id = id; + _versions = versions; + _mapping = mapping; + } + + + public const string COMMAND_TEXT = "select order_summary.mt_overwrite_order(?, ?, ?, ?)"; + + + public override void Postprocess(System.Data.Common.DbDataReader reader, System.Collections.Generic.IList exceptions) + { + if (postprocessRevision(reader, exceptions)) + { + } + + } + + + public override async System.Threading.Tasks.Task PostprocessAsync(System.Data.Common.DbDataReader reader, System.Collections.Generic.IList exceptions, System.Threading.CancellationToken token) + { + if (await postprocessRevisionAsync(reader, exceptions, token)) + { + } + + } + + + public override Marten.Internal.Operations.OperationRole Role() + { + return Marten.Internal.Operations.OperationRole.Update; + } + + + public override string CommandText() + { + return COMMAND_TEXT; + } + + + public override NpgsqlTypes.NpgsqlDbType DbType() + { + return NpgsqlTypes.NpgsqlDbType.Uuid; + } + + + public override void ConfigureParameters(Npgsql.NpgsqlParameter[] parameters, CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session) + { + parameters[0].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Jsonb; + parameters[0].Value = session.Serializer.ToJson(_document); + // .Net Class Type + parameters[1].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Varchar; + parameters[1].Value = _document.GetType().FullName; + parameters[2].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Uuid; + parameters[2].Value = document.Id; + setCurrentRevisionParameter(parameters[3]); + } + + } + + // END: OverwriteOrderOperation60467594 + + + // START: QueryOnlyOrderDocumentStorage60467594 + public class QueryOnlyOrderDocumentStorage60467594 : Marten.Internal.Storage.QueryOnlyDocumentStorage + { + private readonly Marten.Schema.DocumentMapping _document; + + public QueryOnlyOrderDocumentStorage60467594(Marten.Schema.DocumentMapping document) : base(document) + { + _document = document; + } + + + + public override System.Guid AssignIdentity(CoffeeShop.OrderSummary.Models.Order document, string tenantId, Marten.Storage.IMartenDatabase database) + { + if (document.Id == Guid.Empty) _setter(document, Marten.Schema.Identity.CombGuidIdGeneration.NewGuid()); + return document.Id; + } + + + public override Marten.Internal.Operations.IStorageOperation Update(CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session, string tenant) + { + if (session.Concurrency == Marten.Services.ConcurrencyChecks.Disabled) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + else + { + + return new Marten.Generated.DocumentStorage.UpdateOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + } + + + public override Marten.Internal.Operations.IStorageOperation Insert(CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session, string tenant) + { + + return new Marten.Generated.DocumentStorage.InsertOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + + public override Marten.Internal.Operations.IStorageOperation Upsert(CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session, string tenant) + { + if (session.Concurrency == Marten.Services.ConcurrencyChecks.Disabled) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + else + { + + return new Marten.Generated.DocumentStorage.UpsertOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + } + + + public override Marten.Internal.Operations.IStorageOperation Overwrite(CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session, string tenant) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + + return new Marten.Generated.DocumentStorage.OverwriteOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + + public override System.Guid Identity(CoffeeShop.OrderSummary.Models.Order document) + { + return document.Id; + } + + + public override Marten.Linq.Selectors.ISelector BuildSelector(Marten.Internal.IMartenSession session) + { + return new Marten.Generated.DocumentStorage.QueryOnlyOrderSelector60467594(session, _document); + } + + } + + // END: QueryOnlyOrderDocumentStorage60467594 + + + // START: LightweightOrderDocumentStorage60467594 + public class LightweightOrderDocumentStorage60467594 : Marten.Internal.Storage.LightweightDocumentStorage + { + private readonly Marten.Schema.DocumentMapping _document; + + public LightweightOrderDocumentStorage60467594(Marten.Schema.DocumentMapping document) : base(document) + { + _document = document; + } + + + + public override System.Guid AssignIdentity(CoffeeShop.OrderSummary.Models.Order document, string tenantId, Marten.Storage.IMartenDatabase database) + { + if (document.Id == Guid.Empty) _setter(document, Marten.Schema.Identity.CombGuidIdGeneration.NewGuid()); + return document.Id; + } + + + public override Marten.Internal.Operations.IStorageOperation Update(CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session, string tenant) + { + if (session.Concurrency == Marten.Services.ConcurrencyChecks.Disabled) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + else + { + + return new Marten.Generated.DocumentStorage.UpdateOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + } + + + public override Marten.Internal.Operations.IStorageOperation Insert(CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session, string tenant) + { + + return new Marten.Generated.DocumentStorage.InsertOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + + public override Marten.Internal.Operations.IStorageOperation Upsert(CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session, string tenant) + { + if (session.Concurrency == Marten.Services.ConcurrencyChecks.Disabled) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + else + { + + return new Marten.Generated.DocumentStorage.UpsertOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + } + + + public override Marten.Internal.Operations.IStorageOperation Overwrite(CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session, string tenant) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + + return new Marten.Generated.DocumentStorage.OverwriteOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + + public override System.Guid Identity(CoffeeShop.OrderSummary.Models.Order document) + { + return document.Id; + } + + + public override Marten.Linq.Selectors.ISelector BuildSelector(Marten.Internal.IMartenSession session) + { + return new Marten.Generated.DocumentStorage.LightweightOrderSelector60467594(session, _document); + } + + } + + // END: LightweightOrderDocumentStorage60467594 + + + // START: IdentityMapOrderDocumentStorage60467594 + public class IdentityMapOrderDocumentStorage60467594 : Marten.Internal.Storage.IdentityMapDocumentStorage + { + private readonly Marten.Schema.DocumentMapping _document; + + public IdentityMapOrderDocumentStorage60467594(Marten.Schema.DocumentMapping document) : base(document) + { + _document = document; + } + + + + public override System.Guid AssignIdentity(CoffeeShop.OrderSummary.Models.Order document, string tenantId, Marten.Storage.IMartenDatabase database) + { + if (document.Id == Guid.Empty) _setter(document, Marten.Schema.Identity.CombGuidIdGeneration.NewGuid()); + return document.Id; + } + + + public override Marten.Internal.Operations.IStorageOperation Update(CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session, string tenant) + { + if (session.Concurrency == Marten.Services.ConcurrencyChecks.Disabled) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + else + { + + return new Marten.Generated.DocumentStorage.UpdateOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + } + + + public override Marten.Internal.Operations.IStorageOperation Insert(CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session, string tenant) + { + + return new Marten.Generated.DocumentStorage.InsertOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + + public override Marten.Internal.Operations.IStorageOperation Upsert(CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session, string tenant) + { + if (session.Concurrency == Marten.Services.ConcurrencyChecks.Disabled) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + else + { + + return new Marten.Generated.DocumentStorage.UpsertOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + } + + + public override Marten.Internal.Operations.IStorageOperation Overwrite(CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session, string tenant) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + + return new Marten.Generated.DocumentStorage.OverwriteOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + + public override System.Guid Identity(CoffeeShop.OrderSummary.Models.Order document) + { + return document.Id; + } + + + public override Marten.Linq.Selectors.ISelector BuildSelector(Marten.Internal.IMartenSession session) + { + return new Marten.Generated.DocumentStorage.IdentityMapOrderSelector60467594(session, _document); + } + + } + + // END: IdentityMapOrderDocumentStorage60467594 + + + // START: DirtyTrackingOrderDocumentStorage60467594 + public class DirtyTrackingOrderDocumentStorage60467594 : Marten.Internal.Storage.DirtyCheckedDocumentStorage + { + private readonly Marten.Schema.DocumentMapping _document; + + public DirtyTrackingOrderDocumentStorage60467594(Marten.Schema.DocumentMapping document) : base(document) + { + _document = document; + } + + + + public override System.Guid AssignIdentity(CoffeeShop.OrderSummary.Models.Order document, string tenantId, Marten.Storage.IMartenDatabase database) + { + if (document.Id == Guid.Empty) _setter(document, Marten.Schema.Identity.CombGuidIdGeneration.NewGuid()); + return document.Id; + } + + + public override Marten.Internal.Operations.IStorageOperation Update(CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session, string tenant) + { + if (session.Concurrency == Marten.Services.ConcurrencyChecks.Disabled) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + else + { + + return new Marten.Generated.DocumentStorage.UpdateOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + } + + + public override Marten.Internal.Operations.IStorageOperation Insert(CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session, string tenant) + { + + return new Marten.Generated.DocumentStorage.InsertOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + + public override Marten.Internal.Operations.IStorageOperation Upsert(CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session, string tenant) + { + if (session.Concurrency == Marten.Services.ConcurrencyChecks.Disabled) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + else + { + + return new Marten.Generated.DocumentStorage.UpsertOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + } + + + public override Marten.Internal.Operations.IStorageOperation Overwrite(CoffeeShop.OrderSummary.Models.Order document, Marten.Internal.IMartenSession session, string tenant) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + + return new Marten.Generated.DocumentStorage.OverwriteOrderOperation60467594 + ( + document, Identity(document), + null, + _document + + ); + } + + + public override System.Guid Identity(CoffeeShop.OrderSummary.Models.Order document) + { + return document.Id; + } + + + public override Marten.Linq.Selectors.ISelector BuildSelector(Marten.Internal.IMartenSession session) + { + return new Marten.Generated.DocumentStorage.DirtyTrackingOrderSelector60467594(session, _document); + } + + } + + // END: DirtyTrackingOrderDocumentStorage60467594 + + + // START: OrderBulkLoader60467594 + public class OrderBulkLoader60467594 : Marten.Internal.CodeGeneration.BulkLoader + { + private readonly Marten.Internal.Storage.IDocumentStorage _storage; + + public OrderBulkLoader60467594(Marten.Internal.Storage.IDocumentStorage storage) : base(storage) + { + _storage = storage; + } + + + public const string MAIN_LOADER_SQL = "COPY order_summary.mt_doc_order(\"mt_dotnet_type\", \"id\", \"mt_version\", \"data\") FROM STDIN BINARY"; + + public const string TEMP_LOADER_SQL = "COPY mt_doc_order_temp(\"mt_dotnet_type\", \"id\", \"mt_version\", \"data\") FROM STDIN BINARY"; + + public const string COPY_NEW_DOCUMENTS_SQL = "insert into order_summary.mt_doc_order (\"id\", \"data\", \"mt_dotnet_type\", \"mt_version\", mt_last_modified) (select mt_doc_order_temp.\"id\", mt_doc_order_temp.\"data\", mt_doc_order_temp.\"mt_dotnet_type\", mt_doc_order_temp.\"mt_version\", transaction_timestamp() from mt_doc_order_temp left join order_summary.mt_doc_order on mt_doc_order_temp.id = order_summary.mt_doc_order.id where order_summary.mt_doc_order.id is null)"; + + public const string OVERWRITE_SQL = "update order_summary.mt_doc_order target SET data = source.data, mt_dotnet_type = source.mt_dotnet_type, mt_version = source.mt_version, mt_last_modified = transaction_timestamp() FROM mt_doc_order_temp source WHERE source.id = target.id"; + + public const string CREATE_TEMP_TABLE_FOR_COPYING_SQL = "create temporary table mt_doc_order_temp as select * from order_summary.mt_doc_order limit 0"; + + + public override string CreateTempTableForCopying() + { + return CREATE_TEMP_TABLE_FOR_COPYING_SQL; + } + + + public override string CopyNewDocumentsFromTempTable() + { + return COPY_NEW_DOCUMENTS_SQL; + } + + + public override string OverwriteDuplicatesFromTempTable() + { + return OVERWRITE_SQL; + } + + + public override void LoadRow(Npgsql.NpgsqlBinaryImporter writer, CoffeeShop.OrderSummary.Models.Order document, Marten.Storage.Tenant tenant, Marten.ISerializer serializer) + { + writer.Write(document.GetType().FullName, NpgsqlTypes.NpgsqlDbType.Varchar); + writer.Write(document.Id, NpgsqlTypes.NpgsqlDbType.Uuid); + writer.Write(1, NpgsqlTypes.NpgsqlDbType.Integer); + writer.Write(serializer.ToJson(document), NpgsqlTypes.NpgsqlDbType.Jsonb); + } + + + public override async System.Threading.Tasks.Task LoadRowAsync(Npgsql.NpgsqlBinaryImporter writer, CoffeeShop.OrderSummary.Models.Order document, Marten.Storage.Tenant tenant, Marten.ISerializer serializer, System.Threading.CancellationToken cancellation) + { + await writer.WriteAsync(document.GetType().FullName, NpgsqlTypes.NpgsqlDbType.Varchar, cancellation); + await writer.WriteAsync(document.Id, NpgsqlTypes.NpgsqlDbType.Uuid, cancellation); + await writer.WriteAsync(1, NpgsqlTypes.NpgsqlDbType.Integer, cancellation); + await writer.WriteAsync(serializer.ToJson(document), NpgsqlTypes.NpgsqlDbType.Jsonb, cancellation); + } + + + public override string MainLoaderSql() + { + return MAIN_LOADER_SQL; + } + + + public override string TempLoaderSql() + { + return TEMP_LOADER_SQL; + } + + } + + // END: OrderBulkLoader60467594 + + + // START: OrderProvider60467594 + public class OrderProvider60467594 : Marten.Internal.Storage.DocumentProvider + { + private readonly Marten.Schema.DocumentMapping _mapping; + + public OrderProvider60467594(Marten.Schema.DocumentMapping mapping) : base(new OrderBulkLoader60467594(new QueryOnlyOrderDocumentStorage60467594(mapping)), new QueryOnlyOrderDocumentStorage60467594(mapping), new LightweightOrderDocumentStorage60467594(mapping), new IdentityMapOrderDocumentStorage60467594(mapping), new DirtyTrackingOrderDocumentStorage60467594(mapping)) + { + _mapping = mapping; + } + + + } + + // END: OrderProvider60467594 + + +} + diff --git a/order-summary/Internal/Generated/DocumentStorage/OrderSummaryQueryProvider244148371.cs b/order-summary/Internal/Generated/DocumentStorage/OrderSummaryQueryProvider244148371.cs new file mode 100644 index 0000000..89bee3a --- /dev/null +++ b/order-summary/Internal/Generated/DocumentStorage/OrderSummaryQueryProvider244148371.cs @@ -0,0 +1,1079 @@ +// +#pragma warning disable +using CoffeeShop.OrderSummary.Features; +using Marten.Internal; +using Marten.Internal.Storage; +using Marten.Schema; +using Marten.Schema.Arguments; +using Npgsql; +using System; +using System.Collections.Generic; +using Weasel.Core; +using Weasel.Postgresql; + +namespace Marten.Generated.DocumentStorage +{ + // START: UpsertOrderSummaryQueryOperation244148371 + public class UpsertOrderSummaryQueryOperation244148371 : Marten.Internal.Operations.StorageOperation + { + private readonly CoffeeShop.OrderSummary.Features.OrderSummaryQuery _document; + private readonly System.Guid _id; + private readonly System.Collections.Generic.Dictionary _versions; + private readonly Marten.Schema.DocumentMapping _mapping; + + public UpsertOrderSummaryQueryOperation244148371(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, System.Guid id, System.Collections.Generic.Dictionary versions, Marten.Schema.DocumentMapping mapping) : base(document, id, versions, mapping) + { + _document = document; + _id = id; + _versions = versions; + _mapping = mapping; + } + + + public const string COMMAND_TEXT = "select order_summary.mt_upsert_ordersummaryquery(?, ?, ?, ?)"; + + + public override void Postprocess(System.Data.Common.DbDataReader reader, System.Collections.Generic.IList exceptions) + { + if (postprocessRevision(reader, exceptions)) + { + } + + } + + + public override async System.Threading.Tasks.Task PostprocessAsync(System.Data.Common.DbDataReader reader, System.Collections.Generic.IList exceptions, System.Threading.CancellationToken token) + { + if (await postprocessRevisionAsync(reader, exceptions, token)) + { + } + + } + + + public override Marten.Internal.Operations.OperationRole Role() + { + return Marten.Internal.Operations.OperationRole.Upsert; + } + + + public override string CommandText() + { + return COMMAND_TEXT; + } + + + public override NpgsqlTypes.NpgsqlDbType DbType() + { + return NpgsqlTypes.NpgsqlDbType.Uuid; + } + + + public override void ConfigureParameters(Npgsql.NpgsqlParameter[] parameters, CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session) + { + parameters[0].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Jsonb; + parameters[0].Value = session.Serializer.ToJson(_document); + // .Net Class Type + parameters[1].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Varchar; + parameters[1].Value = _document.GetType().FullName; + parameters[2].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Uuid; + parameters[2].Value = document.Id; + setCurrentRevisionParameter(parameters[3]); + } + + } + + // END: UpsertOrderSummaryQueryOperation244148371 + + + // START: InsertOrderSummaryQueryOperation244148371 + public class InsertOrderSummaryQueryOperation244148371 : Marten.Internal.Operations.StorageOperation + { + private readonly CoffeeShop.OrderSummary.Features.OrderSummaryQuery _document; + private readonly System.Guid _id; + private readonly System.Collections.Generic.Dictionary _versions; + private readonly Marten.Schema.DocumentMapping _mapping; + + public InsertOrderSummaryQueryOperation244148371(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, System.Guid id, System.Collections.Generic.Dictionary versions, Marten.Schema.DocumentMapping mapping) : base(document, id, versions, mapping) + { + _document = document; + _id = id; + _versions = versions; + _mapping = mapping; + } + + + public const string COMMAND_TEXT = "select order_summary.mt_insert_ordersummaryquery(?, ?, ?, ?)"; + + + public override void Postprocess(System.Data.Common.DbDataReader reader, System.Collections.Generic.IList exceptions) + { + if (postprocessRevision(reader, exceptions)) + { + } + + } + + + public override async System.Threading.Tasks.Task PostprocessAsync(System.Data.Common.DbDataReader reader, System.Collections.Generic.IList exceptions, System.Threading.CancellationToken token) + { + if (await postprocessRevisionAsync(reader, exceptions, token)) + { + } + + } + + + public override Marten.Internal.Operations.OperationRole Role() + { + return Marten.Internal.Operations.OperationRole.Insert; + } + + + public override string CommandText() + { + return COMMAND_TEXT; + } + + + public override NpgsqlTypes.NpgsqlDbType DbType() + { + return NpgsqlTypes.NpgsqlDbType.Uuid; + } + + + public override void ConfigureParameters(Npgsql.NpgsqlParameter[] parameters, CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session) + { + parameters[0].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Jsonb; + parameters[0].Value = session.Serializer.ToJson(_document); + // .Net Class Type + parameters[1].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Varchar; + parameters[1].Value = _document.GetType().FullName; + parameters[2].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Uuid; + parameters[2].Value = document.Id; + setCurrentRevisionParameter(parameters[3]); + } + + } + + // END: InsertOrderSummaryQueryOperation244148371 + + + // START: UpdateOrderSummaryQueryOperation244148371 + public class UpdateOrderSummaryQueryOperation244148371 : Marten.Internal.Operations.StorageOperation + { + private readonly CoffeeShop.OrderSummary.Features.OrderSummaryQuery _document; + private readonly System.Guid _id; + private readonly System.Collections.Generic.Dictionary _versions; + private readonly Marten.Schema.DocumentMapping _mapping; + + public UpdateOrderSummaryQueryOperation244148371(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, System.Guid id, System.Collections.Generic.Dictionary versions, Marten.Schema.DocumentMapping mapping) : base(document, id, versions, mapping) + { + _document = document; + _id = id; + _versions = versions; + _mapping = mapping; + } + + + public const string COMMAND_TEXT = "select order_summary.mt_update_ordersummaryquery(?, ?, ?, ?)"; + + + public override void Postprocess(System.Data.Common.DbDataReader reader, System.Collections.Generic.IList exceptions) + { + if (postprocessRevision(reader, exceptions)) + { + } + + } + + + public override async System.Threading.Tasks.Task PostprocessAsync(System.Data.Common.DbDataReader reader, System.Collections.Generic.IList exceptions, System.Threading.CancellationToken token) + { + if (await postprocessRevisionAsync(reader, exceptions, token)) + { + } + + } + + + public override Marten.Internal.Operations.OperationRole Role() + { + return Marten.Internal.Operations.OperationRole.Update; + } + + + public override string CommandText() + { + return COMMAND_TEXT; + } + + + public override NpgsqlTypes.NpgsqlDbType DbType() + { + return NpgsqlTypes.NpgsqlDbType.Uuid; + } + + + public override void ConfigureParameters(Npgsql.NpgsqlParameter[] parameters, CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session) + { + parameters[0].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Jsonb; + parameters[0].Value = session.Serializer.ToJson(_document); + // .Net Class Type + parameters[1].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Varchar; + parameters[1].Value = _document.GetType().FullName; + parameters[2].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Uuid; + parameters[2].Value = document.Id; + setCurrentRevisionParameter(parameters[3]); + } + + } + + // END: UpdateOrderSummaryQueryOperation244148371 + + + // START: QueryOnlyOrderSummaryQuerySelector244148371 + public class QueryOnlyOrderSummaryQuerySelector244148371 : Marten.Internal.CodeGeneration.DocumentSelectorWithOnlySerializer, Marten.Linq.Selectors.ISelector + { + private readonly Marten.Internal.IMartenSession _session; + private readonly Marten.Schema.DocumentMapping _mapping; + + public QueryOnlyOrderSummaryQuerySelector244148371(Marten.Internal.IMartenSession session, Marten.Schema.DocumentMapping mapping) : base(session, mapping) + { + _session = session; + _mapping = mapping; + } + + + + public CoffeeShop.OrderSummary.Features.OrderSummaryQuery Resolve(System.Data.Common.DbDataReader reader) + { + + CoffeeShop.OrderSummary.Features.OrderSummaryQuery document; + document = _serializer.FromJson(reader, 0); + return document; + } + + + public async System.Threading.Tasks.Task ResolveAsync(System.Data.Common.DbDataReader reader, System.Threading.CancellationToken token) + { + + CoffeeShop.OrderSummary.Features.OrderSummaryQuery document; + document = await _serializer.FromJsonAsync(reader, 0, token).ConfigureAwait(false); + return document; + } + + } + + // END: QueryOnlyOrderSummaryQuerySelector244148371 + + + // START: LightweightOrderSummaryQuerySelector244148371 + public class LightweightOrderSummaryQuerySelector244148371 : Marten.Internal.CodeGeneration.DocumentSelectorWithOnlySerializer, Marten.Linq.Selectors.ISelector + { + private readonly Marten.Internal.IMartenSession _session; + private readonly Marten.Schema.DocumentMapping _mapping; + + public LightweightOrderSummaryQuerySelector244148371(Marten.Internal.IMartenSession session, Marten.Schema.DocumentMapping mapping) : base(session, mapping) + { + _session = session; + _mapping = mapping; + } + + + + public CoffeeShop.OrderSummary.Features.OrderSummaryQuery Resolve(System.Data.Common.DbDataReader reader) + { + var id = reader.GetFieldValue(0); + + CoffeeShop.OrderSummary.Features.OrderSummaryQuery document; + document = _serializer.FromJson(reader, 1); + var version = reader.GetFieldValue(2); + _session.MarkAsDocumentLoaded(id, document); + return document; + } + + + public async System.Threading.Tasks.Task ResolveAsync(System.Data.Common.DbDataReader reader, System.Threading.CancellationToken token) + { + var id = await reader.GetFieldValueAsync(0, token); + + CoffeeShop.OrderSummary.Features.OrderSummaryQuery document; + document = await _serializer.FromJsonAsync(reader, 1, token).ConfigureAwait(false); + var version = await reader.GetFieldValueAsync(2, token); + _session.MarkAsDocumentLoaded(id, document); + return document; + } + + } + + // END: LightweightOrderSummaryQuerySelector244148371 + + + // START: IdentityMapOrderSummaryQuerySelector244148371 + public class IdentityMapOrderSummaryQuerySelector244148371 : Marten.Internal.CodeGeneration.DocumentSelectorWithIdentityMap, Marten.Linq.Selectors.ISelector + { + private readonly Marten.Internal.IMartenSession _session; + private readonly Marten.Schema.DocumentMapping _mapping; + + public IdentityMapOrderSummaryQuerySelector244148371(Marten.Internal.IMartenSession session, Marten.Schema.DocumentMapping mapping) : base(session, mapping) + { + _session = session; + _mapping = mapping; + } + + + + public CoffeeShop.OrderSummary.Features.OrderSummaryQuery Resolve(System.Data.Common.DbDataReader reader) + { + var id = reader.GetFieldValue(0); + if (_identityMap.TryGetValue(id, out var existing)) return existing; + + CoffeeShop.OrderSummary.Features.OrderSummaryQuery document; + document = _serializer.FromJson(reader, 1); + var version = reader.GetFieldValue(2); + _session.MarkAsDocumentLoaded(id, document); + _identityMap[id] = document; + return document; + } + + + public async System.Threading.Tasks.Task ResolveAsync(System.Data.Common.DbDataReader reader, System.Threading.CancellationToken token) + { + var id = await reader.GetFieldValueAsync(0, token); + if (_identityMap.TryGetValue(id, out var existing)) return existing; + + CoffeeShop.OrderSummary.Features.OrderSummaryQuery document; + document = await _serializer.FromJsonAsync(reader, 1, token).ConfigureAwait(false); + var version = await reader.GetFieldValueAsync(2, token); + _session.MarkAsDocumentLoaded(id, document); + _identityMap[id] = document; + return document; + } + + } + + // END: IdentityMapOrderSummaryQuerySelector244148371 + + + // START: DirtyTrackingOrderSummaryQuerySelector244148371 + public class DirtyTrackingOrderSummaryQuerySelector244148371 : Marten.Internal.CodeGeneration.DocumentSelectorWithDirtyChecking, Marten.Linq.Selectors.ISelector + { + private readonly Marten.Internal.IMartenSession _session; + private readonly Marten.Schema.DocumentMapping _mapping; + + public DirtyTrackingOrderSummaryQuerySelector244148371(Marten.Internal.IMartenSession session, Marten.Schema.DocumentMapping mapping) : base(session, mapping) + { + _session = session; + _mapping = mapping; + } + + + + public CoffeeShop.OrderSummary.Features.OrderSummaryQuery Resolve(System.Data.Common.DbDataReader reader) + { + var id = reader.GetFieldValue(0); + if (_identityMap.TryGetValue(id, out var existing)) return existing; + + CoffeeShop.OrderSummary.Features.OrderSummaryQuery document; + document = _serializer.FromJson(reader, 1); + var version = reader.GetFieldValue(2); + _session.MarkAsDocumentLoaded(id, document); + _identityMap[id] = document; + StoreTracker(_session, document); + return document; + } + + + public async System.Threading.Tasks.Task ResolveAsync(System.Data.Common.DbDataReader reader, System.Threading.CancellationToken token) + { + var id = await reader.GetFieldValueAsync(0, token); + if (_identityMap.TryGetValue(id, out var existing)) return existing; + + CoffeeShop.OrderSummary.Features.OrderSummaryQuery document; + document = await _serializer.FromJsonAsync(reader, 1, token).ConfigureAwait(false); + var version = await reader.GetFieldValueAsync(2, token); + _session.MarkAsDocumentLoaded(id, document); + _identityMap[id] = document; + StoreTracker(_session, document); + return document; + } + + } + + // END: DirtyTrackingOrderSummaryQuerySelector244148371 + + + // START: OverwriteOrderSummaryQueryOperation244148371 + public class OverwriteOrderSummaryQueryOperation244148371 : Marten.Internal.Operations.StorageOperation + { + private readonly CoffeeShop.OrderSummary.Features.OrderSummaryQuery _document; + private readonly System.Guid _id; + private readonly System.Collections.Generic.Dictionary _versions; + private readonly Marten.Schema.DocumentMapping _mapping; + + public OverwriteOrderSummaryQueryOperation244148371(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, System.Guid id, System.Collections.Generic.Dictionary versions, Marten.Schema.DocumentMapping mapping) : base(document, id, versions, mapping) + { + _document = document; + _id = id; + _versions = versions; + _mapping = mapping; + } + + + public const string COMMAND_TEXT = "select order_summary.mt_overwrite_ordersummaryquery(?, ?, ?, ?)"; + + + public override void Postprocess(System.Data.Common.DbDataReader reader, System.Collections.Generic.IList exceptions) + { + if (postprocessRevision(reader, exceptions)) + { + } + + } + + + public override async System.Threading.Tasks.Task PostprocessAsync(System.Data.Common.DbDataReader reader, System.Collections.Generic.IList exceptions, System.Threading.CancellationToken token) + { + if (await postprocessRevisionAsync(reader, exceptions, token)) + { + } + + } + + + public override Marten.Internal.Operations.OperationRole Role() + { + return Marten.Internal.Operations.OperationRole.Update; + } + + + public override string CommandText() + { + return COMMAND_TEXT; + } + + + public override NpgsqlTypes.NpgsqlDbType DbType() + { + return NpgsqlTypes.NpgsqlDbType.Uuid; + } + + + public override void ConfigureParameters(Npgsql.NpgsqlParameter[] parameters, CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session) + { + parameters[0].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Jsonb; + parameters[0].Value = session.Serializer.ToJson(_document); + // .Net Class Type + parameters[1].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Varchar; + parameters[1].Value = _document.GetType().FullName; + parameters[2].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Uuid; + parameters[2].Value = document.Id; + setCurrentRevisionParameter(parameters[3]); + } + + } + + // END: OverwriteOrderSummaryQueryOperation244148371 + + + // START: QueryOnlyOrderSummaryQueryDocumentStorage244148371 + public class QueryOnlyOrderSummaryQueryDocumentStorage244148371 : Marten.Internal.Storage.QueryOnlyDocumentStorage + { + private readonly Marten.Schema.DocumentMapping _document; + + public QueryOnlyOrderSummaryQueryDocumentStorage244148371(Marten.Schema.DocumentMapping document) : base(document) + { + _document = document; + } + + + + public override System.Guid AssignIdentity(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, string tenantId, Marten.Storage.IMartenDatabase database) + { + if (document.Id == Guid.Empty) _setter(document, Marten.Schema.Identity.CombGuidIdGeneration.NewGuid()); + return document.Id; + } + + + public override Marten.Internal.Operations.IStorageOperation Update(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session, string tenant) + { + if (session.Concurrency == Marten.Services.ConcurrencyChecks.Disabled) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + else + { + + return new Marten.Generated.DocumentStorage.UpdateOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + } + + + public override Marten.Internal.Operations.IStorageOperation Insert(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session, string tenant) + { + + return new Marten.Generated.DocumentStorage.InsertOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + + public override Marten.Internal.Operations.IStorageOperation Upsert(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session, string tenant) + { + if (session.Concurrency == Marten.Services.ConcurrencyChecks.Disabled) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + else + { + + return new Marten.Generated.DocumentStorage.UpsertOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + } + + + public override Marten.Internal.Operations.IStorageOperation Overwrite(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session, string tenant) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + + return new Marten.Generated.DocumentStorage.OverwriteOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + + public override System.Guid Identity(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document) + { + return document.Id; + } + + + public override Marten.Linq.Selectors.ISelector BuildSelector(Marten.Internal.IMartenSession session) + { + return new Marten.Generated.DocumentStorage.QueryOnlyOrderSummaryQuerySelector244148371(session, _document); + } + + } + + // END: QueryOnlyOrderSummaryQueryDocumentStorage244148371 + + + // START: LightweightOrderSummaryQueryDocumentStorage244148371 + public class LightweightOrderSummaryQueryDocumentStorage244148371 : Marten.Internal.Storage.LightweightDocumentStorage + { + private readonly Marten.Schema.DocumentMapping _document; + + public LightweightOrderSummaryQueryDocumentStorage244148371(Marten.Schema.DocumentMapping document) : base(document) + { + _document = document; + } + + + + public override System.Guid AssignIdentity(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, string tenantId, Marten.Storage.IMartenDatabase database) + { + if (document.Id == Guid.Empty) _setter(document, Marten.Schema.Identity.CombGuidIdGeneration.NewGuid()); + return document.Id; + } + + + public override Marten.Internal.Operations.IStorageOperation Update(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session, string tenant) + { + if (session.Concurrency == Marten.Services.ConcurrencyChecks.Disabled) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + else + { + + return new Marten.Generated.DocumentStorage.UpdateOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + } + + + public override Marten.Internal.Operations.IStorageOperation Insert(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session, string tenant) + { + + return new Marten.Generated.DocumentStorage.InsertOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + + public override Marten.Internal.Operations.IStorageOperation Upsert(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session, string tenant) + { + if (session.Concurrency == Marten.Services.ConcurrencyChecks.Disabled) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + else + { + + return new Marten.Generated.DocumentStorage.UpsertOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + } + + + public override Marten.Internal.Operations.IStorageOperation Overwrite(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session, string tenant) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + + return new Marten.Generated.DocumentStorage.OverwriteOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + + public override System.Guid Identity(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document) + { + return document.Id; + } + + + public override Marten.Linq.Selectors.ISelector BuildSelector(Marten.Internal.IMartenSession session) + { + return new Marten.Generated.DocumentStorage.LightweightOrderSummaryQuerySelector244148371(session, _document); + } + + } + + // END: LightweightOrderSummaryQueryDocumentStorage244148371 + + + // START: IdentityMapOrderSummaryQueryDocumentStorage244148371 + public class IdentityMapOrderSummaryQueryDocumentStorage244148371 : Marten.Internal.Storage.IdentityMapDocumentStorage + { + private readonly Marten.Schema.DocumentMapping _document; + + public IdentityMapOrderSummaryQueryDocumentStorage244148371(Marten.Schema.DocumentMapping document) : base(document) + { + _document = document; + } + + + + public override System.Guid AssignIdentity(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, string tenantId, Marten.Storage.IMartenDatabase database) + { + if (document.Id == Guid.Empty) _setter(document, Marten.Schema.Identity.CombGuidIdGeneration.NewGuid()); + return document.Id; + } + + + public override Marten.Internal.Operations.IStorageOperation Update(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session, string tenant) + { + if (session.Concurrency == Marten.Services.ConcurrencyChecks.Disabled) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + else + { + + return new Marten.Generated.DocumentStorage.UpdateOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + } + + + public override Marten.Internal.Operations.IStorageOperation Insert(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session, string tenant) + { + + return new Marten.Generated.DocumentStorage.InsertOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + + public override Marten.Internal.Operations.IStorageOperation Upsert(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session, string tenant) + { + if (session.Concurrency == Marten.Services.ConcurrencyChecks.Disabled) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + else + { + + return new Marten.Generated.DocumentStorage.UpsertOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + } + + + public override Marten.Internal.Operations.IStorageOperation Overwrite(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session, string tenant) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + + return new Marten.Generated.DocumentStorage.OverwriteOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + + public override System.Guid Identity(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document) + { + return document.Id; + } + + + public override Marten.Linq.Selectors.ISelector BuildSelector(Marten.Internal.IMartenSession session) + { + return new Marten.Generated.DocumentStorage.IdentityMapOrderSummaryQuerySelector244148371(session, _document); + } + + } + + // END: IdentityMapOrderSummaryQueryDocumentStorage244148371 + + + // START: DirtyTrackingOrderSummaryQueryDocumentStorage244148371 + public class DirtyTrackingOrderSummaryQueryDocumentStorage244148371 : Marten.Internal.Storage.DirtyCheckedDocumentStorage + { + private readonly Marten.Schema.DocumentMapping _document; + + public DirtyTrackingOrderSummaryQueryDocumentStorage244148371(Marten.Schema.DocumentMapping document) : base(document) + { + _document = document; + } + + + + public override System.Guid AssignIdentity(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, string tenantId, Marten.Storage.IMartenDatabase database) + { + if (document.Id == Guid.Empty) _setter(document, Marten.Schema.Identity.CombGuidIdGeneration.NewGuid()); + return document.Id; + } + + + public override Marten.Internal.Operations.IStorageOperation Update(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session, string tenant) + { + if (session.Concurrency == Marten.Services.ConcurrencyChecks.Disabled) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + else + { + + return new Marten.Generated.DocumentStorage.UpdateOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + } + + + public override Marten.Internal.Operations.IStorageOperation Insert(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session, string tenant) + { + + return new Marten.Generated.DocumentStorage.InsertOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + + public override Marten.Internal.Operations.IStorageOperation Upsert(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session, string tenant) + { + if (session.Concurrency == Marten.Services.ConcurrencyChecks.Disabled) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + else + { + + return new Marten.Generated.DocumentStorage.UpsertOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + } + + + public override Marten.Internal.Operations.IStorageOperation Overwrite(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Internal.IMartenSession session, string tenant) + { + + return new Marten.Generated.DocumentStorage.OverwriteOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + + return new Marten.Generated.DocumentStorage.OverwriteOrderSummaryQueryOperation244148371 + ( + document, Identity(document), + null, + _document + + ); + } + + + public override System.Guid Identity(CoffeeShop.OrderSummary.Features.OrderSummaryQuery document) + { + return document.Id; + } + + + public override Marten.Linq.Selectors.ISelector BuildSelector(Marten.Internal.IMartenSession session) + { + return new Marten.Generated.DocumentStorage.DirtyTrackingOrderSummaryQuerySelector244148371(session, _document); + } + + } + + // END: DirtyTrackingOrderSummaryQueryDocumentStorage244148371 + + + // START: OrderSummaryQueryBulkLoader244148371 + public class OrderSummaryQueryBulkLoader244148371 : Marten.Internal.CodeGeneration.BulkLoader + { + private readonly Marten.Internal.Storage.IDocumentStorage _storage; + + public OrderSummaryQueryBulkLoader244148371(Marten.Internal.Storage.IDocumentStorage storage) : base(storage) + { + _storage = storage; + } + + + public const string MAIN_LOADER_SQL = "COPY order_summary.mt_doc_ordersummaryquery(\"mt_dotnet_type\", \"id\", \"mt_version\", \"data\") FROM STDIN BINARY"; + + public const string TEMP_LOADER_SQL = "COPY mt_doc_ordersummaryquery_temp(\"mt_dotnet_type\", \"id\", \"mt_version\", \"data\") FROM STDIN BINARY"; + + public const string COPY_NEW_DOCUMENTS_SQL = "insert into order_summary.mt_doc_ordersummaryquery (\"id\", \"data\", \"mt_dotnet_type\", \"mt_version\", mt_last_modified) (select mt_doc_ordersummaryquery_temp.\"id\", mt_doc_ordersummaryquery_temp.\"data\", mt_doc_ordersummaryquery_temp.\"mt_dotnet_type\", mt_doc_ordersummaryquery_temp.\"mt_version\", transaction_timestamp() from mt_doc_ordersummaryquery_temp left join order_summary.mt_doc_ordersummaryquery on mt_doc_ordersummaryquery_temp.id = order_summary.mt_doc_ordersummaryquery.id where order_summary.mt_doc_ordersummaryquery.id is null)"; + + public const string OVERWRITE_SQL = "update order_summary.mt_doc_ordersummaryquery target SET data = source.data, mt_dotnet_type = source.mt_dotnet_type, mt_version = source.mt_version, mt_last_modified = transaction_timestamp() FROM mt_doc_ordersummaryquery_temp source WHERE source.id = target.id"; + + public const string CREATE_TEMP_TABLE_FOR_COPYING_SQL = "create temporary table mt_doc_ordersummaryquery_temp as select * from order_summary.mt_doc_ordersummaryquery limit 0"; + + + public override string CreateTempTableForCopying() + { + return CREATE_TEMP_TABLE_FOR_COPYING_SQL; + } + + + public override string CopyNewDocumentsFromTempTable() + { + return COPY_NEW_DOCUMENTS_SQL; + } + + + public override string OverwriteDuplicatesFromTempTable() + { + return OVERWRITE_SQL; + } + + + public override void LoadRow(Npgsql.NpgsqlBinaryImporter writer, CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Storage.Tenant tenant, Marten.ISerializer serializer) + { + writer.Write(document.GetType().FullName, NpgsqlTypes.NpgsqlDbType.Varchar); + writer.Write(document.Id, NpgsqlTypes.NpgsqlDbType.Uuid); + writer.Write(1, NpgsqlTypes.NpgsqlDbType.Integer); + writer.Write(serializer.ToJson(document), NpgsqlTypes.NpgsqlDbType.Jsonb); + } + + + public override async System.Threading.Tasks.Task LoadRowAsync(Npgsql.NpgsqlBinaryImporter writer, CoffeeShop.OrderSummary.Features.OrderSummaryQuery document, Marten.Storage.Tenant tenant, Marten.ISerializer serializer, System.Threading.CancellationToken cancellation) + { + await writer.WriteAsync(document.GetType().FullName, NpgsqlTypes.NpgsqlDbType.Varchar, cancellation); + await writer.WriteAsync(document.Id, NpgsqlTypes.NpgsqlDbType.Uuid, cancellation); + await writer.WriteAsync(1, NpgsqlTypes.NpgsqlDbType.Integer, cancellation); + await writer.WriteAsync(serializer.ToJson(document), NpgsqlTypes.NpgsqlDbType.Jsonb, cancellation); + } + + + public override string MainLoaderSql() + { + return MAIN_LOADER_SQL; + } + + + public override string TempLoaderSql() + { + return TEMP_LOADER_SQL; + } + + } + + // END: OrderSummaryQueryBulkLoader244148371 + + + // START: OrderSummaryQueryProvider244148371 + public class OrderSummaryQueryProvider244148371 : Marten.Internal.Storage.DocumentProvider + { + private readonly Marten.Schema.DocumentMapping _mapping; + + public OrderSummaryQueryProvider244148371(Marten.Schema.DocumentMapping mapping) : base(new OrderSummaryQueryBulkLoader244148371(new QueryOnlyOrderSummaryQueryDocumentStorage244148371(mapping)), new QueryOnlyOrderSummaryQueryDocumentStorage244148371(mapping), new LightweightOrderSummaryQueryDocumentStorage244148371(mapping), new IdentityMapOrderSummaryQueryDocumentStorage244148371(mapping), new DirtyTrackingOrderSummaryQueryDocumentStorage244148371(mapping)) + { + _mapping = mapping; + } + + + } + + // END: OrderSummaryQueryProvider244148371 + + +} + diff --git a/order-summary/Internal/Generated/EventStore/EventStorage.cs b/order-summary/Internal/Generated/EventStore/EventStorage.cs new file mode 100644 index 0000000..4feb841 --- /dev/null +++ b/order-summary/Internal/Generated/EventStore/EventStorage.cs @@ -0,0 +1,286 @@ +// +#pragma warning disable +using Marten; +using Marten.Events; +using System; + +namespace Marten.Generated.EventStore +{ + // START: GeneratedEventDocumentStorage + public class GeneratedEventDocumentStorage : Marten.Events.EventDocumentStorage + { + private readonly Marten.StoreOptions _options; + + public GeneratedEventDocumentStorage(Marten.StoreOptions options) : base(options) + { + _options = options; + } + + + + public override Marten.Internal.Operations.IStorageOperation AppendEvent(Marten.Events.EventGraph events, Marten.Internal.IMartenSession session, Marten.Events.StreamAction stream, Marten.Events.IEvent e) + { + return new Marten.Generated.EventStore.AppendEventOperation(stream, e); + } + + + public override Marten.Internal.Operations.IStorageOperation InsertStream(Marten.Events.StreamAction stream) + { + return new Marten.Generated.EventStore.GeneratedInsertStream(stream); + } + + + public override Marten.Linq.QueryHandlers.IQueryHandler QueryForStream(Marten.Events.StreamAction stream) + { + return new Marten.Generated.EventStore.GeneratedStreamStateQueryHandler(stream.Id); + } + + + public override Marten.Internal.Operations.IStorageOperation UpdateStreamVersion(Marten.Events.StreamAction stream) + { + return new Marten.Generated.EventStore.GeneratedStreamVersionOperation(stream); + } + + + public override void ApplyReaderDataToEvent(System.Data.Common.DbDataReader reader, Marten.Events.IEvent e) + { + if (!reader.IsDBNull(3)) + { + var sequence = reader.GetFieldValue(3); + e.Sequence = sequence; + } + if (!reader.IsDBNull(4)) + { + var id = reader.GetFieldValue(4); + e.Id = id; + } + var streamId = reader.GetFieldValue(5); + e.StreamId = streamId; + if (!reader.IsDBNull(6)) + { + var version = reader.GetFieldValue(6); + e.Version = version; + } + if (!reader.IsDBNull(7)) + { + var timestamp = reader.GetFieldValue(7); + e.Timestamp = timestamp; + } + if (!reader.IsDBNull(8)) + { + var tenantId = reader.GetFieldValue(8); + e.TenantId = tenantId; + } + var isArchived = reader.GetFieldValue(9); + e.IsArchived = isArchived; + } + + + public override async System.Threading.Tasks.Task ApplyReaderDataToEventAsync(System.Data.Common.DbDataReader reader, Marten.Events.IEvent e, System.Threading.CancellationToken token) + { + if (!(await reader.IsDBNullAsync(3, token).ConfigureAwait(false))) + { + var sequence = await reader.GetFieldValueAsync(3, token).ConfigureAwait(false); + e.Sequence = sequence; + } + if (!(await reader.IsDBNullAsync(4, token).ConfigureAwait(false))) + { + var id = await reader.GetFieldValueAsync(4, token).ConfigureAwait(false); + e.Id = id; + } + var streamId = await reader.GetFieldValueAsync(5, token).ConfigureAwait(false); + e.StreamId = streamId; + if (!(await reader.IsDBNullAsync(6, token).ConfigureAwait(false))) + { + var version = await reader.GetFieldValueAsync(6, token).ConfigureAwait(false); + e.Version = version; + } + if (!(await reader.IsDBNullAsync(7, token).ConfigureAwait(false))) + { + var timestamp = await reader.GetFieldValueAsync(7, token).ConfigureAwait(false); + e.Timestamp = timestamp; + } + if (!(await reader.IsDBNullAsync(8, token).ConfigureAwait(false))) + { + var tenantId = await reader.GetFieldValueAsync(8, token).ConfigureAwait(false); + e.TenantId = tenantId; + } + var isArchived = await reader.GetFieldValueAsync(9, token).ConfigureAwait(false); + e.IsArchived = isArchived; + } + + } + + // END: GeneratedEventDocumentStorage + + + // START: AppendEventOperation + public class AppendEventOperation : Marten.Events.Operations.AppendEventOperationBase + { + private readonly Marten.Events.StreamAction _stream; + private readonly Marten.Events.IEvent _e; + + public AppendEventOperation(Marten.Events.StreamAction stream, Marten.Events.IEvent e) : base(stream, e) + { + _stream = stream; + _e = e; + } + + + public const string SQL = "insert into order_summary.mt_events (data, type, mt_dotnet_type, seq_id, id, stream_id, version, timestamp, tenant_id) values (?, ?, ?, ?, ?, ?, ?, ?, ?)"; + + + public override void ConfigureCommand(Weasel.Postgresql.ICommandBuilder builder, Marten.Internal.IMartenSession session) + { + var parameters = builder.AppendWithParameters(SQL); + parameters[0].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Jsonb; + parameters[0].Value = session.Serializer.ToJson(Event.Data); + parameters[1].Value = Event.EventTypeName != null ? (object)Event.EventTypeName : System.DBNull.Value; + parameters[1].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Text; + parameters[2].Value = Event.DotNetTypeName != null ? (object)Event.DotNetTypeName : System.DBNull.Value; + parameters[2].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Text; + parameters[3].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Bigint; + parameters[3].Value = Event.Sequence; + parameters[4].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Uuid; + parameters[4].Value = Event.Id; + parameters[5].Value = Stream.Id; + parameters[5].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Uuid; + parameters[6].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Bigint; + parameters[6].Value = Event.Version; + parameters[7].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.TimestampTz; + parameters[7].Value = Event.Timestamp; + parameters[8].Value = Stream.TenantId != null ? (object)Stream.TenantId : System.DBNull.Value; + parameters[8].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Text; + } + + } + + // END: AppendEventOperation + + + // START: GeneratedInsertStream + public class GeneratedInsertStream : Marten.Events.Operations.InsertStreamBase + { + private readonly Marten.Events.StreamAction _stream; + + public GeneratedInsertStream(Marten.Events.StreamAction stream) : base(stream) + { + _stream = stream; + } + + + public const string SQL = "insert into order_summary.mt_streams (id, type, version, tenant_id) values (?, ?, ?, ?)"; + + + public override void ConfigureCommand(Weasel.Postgresql.ICommandBuilder builder, Marten.Internal.IMartenSession session) + { + var parameters = builder.AppendWithParameters(SQL); + parameters[0].Value = Stream.Id; + parameters[0].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Uuid; + parameters[1].Value = Stream.AggregateTypeName != null ? (object)Stream.AggregateTypeName : System.DBNull.Value; + parameters[1].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Text; + parameters[2].Value = Stream.Version; + parameters[2].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Bigint; + parameters[3].Value = Stream.TenantId != null ? (object)Stream.TenantId : System.DBNull.Value; + parameters[3].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Text; + } + + } + + // END: GeneratedInsertStream + + + // START: GeneratedStreamStateQueryHandler + public class GeneratedStreamStateQueryHandler : Marten.Events.Querying.StreamStateQueryHandler + { + private readonly System.Guid _streamId; + + public GeneratedStreamStateQueryHandler(System.Guid streamId) + { + _streamId = streamId; + } + + + public const string SQL = "select id, version, type, timestamp, created as timestamp, is_archived from order_summary.mt_streams where id = ?"; + + + public override void ConfigureCommand(Weasel.Postgresql.ICommandBuilder builder, Marten.Internal.IMartenSession session) + { + var npgsqlParameterArray = builder.AppendWithParameters(SQL); + npgsqlParameterArray[0].Value = _streamId; + npgsqlParameterArray[0].DbType = System.Data.DbType.Guid; + } + + + public override Marten.Events.StreamState Resolve(Marten.Internal.IMartenSession session, System.Data.Common.DbDataReader reader) + { + var streamState = new Marten.Events.StreamState(); + var id = reader.GetFieldValue(0); + streamState.Id = id; + var version = reader.GetFieldValue(1); + streamState.Version = version; + SetAggregateType(streamState, reader, session); + var lastTimestamp = reader.GetFieldValue(3); + streamState.LastTimestamp = lastTimestamp; + var created = reader.GetFieldValue(4); + streamState.Created = created; + var isArchived = reader.GetFieldValue(5); + streamState.IsArchived = isArchived; + return streamState; + } + + + public override async System.Threading.Tasks.Task ResolveAsync(Marten.Internal.IMartenSession session, System.Data.Common.DbDataReader reader, System.Threading.CancellationToken token) + { + var streamState = new Marten.Events.StreamState(); + var id = await reader.GetFieldValueAsync(0, token).ConfigureAwait(false); + streamState.Id = id; + var version = await reader.GetFieldValueAsync(1, token).ConfigureAwait(false); + streamState.Version = version; + await SetAggregateTypeAsync(streamState, reader, session, token).ConfigureAwait(false); + var lastTimestamp = await reader.GetFieldValueAsync(3, token).ConfigureAwait(false); + streamState.LastTimestamp = lastTimestamp; + var created = await reader.GetFieldValueAsync(4, token).ConfigureAwait(false); + streamState.Created = created; + var isArchived = await reader.GetFieldValueAsync(5, token).ConfigureAwait(false); + streamState.IsArchived = isArchived; + return streamState; + } + + } + + // END: GeneratedStreamStateQueryHandler + + + // START: GeneratedStreamVersionOperation + public class GeneratedStreamVersionOperation : Marten.Events.Operations.UpdateStreamVersion + { + private readonly Marten.Events.StreamAction _stream; + + public GeneratedStreamVersionOperation(Marten.Events.StreamAction stream) : base(stream) + { + _stream = stream; + } + + + public const string SQL = "update order_summary.mt_streams set version = ? where id = ? and version = ?"; + + + public override void ConfigureCommand(Weasel.Postgresql.ICommandBuilder builder, Marten.Internal.IMartenSession session) + { + var parameters = builder.AppendWithParameters(SQL); + parameters[0].Value = Stream.Version; + parameters[0].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Bigint; + parameters[1].Value = Stream.Id; + parameters[1].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Uuid; + parameters[2].Value = Stream.ExpectedVersionOnServer; + parameters[2].NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Bigint; + } + + } + + // END: GeneratedStreamVersionOperation + + +} + diff --git a/order-summary/Internal/Generated/EventStore/OrderSummaryProjectionRuntimeSupport1590385736.cs b/order-summary/Internal/Generated/EventStore/OrderSummaryProjectionRuntimeSupport1590385736.cs new file mode 100644 index 0000000..db13d1f --- /dev/null +++ b/order-summary/Internal/Generated/EventStore/OrderSummaryProjectionRuntimeSupport1590385736.cs @@ -0,0 +1,133 @@ +// +#pragma warning disable +using CoffeeShop.OrderSummary.Features; +using Marten; +using Marten.Events.Aggregation; +using Marten.Internal.Storage; +using System; +using System.Linq; + +namespace Marten.Generated.EventStore +{ + // START: OrderSummaryProjectionLiveAggregation1590385736 + public class OrderSummaryProjectionLiveAggregation1590385736 : Marten.Events.Aggregation.SyncLiveAggregatorBase + { + private readonly CoffeeShop.OrderSummary.Features.OrderSummaryProjection _orderSummaryProjection; + + public OrderSummaryProjectionLiveAggregation1590385736(CoffeeShop.OrderSummary.Features.OrderSummaryProjection orderSummaryProjection) + { + _orderSummaryProjection = orderSummaryProjection; + } + + + + public override CoffeeShop.OrderSummary.Features.OrderSummaryQuery Build(System.Collections.Generic.IReadOnlyList events, Marten.IQuerySession session, CoffeeShop.OrderSummary.Features.OrderSummaryQuery snapshot) + { + if (!events.Any()) return snapshot; + var usedEventOnCreate = snapshot is null; + snapshot ??= Create(events[0], session);; + if (snapshot is null) + { + usedEventOnCreate = false; + snapshot = CreateDefault(events[0]); + } + + foreach (var @event in events.Skip(usedEventOnCreate ? 1 : 0)) + { + snapshot = Apply(@event, snapshot, session); + } + + return snapshot; + } + + + public CoffeeShop.OrderSummary.Features.OrderSummaryQuery Create(Marten.Events.IEvent @event, Marten.IQuerySession session) + { + return null; + } + + + public CoffeeShop.OrderSummary.Features.OrderSummaryQuery Apply(Marten.Events.IEvent @event, CoffeeShop.OrderSummary.Features.OrderSummaryQuery aggregate, Marten.IQuerySession session) + { + switch (@event) + { + case Marten.Events.IEvent event_BaristaOrderPlaced1: + _orderSummaryProjection.Apply(event_BaristaOrderPlaced1.Data, aggregate); + break; + case Marten.Events.IEvent event_BaristaOrderUpdated3: + _orderSummaryProjection.Apply(event_BaristaOrderUpdated3.Data, aggregate); + break; + case Marten.Events.IEvent event_KitchenOrderPlaced2: + _orderSummaryProjection.Apply(event_KitchenOrderPlaced2.Data, aggregate); + break; + case Marten.Events.IEvent event_KitchenOrderUpdated4: + _orderSummaryProjection.Apply(event_KitchenOrderUpdated4.Data, aggregate); + break; + } + + return aggregate; + } + + } + + // END: OrderSummaryProjectionLiveAggregation1590385736 + + + // START: OrderSummaryProjectionInlineHandler1590385736 + public class OrderSummaryProjectionInlineHandler1590385736 : Marten.Events.Aggregation.CrossStreamAggregationRuntime + { + private readonly Marten.IDocumentStore _store; + private readonly Marten.Events.Aggregation.IAggregateProjection _projection; + private readonly Marten.Events.Aggregation.IEventSlicer _slicer; + private readonly Marten.Internal.Storage.IDocumentStorage _storage; + private readonly CoffeeShop.OrderSummary.Features.OrderSummaryProjection _orderSummaryProjection; + + public OrderSummaryProjectionInlineHandler1590385736(Marten.IDocumentStore store, Marten.Events.Aggregation.IAggregateProjection projection, Marten.Events.Aggregation.IEventSlicer slicer, Marten.Internal.Storage.IDocumentStorage storage, CoffeeShop.OrderSummary.Features.OrderSummaryProjection orderSummaryProjection) : base(store, projection, slicer, storage) + { + _store = store; + _projection = projection; + _slicer = slicer; + _storage = storage; + _orderSummaryProjection = orderSummaryProjection; + } + + + + public override async System.Threading.Tasks.ValueTask ApplyEvent(Marten.IQuerySession session, Marten.Events.Projections.EventSlice slice, Marten.Events.IEvent evt, CoffeeShop.OrderSummary.Features.OrderSummaryQuery aggregate, System.Threading.CancellationToken cancellationToken) + { + switch (evt) + { + case Marten.Events.IEvent event_BaristaOrderPlaced5: + aggregate ??= CreateDefault(evt); + _orderSummaryProjection.Apply(event_BaristaOrderPlaced5.Data, aggregate); + return aggregate; + case Marten.Events.IEvent event_BaristaOrderUpdated7: + aggregate ??= CreateDefault(evt); + _orderSummaryProjection.Apply(event_BaristaOrderUpdated7.Data, aggregate); + return aggregate; + case Marten.Events.IEvent event_KitchenOrderPlaced6: + aggregate ??= CreateDefault(evt); + _orderSummaryProjection.Apply(event_KitchenOrderPlaced6.Data, aggregate); + return aggregate; + case Marten.Events.IEvent event_KitchenOrderUpdated8: + aggregate ??= CreateDefault(evt); + _orderSummaryProjection.Apply(event_KitchenOrderUpdated8.Data, aggregate); + return aggregate; + } + + return aggregate; + } + + + public CoffeeShop.OrderSummary.Features.OrderSummaryQuery Create(Marten.Events.IEvent @event, Marten.IQuerySession session) + { + return null; + } + + } + + // END: OrderSummaryProjectionInlineHandler1590385736 + + +} + diff --git a/order-summary/Internal/Generated/EventStore/SingleStreamProjectionRuntimeSupport1140178087.cs b/order-summary/Internal/Generated/EventStore/SingleStreamProjectionRuntimeSupport1140178087.cs new file mode 100644 index 0000000..601feb8 --- /dev/null +++ b/order-summary/Internal/Generated/EventStore/SingleStreamProjectionRuntimeSupport1140178087.cs @@ -0,0 +1,132 @@ +// +#pragma warning disable +using Marten; +using Marten.Events.Aggregation; +using Marten.Internal.Storage; +using System; +using System.Linq; + +namespace Marten.Generated.EventStore +{ + // START: SingleStreamProjectionLiveAggregation1140178087 + public class SingleStreamProjectionLiveAggregation1140178087 : Marten.Events.Aggregation.SyncLiveAggregatorBase + { + private readonly Marten.Events.Aggregation.SingleStreamProjection _singleStreamProjection; + + public SingleStreamProjectionLiveAggregation1140178087(Marten.Events.Aggregation.SingleStreamProjection singleStreamProjection) + { + _singleStreamProjection = singleStreamProjection; + } + + + + public override CoffeeShop.OrderSummary.Models.Order Build(System.Collections.Generic.IReadOnlyList events, Marten.IQuerySession session, CoffeeShop.OrderSummary.Models.Order snapshot) + { + if (!events.Any()) return snapshot; + var usedEventOnCreate = snapshot is null; + snapshot ??= Create(events[0], session);; + if (snapshot is null) + { + usedEventOnCreate = false; + snapshot = CreateDefault(events[0]); + } + + foreach (var @event in events.Skip(usedEventOnCreate ? 1 : 0)) + { + snapshot = Apply(@event, snapshot, session); + } + + return snapshot; + } + + + public CoffeeShop.OrderSummary.Models.Order Create(Marten.Events.IEvent @event, Marten.IQuerySession session) + { + return null; + } + + + public CoffeeShop.OrderSummary.Models.Order Apply(Marten.Events.IEvent @event, CoffeeShop.OrderSummary.Models.Order aggregate, Marten.IQuerySession session) + { + switch (@event) + { + case Marten.Events.IEvent event_BaristaOrderPlaced9: + aggregate = aggregate.Apply(event_BaristaOrderPlaced9.Data); + break; + case Marten.Events.IEvent event_BaristaOrderUpdated11: + aggregate = aggregate.Apply(event_BaristaOrderUpdated11.Data); + break; + case Marten.Events.IEvent event_KitchenOrderPlaced10: + aggregate = aggregate.Apply(event_KitchenOrderPlaced10.Data); + break; + case Marten.Events.IEvent event_KitchenOrderUpdated12: + aggregate = aggregate.Apply(event_KitchenOrderUpdated12.Data); + break; + } + + return aggregate; + } + + } + + // END: SingleStreamProjectionLiveAggregation1140178087 + + + // START: SingleStreamProjectionInlineHandler1140178087 + public class SingleStreamProjectionInlineHandler1140178087 : Marten.Events.Aggregation.AggregationRuntime + { + private readonly Marten.IDocumentStore _store; + private readonly Marten.Events.Aggregation.IAggregateProjection _projection; + private readonly Marten.Events.Aggregation.IEventSlicer _slicer; + private readonly Marten.Internal.Storage.IDocumentStorage _storage; + private readonly Marten.Events.Aggregation.SingleStreamProjection _singleStreamProjection; + + public SingleStreamProjectionInlineHandler1140178087(Marten.IDocumentStore store, Marten.Events.Aggregation.IAggregateProjection projection, Marten.Events.Aggregation.IEventSlicer slicer, Marten.Internal.Storage.IDocumentStorage storage, Marten.Events.Aggregation.SingleStreamProjection singleStreamProjection) : base(store, projection, slicer, storage) + { + _store = store; + _projection = projection; + _slicer = slicer; + _storage = storage; + _singleStreamProjection = singleStreamProjection; + } + + + + public override async System.Threading.Tasks.ValueTask ApplyEvent(Marten.IQuerySession session, Marten.Events.Projections.EventSlice slice, Marten.Events.IEvent evt, CoffeeShop.OrderSummary.Models.Order aggregate, System.Threading.CancellationToken cancellationToken) + { + switch (evt) + { + case Marten.Events.IEvent event_BaristaOrderPlaced13: + aggregate ??= CreateDefault(evt); + aggregate = aggregate.Apply(event_BaristaOrderPlaced13.Data); + return aggregate; + case Marten.Events.IEvent event_BaristaOrderUpdated15: + aggregate ??= CreateDefault(evt); + aggregate = aggregate.Apply(event_BaristaOrderUpdated15.Data); + return aggregate; + case Marten.Events.IEvent event_KitchenOrderPlaced14: + aggregate ??= CreateDefault(evt); + aggregate = aggregate.Apply(event_KitchenOrderPlaced14.Data); + return aggregate; + case Marten.Events.IEvent event_KitchenOrderUpdated16: + aggregate ??= CreateDefault(evt); + aggregate = aggregate.Apply(event_KitchenOrderUpdated16.Data); + return aggregate; + } + + return aggregate; + } + + + public CoffeeShop.OrderSummary.Models.Order Create(Marten.Events.IEvent @event, Marten.IQuerySession session) + { + return null; + } + + } + + // END: SingleStreamProjectionInlineHandler1140178087 + + +} + diff --git a/order-summary/Models/OrderSummaryModel.cs b/order-summary/Models/OrderSummaryModel.cs new file mode 100644 index 0000000..9d9a7c4 --- /dev/null +++ b/order-summary/Models/OrderSummaryModel.cs @@ -0,0 +1,18 @@ +using CoffeeShop.MessageContracts; + +namespace CoffeeShop.OrderSummary.Models; + +public record Order(Guid Id) +{ + public Order Apply(BaristaOrderPlaced @event) => + this with { }; + + public Order Apply(KitchenOrderPlaced @event) => + this with { }; + + public Order Apply(BaristaOrderUpdated @event) => + this with { }; + + public Order Apply(KitchenOrderUpdated @event) => + this with { }; +} diff --git a/order-summary/Program.cs b/order-summary/Program.cs new file mode 100644 index 0000000..d266e31 --- /dev/null +++ b/order-summary/Program.cs @@ -0,0 +1,109 @@ +using CoffeeShop.OrderSummary.Consumers; +using CoffeeShop.OrderSummary.Features; +using CoffeeShop.OrderSummary.Models; +using CoffeeShop.Shared.Endpoint; +using CoffeeShop.Shared.Exceptions; +using CoffeeShop.Shared.OpenTelemetry; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddExceptionHandler(); +builder.Services.AddExceptionHandler(); +builder.Services.AddProblemDetails(); + +builder.Services.AddHttpContextAccessor(); +builder.Services.AddMediatR(cfg => { + cfg.RegisterServicesFromAssemblyContaining(); + cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); + cfg.AddOpenBehavior(typeof(HandlerBehavior<,>)); +}); +builder.Services.AddValidatorsFromAssemblyContaining(includeInternalTypes: true); + +builder.Services.AddSwaggerGen(); +builder.Services.AddEndpointsApiExplorer(); + +builder.Services.AddApiVersioning(options => +{ + options.DefaultApiVersion = new ApiVersion(1); + options.ApiVersionReader = new UrlSegmentApiVersionReader(); +}).AddApiExplorer(options => +{ + options.GroupNameFormat = "'v'V"; + options.SubstituteApiVersionInUrl = true; +}); + +builder.Services.AddEndpoints(typeof(Program).Assembly); + +builder.Services.AddMassTransit(x => +{ + x.AddConsumer(); + + x.SetKebabCaseEndpointNameFormatter(); + + x.UsingRabbitMq((context, cfg) => + { + cfg.Host(builder.Configuration.GetConnectionString("rabbitmq")!); + cfg.ConfigureEndpoints(context); + }); +}); + +builder.Services.AddMarten(sp => +{ + var options = new StoreOptions(); + + var schemaName = Environment.GetEnvironmentVariable("SchemaName") ?? "order_summary"; + options.Events.DatabaseSchemaName = schemaName; + options.DatabaseSchemaName = schemaName; + options.Connection(builder.Configuration.GetConnectionString("postgres") ?? + throw new InvalidOperationException()); + + options.UseSystemTextJsonForSerialization(EnumStorage.AsString); + + options.Projections.Errors.SkipApplyErrors = false; + options.Projections.Errors.SkipSerializationErrors = false; + options.Projections.Errors.SkipUnknownEvents = false; + + options.Projections.LiveStreamAggregation(); + options.Projections.Add(ProjectionLifecycle.Async); + + return options; +}) +.OptimizeArtifactWorkflow(TypeLoadMode.Static) +.UseLightweightSessions() +.AddAsyncDaemon(DaemonMode.Solo); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +var apiVersionSet = app.NewApiVersionSet() + .HasApiVersion(new ApiVersion(1)) + .ReportApiVersions() + .Build(); + +var versionedGroup = app + .MapGroup("api/v{version:apiVersion}") + .WithApiVersionSet(apiVersionSet); + +app.UseExceptionHandler(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); +} + +app.UseRouting(); + +app.MapDefaultEndpoints(); + +app.MapEndpoints(versionedGroup); + +app.Run(); + +public partial class Program; + + diff --git a/order-summary/Properties/launchSettings.json b/order-summary/Properties/launchSettings.json new file mode 100644 index 0000000..9aa205f --- /dev/null +++ b/order-summary/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5005", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/order-summary/appsettings.Development.json b/order-summary/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/order-summary/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/order-summary/appsettings.json b/order-summary/appsettings.json new file mode 100644 index 0000000..69116ca --- /dev/null +++ b/order-summary/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "ordersummarydb": "Server=localhost;Port=5432;Database=postgres;", + "rabbitmq": "amqp://localhost" + } +} diff --git a/barista-api/barista-api.csproj b/product-api/CoffeeShop.ProductApi.csproj old mode 100755 new mode 100644 similarity index 51% rename from barista-api/barista-api.csproj rename to product-api/CoffeeShop.ProductApi.csproj index f871dab..a9aa81e --- a/barista-api/barista-api.csproj +++ b/product-api/CoffeeShop.ProductApi.csproj @@ -1,24 +1,24 @@ - - - - Exe - BaristaApi - barista-api - latest - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + Exe + ProductApi + k3d-myregistry.vn:12345 + product-api + latest + + + + + + + + + + + + + + + + diff --git a/product-api/Dto/ItemDto.cs b/product-api/Dto/ItemDto.cs new file mode 100644 index 0000000..a0907ad --- /dev/null +++ b/product-api/Dto/ItemDto.cs @@ -0,0 +1,11 @@ +using ProductApi.Domain; + +namespace ProductApi.Dto; + +public record ItemDto(ItemType Type, decimal Price); + +public class ItemTypeDto +{ + public ItemType ItemType { get; set; } + public string Name { get; set; } = null!; +} \ No newline at end of file diff --git a/product-api/Dtos/ItemDto.cs b/product-api/Dtos/ItemDto.cs deleted file mode 100755 index 9340bc9..0000000 --- a/product-api/Dtos/ItemDto.cs +++ /dev/null @@ -1,11 +0,0 @@ -using ProductApi.Domain; - -namespace ProductApi.Dtos; - -public record ItemDto(ItemType Type, decimal Price); - -public class ItemTypeDto -{ - public ItemType ItemType { get; set; } - public string Name { get; set; } = null!; -} \ No newline at end of file diff --git a/product-api/GlobalUsings.cs b/product-api/GlobalUsings.cs new file mode 100644 index 0000000..7ce305a --- /dev/null +++ b/product-api/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using FluentValidation; +global using MediatR; +global using Asp.Versioning; +global using Asp.Versioning.Builder; \ No newline at end of file diff --git a/product-api/Program.cs b/product-api/Program.cs index 4e2186b..25dbe9e 100755 --- a/product-api/Program.cs +++ b/product-api/Program.cs @@ -1,36 +1,65 @@ -using FluentValidation; - -using ProductApi.UseCases; +using CoffeeShop.Shared.Endpoint; +using CoffeeShop.Shared.Exceptions; +using CoffeeShop.Shared.OpenTelemetry; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); +builder.Services.AddExceptionHandler(); +builder.Services.AddExceptionHandler(); builder.Services.AddProblemDetails(); builder.Services.AddHttpContextAccessor(); -builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining()); -builder.Services.AddValidatorsFromAssemblyContaining(); +builder.Services.AddMediatR(cfg => { + cfg.RegisterServicesFromAssemblyContaining(); + cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); + cfg.AddOpenBehavior(typeof(HandlerBehavior<,>)); +}); +builder.Services.AddValidatorsFromAssemblyContaining(includeInternalTypes: true); -builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +builder.Services.AddEndpointsApiExplorer(); + +builder.Services.AddApiVersioning(options => +{ + options.DefaultApiVersion = new ApiVersion(1); + options.ApiVersionReader = new UrlSegmentApiVersionReader(); +}).AddApiExplorer(options => +{ + options.GroupNameFormat = "'v'V"; + options.SubstituteApiVersionInUrl = true; +}); + +builder.Services.AddEndpoints(typeof(Program).Assembly); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); var app = builder.Build(); -if (!app.Environment.IsDevelopment()) -{ - app.UseExceptionHandler(); -} -else +var apiVersionSet = app.NewApiVersionSet() + .HasApiVersion(new ApiVersion(1)) + .ReportApiVersions() + .Build(); + +var versionedGroup = app + .MapGroup("api/v{version:apiVersion}") + .WithApiVersionSet(apiVersionSet); + +app.UseExceptionHandler(); + +if (app.Environment.IsDevelopment()) { - app.UseSwagger(); - app.UseSwaggerUI(); + app.UseSwagger(); } -app.MapDefaultEndpoints(); -app.Map("/", () => Results.Redirect("/swagger")); +app.UseRouting(); -_ = app.MapItemTypesQueryApiRoutes() - .MapItemsByIdsQueryApiRoutes(); +app.MapDefaultEndpoints(); +app.MapEndpoints(versionedGroup); app.Run(); + +public partial class Program; diff --git a/product-api/Properties/launchSettings.json b/product-api/Properties/launchSettings.json index 45bb8a8..dd0fe1d 100755 --- a/product-api/Properties/launchSettings.json +++ b/product-api/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "launchUrl": "swagger", + "launchUrl": "", "applicationUrl": "http://localhost:5001", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/product-api/UseCases/ItemTypesQuery.cs b/product-api/UseCases/ItemTypesQuery.cs index 3731ef6..eb1a74c 100755 --- a/product-api/UseCases/ItemTypesQuery.cs +++ b/product-api/UseCases/ItemTypesQuery.cs @@ -1,23 +1,18 @@ -using FluentValidation; -using MediatR; +using CoffeeShop.Shared.Endpoint; using ProductApi.Domain; -using ProductApi.Dtos; +using ProductApi.Dto; namespace ProductApi.UseCases; -internal static class ItemTypesQueryRouter +public class ItemTypesEndpoint : IEndpoint { - public static IEndpointRouteBuilder MapItemsByIdsQueryApiRoutes(this IEndpointRouteBuilder builder) - { - builder.MapGet("/v1/api/item-types", - async (ISender sender) => - await sender.Send(new ItemTypesQuery())); - // builder.MapGet("/v1-get-item-types", - // async (ISender sender) => - // await sender.Send(new ItemTypesQuery())); - return builder; - } + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet("item-types", + async (ISender sender) => + await sender.Send(new ItemTypesQuery())); + } } public record ItemTypesQuery : IRequest>; @@ -28,26 +23,26 @@ internal class ItemTypesQueryValidator : AbstractValidator internal class ItemTypesQueryHandler(ILogger logger) : IRequestHandler> { - public Task> Handle(ItemTypesQuery request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); + public Task> Handle(ItemTypesQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); - var results = new List - { + var results = new List + { // beverages new() {Name = ItemType.CAPPUCCINO.ToString(), ItemType = ItemType.CAPPUCCINO}, - new() {Name = ItemType.COFFEE_BLACK.ToString(), ItemType = ItemType.COFFEE_BLACK}, - new() {Name = ItemType.COFFEE_WITH_ROOM.ToString(), ItemType = ItemType.COFFEE_WITH_ROOM}, - new() {Name = ItemType.ESPRESSO.ToString(), ItemType = ItemType.ESPRESSO}, - new() {Name = ItemType.ESPRESSO_DOUBLE.ToString(), ItemType = ItemType.ESPRESSO_DOUBLE}, - new() {Name = ItemType.LATTE.ToString(), ItemType = ItemType.LATTE}, + new() {Name = ItemType.COFFEE_BLACK.ToString(), ItemType = ItemType.COFFEE_BLACK}, + new() {Name = ItemType.COFFEE_WITH_ROOM.ToString(), ItemType = ItemType.COFFEE_WITH_ROOM}, + new() {Name = ItemType.ESPRESSO.ToString(), ItemType = ItemType.ESPRESSO}, + new() {Name = ItemType.ESPRESSO_DOUBLE.ToString(), ItemType = ItemType.ESPRESSO_DOUBLE}, + new() {Name = ItemType.LATTE.ToString(), ItemType = ItemType.LATTE}, // food new() {Name = ItemType.CAKEPOP.ToString(), ItemType = ItemType.CAKEPOP}, - new() {Name = ItemType.CROISSANT.ToString(), ItemType = ItemType.CROISSANT}, - new() {Name = ItemType.MUFFIN.ToString(), ItemType = ItemType.MUFFIN}, - new() {Name = ItemType.CROISSANT_CHOCOLATE.ToString(), ItemType = ItemType.CROISSANT_CHOCOLATE} - }; + new() {Name = ItemType.CROISSANT.ToString(), ItemType = ItemType.CROISSANT}, + new() {Name = ItemType.MUFFIN.ToString(), ItemType = ItemType.MUFFIN}, + new() {Name = ItemType.CROISSANT_CHOCOLATE.ToString(), ItemType = ItemType.CROISSANT_CHOCOLATE} + }; - return Task.FromResult(results.Distinct()); - } + return Task.FromResult(results.Distinct()); + } } \ No newline at end of file diff --git a/product-api/UseCases/ItemsByIdsQuery.cs b/product-api/UseCases/ItemsByIdsQuery.cs index 0dfec83..cce786a 100755 --- a/product-api/UseCases/ItemsByIdsQuery.cs +++ b/product-api/UseCases/ItemsByIdsQuery.cs @@ -1,50 +1,45 @@ -using FluentValidation; -using MediatR; +using CoffeeShop.Shared.Endpoint; using ProductApi.Domain; -using ProductApi.Dtos; +using ProductApi.Dto; namespace ProductApi.UseCases; -internal static class ItemsByIdsQueryRouter +public class ItemsByIdsEndpoint : IEndpoint { - public static IEndpointRouteBuilder MapItemTypesQueryApiRoutes(this IEndpointRouteBuilder builder) - { - builder.MapGet("/v1/api/items-by-types/{itemTypes}", - async (ISender sender, string itemTypes) => - await sender.Send(new ItemsByIdsQuery(itemTypes))); - // builder.MapGet("/v1-items-by-types/{itemTypes}", - // async (ISender sender, string itemTypes) => - // await sender.Send(new ItemsByIdsQuery(itemTypes))); - return builder; - } + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet("items-by-types/{itemTypes}", + async (ISender sender, string itemTypes) => + await sender.Send(new ItemsByIdsQuery(itemTypes))); + } } public record ItemsByIdsQuery(string ItemTypes) : IRequest>; internal class ItemsByIdsQueryValidator : AbstractValidator { - public ItemsByIdsQueryValidator() - { - RuleFor(v => v.ItemTypes) - .NotEmpty().WithMessage("ItemTypes is required."); - } + public ItemsByIdsQueryValidator() + { + RuleFor(v => v.ItemTypes) + .NotEmpty().WithMessage("ItemTypes is required."); + } } internal class ItemsByIdsQueryHandler(ILogger logger) : IRequestHandler> { - public Task> Handle(ItemsByIdsQuery request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - - var results = new List(); - var itemTypes = request.ItemTypes.Split(",").Select(id => (ItemType)Convert.ToInt16(id)); - foreach (var itemType in itemTypes) - { - var temp = Item.GetItem(itemType); - results.Add(new ItemDto(temp.Type, temp.Price)); - } - - return Task.FromResult(results.Distinct()); - } + public Task> Handle(ItemsByIdsQuery request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var results = new List(); + var itemTypes = request.ItemTypes.Split(",").Select(id => (ItemType)Convert.ToInt16(id)); + foreach (var itemType in itemTypes) + { + var temp = Item.GetItem(itemType); + results.Add(new ItemDto(temp.Type, temp.Price)); + } + + return Task.FromResult(results.Distinct()); + } } \ No newline at end of file diff --git a/product-api/appsettings.json b/product-api/appsettings.json index 4d56694..0cf4b11 100755 --- a/product-api/appsettings.json +++ b/product-api/appsettings.json @@ -5,5 +5,5 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*" } diff --git a/product-api/product-api.csproj b/product-api/product-api.csproj deleted file mode 100755 index c1e281a..0000000 --- a/product-api/product-api.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - ProductApi - product-api - latest - - - - - - - - - - - - - - - diff --git a/service-defaults/CoffeeShop.ServiceDefaults.csproj b/service-defaults/CoffeeShop.ServiceDefaults.csproj new file mode 100644 index 0000000..51bdb39 --- /dev/null +++ b/service-defaults/CoffeeShop.ServiceDefaults.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + + + + + + + + + + + + + + + diff --git a/service-defaults/Extensions.cs b/service-defaults/Extensions.cs deleted file mode 100755 index 2420a6c..0000000 --- a/service-defaults/Extensions.cs +++ /dev/null @@ -1,121 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Logging; -using OpenTelemetry.Logs; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; - -namespace Microsoft.Extensions.Hosting; - -public static class Extensions -{ - public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) - { - builder.ConfigureOpenTelemetry(); - - builder.AddDefaultHealthChecks(); - - builder.Services.AddServiceDiscovery(); - - builder.Services.ConfigureHttpClientDefaults(http => - { - // Turn on resilience by default - http.AddStandardResilienceHandler(); - - // Turn on service discovery by default - http.UseServiceDiscovery(); - }); - - return builder; - } - - public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) - { - builder.Logging.AddOpenTelemetry(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - }); - - builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - metrics.AddRuntimeInstrumentation() - .AddBuiltInMeters(); - }) - .WithTracing(tracing => - { - if (builder.Environment.IsDevelopment()) - { - // We want to view all traces in development - tracing.SetSampler(new AlwaysOnSampler()); - } - - tracing.AddAspNetCoreInstrumentation() - .AddGrpcClientInstrumentation() - .AddHttpClientInstrumentation() - .AddSource("MassTransit") // https://github.com/open-telemetry/opentelemetry-dotnet-contrib/issues/326 - ; - }); - - builder.AddOpenTelemetryExporters(); - - return builder; - } - - private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) - { - var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - - if (useOtlpExporter) - { - builder.Services.Configure(logging => logging.AddOtlpExporter()); - builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); - builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); - } - - // Uncomment the following lines to enable the Prometheus exporter (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) - // builder.Services.AddOpenTelemetry() - // .WithMetrics(metrics => metrics.AddPrometheusExporter()); - - // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.Exporter package) - // builder.Services.AddOpenTelemetry() - // .UseAzureMonitor(); - - return builder; - } - - public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) - { - builder.Services.AddHealthChecks() - // Add a default liveness check to ensure app is responsive - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - - return builder; - } - - public static WebApplication MapDefaultEndpoints(this WebApplication app) - { - // Uncomment the following line to enable the Prometheus endpoint (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) - // app.MapPrometheusScrapingEndpoint(); - - // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks("/health"); - - // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks("/alive", new HealthCheckOptions - { - Predicate = r => r.Tags.Contains("live") - }); - - return app; - } - - private static MeterProviderBuilder AddBuiltInMeters(this MeterProviderBuilder meterProviderBuilder) => - meterProviderBuilder.AddMeter( - "Microsoft.AspNetCore.Hosting", - "Microsoft.AspNetCore.Server.Kestrel", - "System.Net.Http"); -} diff --git a/service-defaults/service-defaults.csproj b/service-defaults/service-defaults.csproj deleted file mode 100755 index 9c145b7..0000000 --- a/service-defaults/service-defaults.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - Library - net8.0 - enable - enable - true - - - - - - - - - - - - - - - - diff --git a/shared/CoffeeShop.Shared/Aspire/Extensions.cs b/shared/CoffeeShop.Shared/Aspire/Extensions.cs new file mode 100644 index 0000000..cd53b42 --- /dev/null +++ b/shared/CoffeeShop.Shared/Aspire/Extensions.cs @@ -0,0 +1,115 @@ +using CoffeeShop.Shared.Logging; +using CoffeeShop.Shared.OpenTelemetry; + +using MassTransit.Logging; +using MassTransit.Monitoring; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +public static class Extensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.EnableEnrichment(); + builder.Services.AddLogEnricher(); + + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddMeter(InstrumentationOptions.MeterName) + .AddMeter("Marten") + .AddMeter(ActivitySourceProvider.DefaultSourceName); + }) + .WithTracing(tracing => + { + tracing.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddSource(DiagnosticHeaders.DefaultListenerName) + .AddSource("Marten") + .AddSource(ActivitySourceProvider.DefaultSourceName); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + return builder; + } + + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} \ No newline at end of file diff --git a/shared/CoffeeShop.Shared/Aspire/ServiceReferenceExtensions.cs b/shared/CoffeeShop.Shared/Aspire/ServiceReferenceExtensions.cs new file mode 100644 index 0000000..a312bb6 --- /dev/null +++ b/shared/CoffeeShop.Shared/Aspire/ServiceReferenceExtensions.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace System.Net.Http; + +public static class ServiceReferenceExtensions +{ + public static IHttpClientBuilder AddHttpServiceReference(this IServiceCollection services, string baseAddress) + where TClient : class + { + ArgumentNullException.ThrowIfNull(services); + + if (!Uri.IsWellFormedUriString(baseAddress, UriKind.Absolute)) + { + throw new ArgumentException("Base address must be a valid absolute URI.", nameof(baseAddress)); + } + + return services.AddHttpClient(c => c.BaseAddress = new(baseAddress)); + } + + + public static IHttpClientBuilder AddHttpServiceReference(this IServiceCollection services, string baseAddress, string healthRelativePath, string? healthCheckName = default, HealthStatus failureStatus = default) + where TClient : class + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrEmpty(healthRelativePath); + + if (!Uri.IsWellFormedUriString(baseAddress, UriKind.Absolute)) + { + throw new ArgumentException("Base address must be a valid absolute URI.", nameof(baseAddress)); + } + + if (!Uri.IsWellFormedUriString(healthRelativePath, UriKind.Relative)) + { + throw new ArgumentException("Health check path must be a valid relative URI.", nameof(healthRelativePath)); + } + + var uri = new Uri(baseAddress); + var builder = services.AddHttpClient(c => c.BaseAddress = uri); + + services.AddHealthChecks() + .AddUrlGroup( + new Uri(uri, healthRelativePath), + healthCheckName ?? $"{typeof(TClient).Name}-health", + failureStatus, + //configureClient: (s, c) => s.GetRequiredService().CreateClient + configurePrimaryHttpMessageHandler: s => s.GetRequiredService().CreateHandler() + ); + + return builder; + } +} \ No newline at end of file diff --git a/shared/CoffeeShop.Shared/CoffeeShop.Shared.csproj b/shared/CoffeeShop.Shared/CoffeeShop.Shared.csproj new file mode 100644 index 0000000..506252e --- /dev/null +++ b/shared/CoffeeShop.Shared/CoffeeShop.Shared.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + diff --git a/shared/CoffeeShop.Shared/Domain/Entity.cs b/shared/CoffeeShop.Shared/Domain/Entity.cs new file mode 100644 index 0000000..9ddc00d --- /dev/null +++ b/shared/CoffeeShop.Shared/Domain/Entity.cs @@ -0,0 +1,106 @@ +using CoffeeShop.Shared.Helpers; + +namespace CoffeeShop.Shared.Domain; + +public interface IEntity +{ + public Guid Id { get; } + public DateTime Created { get; } + public DateTime? Updated { get; } +} + +public interface IAggregateRoot : IEntity +{ + public HashSet DomainEvents { get; } +} + +public interface ITxRequest { } + +public abstract class EntityRootBase(Guid? id) : EntityBase, IAggregateRoot +{ + public new Guid Id { get; } = id ?? GuidHelper.NewGuid(); + + [JsonIgnore] + public HashSet DomainEvents { get; private set; } = []; + + public void AddDomainEvent(IDomainEvent eventItem) + { + DomainEvents.Add(eventItem); + } + + public void RemoveDomainEvent(EventBase eventItem) + { + DomainEvents?.Remove(eventItem); + } +} + +public abstract class EntityBase : IEntity +{ + public Guid Id { get; private set; } = GuidHelper.NewGuid(); + public DateTime Created { get; } = DateTimeHelper.NewDateTime(); + public DateTime? Updated { get; protected set; } +} + +/// +/// ref: https://github.com/dotnet/eShop/blob/main/src/Ordering.Domain/SeedWork/ValueObject.cs +/// +public abstract class ValueObject +{ + protected static bool EqualOperator(ValueObject left, ValueObject right) + { + if (ReferenceEquals(left, null) ^ ReferenceEquals(right, null)) + { + return false; + } + return ReferenceEquals(left, null) || left.Equals(right); + } + + protected static bool NotEqualOperator(ValueObject left, ValueObject right) + { + return !(EqualOperator(left, right)); + } + + protected abstract IEnumerable GetEqualityComponents(); + + public override bool Equals(object obj) + { + if (obj == null || obj.GetType() != GetType()) + { + return false; + } + + var other = (ValueObject)obj; + + return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents()); + } + + public override int GetHashCode() + { + return GetEqualityComponents() + .Select(x => x != null ? x.GetHashCode() : 0) + .Aggregate((x, y) => x ^ y); + } + + public ValueObject GetCopy() + { + return MemberwiseClone() as ValueObject; + } +} + +public static class AggregateRootExtensions +{ + public static async Task RelayAndPublishEvents(this IAggregateRoot aggregateRoot, IPublisher publisher, CancellationToken cancellationToken = default) + { + if (aggregateRoot.DomainEvents is not null) + { + var @events = new IDomainEvent[aggregateRoot.DomainEvents.Count]; + aggregateRoot.DomainEvents.CopyTo(@events); + aggregateRoot.DomainEvents.Clear(); + + foreach (var @event in @events) + { + await publisher.Publish(new EventWrapper(@event), cancellationToken); + } + } + } +} diff --git a/shared/CoffeeShop.Shared/Domain/Event.cs b/shared/CoffeeShop.Shared/Domain/Event.cs new file mode 100644 index 0000000..7acbad9 --- /dev/null +++ b/shared/CoffeeShop.Shared/Domain/Event.cs @@ -0,0 +1,25 @@ +using CoffeeShop.Shared.Helpers; + +namespace CoffeeShop.Shared.Domain; + +public interface IDomainEvent : INotification +{ + DateTime CreatedAt { get; } +} + +public interface IDomainEventContext +{ + IEnumerable GetDomainEvents(); +} + +public abstract class EventBase : IDomainEvent +{ + public string EventType => GetType().FullName; + + public DateTime CreatedAt { get; } = DateTimeHelper.NewDateTime(); +} + +public class EventWrapper(IDomainEvent @event) : INotification +{ + public IDomainEvent Event { get; } = @event; +} diff --git a/shared/CoffeeShop.Shared/Domain/Exception.cs b/shared/CoffeeShop.Shared/Domain/Exception.cs new file mode 100644 index 0000000..e5f9857 --- /dev/null +++ b/shared/CoffeeShop.Shared/Domain/Exception.cs @@ -0,0 +1,28 @@ +namespace CoffeeShop.Shared.Domain; + +public class CoreException : System.Exception +{ + public CoreException(string message) : base(message) + { + } + + public static CoreException Exception(string message) + { + return new(message); + } + + public static CoreException NullArgument(string arg) + { + return new($"{arg} cannot be null"); + } + + public static CoreException InvalidArgument(string arg) + { + return new($"{arg} is invalid"); + } + + public static CoreException NotFound(string arg) + { + return new($"{arg} was not found"); + } +} diff --git a/shared/CoffeeShop.Shared/Endpoint/EndpointExtensions.cs b/shared/CoffeeShop.Shared/Endpoint/EndpointExtensions.cs new file mode 100644 index 0000000..2b62738 --- /dev/null +++ b/shared/CoffeeShop.Shared/Endpoint/EndpointExtensions.cs @@ -0,0 +1,43 @@ +using System.Reflection; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CoffeeShop.Shared.Endpoint; + +/// +/// Ref: https://github.com/m-jovanovic/minimal-endpoints/blob/main/MinimalEndpoints/Extensions/EndpointExtensions.cs +/// +public static class EndpointExtensions +{ + public static IServiceCollection AddEndpoints(this IServiceCollection services, Assembly assembly) + { + ServiceDescriptor[] serviceDescriptors = assembly + .DefinedTypes + .Where(type => type is { IsAbstract: false, IsInterface: false } && + type.IsAssignableTo(typeof(IEndpoint))) + .Select(type => ServiceDescriptor.Transient(typeof(IEndpoint), type)) + .ToArray(); + + services.TryAddEnumerable(serviceDescriptors); + + return services; + } + + public static IApplicationBuilder MapEndpoints(this WebApplication app, RouteGroupBuilder? routeGroupBuilder = null) + { + IEnumerable endpoints = app.Services.GetRequiredService>(); + + IEndpointRouteBuilder builder = routeGroupBuilder is null ? app : routeGroupBuilder; + + foreach (IEndpoint endpoint in endpoints) + { + endpoint.MapEndpoint(builder); + } + + return app; + } +} diff --git a/shared/CoffeeShop.Shared/Endpoint/IEndpoint.cs b/shared/CoffeeShop.Shared/Endpoint/IEndpoint.cs new file mode 100644 index 0000000..06cd099 --- /dev/null +++ b/shared/CoffeeShop.Shared/Endpoint/IEndpoint.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Routing; + +namespace CoffeeShop.Shared.Endpoint; + +public interface IEndpoint +{ + void MapEndpoint(IEndpointRouteBuilder app); +} diff --git a/shared/CoffeeShop.Shared/Exceptions/GlobalExceptionHandler.cs b/shared/CoffeeShop.Shared/Exceptions/GlobalExceptionHandler.cs new file mode 100644 index 0000000..e6861c3 --- /dev/null +++ b/shared/CoffeeShop.Shared/Exceptions/GlobalExceptionHandler.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace CoffeeShop.Shared.Exceptions; + +/// +/// Ref: https://juliocasal.com/blog/Global-Error-Handling-In-AspNet-Core-APIs +/// +/// +public class GlobalExceptionHandler(ILogger logger) : IExceptionHandler +{ + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + { + var traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier; + + logger.LogError( + exception, + "Could not process a request on machine {MachineName}. TraceId: {TraceId}", + Environment.MachineName, + traceId + ); + + var (statusCode, title) = MapException(exception); + + await Results.Problem( + title: title, + statusCode: statusCode, + extensions: new Dictionary + { + {"traceId", traceId} + } + ).ExecuteAsync(httpContext); + + return true; + } + + private static (int StatusCode, string Title) MapException(Exception exception) + { + return exception switch + { + ArgumentOutOfRangeException => (StatusCodes.Status400BadRequest, exception.Message), + _ => (StatusCodes.Status500InternalServerError, "We made a mistake but we are on it!") + }; + } +} diff --git a/shared/CoffeeShop.Shared/Exceptions/ValidationExceptionHandler.cs b/shared/CoffeeShop.Shared/Exceptions/ValidationExceptionHandler.cs new file mode 100644 index 0000000..d03268e --- /dev/null +++ b/shared/CoffeeShop.Shared/Exceptions/ValidationExceptionHandler.cs @@ -0,0 +1,42 @@ +using CoffeeShop.Shared.OpenTelemetry; + +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace CoffeeShop.Shared.Exceptions; + +public class ValidationExceptionHandler(ILogger logger) : IExceptionHandler +{ + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + { + if (exception is not ValidationException validationException) + { + return false; + } + + logger.LogError( + validationException, "Exception occurred: {Message}", validationException.Message); + + var problemDetails = new ProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Type = "ValidationFailure", + Title = "Validation error", + Detail = "One or more validation errors has occurred" + }; + + if (validationException.Errors is not null) + { + problemDetails.Extensions["errors"] = validationException.Errors; + } + + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + + await httpContext.Response + .WriteAsJsonAsync(problemDetails, cancellationToken); + + return true; + } +} diff --git a/shared/CoffeeShop.Shared/GlobalUsings.cs b/shared/CoffeeShop.Shared/GlobalUsings.cs new file mode 100644 index 0000000..afa439c --- /dev/null +++ b/shared/CoffeeShop.Shared/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using System.Diagnostics; +global using MassTransit; +global using MediatR; +global using System.Text.Json.Serialization; \ No newline at end of file diff --git a/shared/CoffeeShop.Shared/Helpers/DateTimeHelper.cs b/shared/CoffeeShop.Shared/Helpers/DateTimeHelper.cs new file mode 100644 index 0000000..4dd76be --- /dev/null +++ b/shared/CoffeeShop.Shared/Helpers/DateTimeHelper.cs @@ -0,0 +1,16 @@ +namespace CoffeeShop.Shared.Helpers; + +public static class DateTimeHelper +{ + [DebuggerStepThrough] + public static DateTime NewDateTime() + { + return ToDateTime(DateTimeOffset.Now.UtcDateTime); + } + + public static DateTime ToDateTime(this DateTime datetime) + { + // https://github.com/npgsql/efcore.pg/issues/2050 + return DateTime.SpecifyKind(datetime, DateTimeKind.Utc); + } +} diff --git a/shared/CoffeeShop.Shared/Helpers/GuidHelper.cs b/shared/CoffeeShop.Shared/Helpers/GuidHelper.cs new file mode 100644 index 0000000..f906e63 --- /dev/null +++ b/shared/CoffeeShop.Shared/Helpers/GuidHelper.cs @@ -0,0 +1,15 @@ +namespace CoffeeShop.Shared.Helpers; + +[DebuggerStepThrough] +public static class GuidHelper +{ + public static Guid NewGuid() + { + return NewId.NextGuid(); + } + + public static bool BeAGuid(string guid) + { + return Guid.TryParse(guid, out _); + } +} diff --git a/shared/CoffeeShop.Shared/Logging/ApplicationEnricher.cs b/shared/CoffeeShop.Shared/Logging/ApplicationEnricher.cs new file mode 100644 index 0000000..565887c --- /dev/null +++ b/shared/CoffeeShop.Shared/Logging/ApplicationEnricher.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Diagnostics.Enrichment; + +namespace CoffeeShop.Shared.Logging; + +/// +/// Ref: https://andrewlock.net/customising-the-new-telemetry-logging-source-generator/ +/// +/// +internal class ApplicationEnricher(IHttpContextAccessor httpContextAccessor) : ILogEnricher +{ + public void Enrich(IEnrichmentTagCollector collector) + { + collector.Add("MachineName", Environment.MachineName); + + var httpContext = httpContextAccessor.HttpContext; + if (httpContext is not null) + { + collector.Add("IsAuthenticated", httpContext?.User?.Identity?.IsAuthenticated!); + } + } +} diff --git a/shared/CoffeeShop.Shared/OpenTelemetry/ActivityScope.cs b/shared/CoffeeShop.Shared/OpenTelemetry/ActivityScope.cs new file mode 100644 index 0000000..c0e453b --- /dev/null +++ b/shared/CoffeeShop.Shared/OpenTelemetry/ActivityScope.cs @@ -0,0 +1,119 @@ +using OpenTelemetry.Trace; + +namespace CoffeeShop.Shared.OpenTelemetry; +public interface IActivityScope +{ + Activity? Start(string name) => + Start(name, new StartActivityOptions()); + + Activity? Start(string name, StartActivityOptions options); + + Task Run( + string name, + Func run, + CancellationToken ct + ) => Run(name, run, new StartActivityOptions(), ct); + + Task Run( + string name, + Func run, + StartActivityOptions options, + CancellationToken ct + ); + + Task Run( + string name, + Func> run, + CancellationToken ct + ) => Run(name, run, new StartActivityOptions(), ct); + + Task Run( + string name, + Func> run, + StartActivityOptions options, + CancellationToken ct + ); +} + +public class ActivityScope : IActivityScope +{ + public static readonly IActivityScope Instance = new ActivityScope(); + + public Activity? Start(string name, StartActivityOptions options) => + options.Parent.HasValue + ? ActivitySourceProvider.Instance + .CreateActivity( + $"{ActivitySourceProvider.DefaultSourceName}.{name}", + options.Kind, + parentContext: options.Parent.Value, + idFormat: ActivityIdFormat.W3C, + tags: options.Tags + )?.Start() + : ActivitySourceProvider.Instance + .CreateActivity( + $"{ActivitySourceProvider.DefaultSourceName}.{name}", + options.Kind, + parentId: options.ParentId ?? Activity.Current?.ParentId, + idFormat: ActivityIdFormat.W3C, + tags: options.Tags + )?.Start(); + + public async Task Run( + string name, + Func run, + StartActivityOptions options, + CancellationToken ct + ) + { + using var activity = Start(name, options) ?? Activity.Current; + + try + { + await run(activity, ct).ConfigureAwait(false); + + activity?.SetStatus(ActivityStatusCode.Ok); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error); + activity?.RecordException(ex); + throw; + } + } + + public async Task Run( + string name, + Func> run, + StartActivityOptions options, + CancellationToken ct + ) + { + using var activity = Start(name, options) ?? Activity.Current; + + try + { + var result = await run(activity, ct).ConfigureAwait(false); + + activity?.SetStatus(ActivityStatusCode.Ok); + + return result; + } + catch (Exception ex) + { + activity?.RecordException(ex); + activity?.SetStatus(ActivityStatusCode.Error); + throw; + } + } +} + +public record StartActivityOptions +{ + public Dictionary Tags { get; set; } = new(); + + public string? ParentId { get; set; } + + public ActivityContext? Parent { get; set; } + + public ActivityKind Kind = ActivityKind.Internal; +} diff --git a/shared/CoffeeShop.Shared/OpenTelemetry/ActivitySourceProvider.cs b/shared/CoffeeShop.Shared/OpenTelemetry/ActivitySourceProvider.cs new file mode 100644 index 0000000..a38377d --- /dev/null +++ b/shared/CoffeeShop.Shared/OpenTelemetry/ActivitySourceProvider.cs @@ -0,0 +1,23 @@ +namespace CoffeeShop.Shared.OpenTelemetry; + +public static class ActivitySourceProvider +{ + public const string DefaultSourceName = "coffeeshop"; + public static readonly ActivitySource Instance = new(DefaultSourceName, "v1"); + + public static ActivityListener AddDummyListener( + ActivitySamplingResult samplingResult = ActivitySamplingResult.AllDataAndRecorded + ) + { + var listener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => + samplingResult + }; + + ActivitySource.AddActivityListener(listener); + + return listener; + } +} diff --git a/shared/CoffeeShop.Shared/OpenTelemetry/CommandHandlerMetrics.cs b/shared/CoffeeShop.Shared/OpenTelemetry/CommandHandlerMetrics.cs new file mode 100644 index 0000000..672908e --- /dev/null +++ b/shared/CoffeeShop.Shared/OpenTelemetry/CommandHandlerMetrics.cs @@ -0,0 +1,75 @@ +using System.Diagnostics.Metrics; + +namespace CoffeeShop.Shared.OpenTelemetry; + +public class CommandHandlerMetrics : IDisposable +{ + private readonly TimeProvider _timeProvider; + private readonly Meter _meter; + private readonly UpDownCounter _activeEventHandlingCounter; + private readonly Counter _totalCommandsNumber; + private readonly Histogram _eventHandlingDuration; + + public CommandHandlerMetrics( + IMeterFactory meterFactory, + TimeProvider timeProvider + ) + { + _timeProvider = timeProvider; + _meter = meterFactory.Create(ActivitySourceProvider.DefaultSourceName); + + _totalCommandsNumber = _meter.CreateCounter( + TelemetryTags.Commands.TotalCommandsNumber, + unit: "{command}", + description: "Total number of commands send to command handlers"); + + _activeEventHandlingCounter = _meter.CreateUpDownCounter( + TelemetryTags.Commands.ActiveCommandsNumber, + unit: "{command}", + description: "Number of commands currently being handled"); + + _eventHandlingDuration = _meter.CreateHistogram( + TelemetryTags.Commands.CommandHandlingDuration, + unit: "s", + description: "Measures the duration of inbound commands"); + } + + public long CommandHandlingStart(string commandType) + { + var tags = new TagList { { TelemetryTags.Commands.CommandType, commandType } }; + + if (_activeEventHandlingCounter.Enabled) + { + _activeEventHandlingCounter.Add(1, tags); + } + + if (_totalCommandsNumber.Enabled) + { + _totalCommandsNumber.Add(1, tags); + } + + return _timeProvider.GetTimestamp(); + } + + public void CommandHandlingEnd(string commandType, long startingTimestamp) + { + var tags = _activeEventHandlingCounter.Enabled + || _eventHandlingDuration.Enabled + ? new TagList { { TelemetryTags.Commands.CommandType, commandType } } + : default; + + if (_activeEventHandlingCounter.Enabled) + { + _activeEventHandlingCounter.Add(-1, tags); + } + + if (!_eventHandlingDuration.Enabled) return; + + var elapsed = _timeProvider.GetElapsedTime(startingTimestamp); + + _eventHandlingDuration.Record( + elapsed.TotalSeconds, + tags); + } + public void Dispose() => _meter.Dispose(); +} diff --git a/shared/CoffeeShop.Shared/OpenTelemetry/HandlerBehavior.cs b/shared/CoffeeShop.Shared/OpenTelemetry/HandlerBehavior.cs new file mode 100644 index 0000000..381f771 --- /dev/null +++ b/shared/CoffeeShop.Shared/OpenTelemetry/HandlerBehavior.cs @@ -0,0 +1,58 @@ +using System.Reflection; + +using Microsoft.Extensions.Logging; + +namespace CoffeeShop.Shared.OpenTelemetry; + +public class HandlerBehavior( + IRequestHandler outerHandler, + IActivityScope activityScope, + CommandHandlerMetrics commandMetrics, + QueryHandlerMetrics queryMetrics, + ILogger> logger) : IPipelineBehavior + where TRequest : notnull, IRequest + where TResponse : notnull +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + logger.LogInformation("Handled {RequestName}", typeof(TRequest).FullName); + + var attr = outerHandler + .GetType().GetCustomAttribute(); + + if (attr is not null) + { + return await next(); + } + + var handlerName = outerHandler.GetType().Name; + var queryName = typeof(TRequest).Name; + var activityName = $"{queryName}-{handlerName}"; + + var isCommand = queryName.ToLowerInvariant().EndsWith("command"); + + var tagName = isCommand ? TelemetryTags.Commands.Command : TelemetryTags.Queries.Query; + + var startingTimestamp = isCommand ? commandMetrics.CommandHandlingStart(handlerName) : queryMetrics.QueryHandlingStart(handlerName); + + try + { + return await activityScope.Run( + activityName, + async (_, token) => await next(), + new StartActivityOptions { Tags = { { tagName, queryName } } }, + cancellationToken + ); + } + finally + { + if (isCommand) + { + commandMetrics.CommandHandlingEnd(handlerName, startingTimestamp); + } + else { + queryMetrics.QueryHandlingEnd(handlerName, startingTimestamp); + } + } + } +} diff --git a/shared/CoffeeShop.Shared/OpenTelemetry/IgnoreOTelOnHandlerAttribute.cs b/shared/CoffeeShop.Shared/OpenTelemetry/IgnoreOTelOnHandlerAttribute.cs new file mode 100644 index 0000000..aa7ef68 --- /dev/null +++ b/shared/CoffeeShop.Shared/OpenTelemetry/IgnoreOTelOnHandlerAttribute.cs @@ -0,0 +1,6 @@ +namespace CoffeeShop.Shared.OpenTelemetry; + +[AttributeUsage(AttributeTargets.Class)] +public class IgnoreOTelOnHandlerAttribute : Attribute +{ +} diff --git a/shared/CoffeeShop.Shared/OpenTelemetry/OtelMassTransit/OTelConsumeFilter.cs b/shared/CoffeeShop.Shared/OpenTelemetry/OtelMassTransit/OTelConsumeFilter.cs new file mode 100644 index 0000000..2624ea4 --- /dev/null +++ b/shared/CoffeeShop.Shared/OpenTelemetry/OtelMassTransit/OTelConsumeFilter.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; + +namespace CoffeeShop.Shared.OpenTelemetry.OtelMassTransit; + +public class OTelConsumeFilter(IActivityScope activityScope, IHttpContextAccessor httpContextAccessor) : IFilter> where T : class +{ + public void Probe(ProbeContext context) + { + } + + public async Task Send(ConsumeContext context, IPipe> next) + { + context.TryGetHeader("USER-CONTEXT", out string userContext); + + var temp = httpContextAccessor; + var activityName = $"{context.Message.GetType().FullName}-enrich"; + + await activityScope.Run( + activityName, + async (_, token) => await next.Send(context), + new StartActivityOptions { Tags = { { "USER-CONTEXT", userContext } } }, + default + ); + } +} diff --git a/shared/CoffeeShop.Shared/OpenTelemetry/OtelMassTransit/OtelPublishFilter.cs b/shared/CoffeeShop.Shared/OpenTelemetry/OtelMassTransit/OtelPublishFilter.cs new file mode 100644 index 0000000..17e9345 --- /dev/null +++ b/shared/CoffeeShop.Shared/OpenTelemetry/OtelMassTransit/OtelPublishFilter.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; + +namespace CoffeeShop.Shared.OpenTelemetry.OtelMassTransit; + +public class OtelPublishFilter(IActivityScope activityScope, IHttpContextAccessor httpContextAccessor) : IFilter> where T : class +{ + public void Probe(ProbeContext context) + { + } + + public async Task Send(PublishContext context, IPipe> next) + { + context.Headers.Set("USER-CONTEXT", "some context", overwrite: true); + + var temp = httpContextAccessor; + var activityName = $"{context.Message.GetType().FullName}-enrich"; + + await activityScope.Run( + activityName, + async (_, token) => await next.Send(context), + new StartActivityOptions { Tags = { { "USER-CONTEXT", "some context" } } }, + default + ); + } +} diff --git a/shared/CoffeeShop.Shared/OpenTelemetry/OtelMassTransit/OtelSendFilter.cs b/shared/CoffeeShop.Shared/OpenTelemetry/OtelMassTransit/OtelSendFilter.cs new file mode 100644 index 0000000..739126e --- /dev/null +++ b/shared/CoffeeShop.Shared/OpenTelemetry/OtelMassTransit/OtelSendFilter.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; + +namespace CoffeeShop.Shared.OpenTelemetry.OtelMassTransit; + +public class OtelSendFilter(IActivityScope activityScope, IHttpContextAccessor httpContextAccessor) : IFilter> where T : class +{ + public void Probe(ProbeContext context) + { + } + + public async Task Send(SendContext context, IPipe> next) + { + context.Headers.Set("USER-CONTEXT", "some context", overwrite: true); + + var temp = httpContextAccessor; + var activityName = $"{context.Message.GetType().FullName}-enrich"; + + await activityScope.Run( + activityName, + async (_, token) => await next.Send(context), + new StartActivityOptions { Tags = { { "USER-CONTEXT", "some context" } } }, + default + ); + } +} diff --git a/shared/CoffeeShop.Shared/OpenTelemetry/QueryHandlerMetrics.cs b/shared/CoffeeShop.Shared/OpenTelemetry/QueryHandlerMetrics.cs new file mode 100644 index 0000000..cb05bc5 --- /dev/null +++ b/shared/CoffeeShop.Shared/OpenTelemetry/QueryHandlerMetrics.cs @@ -0,0 +1,75 @@ +using System.Diagnostics.Metrics; + +namespace CoffeeShop.Shared.OpenTelemetry; + +public class QueryHandlerMetrics : IDisposable +{ + private readonly TimeProvider _timeProvider; + private readonly Meter _meter; + private readonly UpDownCounter _activeEventHandlingCounter; + private readonly Counter _totalCommandsNumber; + private readonly Histogram _eventHandlingDuration; + + public QueryHandlerMetrics( + IMeterFactory meterFactory, + TimeProvider timeProvider + ) + { + _timeProvider = timeProvider; + _meter = meterFactory.Create(ActivitySourceProvider.DefaultSourceName); + + _totalCommandsNumber = _meter.CreateCounter( + TelemetryTags.Queries.TotalQueriesNumber, + unit: "{query}", + description: "Total number of queries send to query handlers"); + + _activeEventHandlingCounter = _meter.CreateUpDownCounter( + TelemetryTags.Queries.ActiveQueriesNumber, + unit: "{query}", + description: "Number of queries currently being handled"); + + _eventHandlingDuration = _meter.CreateHistogram( + TelemetryTags.Queries.QueryHandlingDuration, + unit: "s", + description: "Measures the duration of inbound queries"); + } + + public long QueryHandlingStart(string queryType) + { + var tags = new TagList { { TelemetryTags.Queries.QueryType, queryType } }; + + if (_activeEventHandlingCounter.Enabled) + { + _activeEventHandlingCounter.Add(1, tags); + } + + if (_totalCommandsNumber.Enabled) + { + _totalCommandsNumber.Add(1, tags); + } + + return _timeProvider.GetTimestamp(); + } + + public void QueryHandlingEnd(string queryType, long startingTimestamp) + { + var tags = _activeEventHandlingCounter.Enabled + || _eventHandlingDuration.Enabled + ? new TagList { { TelemetryTags.Queries.QueryType, queryType } } + : default; + + if (_activeEventHandlingCounter.Enabled) + { + _activeEventHandlingCounter.Add(-1, tags); + } + + if (!_eventHandlingDuration.Enabled) return; + + var elapsed = _timeProvider.GetElapsedTime(startingTimestamp); + + _eventHandlingDuration.Record( + elapsed.TotalSeconds, + tags); + } + public void Dispose() => _meter.Dispose(); +} diff --git a/shared/CoffeeShop.Shared/OpenTelemetry/TelemetryTags.cs b/shared/CoffeeShop.Shared/OpenTelemetry/TelemetryTags.cs new file mode 100644 index 0000000..d5e921f --- /dev/null +++ b/shared/CoffeeShop.Shared/OpenTelemetry/TelemetryTags.cs @@ -0,0 +1,31 @@ +namespace CoffeeShop.Shared.OpenTelemetry; + +public static class TelemetryTags +{ + public static class Commands + { + public const string Command = $"{ActivitySourceProvider.DefaultSourceName}.command"; + public const string CommandType = $"{Command}.type"; + public const string CommandsMeter = $"{ActivitySourceProvider.DefaultSourceName}.commands"; + public const string CommandHandling = $"{CommandsMeter}.handling"; + public const string ActiveCommandsNumber = $"{CommandHandling}.active.number"; + public const string TotalCommandsNumber = $"{CommandHandling}.total"; + public const string CommandHandlingDuration = $"{CommandHandling}.duration"; + } + + public static class Queries + { + public const string Query = $"{ActivitySourceProvider.DefaultSourceName}.query"; + public const string QueryType = $"{Query}.type"; + public const string QueriesMeter = $"{ActivitySourceProvider.DefaultSourceName}.queries"; + public const string QueryHandling = $"{QueriesMeter}.handling"; + public const string ActiveQueriesNumber = $"{QueryHandling}.active.number"; + public const string TotalQueriesNumber = $"{QueryHandling}.total"; + public const string QueryHandlingDuration = $"{QueryHandling}.duration"; + } + + public static class Validator + { + public const string Validation = $"{ActivitySourceProvider.DefaultSourceName}.validator"; + } +} diff --git a/shared/CoffeeShop.Shared/OpenTelemetry/ValidationBehavior.cs b/shared/CoffeeShop.Shared/OpenTelemetry/ValidationBehavior.cs new file mode 100644 index 0000000..d9d143a --- /dev/null +++ b/shared/CoffeeShop.Shared/OpenTelemetry/ValidationBehavior.cs @@ -0,0 +1,47 @@ +using FluentValidation; + +namespace CoffeeShop.Shared.OpenTelemetry; + +public record ValidationError(string PropertyName, string ErrorMessage); + +public class ValidationException(IEnumerable errors) : Exception +{ + public IEnumerable Errors => errors; +} + +public class ValidationBehavior(IActivityScope activityScope, IEnumerable> validators) : IPipelineBehavior + where TRequest : notnull, IRequest + where TResponse : notnull +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var context = new ValidationContext(request); + + var validationFailures = await Task.WhenAll( + validators.Select(validator => validator.ValidateAsync(context))); + + var errors = validationFailures + .Where(validationResult => !validationResult.IsValid) + .SelectMany(validationResult => validationResult.Errors) + .Select(validationFailure => new ValidationError( + validationFailure.PropertyName, + validationFailure.ErrorMessage)) + .ToList(); + + if (errors.Count != 0) + { + throw new ValidationException(errors); + } + + var queryName = typeof(TRequest).Name; + var validatorNames = validators.Aggregate("", (c, x) => $"{x.GetType().Name}, {c}"); + var activityName = $"{queryName}-{validatorNames.Trim().TrimEnd(',')}"; + + return await activityScope.Run( + activityName, + async (_, token) => await next(), + new StartActivityOptions { Tags = { { TelemetryTags.Validator.Validation, queryName } } }, + cancellationToken + ); + } +} diff --git a/tests.runsettings b/tests.runsettings new file mode 100644 index 0000000..e22571f --- /dev/null +++ b/tests.runsettings @@ -0,0 +1,17 @@ + + + + + + + cobertura,opencover + [*.Tests?]* + + **/Migrations/*.cs, + + true + + + + + \ No newline at end of file