diff --git a/.github/workflows/cn.pricewatcher.ci.yml b/.github/workflows/cn.pricewatcher.ci.yml deleted file mode 100644 index 0c0beda..0000000 --- a/.github/workflows/cn.pricewatcher.ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: "CI/CD: PriceWatcher Service" -on: - push: - branches: - - "main" - paths: - - "src/PriceWatcherService/**" - - "charts/price-watcher/**" - - ".github/workflows/cn.pricewatcher.*.yml" - - ".github/templates/**/*.yml" - workflow_dispatch: -jobs: - ci: - name: "CI: PriceWatcherService" - - runs-on: ubuntu-latest - steps: - - name: Checkout Repository - uses: actions/checkout@v3 - - name: Build & Push Products Service of Cloud-Native Sample - uses: "./.github/templates/build-and-push" - with: - acr_name: ${{ secrets.ACR_NAME}} - acr_token_name: ${{ secrets.ACR_TOKEN_NAME }} - acr_token_password: ${{ secrets.ACR_TOKEN_PASSWORD }} - app_name: price-watcher - app_display_name: PriceWatcher Service - working_dir: ./src/PriceWatcherService - github_token: ${{ secrets.github_token }} - cd: - name: "CD: PriceWatcherService" - runs-on: ubuntu-latest - needs: ci - env: - AKS_NAME: aks-cloud-native-sample - RG_NAME: rg-cloud-native-sample-develop - KUBELOGIN_VERSION: v0.0.24 - steps: - - name: Checkout Repository - uses: actions/checkout@v3 - - name: Deploy PriceWatcher Service of Cloud-Native Sample - uses: "./.github/templates/deploy" - with: - acr_name: ${{ secrets.ACR_NAME}} - app_name: price-watcher - app_display_name: PriceWatcher Service - working_dir: ./src/PriceWatcherService - helm_chart_location: ./charts/price-watcher - aks_name: ${{ env.AKS_NAME }} - aks_resource_group_name: ${{ env.RG_NAME }} - kubelogin_version: ${{ env.KUBELOGIN_VERSION }} - kubernetes_namespace: cloud-native-sample - github_token: ${{ secrets.github_token }} - terraform_client_id: ${{ secrets.TERRAFORM_CLIENT_ID }} - terraform_client_secret: ${{ secrets.TERRAFORM_CLIENT_SECRET }} - terraform_subscription_id: ${{ secrets.TERRAFORM_SUBSCRIPTION_ID }} - terraform_tenant_id: ${{ secrets.TERRAFORM_TENANT_ID }} diff --git a/charts/price-watcher/.helmignore b/charts/price-watcher/.helmignore deleted file mode 100644 index 0e8a0eb..0000000 --- a/charts/price-watcher/.helmignore +++ /dev/null @@ -1,23 +0,0 @@ -# Patterns to ignore when building packages. -# This supports shell glob matching, relative path matching, and -# negation (prefixed with !). Only one pattern per line. -.DS_Store -# Common VCS dirs -.git/ -.gitignore -.bzr/ -.bzrignore -.hg/ -.hgignore -.svn/ -# Common backup files -*.swp -*.bak -*.tmp -*.orig -*~ -# Various IDEs -.project -.idea/ -*.tmproj -.vscode/ diff --git a/charts/price-watcher/Chart.yaml b/charts/price-watcher/Chart.yaml deleted file mode 100644 index 697c79a..0000000 --- a/charts/price-watcher/Chart.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v2 -name: price-watcher -description: A Helm chart for Kubernetes -type: application -version: 0.1.0 -appVersion: "1.16.0" diff --git a/charts/price-watcher/templates/NOTES.txt b/charts/price-watcher/templates/NOTES.txt deleted file mode 100644 index 450c249..0000000 --- a/charts/price-watcher/templates/NOTES.txt +++ /dev/null @@ -1 +0,0 @@ -PriceWatcher Service has been provisioned to Namespace {{ .Release.Namespace }} diff --git a/charts/price-watcher/templates/_helpers.tpl b/charts/price-watcher/templates/_helpers.tpl deleted file mode 100644 index 28966c8..0000000 --- a/charts/price-watcher/templates/_helpers.tpl +++ /dev/null @@ -1,62 +0,0 @@ -{{/* -Expand the name of the chart. -*/}} -{{- define "price-watcher.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Create a default fully qualified app name. -We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). -If release name contains chart name it will be used as a full name. -*/}} -{{- define "price-watcher.fullname" -}} -{{- if .Values.fullnameOverride }} -{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- $name := default .Chart.Name .Values.nameOverride }} -{{- if contains $name .Release.Name }} -{{- .Release.Name | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} -{{- end }} -{{- end }} -{{- end }} - -{{/* -Create chart name and version as used by the chart label. -*/}} -{{- define "price-watcher.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Common labels -*/}} -{{- define "price-watcher.labels" -}} -helm.sh/chart: {{ include "price-watcher.chart" . }} -{{ include "price-watcher.selectorLabels" . }} -{{- if .Chart.AppVersion }} -app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} -{{- end }} -app.kubernetes.io/managed-by: {{ .Release.Service }} -{{- end }} - -{{/* -Selector labels -*/}} -{{- define "price-watcher.selectorLabels" -}} -app.kubernetes.io/name: {{ include "price-watcher.name" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end }} - -{{/* -Create the name of the service account to use -*/}} -{{- define "price-watcher.serviceAccountName" -}} -{{- if .Values.serviceAccount.create }} -{{- default (include "price-watcher.fullname" .) .Values.serviceAccount.name }} -{{- else }} -{{- default "default" .Values.serviceAccount.name }} -{{- end }} -{{- end }} diff --git a/charts/price-watcher/templates/deployment.yaml b/charts/price-watcher/templates/deployment.yaml deleted file mode 100644 index e80823d..0000000 --- a/charts/price-watcher/templates/deployment.yaml +++ /dev/null @@ -1,80 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "price-watcher.fullname" . }} - labels: - {{- include "price-watcher.labels" . | nindent 4 }} -spec: - {{- if not .Values.autoscaling.enabled }} - replicas: {{ .Values.replicaCount }} - {{- end }} - selector: - matchLabels: - {{- include "price-watcher.selectorLabels" . | nindent 6 }} - template: - metadata: - {{- with .Values.podAnnotations }} - annotations: - {{- toYaml . | nindent 8 }} - {{- end }} - labels: - {{- include "price-watcher.selectorLabels" . | nindent 8 }} - spec: - {{- with .Values.imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} - serviceAccountName: {{ include "price-watcher.serviceAccountName" . }} - securityContext: - {{- toYaml .Values.podSecurityContext | nindent 8 }} - containers: - - name: {{ .Chart.Name }} - securityContext: - {{- toYaml .Values.securityContext | nindent 12 }} - image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - env: - - name: PriceWatcherService__ZipkinEndpoint - valueFrom: - configMapKeyRef: - name: zipkin - key: endpoint - - name: PriceWatcherService__ExposePrometheusMetrics - value: {{ .Values.exposePrometheusMetrics | quote }} - - name: PriceWatcherService__IdentityServer__Authority - valueFrom: - configMapKeyRef: - name: authentication - key: authority - optional: true - ports: - - name: http-pwatcher - containerPort: {{ .Values.containerPort }} - protocol: TCP - livenessProbe: - initialDelaySeconds: 30 - timeoutSeconds: 3 - periodSeconds: 20 - httpGet: - path: /healthz/liveness - port: http-pwatcher - readinessProbe: - initialDelaySeconds: 20 - timeoutSeconds: 3 - httpGet: - path: /healthz/readiness - port: http-pwatcher - resources: - {{- toYaml .Values.resources | nindent 12 }} - {{- with .Values.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} diff --git a/charts/price-watcher/templates/hpa.yaml b/charts/price-watcher/templates/hpa.yaml deleted file mode 100644 index acff5e7..0000000 --- a/charts/price-watcher/templates/hpa.yaml +++ /dev/null @@ -1,28 +0,0 @@ -{{- if .Values.autoscaling.enabled }} -apiVersion: autoscaling/v2beta1 -kind: HorizontalPodAutoscaler -metadata: - name: {{ include "price-watcher.fullname" . }} - labels: - {{- include "price-watcher.labels" . | nindent 4 }} -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: {{ include "price-watcher.fullname" . }} - minReplicas: {{ .Values.autoscaling.minReplicas }} - maxReplicas: {{ .Values.autoscaling.maxReplicas }} - metrics: - {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} - - type: Resource - resource: - name: cpu - targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} - {{- end }} - {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} - - type: Resource - resource: - name: memory - targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} - {{- end }} -{{- end }} diff --git a/charts/price-watcher/templates/service.yaml b/charts/price-watcher/templates/service.yaml deleted file mode 100644 index 2933ac6..0000000 --- a/charts/price-watcher/templates/service.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ include "price-watcher.fullname" . }} - labels: - {{- include "price-watcher.labels" . | nindent 4 }} -spec: - type: {{ .Values.service.type }} - ports: - - port: {{ .Values.service.port }} - targetPort: http-pwatcher - protocol: TCP - name: http - selector: - {{- include "price-watcher.selectorLabels" . | nindent 4 }} diff --git a/charts/price-watcher/templates/serviceaccount.yaml b/charts/price-watcher/templates/serviceaccount.yaml deleted file mode 100644 index ac55805..0000000 --- a/charts/price-watcher/templates/serviceaccount.yaml +++ /dev/null @@ -1,12 +0,0 @@ -{{- if .Values.serviceAccount.create -}} -apiVersion: v1 -kind: ServiceAccount -metadata: - name: {{ include "price-watcher.serviceAccountName" . }} - labels: - {{- include "price-watcher.labels" . | nindent 4 }} - {{- with .Values.serviceAccount.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -{{- end }} diff --git a/charts/price-watcher/templates/tests/test-connection.yaml b/charts/price-watcher/templates/tests/test-connection.yaml deleted file mode 100644 index 8c6b81b..0000000 --- a/charts/price-watcher/templates/tests/test-connection.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -kind: Pod -metadata: - name: "{{ include "price-watcher.fullname" . }}-test-connection" - labels: - {{- include "price-watcher.labels" . | nindent 4 }} - annotations: - "helm.sh/hook": test -spec: - containers: - - name: wget - image: busybox - command: ['wget'] - args: ['{{ include "price-watcher.fullname" . }}:{{ .Values.service.port }}'] - restartPolicy: Never diff --git a/charts/price-watcher/values.yaml b/charts/price-watcher/values.yaml deleted file mode 100644 index 4008aae..0000000 --- a/charts/price-watcher/values.yaml +++ /dev/null @@ -1,66 +0,0 @@ -replicaCount: 1 -exposePrometheusMetrics: true - -image: - repository: nginx - pullPolicy: IfNotPresent - tag: alpine - -containerPort: 5000 - -imagePullSecrets: [] -nameOverride: "" -fullnameOverride: "" - -serviceAccount: - # Specifies whether a service account should be created - create: true - # Annotations to add to the service account - annotations: {} - # The name of the service account to use. - # If not set and create is true, a name is generated using the fullname template - name: "" - -podAnnotations: - dapr.io/enabled: "true" - dapr.io/app-id: "price-watcher" - dapr.io/app-port: "5000" - dapr.io/config: "cloud-native-sample" - prometheus.io/scrape: 'true' - prometheus.io/port: '5000' - -podSecurityContext: {} - # fsGroup: 2000 - -securityContext: {} - # capabilities: - # drop: - # - ALL - # readOnlyRootFilesystem: true - # runAsNonRoot: true - # runAsUser: 1000 - -service: - type: ClusterIP - port: 80 - -resources: - requests: - cpu: 150m - memory: 128Mi - limits: - cpu: 200m - memory: 196Mi - -autoscaling: - enabled: false - minReplicas: 1 - maxReplicas: 100 - targetCPUUtilizationPercentage: 80 - # targetMemoryUtilizationPercentage: 80 - -nodeSelector: {} - -tolerations: [] - -affinity: {} diff --git a/docker-compose.yml b/docker-compose.yml index babbf2b..f5c44f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -167,24 +167,6 @@ services: options: loki-url: http://10.5.0.99:3100/loki/api/v1/push loki-retries: 5 - pricewatcher: - build: ./src/PriceWatcherService - container_name: pricewatcher - networks: - - cloud-native - depends_on: - - rabbit - environment: - PriceWatcherService__ExposePrometheusMetrics: true - PriceWatcherService__ZipkinEndpoint: http://zipkin:9411/api/v2/spans - PriceWatcherService__IdentityServer__Authority: http://localhost:5009 - PriceWatcherService__IdentityServer__RequireHttpsMetadata: false - PriceWatcherService__IdentityServer__MetadataAddress: http://authn:5000/.well-known/openid-configuration - logging: - driver: loki - options: - loki-url: http://10.5.0.99:3100/loki/api/v1/push - loki-retries: 5 pricedropnotifier: build: ./src/PriceDropNotifierService container_name: pricedropnotifier @@ -346,27 +328,6 @@ services: - ./docker-compose-configs/dapr/components:/dapr/components:ro depends_on: - shipping - pricewatcher-dapr: - image: "daprio/daprd:edge" - container_name: pricewatcher-dapr - command: - [ - "./daprd", - "-app-id", - "pricewatcher", - "-app-port", - "5000", - "-components-path", - "/dapr/components", - "-config", - "/dapr/config/config.yml" - ] - network_mode: "service:pricewatcher" - volumes: - - ./docker-compose-configs/dapr/config.yml:/dapr/config/config.yml:ro - - ./docker-compose-configs/dapr/components:/dapr/components:ro - depends_on: - - pricewatcher pricedropnotifier-dapr: image: "daprio/daprd:edge" container_name: pricedropnotifier-dapr diff --git a/src/CloudNativeSample.sln b/src/CloudNativeSample.sln index f0dff48..4d5ad59 100644 --- a/src/CloudNativeSample.sln +++ b/src/CloudNativeSample.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderMonitorClient", "Order EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AuthenticationService", "AuthenticationService\AuthenticationService.csproj", "{94224E64-0B3C-46DF-88C6-FF712BA5D3EE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PriceDropNotifier", "PriceDropNotifierService\PriceDropNotifier.csproj", "{6AA94317-04DF-44FE-9FEE-A3B360D0824C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -43,5 +45,9 @@ Global {94224E64-0B3C-46DF-88C6-FF712BA5D3EE}.Debug|Any CPU.Build.0 = Debug|Any CPU {94224E64-0B3C-46DF-88C6-FF712BA5D3EE}.Release|Any CPU.ActiveCfg = Release|Any CPU {94224E64-0B3C-46DF-88C6-FF712BA5D3EE}.Release|Any CPU.Build.0 = Release|Any CPU + {6AA94317-04DF-44FE-9FEE-A3B360D0824C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6AA94317-04DF-44FE-9FEE-A3B360D0824C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6AA94317-04DF-44FE-9FEE-A3B360D0824C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6AA94317-04DF-44FE-9FEE-A3B360D0824C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/Gateway/appsettings.DockerCompose.json b/src/Gateway/appsettings.DockerCompose.json index 58b3cd3..5db1c8d 100644 --- a/src/Gateway/appsettings.DockerCompose.json +++ b/src/Gateway/appsettings.DockerCompose.json @@ -68,14 +68,14 @@ } }, "pricewatcher": { - "ClusterId": "pricewatcher", + "ClusterId": "pricedropnotifier", "CorsPolicy": "GatewayPolicy", "Match": { "Path": "/pricewatcher/{**catch-all}" } }, "pricedrops": { - "ClusterId": "pricewatcher", + "ClusterId": "products", "CorsPolicy": "GatewayPolicy", "Match": { "Path": "/pricedrops/{**catch-all}" @@ -117,17 +117,17 @@ } } }, - "pricewatcher": { + "notifications": { "Destinations": { - "pricewatcher/destination1": { - "Address": "http://pricewatcher:5000" + "notifications/destination1": { + "Address": "http://notification:5000" } } }, - "notifications": { + "pricedropnotifier": { "Destinations": { - "notifications/destination1": { - "Address": "http://notification:5000" + "pricedropnotifier/destination1": { + "Address": "http://pricedropnotifier:5000" } } } diff --git a/src/Gateway/appsettings.Production.json b/src/Gateway/appsettings.Production.json index 076eb89..05e4811 100644 --- a/src/Gateway/appsettings.Production.json +++ b/src/Gateway/appsettings.Production.json @@ -66,14 +66,14 @@ } }, "pricewatcher": { - "ClusterId": "pricewatcher", + "ClusterId": "pricedropnotifier", "CorsPolicy": "GatewayPolicy", "Match": { "Path": "/pricewatcher/{**catch-all}" } }, "pricedrops": { - "ClusterId": "pricewatcher", + "ClusterId": "products", "CorsPolicy": "GatewayPolicy", "Match": { "Path": "/pricedrops/{**catch-all}" @@ -109,10 +109,10 @@ } } }, - "pricewatcher": { + "pricedropnotifier": { "Destinations": { - "pricewatcher/destination1": { - "Address": "http://price-watcher:80" + "pricedropnotifier/destination1": { + "Address": "http://pricedropnotifier:80" } } } diff --git a/src/PriceDropNotifierService/Controllers/PriceDropNotifierController.cs b/src/PriceDropNotifierService/Controllers/PriceDropNotifierController.cs index 046cb0d..959d4a3 100644 --- a/src/PriceDropNotifierService/Controllers/PriceDropNotifierController.cs +++ b/src/PriceDropNotifierService/Controllers/PriceDropNotifierController.cs @@ -3,6 +3,7 @@ using Dapr.Client; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using PriceDropNotifier.Data.UnitOfWork; using PriceDropNotifier.Models; namespace PriceDropNotifier.Controllers; @@ -11,21 +12,27 @@ namespace PriceDropNotifier.Controllers; [Route("pricedrops")] public class PriceDropNotifierController : ControllerBase { - private readonly DaprClient _client; private readonly ILogger _logger; + private readonly INotificationUnitOfWork _unitOfWork; - public PriceDropNotifierController(DaprClient client, ILogger logger) + public PriceDropNotifierController(DaprClient client, + ILogger logger, + INotificationUnitOfWork unitOfWork) { _client = client; _logger = logger; + _unitOfWork = unitOfWork; } [HttpPost] [Route("notify")] [AllowAnonymous] - public async Task Notify([FromBody] CloudEvent model) + public async Task Notify([FromBody] CloudEvent model, + CancellationToken cancellationToken) { + // I think we can get rid of this try-catch block. The same thing is done + // by the HTTP pipeline of ASP.NET Core try { if (!HttpContext.Request.HasValidDaprApiToken()) @@ -37,17 +44,38 @@ public async Task Notify([FromBody] CloudEventPriceDrop for {data.ProductName} +

Price dropped to {data.Price:F2}

"; + foreach (var registration in targetRegistrations) + { + await _client.InvokeBindingAsync( + "email", + "create", + mailBody, + new Dictionary + { + { "emailTo", registration.Email }, + { "subject", $"📉 Price drop for {data.ProductName}" } + } + ); } - - _logger.LogInformation("Received notification request for {productName} with price {price}", model.Data.ProductName, model.Data.Price); - var mailBody = @$"

PriceDrop for {model.Data.ProductName}

-

Price dropped to {model.Data.Price:F2}

"; - - await _client.InvokeBindingAsync("email", "create", mailBody, new Dictionary { - {"emailTo", model.Data.Recipient}, - {"subject", $"📉 Price drop for {model.Data.ProductName}"} - }); return Ok(); } diff --git a/src/PriceDropNotifierService/Controllers/PriceWatcherController.cs b/src/PriceDropNotifierService/Controllers/PriceWatcherController.cs new file mode 100644 index 0000000..a96b109 --- /dev/null +++ b/src/PriceDropNotifierService/Controllers/PriceWatcherController.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc; +using PriceDropNotifier.Data.Model; +using PriceDropNotifier.Data.UnitOfWork; +using PriceDropNotifier.Models; + +namespace PriceDropNotifier.Controllers; + +[ApiController] +[Route("/pricewatcher/register")] +public sealed class PriceWatcherController : ControllerBase +{ + public PriceWatcherController(IPriceWatcherUnitOfWork unitOfWork, + ILogger logger) + { + UnitOfWork = unitOfWork; + Logger = logger; + } + + private IPriceWatcherUnitOfWork UnitOfWork { get; } + private ILogger Logger { get; } + + [HttpPost] + public async Task Register(RegisterModel model, CancellationToken cancellationToken) + { + var existingRegistration = await UnitOfWork.GetExistingRegistrationAsync(model.Email, + model.ProductId, + cancellationToken); + if (existingRegistration is not null) + { + var oldPrice = existingRegistration.TargetPrice; + existingRegistration.TargetPrice = model.Price; + await UnitOfWork.UpdateRegistrationAsync(existingRegistration, cancellationToken); + await UnitOfWork.SaveChangesAsync(cancellationToken); + Logger.LogInformation("Existing {Registration} was updated (old price {OldPrice})", + existingRegistration, + oldPrice); + } + else + { + var newRegistration = PriceWatchRegistration.Create(model.Email, model.ProductId, model.Price); + await UnitOfWork.InsertRegistrationAsync(newRegistration, cancellationToken); + await UnitOfWork.SaveChangesAsync(cancellationToken); + Logger.LogInformation("New {Registration} was created", newRegistration); + } + + + return NoContent(); + } +} diff --git a/src/PriceDropNotifierService/Data/DataModule.cs b/src/PriceDropNotifierService/Data/DataModule.cs new file mode 100644 index 0000000..147d7df --- /dev/null +++ b/src/PriceDropNotifierService/Data/DataModule.cs @@ -0,0 +1,11 @@ +using PriceDropNotifier.Data.UnitOfWork; + +namespace PriceDropNotifier.Data; + +public static class DataModule +{ + public static IServiceCollection AddDataAccess(this IServiceCollection services) => + services.AddSingleton() + .AddSingleton(sp => sp.GetRequiredService()) + .AddSingleton(sp => sp.GetRequiredService()); +} diff --git a/src/PriceDropNotifierService/Data/InMemoryPriceWatcherContext.cs b/src/PriceDropNotifierService/Data/InMemoryPriceWatcherContext.cs new file mode 100644 index 0000000..2cbe2f4 --- /dev/null +++ b/src/PriceDropNotifierService/Data/InMemoryPriceWatcherContext.cs @@ -0,0 +1,37 @@ +using PriceDropNotifier.Data.Model; +using PriceDropNotifier.Data.UnitOfWork; + +namespace PriceDropNotifier.Data; + +public sealed class InMemoryPriceWatcherContext : IPriceWatcherUnitOfWork, + INotificationUnitOfWork +{ + private List Registrations { get; } = new (); + + public Task> GetRegistrationsToBeNotifiedAsync(Guid productId, + double newPrice, + CancellationToken cancellationToken) => + Task.FromResult(Registrations.Where(r => r.ProductId == productId && + r.TargetPrice >= newPrice) + .ToList()); + + + public Task GetExistingRegistrationAsync(string email, + Guid productId, + CancellationToken cancellationToken = default) => + Task.FromResult(Registrations.FirstOrDefault(r => r.Email == email && + r.ProductId == productId)); + + public Task UpdateRegistrationAsync(PriceWatchRegistration registration, + CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task InsertRegistrationAsync(PriceWatchRegistration registration, + CancellationToken cancellationToken = default) + { + Registrations.Add(registration); + return Task.CompletedTask; + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; +} diff --git a/src/PriceDropNotifierService/Data/Model/PriceWatchRegistration.cs b/src/PriceDropNotifierService/Data/Model/PriceWatchRegistration.cs new file mode 100644 index 0000000..7118a56 --- /dev/null +++ b/src/PriceDropNotifierService/Data/Model/PriceWatchRegistration.cs @@ -0,0 +1,18 @@ +namespace PriceDropNotifier.Data.Model; + +public sealed class PriceWatchRegistration +{ + public Guid Id { get; set; } + public string Email { get; set; } = string.Empty; + public Guid ProductId { get; set; } + public double TargetPrice { get; set; } + + public static PriceWatchRegistration Create(string email, Guid productId, double targetPrice) => + new () + { + Id = Guid.NewGuid(), + Email = email, + ProductId = productId, + TargetPrice = targetPrice + }; +} diff --git a/src/PriceDropNotifierService/Data/UnitOfWork/INotificationUnitOfWork.cs b/src/PriceDropNotifierService/Data/UnitOfWork/INotificationUnitOfWork.cs new file mode 100644 index 0000000..eeb30b7 --- /dev/null +++ b/src/PriceDropNotifierService/Data/UnitOfWork/INotificationUnitOfWork.cs @@ -0,0 +1,10 @@ +using PriceDropNotifier.Data.Model; + +namespace PriceDropNotifier.Data.UnitOfWork; + +public interface INotificationUnitOfWork +{ + Task> GetRegistrationsToBeNotifiedAsync(Guid productId, + double newPrice, + CancellationToken cancellationToken = default); +} diff --git a/src/PriceDropNotifierService/Data/UnitOfWork/IPriceWatcherUnitOfWork.cs b/src/PriceDropNotifierService/Data/UnitOfWork/IPriceWatcherUnitOfWork.cs new file mode 100644 index 0000000..91b3fe9 --- /dev/null +++ b/src/PriceDropNotifierService/Data/UnitOfWork/IPriceWatcherUnitOfWork.cs @@ -0,0 +1,14 @@ +using PriceDropNotifier.Data.Model; + +namespace PriceDropNotifier.Data.UnitOfWork; + +public interface IPriceWatcherUnitOfWork +{ + Task GetExistingRegistrationAsync(string email, + Guid productId, + CancellationToken cancellationToken = default); + + Task UpdateRegistrationAsync(PriceWatchRegistration registration, CancellationToken cancellationToken = default); + Task InsertRegistrationAsync(PriceWatchRegistration registration, CancellationToken cancellationToken = default); + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/PriceDropNotifierService/Models/NotificationRequest.cs b/src/PriceDropNotifierService/Models/NotificationRequest.cs index 8ae345b..0ffddca 100644 --- a/src/PriceDropNotifierService/Models/NotificationRequest.cs +++ b/src/PriceDropNotifierService/Models/NotificationRequest.cs @@ -2,14 +2,15 @@ namespace PriceDropNotifier.Models; -public class NotificationRequest +public class NotificationRequest { + [JsonPropertyName("productId")] + + public Guid ProductId { get; set; } - [JsonPropertyName("recipient")] - public string Recipient {get;set;} [JsonPropertyName("productName")] - public string ProductName {get;set;} - [JsonPropertyName("price")] - public double Price {get;set;} + public string ProductName { get; set; } = string.Empty; + [JsonPropertyName("price")] + public double Price { get; set; } } diff --git a/src/PriceWatcherService/Models/RegisterModel.cs b/src/PriceDropNotifierService/Models/RegisterModel.cs similarity index 52% rename from src/PriceWatcherService/Models/RegisterModel.cs rename to src/PriceDropNotifierService/Models/RegisterModel.cs index b4a8e66..d2ce2b2 100644 --- a/src/PriceWatcherService/Models/RegisterModel.cs +++ b/src/PriceDropNotifierService/Models/RegisterModel.cs @@ -1,16 +1,15 @@ using System.ComponentModel.DataAnnotations; -namespace PriceWatcher.Models; +namespace PriceDropNotifier.Models; -public class RegisterModel +public sealed class RegisterModel { [Required] [EmailAddress] - public string Email { get; set; } + public string Email { get; set; } = string.Empty; - [Required] public Guid ProductId { get; set; } - [Required] + [Range(0.0, double.MaxValue)] public double Price { get; set; } } diff --git a/src/PriceDropNotifierService/PriceDropNotifier.csproj b/src/PriceDropNotifierService/PriceDropNotifier.csproj index 98148b5..884d694 100644 --- a/src/PriceDropNotifierService/PriceDropNotifier.csproj +++ b/src/PriceDropNotifierService/PriceDropNotifier.csproj @@ -21,5 +21,5 @@ - + diff --git a/src/PriceDropNotifierService/Program.cs b/src/PriceDropNotifierService/Program.cs index 8beaa9c..f762f0f 100644 --- a/src/PriceDropNotifierService/Program.cs +++ b/src/PriceDropNotifierService/Program.cs @@ -1,6 +1,6 @@ -using Microsoft.Extensions.Logging.Console; using Microsoft.OpenApi.Models; using PriceDropNotifier.Configuration; +using PriceDropNotifier.Data; var builder = WebApplication.CreateBuilder(args); @@ -8,7 +8,7 @@ var cfg = new PriceDropNotifierServiceConfiguration(); var cfgSection = builder.Configuration.GetSection(PriceDropNotifierServiceConfiguration.SectionName); -if (cfgSection == null || !cfgSection.Exists()) +if (!cfgSection.Exists()) { throw new ApplicationException( $"Could not find service config. Please provide a '{PriceDropNotifierServiceConfiguration.SectionName}' section in your appsettings.json file." @@ -19,16 +19,17 @@ builder.Services.AddSingleton(cfg); builder.ConfigureLogging(cfg) - .ConfigureTracing(cfg) - .ConfigureMetrics(cfg) - .ConfigureAuthN(cfg) - .ConfigureAuthZ(cfg) - .Services.AddHealthChecks() - .Services.AddDaprClient(); + .ConfigureTracing(cfg) + .ConfigureMetrics(cfg) + .ConfigureAuthN(cfg) + .ConfigureAuthZ(cfg) + .Services.AddHealthChecks() + .Services.AddDaprClient(); // Add services to the container. - -builder.Services.AddControllers(); +builder.Services + .AddDataAccess() + .AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(config => diff --git a/src/PriceWatcherService/.dockerignore b/src/PriceWatcherService/.dockerignore deleted file mode 100644 index 720e7a0..0000000 --- a/src/PriceWatcherService/.dockerignore +++ /dev/null @@ -1,24 +0,0 @@ -**/.classpath -**/.dockerignore -**/.env -**/.git -**/.gitignore -**/.project -**/.settings -**/.toolstarget -**/.vs -**/.vscode -**/*.*proj.user -**/*.dbmdl -**/*.jfm -**/bin -**/charts -**/docker-compose* -**/compose* -**/Dockerfile* -**/node_modules -**/npm-debug.log -**/obj -**/secrets.dev.yaml -**/values.dev.yaml -README.md diff --git a/src/PriceWatcherService/Configuration/Authorization.cs b/src/PriceWatcherService/Configuration/Authorization.cs deleted file mode 100644 index c76861f..0000000 --- a/src/PriceWatcherService/Configuration/Authorization.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace PriceWatcher.Configuration; - -public class Authorization -{ - public string RequiredClaimName { get; set; } = "scope"; - public string RequiredClaimValue {get;set;} = "sample"; - public string AdminScopeName {get;set;} = "admin"; -} diff --git a/src/PriceWatcherService/Configuration/IdentityServerConfiguration.cs b/src/PriceWatcherService/Configuration/IdentityServerConfiguration.cs deleted file mode 100644 index 957e8f6..0000000 --- a/src/PriceWatcherService/Configuration/IdentityServerConfiguration.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace PriceWatcher.Configuration; - -public class IdentityServerConfiguration -{ - public string Authority {get;set;} - public string MetadataAddress {get;set;} - public bool RequireHttpsMetadata { get; set;} = true; -} diff --git a/src/PriceWatcherService/Configuration/PriceWatcherServiceConfiguration.cs b/src/PriceWatcherService/Configuration/PriceWatcherServiceConfiguration.cs deleted file mode 100644 index f0d4047..0000000 --- a/src/PriceWatcherService/Configuration/PriceWatcherServiceConfiguration.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.Extensions.Logging.Console; - -namespace PriceWatcher.Configuration; - -public class PriceWatcherServiceConfiguration -{ - public PriceWatcherServiceConfiguration() - { - IdentityServer = new IdentityServerConfiguration(); - Authorization = new Authorization(); - } - public const string SectionName = "PriceWatcherService"; - public string PriceDropsPubSubName { get; set; } = "pricedrops"; - public string PriceDropsTopicName { get; set; } = "notifications"; - public string ConsoleFormatterName { get; set; } = ConsoleFormatterNames.Json; - public bool DisableConsoleLog { get; set; } - public string ZipkinEndpoint { get; set; } - public bool ExposePrometheusMetrics {get;set;} - public string ApplicationInsightsConnectionString {get;set;} - public IdentityServerConfiguration IdentityServer { get;set; } - public Authorization Authorization { get; set; } -} diff --git a/src/PriceWatcherService/Controllers/PriceDropController.cs b/src/PriceWatcherService/Controllers/PriceDropController.cs deleted file mode 100644 index 0d30002..0000000 --- a/src/PriceWatcherService/Controllers/PriceDropController.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using PriceWatcher.Models; -using PriceWatcher.Repositories; - -namespace PriceWatcher.Controllers; - -[ApiController] -[Route("pricedrops")] -public class PriceDropController : ControllerBase -{ - private readonly IPriceWatcherRepository _repository; - private readonly ILogger _logger; - - public PriceDropController(IPriceWatcherRepository repository, ILogger logger) - { - _repository = repository; - _logger = logger; - } - - [HttpPost("invoke")] - [Authorize(Policy = "RequiresAdminScope")] - public IActionResult InvokePriceDrop([FromBody] PriceDropModel model) - { - var dropped = _repository.DropPrice(model.ProductId, model.DropPriceBy); - if (!dropped) - { - return NotFound($"Product with Id {model.ProductId} not found"); - } - - return Ok(new ResponseMessage - { - Message = $"Dropped price for {model.ProductId} by {model.DropPriceBy * 100}%." - }); - } -} diff --git a/src/PriceWatcherService/Controllers/PriceWatcherController.cs b/src/PriceWatcherService/Controllers/PriceWatcherController.cs deleted file mode 100644 index 4e11964..0000000 --- a/src/PriceWatcherService/Controllers/PriceWatcherController.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Runtime.CompilerServices; -using Microsoft.AspNetCore.Mvc; -using PriceWatcher.Models; -using PriceWatcher.Repositories; - -namespace PriceWatcher.Controllers; - -[ApiController] -[Route("pricewatcher")] -public class PriceWatcherController : ControllerBase -{ - private IPriceWatcherRepository _repository; - private ILogger _logger; - - public PriceWatcherController(IPriceWatcherRepository repository, ILogger logger) - { - _repository = repository; - _logger = logger; - } - - - [HttpPost("register")] - public IActionResult RegisterAsync([FromBody] RegisterModel model) - { - var success = _repository.Register(model.Email, model.ProductId, model.Price); - if (!success) - { - return NotFound($"Product with Id {model.ProductId} not found"); - } - return Ok(new ResponseMessage - { - Message = "Registration succeeded" - }); - } - -} diff --git a/src/PriceWatcherService/CustomMetrics.cs b/src/PriceWatcherService/CustomMetrics.cs deleted file mode 100644 index df0cb6b..0000000 --- a/src/PriceWatcherService/CustomMetrics.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Reflection; -using System.Diagnostics.Metrics; - -namespace PriceWatcher; - -public class CustomMetrics -{ - public static readonly Meter Default = new Meter("PriceWatcherService", - Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0"); - - public static readonly Counter PriceWatchers = - Default.CreateCounter("cloud_native_new_price_watch", - description: "Number of users watching for a price drop"); - - public static readonly Counter PriceDrops = - Default.CreateCounter("cloud_native_price_drop", - description: "Number of a price drop issued"); - -} diff --git a/src/PriceWatcherService/CustomProcessor.cs b/src/PriceWatcherService/CustomProcessor.cs deleted file mode 100644 index 2b9ed5e..0000000 --- a/src/PriceWatcherService/CustomProcessor.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Diagnostics; -using OpenTelemetry; - -namespace PriceWatcher; - -internal sealed class CustomProcessor : BaseProcessor -{ - public override void OnEnd(Activity activity) - { - if (IsHealthOrMetricsEndpoint(activity.DisplayName)) - { - activity.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded; - } - } - - private static bool IsHealthOrMetricsEndpoint(string displayName) - { - if (string.IsNullOrEmpty(displayName)) - { - return false; - } - return displayName.StartsWith("/healthz/") || - displayName.StartsWith("/metrics"); - } -} diff --git a/src/PriceWatcherService/Dockerfile b/src/PriceWatcherService/Dockerfile deleted file mode 100644 index fe78210..0000000 --- a/src/PriceWatcherService/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base -WORKDIR /app -EXPOSE 5000 - -ENV ASPNETCORE_URLS=http://+:5000 - -# Creates a non-root user with an explicit UID and adds permission to access the /app folder -# For more info, please refer to https://aka.ms/vscode-docker-dotnet-configure-containers -RUN adduser -u 5678 --disabled-password --gecos "" appuser && chown -R appuser /app -USER appuser - -FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build -WORKDIR /src -COPY ["PriceWatcher.csproj", "./"] -RUN dotnet restore "PriceWatcher.csproj" -COPY . . -WORKDIR "/src/." -RUN dotnet build "PriceWatcher.csproj" -c Release -o /app/build - -FROM build AS publish -RUN dotnet publish "PriceWatcher.csproj" -c Release -o /app/publish /p:UseAppHost=false - -FROM base AS final -WORKDIR /app -COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "PriceWatcher.dll"] diff --git a/src/PriceWatcherService/Entities/Products.cs b/src/PriceWatcherService/Entities/Products.cs deleted file mode 100644 index 7490b76..0000000 --- a/src/PriceWatcherService/Entities/Products.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace PriceWatcher.Entities; - -public class Product -{ - - public Guid Id { get; set; } - public string Name { get; set; } - public string Description {get;set;} - public double Price { get; set; } -} diff --git a/src/PriceWatcherService/Entities/Watcher.cs b/src/PriceWatcherService/Entities/Watcher.cs deleted file mode 100644 index fcf6342..0000000 --- a/src/PriceWatcherService/Entities/Watcher.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace PriceWatcher.Entities; - -public class Watcher -{ - public string Email { get; set; } - public Guid ProductId { get; set; } - public double Price { get; set; } -} diff --git a/src/PriceWatcherService/Extensions/WebApplicationBuilderExtensions.cs b/src/PriceWatcherService/Extensions/WebApplicationBuilderExtensions.cs deleted file mode 100644 index 260cfd6..0000000 --- a/src/PriceWatcherService/Extensions/WebApplicationBuilderExtensions.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System.Reflection; -using Azure.Monitor.OpenTelemetry.Exporter; -using Microsoft.IdentityModel.Tokens; -using OpenTelemetry; -using OpenTelemetry.Metrics; -using OpenTelemetry.Resources; -using OpenTelemetry.Trace; -using PriceWatcher; -using PriceWatcher.Configuration; - -namespace Microsoft.AspNetCore.Builder; - -public static class WebApplicationBuilderExtensions -{ - private static string serviceName = "PriceWatcherService"; - private static string appVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0"; - private static Action ConfigureOpenTelemetryResource = builder => builder.AddService(serviceName, - serviceVersion: appVersion, - serviceInstanceId: Environment.MachineName - ); - - public static WebApplicationBuilder ConfigureLogging(this WebApplicationBuilder builder, PriceWatcherServiceConfiguration cfg) - { - builder.Logging.ClearProviders(); - if (System.Diagnostics.Debugger.IsAttached) - { - builder.Logging.AddDebug(); - } - if (!cfg.DisableConsoleLog) - { - builder.Logging.AddConsole(options => - { - options.FormatterName = cfg.ConsoleFormatterName; - }); - } - builder.Logging.AddOpenTelemetry(options => - { - var b = ResourceBuilder.CreateDefault(); - ConfigureOpenTelemetryResource(b); - options.SetResourceBuilder(b); - if (!string.IsNullOrWhiteSpace(cfg.ApplicationInsightsConnectionString)) - { - Console.WriteLine("Logs: Sending to Azure Monitor"); - options.AddAzureMonitorLogExporter(o => o.ConnectionString = cfg.ApplicationInsightsConnectionString); - } - - }); - return builder; - } - - public static WebApplicationBuilder ConfigureTracing(this WebApplicationBuilder builder, PriceWatcherServiceConfiguration cfg) - { - builder.Services - .AddOpenTelemetry() - .ConfigureResource(ConfigureOpenTelemetryResource) - .WithTracing(options => - { - options.AddProcessor(); - options - .AddHttpClientInstrumentation() - .AddAspNetCoreInstrumentation(); - if (!string.IsNullOrWhiteSpace(cfg.ApplicationInsightsConnectionString)) - { - Console.WriteLine("Tracing: Adding Azure Monitor Exporter"); - options.AddAzureMonitorTraceExporter(o => o.ConnectionString = cfg.ApplicationInsightsConnectionString); - } - if (!string.IsNullOrWhiteSpace(cfg.ZipkinEndpoint)) - { - Console.WriteLine("Tracing: Adding Zipkin Exporter (" + cfg.ZipkinEndpoint + ")"); - options.AddZipkinExporter(o => - { - o.Endpoint = new Uri(cfg.ZipkinEndpoint); - }); - } - }).StartWithHost(); - - return builder; - } - - public static WebApplicationBuilder ConfigureMetrics(this WebApplicationBuilder builder, PriceWatcherServiceConfiguration cfg) - { - builder.Services - .AddOpenTelemetry() - .ConfigureResource(ConfigureOpenTelemetryResource) - .WithMetrics(options => - { - options - .AddRuntimeInstrumentation() - .AddHttpClientInstrumentation() - .AddAspNetCoreInstrumentation() - .AddMeter(CustomMetrics.Default.Name); - - if (!string.IsNullOrWhiteSpace(cfg.ApplicationInsightsConnectionString)) - { - Console.WriteLine("Metrics: Adding Azure Monitor Exporter"); - options.AddAzureMonitorMetricExporter(o => o.ConnectionString = cfg.ApplicationInsightsConnectionString); - } - if (cfg.ExposePrometheusMetrics) - { - Console.WriteLine("Exposing Prometheus Metrics"); - options.AddPrometheusExporter(); - } - }).StartWithHost(); - - return builder; - } - - public static WebApplicationBuilder ConfigureAuthN(this WebApplicationBuilder builder, PriceWatcherServiceConfiguration cfg) - { - builder.Services.AddAuthentication("Bearer") - .AddJwtBearer("Bearer", options => - { - options.Authority = cfg.IdentityServer.Authority; - options.RequireHttpsMetadata = cfg.IdentityServer.RequireHttpsMetadata; - - if (!string.IsNullOrWhiteSpace(cfg.IdentityServer.MetadataAddress)) - { - options.MetadataAddress = cfg.IdentityServer.MetadataAddress; - } - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateAudience = false, - ValidIssuer = cfg.IdentityServer.Authority - }; - }); - return builder; - } - - public static WebApplicationBuilder ConfigureAuthZ(this WebApplicationBuilder builder, PriceWatcherServiceConfiguration cfg) - { - builder.Services.AddAuthorization(options => - { - options.AddPolicy("RequiresApiScope", policy => - { - policy.RequireAuthenticatedUser(); - policy.RequireClaim(cfg.Authorization.RequiredClaimName, cfg.Authorization.RequiredClaimValue); - }); - - options.AddPolicy("RequiresAdminScope", policy => - { - policy.RequireAuthenticatedUser(); - policy.RequireClaim(cfg.Authorization.RequiredClaimName, cfg.Authorization.AdminScopeName); - }); - }); - return builder; - } -} diff --git a/src/PriceWatcherService/Models/PriceDropModel.cs b/src/PriceWatcherService/Models/PriceDropModel.cs deleted file mode 100644 index 849a3fc..0000000 --- a/src/PriceWatcherService/Models/PriceDropModel.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace PriceWatcher.Models; - -public class PriceDropModel -{ - public Guid ProductId { get; set; } - public double DropPriceBy { get; set; } -} diff --git a/src/PriceWatcherService/Models/PriceDropNotificationModel.cs b/src/PriceWatcherService/Models/PriceDropNotificationModel.cs deleted file mode 100644 index 28c8b9b..0000000 --- a/src/PriceWatcherService/Models/PriceDropNotificationModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace PriceWatcher.Models -{ - public class PriceDropNotificationModel - { - public string Recipient { get; set; } - public string ProductName { get; set; } - public double Price { get; set; } - } -} diff --git a/src/PriceWatcherService/Models/ResponseMessage.cs b/src/PriceWatcherService/Models/ResponseMessage.cs deleted file mode 100644 index 5d7fca4..0000000 --- a/src/PriceWatcherService/Models/ResponseMessage.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace PriceWatcher.Models; - -public class ResponseMessage -{ - public string Message { get; set; } -} diff --git a/src/PriceWatcherService/PriceWatcher.csproj b/src/PriceWatcherService/PriceWatcher.csproj deleted file mode 100644 index 98148b5..0000000 --- a/src/PriceWatcherService/PriceWatcher.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - net7.0 - enable - enable - - - - - - - - - - - - - - - - - - - diff --git a/src/PriceWatcherService/Program.cs b/src/PriceWatcherService/Program.cs deleted file mode 100644 index 9ea7f32..0000000 --- a/src/PriceWatcherService/Program.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Microsoft.OpenApi.Models; -using PriceWatcher.Configuration; -using PriceWatcher.Repositories; - -var builder = WebApplication.CreateBuilder(args); -var cfg = new PriceWatcherServiceConfiguration(); -var cfgSection = builder.Configuration.GetSection(PriceWatcherServiceConfiguration.SectionName); - -if (cfgSection == null || !cfgSection.Exists()) -{ - throw new ApplicationException( - $"Could not find service config. Please provide a '{PriceWatcherServiceConfiguration.SectionName}' section in your appsettings.json file." - ); -} - -cfgSection.Bind(cfg); -builder.Services.AddSingleton(cfg); - -builder.ConfigureLogging(cfg) - .ConfigureTracing(cfg) - .ConfigureMetrics(cfg) - .ConfigureAuthN(cfg) - .ConfigureAuthZ(cfg) - .Services.AddHealthChecks() - .Services.AddDaprClient(); - - -// Add services to the container. -builder.Services.AddSingleton(); -builder.Services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(config => { - config.EnableAnnotations(); - config.SwaggerDoc("v1", new OpenApiInfo() - { - Version = "v1", - Title = "PriceWatcher Service", - Description = "Fairly simple .NET API to watch for price drops.", - Contact = new OpenApiContact - { - Name = "Thinktecture AG", - Email = "info@thinktecture.com", - Url = new Uri("https://thinktecture.com") - } - }); -}); - -var app = builder.Build(); - -app.UseSwagger(); -app.UseSwaggerUI(); - -app.UseAuthentication(); -app.UseAuthorization(); - -app.MapControllers() - .RequireAuthorization("RequiresApiScope"); - -if (cfg.ExposePrometheusMetrics) -{ - app.UseOpenTelemetryPrometheusScrapingEndpoint(); -} - -app.MapHealthChecks("/healthz/readiness"); -app.MapHealthChecks("/healthz/liveness"); - -app.Run(); diff --git a/src/PriceWatcherService/Properties/launchSettings.json b/src/PriceWatcherService/Properties/launchSettings.json deleted file mode 100644 index b3c13b1..0000000 --- a/src/PriceWatcherService/Properties/launchSettings.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:15562", - "sslPort": 44326 - } - }, - "profiles": { - "PriceWatcher": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "https://localhost:7045;http://localhost:5281", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/PriceWatcherService/Repositories/IPriceWatcherRepository.cs b/src/PriceWatcherService/Repositories/IPriceWatcherRepository.cs deleted file mode 100644 index 9e6093c..0000000 --- a/src/PriceWatcherService/Repositories/IPriceWatcherRepository.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace PriceWatcher.Repositories; - -public interface IPriceWatcherRepository -{ - bool Register(string email, Guid productId, double price); - bool DropPrice(Guid productId, double dropBy); -} diff --git a/src/PriceWatcherService/Repositories/PriceWatcherRepository.cs b/src/PriceWatcherService/Repositories/PriceWatcherRepository.cs deleted file mode 100644 index ab2ac73..0000000 --- a/src/PriceWatcherService/Repositories/PriceWatcherRepository.cs +++ /dev/null @@ -1,100 +0,0 @@ -using Dapr; -using Dapr.Client; -using PriceWatcher.Configuration; -using PriceWatcher.Entities; -using PriceWatcher.Models; - -namespace PriceWatcher.Repositories; - -public class PriceWatcherRepository : IPriceWatcherRepository -{ - private readonly DaprClient _dapr; - private readonly PriceWatcherServiceConfiguration _cfg; - private readonly ILogger _logger; - private readonly List _watchers = new List(); - - private static readonly List _products = new List { - new Product { Id = Guid.Parse("08ae4294-47e1-4b76-8cd3-052d6308d699"), Name = "Ice cream", Description = "Cool down on hot days", Price = 4.49 }, - new Product { Id = Guid.Parse("67611138-7dc1-42d9-b910-42c5d0247c52"), Name = "Bread", Description = "Yummy! Fresh bread smells super good", Price = 4.29 }, - new Product { Id = Guid.Parse("fed436f8-76a2-4ce0-83a2-6bdb0fed705b"), Name = "Coffee", Description = "Delicious Coffee", Price = 2.49 }, - new Product { Id = Guid.Parse("870d8ca1-1936-41a2-9f40-7d399f29ac38"), Name = "Bacon Burger", Description = "Everything is better with bacon", Price = 8.99 }, - new Product { Id = Guid.Parse("d96798d2-b429-4842-9a29-9a2a448d4ff2"), Name = "Whisky", Description = "Gentle drink for cold evenings", Price = 49.99 }, - new Product { Id = Guid.Parse("83fc59d6-9e20-450a-84c0-c8bc8fd80ee1"), Name = "Coke", Description = "Tasty coke", Price = 1.99 }, - new Product { Id = Guid.Parse("e2810857-327d-47d1-918c-cf3e3709d2d8"), Name = "Sausage", Description = "Time for some BBQ", Price = 3.79 }, - new Product { Id = Guid.Parse("525f1786-c045-46b1-aac2-d06da196bac4"), Name = "Beer", Description = "Tasty craft beer", Price = 3.99 }, - new Product { Id = Guid.Parse("9b699928-4600-44bf-9923-ec41a428b809"), Name = "Coffee", Description = "Delicious", Price = 2.49 }, - new Product { Id = Guid.Parse("2620540e-bfcb-4a06-87a4-f6ed2b3c069b"), Name = "Pizza", Description = "It comes with Bacon. You know! Because everything is better with bacon", Price = 7.99 } - }; - - public PriceWatcherRepository(DaprClient dapr, PriceWatcherServiceConfiguration cfg, ILogger logger) - { - _dapr = dapr; - _cfg = cfg; - _logger = logger; - } - - public bool Register(string email, Guid productId, double price) - { - if (!_products.Any(p => p.Id.Equals(productId))) - { - _logger.LogInformation("Product {ProductId} not found", productId); - return false; - } - - var found = _watchers.FirstOrDefault(w => w.Email == email && w.ProductId == productId); - if (found != null) - { - found.Price = price; - return true; - } - - _watchers.Add(new Watcher - { - Email = email, - ProductId = productId, - Price = price - }); - _logger.LogInformation("Price Watch for Product {ProductId} registered ({Watcher})", productId, email); - return true; - } - - public bool DropPrice(Guid productId, double dropBy) - { - - var found = _products.FirstOrDefault(p => p.Id.Equals(productId)); - if (found == null) - { - _logger.LogInformation("Product {ProductId} not found", productId); - return false; - } - - found.Price *= (1 - dropBy); - _logger.LogInformation("Price for {ProductName} dropped by {DroppedBy}% - new price: {NewPrice} ", found.Name, dropBy * 100, found.Price); - _watchers - .Where(w => w.ProductId.Equals(productId)) - .Where(w => w.Price > found.Price) - .ToList() - .ForEach(async w => - { - _logger.LogInformation("Issue notification for {Watcher} because price dropped for {ProductName} ({ProductId})", w.Email, found.Name, found.Id); - _logger.LogWarning($"Publishing message in {_cfg.PriceDropsPubSubName}:{_cfg.PriceDropsTopicName}"); - var model = new PriceDropNotificationModel - { - Recipient = w.Email, - ProductName = found.Name, - Price = found.Price - }; - var cloudEvent = new CloudEvent(model){ - Type = "com.thinktecture/price-drop-notification" - }; - - await _dapr.PublishEventAsync>( - _cfg.PriceDropsPubSubName, - _cfg.PriceDropsTopicName, - cloudEvent, - cancellationToken: CancellationToken.None); - - }); - return true; - } -} diff --git a/src/PriceWatcherService/appsettings.Development.json b/src/PriceWatcherService/appsettings.Development.json deleted file mode 100644 index 3e1a225..0000000 --- a/src/PriceWatcherService/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Information" - } - } -} diff --git a/src/PriceWatcherService/appsettings.json b/src/PriceWatcherService/appsettings.json deleted file mode 100644 index 12261ef..0000000 --- a/src/PriceWatcherService/appsettings.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Information" - } - }, - "PriceWatcherService": { - "DisableConsoleLog": false, - "ExposePrometheusMetrics": true, - "IdentityServer": { - "Authority": "", - "RequireHttpsMetadata": true - } - }, - "AllowedHosts": "*" -} diff --git a/src/ProductsService/Configuration/Authorization.cs b/src/ProductsService/Configuration/Authorization.cs index 57b59a5..acf57a7 100644 --- a/src/ProductsService/Configuration/Authorization.cs +++ b/src/ProductsService/Configuration/Authorization.cs @@ -3,5 +3,6 @@ namespace ProductsService.Configuration; public class Authorization { public string RequiredClaimName { get; set; } = "scope"; - public string RequiredClaimValue {get;set;} = "sample"; + public string RequiredClaimValue { get; set; } = "sample"; + public string RequiredAdminClaimValue { get; set; } = "admin"; } diff --git a/src/ProductsService/Configuration/ProductsServiceConfiguration.cs b/src/ProductsService/Configuration/ProductsServiceConfiguration.cs index 6cdcefb..f357d9b 100644 --- a/src/ProductsService/Configuration/ProductsServiceConfiguration.cs +++ b/src/ProductsService/Configuration/ProductsServiceConfiguration.cs @@ -20,4 +20,8 @@ public ProductsServiceConfiguration() public IdentityServerConfiguration IdentityServer { get;set; } public Authorization Authorization { get; set; } public string ConnectionString { get; set; } + public string PriceDropsPubSubName { get; set; } = "pricedrops"; + public string PriceDropsTopicName { get; set; } = "notifications"; + public bool UseFakeEventPublisher { get; set; } + public bool EnableOutboxProcessing { get; set; } = true; } diff --git a/src/ProductsService/Controllers/PriceDropController.cs b/src/ProductsService/Controllers/PriceDropController.cs new file mode 100644 index 0000000..fa9e2d5 --- /dev/null +++ b/src/ProductsService/Controllers/PriceDropController.cs @@ -0,0 +1,155 @@ +using System.Data; +using System.Data.SqlClient; +using System.Diagnostics; +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ProductsService.Data.Entities; +using ProductsService.Data.Repositories; +using ProductsService.Data.UnitOfWork; +using ProductsService.Data.UnitOfWork.InMemory; +using ProductsService.Data.UnitOfWork.Sql; +using ProductsService.Extensions; +using ProductsService.Models; +using ProductsService.OutboxProcessing; + +namespace ProductsService.Controllers; + +[ApiController] +[Authorize(AuthPolicies.RequiresAdminScope)] +[Route("/pricedrops/invoke")] +public sealed class PriceDropController : ControllerBase +{ + public PriceDropController(IAsyncFactory unitOfWorkFactory, + IOutboxProcessor outboxProcessor, + ILogger logger) + { + UnitOfWorkFactory = unitOfWorkFactory; + OutboxProcessor = outboxProcessor; + Logger = logger; + } + + private IAsyncFactory UnitOfWorkFactory { get; } + private IOutboxProcessor OutboxProcessor { get; } + private ILogger Logger { get; } + + [HttpPost] + public async Task InvokePriceDrop(PriceDropModel model, CancellationToken cancellationToken) + { + // UnitOfWorkFactory.CreateAsync will resolve the unit of work and asynchronously open + // the underlying SqlConnection and SqlTransaction (well, ADO.NET for SQL Server does not + // support async transactions, but that's another topic). The AsyncFactory implementation also supports + // scoped lifetimes. + // I prefer this approach as business logic (well, in this small example it's only + // product.Price *= 1 - model.DropPriceBy;) is clearly decoupled from I/O. In the other examples + // in this repo, business logic is mixed with I/O logic in the Repository classes, + // which results in way more pain when you want to write unit tests. + // The unit of works are Humble Objects that simply perform I/O and serialize/deserialize outgoing or + // incoming data. + await using var unitOfWork = await UnitOfWorkFactory.CreateAsync(cancellationToken); + var product = await unitOfWork.GetProductAsync(model.ProductId, cancellationToken); + if (product is null) + return NotFound(); + + var previousPrice = product.Price; + product.Price *= 1 - model.DropPriceBy; + await unitOfWork.UpdateProductPriceAsync(product, cancellationToken); + + // Here we can also see one part of the Transactional Outbox pattern: + // it might be that committing to the database succeeds, but publishing + // the event to the message broker does not. In this case, we would have + // lost a message. To accomodate for this, we save the message to the database + // so that we can use its transactional capabilities. The OutboxProcessor will + // then send the messages in an endless loop. This ensures at-least-once semantics. + // I thought that Dapr would offer at-least-once semantics when publishing events out of the box, + // but according to this GitHub issue https://github.com/dapr/dapr/issues/4233, it doesn't. The Dapr + // sidecar does not persist events to be published and relies on the underlying + // mechanisms of the message broker to ensure its at-least-once semantics claim when sending messages + // to subscribers. This can be found in the docs: + // https://docs.dapr.io/developing-applications/building-blocks/pubsub/pubsub-overview/ + await unitOfWork.InsertPriceDropIntoOutbox(product, cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + + Logger.LogInformation("Price was dropped from {PreviousPrice:N2} to {NewPrice:N2} for product {ProductId}", + previousPrice, + product.Price, + product.Id); + + OutboxProcessor.StartProcessingIfNecessary(); + + return Ok($"Dropped price for {model.ProductId} by {model.DropPriceBy * 100}%."); + } +} + +public interface IPriceDropUnitOfWork : IAsyncUnitOfWork +{ + Task GetProductAsync(Guid productId, CancellationToken cancellationToken = default); + Task UpdateProductPriceAsync(Product product, CancellationToken cancellationToken = default); + Task InsertPriceDropIntoOutbox(Product product, CancellationToken cancellationToken); +} + +public sealed class SqlPriceDropUnitOfWork : SqlAsyncUnitOfWork, IPriceDropUnitOfWork +{ + public SqlPriceDropUnitOfWork(SqlConnection connection) : base(connection) { } + + public async Task GetProductAsync(Guid productId, CancellationToken cancellationToken = default) + { + await using var command = CreateCommand("SELECT * FROM Products WHERE Id = @ProductId"); + command.Parameters.Add("@ProductId", SqlDbType.UniqueIdentifier).Value = productId; + + await using var reader = await command.ExecuteReaderAsync( + CommandBehavior.SingleResult | CommandBehavior.SingleRow, + cancellationToken + ); + return await reader.DeserializeProductAsync(cancellationToken); + } + + public async Task UpdateProductPriceAsync(Product product, CancellationToken cancellationToken = default) + { + await using var command = CreateCommand("UPDATE Products SET Price = @NewPrice WHERE Id = @ProductId"); + command.Parameters.Add("@ProductId", SqlDbType.UniqueIdentifier).Value = product.Id; + command.Parameters.Add("@NewPrice", SqlDbType.Float).Value = product.Price; + + var numberOfRowsAffected = await command.ExecuteNonQueryAsync(cancellationToken); + Debug.Assert(numberOfRowsAffected == 1); + } + + public async Task InsertPriceDropIntoOutbox(Product product, CancellationToken cancellationToken) + { + await using var command = CreateCommand("INSERT INTO Outbox(Type, Data) VALUES (@Type, @Data);"); + var priceDropMessageData = new PriceDropMessageData(product.Id, product.Name, product.Price); + var json = JsonSerializer.Serialize(priceDropMessageData); + command.Parameters.Add("@Type", SqlDbType.NVarChar).Value = OutboxItemTypes.PriceDrop; + command.Parameters.Add("@Data", SqlDbType.NVarChar).Value = json; + + await command.ExecuteNonQueryAsync(cancellationToken); + } +} + +public sealed class InMemoryPriceDropUnitOfWork : InMemoryAsyncUnitOfWork, IPriceDropUnitOfWork +{ + public InMemoryPriceDropUnitOfWork(InMemoryProductsRepository productsRepository) => + ProductsRepository = productsRepository; + + private InMemoryProductsRepository ProductsRepository { get; } + + public Task GetProductAsync(Guid productId, CancellationToken cancellationToken = default) => + ProductsRepository.GetByIdAsync(productId); + + public Task UpdateProductPriceAsync(Product product, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task InsertPriceDropIntoOutbox(Product product, CancellationToken cancellationToken) + { + var data = new PriceDropMessageData(product.Id, product.Name, product.Price); + var json = JsonSerializer.Serialize(data); + var outboxEntry = new OutboxItem(0, + OutboxItemTypes.PriceDrop, + json, + DateTime.UtcNow); + ProductsRepository.AddOutboxItem(outboxEntry); + return Task.CompletedTask; + } +} + +public sealed record PriceDropMessageData(Guid ProductId, string ProductName, double Price); diff --git a/src/ProductsService/Controllers/ProductsController.cs b/src/ProductsService/Controllers/ProductsController.cs index fced1cd..9d5e796 100644 --- a/src/ProductsService/Controllers/ProductsController.cs +++ b/src/ProductsService/Controllers/ProductsController.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using ProductsService.Data.Repositories; using ProductsService.Extensions; using ProductsService.Models; @@ -7,6 +8,7 @@ namespace ProductsService.Controllers; [ApiController] +[Authorize(AuthPolicies.RequiresApiScope)] [Route("products")] [Produces("application/json")] public class ProductsController : ControllerBase diff --git a/src/ProductsService/Data/Entities/DeserializationExtensions.cs b/src/ProductsService/Data/Entities/DeserializationExtensions.cs new file mode 100644 index 0000000..0ee6a14 --- /dev/null +++ b/src/ProductsService/Data/Entities/DeserializationExtensions.cs @@ -0,0 +1,55 @@ +using System.Data.SqlClient; + +namespace ProductsService.Data.Entities; + +public static class DeserializationExtensions +{ + public static async Task DeserializeProductAsync(this SqlDataReader reader, + CancellationToken cancellationToken = default) + { + if (!reader.HasRows) + return null; + + var idOrdinal = reader.GetOrdinal(nameof(Product.Id)); + var nameOrdinal = reader.GetOrdinal(nameof(Product.Name)); + var descriptionOrdinal = reader.GetOrdinal(nameof(Product.Description)); + var tagsOrdinal = reader.GetOrdinal("Tags"); + var priceOrdinal = reader.GetOrdinal(nameof(Product.Price)); + + await reader.ReadAsync(cancellationToken); + + var id = reader.GetGuid(idOrdinal); + var name = reader.GetString(nameOrdinal); + var description = reader.GetString(descriptionOrdinal); + var categories = reader.GetString(tagsOrdinal) + .Split(','); + var price = Convert.ToDouble(reader.GetDecimal(priceOrdinal)); + + return new (id, name, description, categories, price); + } + + public static async Task> DeserializeOutboxItemsAsync(this SqlDataReader reader, + CancellationToken cancellationToken = default) + { + var outboxItems = new List(); + if (!reader.HasRows) + return outboxItems; + + var idOrdinal = reader.GetOrdinal(nameof(OutboxItem.Id)); + var typeOrdinal = reader.GetOrdinal(nameof(OutboxItem.Type)); + var dataOrdinal = reader.GetOrdinal(nameof(OutboxItem.Data)); + var createdAtUtcOrdinal = reader.GetOrdinal(nameof(OutboxItem.CreatedAtUtc)); + + while (await reader.ReadAsync(cancellationToken)) + { + var id = reader.GetInt64(idOrdinal); + var type = reader.GetString(typeOrdinal); + var data = reader.GetString(dataOrdinal); + var createdAtUtc = reader.GetDateTime(createdAtUtcOrdinal); + var outboxItem = new OutboxItem(id, type, data, createdAtUtc); + outboxItems.Add(outboxItem); + } + + return outboxItems; + } +} diff --git a/src/ProductsService/Data/Entities/OutboxItem.cs b/src/ProductsService/Data/Entities/OutboxItem.cs new file mode 100644 index 0000000..5820fac --- /dev/null +++ b/src/ProductsService/Data/Entities/OutboxItem.cs @@ -0,0 +1,8 @@ +namespace ProductsService.Data.Entities; + +public sealed record OutboxItem(long Id, string Type, string Data, DateTime CreatedAtUtc); + +public static class OutboxItemTypes +{ + public const string PriceDrop = "PriceDrop"; +} diff --git a/src/ProductsService/Data/Repositories/IProductsRepository.cs b/src/ProductsService/Data/Repositories/IProductsRepository.cs index 335b2bc..c34f20d 100644 --- a/src/ProductsService/Data/Repositories/IProductsRepository.cs +++ b/src/ProductsService/Data/Repositories/IProductsRepository.cs @@ -5,7 +5,7 @@ namespace ProductsService.Data.Repositories public interface IProductsRepository { Task CreateAsync(Product product); - Task> GetAllAsync(); + Task> GetAllAsync(); Task GetByIdAsync(Guid id); } } diff --git a/src/ProductsService/Data/Repositories/InMemoryProductsRepository.cs b/src/ProductsService/Data/Repositories/InMemoryProductsRepository.cs index 9f756c9..261d316 100644 --- a/src/ProductsService/Data/Repositories/InMemoryProductsRepository.cs +++ b/src/ProductsService/Data/Repositories/InMemoryProductsRepository.cs @@ -1,69 +1,75 @@ using ProductsService.Data.Entities; -namespace ProductsService.Data.Repositories +namespace ProductsService.Data.Repositories; + +public class InMemoryProductsRepository : IProductsRepository { - public class InMemoryProductsRepository : IProductsRepository + private readonly ILogger _logger; + private readonly List _products = new() { - private readonly ILogger _logger; - private readonly List _products = new() - { - new Product(Guid.Parse("b3b749d1-fd02-4b47-8e3c-540555439db6"), "Milk", "Good milk", - new List { "Food" }, 0.99), - new Product(Guid.Parse("aaaaaaaa-fd02-4b47-8e3c-540555439db6"), "Coffee", "Delicious Coffee", - new List { "Food" }, 1.99), - new Product(Guid.Parse("08c64d77-4e3e-45f0-8455-078fca893049"), "Coke", "Tasty coke", - new List { "Food" }, 1.49), - new Product(Guid.Parse("f6877871-2a14-4f40-a61a-e1153592c0fb"), "Beer", "Good beer", - new List { "Food" }, 2.99), - new Product(Guid.Parse("9dfeb719-32e1-49a9-b55d-539f5b116dd6"), "Bread", "Delicious bread", - new List { "Food" }, 0.99), - new Product(Guid.Parse("1316ef5e-96b3-4976-adc4-ca97fd121078"), "Sausage", "Tasty sausage", - new List { "Food" }, 1.49), - new Product(Guid.Parse("d06c4115-85d5-4448-b398-464850eebf4e"), "Cheese", "Good cheese", - new List { "Food" }, 2.99), - new Product(Guid.Parse("4382ba39-c9e3-48bb-83b3-9f9171b4c33f"), "Chocolate", "Delicious chocolate", - new List { "Food" }, 0.99), - new Product(Guid.Parse("9d428166-3cb7-4513-ae0d-e1cb18ac1416"), "Candy", "Tasty candy", - new List { "Food" }, 1.49), - new Product(Guid.Parse("782080a1-7953-4ac0-92d8-59ec5497563b"), "Ice cream", "Good ice cream", - new List { "Food" }, 2.99), - new Product(Guid.Parse("128cc5a0-9a73-4cb8-896b-7d1f8e9fb5f3"), "Burger", "Delicious burger", - new List { "Food" }, 7.99), - new Product(Guid.Parse("a028d630-2da8-432d-ad8c-b4990d288841"), "Pizza", "Tasty pizza", - new List { "Food" }, 9.99), - }; + new Product(Guid.Parse("b3b749d1-fd02-4b47-8e3c-540555439db6"), "Milk", "Good milk", + new List { "Food" }, 0.99), + new Product(Guid.Parse("aaaaaaaa-fd02-4b47-8e3c-540555439db6"), "Coffee", "Delicious Coffee", + new List { "Food" }, 1.99), + new Product(Guid.Parse("08c64d77-4e3e-45f0-8455-078fca893049"), "Coke", "Tasty coke", + new List { "Food" }, 1.49), + new Product(Guid.Parse("f6877871-2a14-4f40-a61a-e1153592c0fb"), "Beer", "Good beer", + new List { "Food" }, 2.99), + new Product(Guid.Parse("9dfeb719-32e1-49a9-b55d-539f5b116dd6"), "Bread", "Delicious bread", + new List { "Food" }, 0.99), + new Product(Guid.Parse("1316ef5e-96b3-4976-adc4-ca97fd121078"), "Sausage", "Tasty sausage", + new List { "Food" }, 1.49), + new Product(Guid.Parse("d06c4115-85d5-4448-b398-464850eebf4e"), "Cheese", "Good cheese", + new List { "Food" }, 2.99), + new Product(Guid.Parse("4382ba39-c9e3-48bb-83b3-9f9171b4c33f"), "Chocolate", "Delicious chocolate", + new List { "Food" }, 0.99), + new Product(Guid.Parse("9d428166-3cb7-4513-ae0d-e1cb18ac1416"), "Candy", "Tasty candy", + new List { "Food" }, 1.49), + new Product(Guid.Parse("782080a1-7953-4ac0-92d8-59ec5497563b"), "Ice cream", "Good ice cream", + new List { "Food" }, 2.99), + new Product(Guid.Parse("128cc5a0-9a73-4cb8-896b-7d1f8e9fb5f3"), "Burger", "Delicious burger", + new List { "Food" }, 7.99), + new Product(Guid.Parse("a028d630-2da8-432d-ad8c-b4990d288841"), "Pizza", "Tasty pizza", + new List { "Food" }, 9.99), + }; - public InMemoryProductsRepository(ILogger logger) - { - _logger = logger; - } + private readonly List _outbox = new (); - public async Task CreateAsync(Product product) - { - return await Task.Run(() => { - product.Id = Guid.NewGuid(); - _products.Add(product); - return product; - }); - } + public InMemoryProductsRepository(ILogger logger) + { + _logger = logger; + } - public async Task> GetAllAsync() - { - _logger.LogTrace("List of all products has been requested"); + public async Task CreateAsync(Product product) + { + return await Task.Run(() => { + product.Id = Guid.NewGuid(); + _products.Add(product); + return product; + }); + } - return await Task.Run(() => { return _products; }); - } + public Task> GetAllAsync() + { + _logger.LogTrace("List of all products has been requested"); + return Task.FromResult(_products); + } - public async Task GetByIdAsync(Guid id) - { - _logger.LogTrace("Product with id {Id} has been requested", id); + public Task GetByIdAsync(Guid id) + { + _logger.LogTrace("Product with id {Id} has been requested", id); + return Task.FromResult(_products.FirstOrDefault(p => p.Id.Equals(id))); + } + + public void AddOutboxItem(OutboxItem outboxItem) => _outbox.Add(outboxItem); - return await Task.Run(() => - { - var found = _products.FirstOrDefault(p => p.Id.Equals(id)); + public List GetNextOutboxItems(int pageSize = 50) + { + if (pageSize >= _outbox.Count) + return _outbox.ToList(); - return found; - }); - } + return _outbox.Take(pageSize).ToList(); } + + public void RemoveOutboxItem(OutboxItem outboxItem) => _outbox.Remove(outboxItem); } diff --git a/src/ProductsService/Data/Repositories/ProductsRepository.cs b/src/ProductsService/Data/Repositories/ProductsRepository.cs index d1876a8..a3ff868 100644 --- a/src/ProductsService/Data/Repositories/ProductsRepository.cs +++ b/src/ProductsService/Data/Repositories/ProductsRepository.cs @@ -29,7 +29,7 @@ public async Task CreateAsync(Product product) return product; } - public async Task> GetAllAsync() + public async Task> GetAllAsync() { using var con = new SqlConnection(_cfg.ConnectionString); con.Open(); diff --git a/src/ProductsService/Data/UnitOfWork/AsyncFactory.cs b/src/ProductsService/Data/UnitOfWork/AsyncFactory.cs new file mode 100644 index 0000000..da26527 --- /dev/null +++ b/src/ProductsService/Data/UnitOfWork/AsyncFactory.cs @@ -0,0 +1,23 @@ +namespace ProductsService.Data.UnitOfWork; + +public sealed class AsyncFactory : IAsyncFactory + where TImplementation : class, TAbstraction, IInitializeAsync + +{ + public AsyncFactory(Func resolveObject) => ResolveObject = resolveObject; + + private Func ResolveObject { get; } + + public ValueTask CreateAsync(CancellationToken cancellationToken = default) + { + var @object = ResolveObject(); + return @object.IsInitialized ? new (@object) : InitializeObjectAsync(@object, cancellationToken); + } + + private static async ValueTask InitializeObjectAsync(TImplementation @object, + CancellationToken cancellationToken) + { + await @object.InitializeAsync(cancellationToken); + return @object; + } +} diff --git a/src/ProductsService/Data/UnitOfWork/IAsyncFactory.cs b/src/ProductsService/Data/UnitOfWork/IAsyncFactory.cs new file mode 100644 index 0000000..aa6509c --- /dev/null +++ b/src/ProductsService/Data/UnitOfWork/IAsyncFactory.cs @@ -0,0 +1,6 @@ +namespace ProductsService.Data.UnitOfWork; + +public interface IAsyncFactory +{ + ValueTask CreateAsync(CancellationToken cancellationToken = default); +} diff --git a/src/ProductsService/Data/UnitOfWork/IAsyncReadOnlyUnitOfWork.cs b/src/ProductsService/Data/UnitOfWork/IAsyncReadOnlyUnitOfWork.cs new file mode 100644 index 0000000..dbcd883 --- /dev/null +++ b/src/ProductsService/Data/UnitOfWork/IAsyncReadOnlyUnitOfWork.cs @@ -0,0 +1,4 @@ +namespace ProductsService.Data.UnitOfWork; + +// A unit of work that only reads data and thus doesn't need the SaveChangesAsync method +public interface IAsyncReadOnlyUnitOfWork : IAsyncDisposable, IDisposable { } diff --git a/src/ProductsService/Data/UnitOfWork/IAsyncUnitOfWork.cs b/src/ProductsService/Data/UnitOfWork/IAsyncUnitOfWork.cs new file mode 100644 index 0000000..36231a8 --- /dev/null +++ b/src/ProductsService/Data/UnitOfWork/IAsyncUnitOfWork.cs @@ -0,0 +1,8 @@ +namespace ProductsService.Data.UnitOfWork; + +// A unit of work that also manipulates data and thus incorporates +// a SaveChangesAsync method. +public interface IAsyncUnitOfWork : IAsyncReadOnlyUnitOfWork +{ + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/ProductsService/Data/UnitOfWork/IInitializeAsync.cs b/src/ProductsService/Data/UnitOfWork/IInitializeAsync.cs new file mode 100644 index 0000000..6ad70c2 --- /dev/null +++ b/src/ProductsService/Data/UnitOfWork/IInitializeAsync.cs @@ -0,0 +1,7 @@ +namespace ProductsService.Data.UnitOfWork; + +public interface IInitializeAsync +{ + bool IsInitialized { get; } + Task InitializeAsync(CancellationToken cancellationToken = default); +} diff --git a/src/ProductsService/Data/UnitOfWork/InMemory/InMemoryAsyncReadOnlyUnitOfWork.cs b/src/ProductsService/Data/UnitOfWork/InMemory/InMemoryAsyncReadOnlyUnitOfWork.cs new file mode 100644 index 0000000..498a489 --- /dev/null +++ b/src/ProductsService/Data/UnitOfWork/InMemory/InMemoryAsyncReadOnlyUnitOfWork.cs @@ -0,0 +1,9 @@ +namespace ProductsService.Data.UnitOfWork.InMemory; + +public abstract class InMemoryAsyncReadOnlyUnitOfWork : IAsyncReadOnlyUnitOfWork, IInitializeAsync +{ + public ValueTask DisposeAsync() => default; + public void Dispose() { } + bool IInitializeAsync.IsInitialized => true; + public Task InitializeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; +} diff --git a/src/ProductsService/Data/UnitOfWork/InMemory/InMemoryAsyncUnitOfWork.cs b/src/ProductsService/Data/UnitOfWork/InMemory/InMemoryAsyncUnitOfWork.cs new file mode 100644 index 0000000..49e91cc --- /dev/null +++ b/src/ProductsService/Data/UnitOfWork/InMemory/InMemoryAsyncUnitOfWork.cs @@ -0,0 +1,6 @@ +namespace ProductsService.Data.UnitOfWork.InMemory; + +public abstract class InMemoryAsyncUnitOfWork : InMemoryAsyncReadOnlyUnitOfWork, IAsyncUnitOfWork +{ + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; +} diff --git a/src/ProductsService/Data/UnitOfWork/ServiceCollectionExtensions.cs b/src/ProductsService/Data/UnitOfWork/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..2506c82 --- /dev/null +++ b/src/ProductsService/Data/UnitOfWork/ServiceCollectionExtensions.cs @@ -0,0 +1,23 @@ +namespace ProductsService.Data.UnitOfWork; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUnitOfWorkWithFactory( + this IServiceCollection services, + ServiceLifetime unitOfWorkLifetime = ServiceLifetime.Scoped, + ServiceLifetime factoryLifetime = ServiceLifetime.Scoped + ) + where TAbstraction : class, IAsyncReadOnlyUnitOfWork + where TImplementation : class, TAbstraction, IInitializeAsync + { + services.Add(new (typeof(TImplementation), typeof(TImplementation), unitOfWorkLifetime)); + services.Add(new (typeof(IAsyncFactory), + typeof(AsyncFactory), + factoryLifetime)); + services.Add(new (typeof(Func), + serviceProvider => + new Func(serviceProvider.GetRequiredService), + factoryLifetime)); + return services; + } +} diff --git a/src/ProductsService/Data/UnitOfWork/Sql/SqlAsyncReadOnlyUnitOfWork.cs b/src/ProductsService/Data/UnitOfWork/Sql/SqlAsyncReadOnlyUnitOfWork.cs new file mode 100644 index 0000000..9c0c6d4 --- /dev/null +++ b/src/ProductsService/Data/UnitOfWork/Sql/SqlAsyncReadOnlyUnitOfWork.cs @@ -0,0 +1,58 @@ +using System.Data; +using System.Data.SqlClient; + +namespace ProductsService.Data.UnitOfWork.Sql; + +public abstract class SqlAsyncReadOnlyUnitOfWork : IAsyncReadOnlyUnitOfWork, IInitializeAsync +{ + protected SqlAsyncReadOnlyUnitOfWork(SqlConnection connection, IsolationLevel? transactionLevel = null) + { + Connection = connection ?? throw new ArgumentNullException(nameof(connection)); + TransactionLevel = transactionLevel; + } + + protected SqlConnection Connection { get; } + protected SqlTransaction? Transaction { get; private set; } + protected IsolationLevel? TransactionLevel { get; } + + public async ValueTask DisposeAsync() + { + if (Transaction is not null) + await Transaction.DisposeAsync(); + await Connection.DisposeAsync(); + } + + public void Dispose() + { + Transaction?.Dispose(); + Connection.Dispose(); + } + + protected bool IsInitialized { get; private set; } + + bool IInitializeAsync.IsInitialized => IsInitialized; + + async Task IInitializeAsync.InitializeAsync(CancellationToken cancellationToken) + { + if (IsInitialized) + return; + + await Connection.OpenAsync(cancellationToken); + + if (TransactionLevel.HasValue) + Transaction = Connection.BeginTransaction(TransactionLevel.Value); + + IsInitialized = true; + } + + protected SqlCommand CreateCommand(string? sql = null, CommandType commandType = CommandType.Text) + { + var sqlCommand = Connection.CreateCommand(); + if (Transaction is not null) + sqlCommand.Transaction = Transaction; + if (!string.IsNullOrWhiteSpace(sql)) + sqlCommand.CommandText = sql; + sqlCommand.CommandType = commandType; + return sqlCommand; + } +} diff --git a/src/ProductsService/Data/UnitOfWork/Sql/SqlAsyncUnitOfWork.cs b/src/ProductsService/Data/UnitOfWork/Sql/SqlAsyncUnitOfWork.cs new file mode 100644 index 0000000..a10dc3b --- /dev/null +++ b/src/ProductsService/Data/UnitOfWork/Sql/SqlAsyncUnitOfWork.cs @@ -0,0 +1,17 @@ +using System.Data; +using System.Data.SqlClient; + +namespace ProductsService.Data.UnitOfWork.Sql; + +public abstract class SqlAsyncUnitOfWork : SqlAsyncReadOnlyUnitOfWork, IAsyncUnitOfWork +{ + protected SqlAsyncUnitOfWork(SqlConnection connection, + IsolationLevel? transactionLevel = IsolationLevel.Serializable) + : base(connection, transactionLevel) { } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + Transaction?.Commit(); + return Task.CompletedTask; + } +} diff --git a/src/ProductsService/Extensions/AuthPolicies.cs b/src/ProductsService/Extensions/AuthPolicies.cs new file mode 100644 index 0000000..4e60e84 --- /dev/null +++ b/src/ProductsService/Extensions/AuthPolicies.cs @@ -0,0 +1,7 @@ +namespace ProductsService.Extensions; + +public static class AuthPolicies +{ + public const string RequiresApiScope = "RequiresApiScope"; + public const string RequiresAdminScope = "RequiresAdminScope"; +} diff --git a/src/ProductsService/Extensions/WebApplicationBuilderExtensions.cs b/src/ProductsService/Extensions/WebApplicationBuilderExtensions.cs index f58de84..da593ae 100644 --- a/src/ProductsService/Extensions/WebApplicationBuilderExtensions.cs +++ b/src/ProductsService/Extensions/WebApplicationBuilderExtensions.cs @@ -8,6 +8,7 @@ using ProductsService.Configuration; using ProductsService.Migrations; using ProductsService; +using ProductsService.Extensions; namespace Microsoft.AspNetCore.Builder; @@ -149,11 +150,20 @@ public static WebApplicationBuilder ConfigureAuthZ(this WebApplicationBuilder bu { builder.Services.AddAuthorization(options => { - options.AddPolicy("RequiresApiScope", policy => - { - policy.RequireAuthenticatedUser(); - policy.RequireClaim(cfg.Authorization.RequiredClaimName, cfg.Authorization.RequiredClaimValue); - }); + options.AddPolicy(AuthPolicies.RequiresApiScope, + policy => + { + policy.RequireAuthenticatedUser(); + policy.RequireClaim(cfg.Authorization.RequiredClaimName, + cfg.Authorization.RequiredClaimValue); + }); + options.AddPolicy(AuthPolicies.RequiresAdminScope, + policy => + { + policy.RequireAuthenticatedUser() + .RequireClaim(cfg.Authorization.RequiredClaimName, + cfg.Authorization.RequiredAdminClaimValue); + }); }); return builder; } diff --git a/src/ProductsService/Migrations/TransactionalOutboxMigration.cs b/src/ProductsService/Migrations/TransactionalOutboxMigration.cs new file mode 100644 index 0000000..8510b51 --- /dev/null +++ b/src/ProductsService/Migrations/TransactionalOutboxMigration.cs @@ -0,0 +1,17 @@ +using System.Data.SqlClient; + +namespace ProductsService.Migrations; + +public sealed class TransactionalOutboxMigration : IMigration +{ + public int Version => 2; + + public string Script => @" +CREATE TABLE Outbox ( + Id BIGINT IDENTITY(1, 1) CONSTRAINT PK_Outbox PRIMARY KEY CLUSTERED, + Type NVARCHAR(100) NOT NULL, + Data NVARCHAR(2048) NOT NULL, + CreatedAtUtc DATETIME2 NOT NULL CONSTRAINT DF_Outbox_CreatedAtUtc_Now DEFAULT GETUTCDATE() +);"; + public void PostMigrate(SqlConnection con) { } +} diff --git a/src/ProductsService/Models/PriceDropModel.cs b/src/ProductsService/Models/PriceDropModel.cs new file mode 100644 index 0000000..c7255e7 --- /dev/null +++ b/src/ProductsService/Models/PriceDropModel.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace ProductsService.Models; + +// I'd actually call this PriceDropDto, but went with the existing code style +public sealed class PriceDropModel +{ + public Guid ProductId { get; set; } + + [Range(0.0, 1.0, ErrorMessage = "You must specify a percentage value between 0.0 and 1.0")] + public double DropPriceBy { get; set; } +} diff --git a/src/ProductsService/OutboxProcessing/DaprEventPublisher.cs b/src/ProductsService/OutboxProcessing/DaprEventPublisher.cs new file mode 100644 index 0000000..2cec29b --- /dev/null +++ b/src/ProductsService/OutboxProcessing/DaprEventPublisher.cs @@ -0,0 +1,19 @@ +using Dapr.Client; + +namespace ProductsService.OutboxProcessing; + +public sealed class DaprEventPublisher : IEventPublisher +{ + public DaprEventPublisher(DaprClient daprClient) => DaprClient = daprClient; + + private DaprClient DaprClient { get; } + + public Task PublishEventAsync(string pubSubName, + string topicName, + T eventPayload, + CancellationToken cancellationToken = default) => + DaprClient.PublishEventAsync(pubSubName, + topicName, + eventPayload, + cancellationToken); +} diff --git a/src/ProductsService/OutboxProcessing/FakeEventPublisher.cs b/src/ProductsService/OutboxProcessing/FakeEventPublisher.cs new file mode 100644 index 0000000..daefa22 --- /dev/null +++ b/src/ProductsService/OutboxProcessing/FakeEventPublisher.cs @@ -0,0 +1,24 @@ +namespace ProductsService.OutboxProcessing; + +public sealed class FakeEventPublisher : IEventPublisher +{ + public FakeEventPublisher(ILogger logger) + { + Logger = logger; + } + + private ILogger Logger { get; } + + public Task PublishEventAsync(string pubSubName, + string topicName, + T eventPayload, + CancellationToken cancellationToken = default) + { + Logger.LogInformation("Published event {Event} to {PubSubName} {TopicName}", + eventPayload, + pubSubName, + topicName); + return Task.CompletedTask; + } + +} diff --git a/src/ProductsService/OutboxProcessing/IEventPublisher.cs b/src/ProductsService/OutboxProcessing/IEventPublisher.cs new file mode 100644 index 0000000..036e5d7 --- /dev/null +++ b/src/ProductsService/OutboxProcessing/IEventPublisher.cs @@ -0,0 +1,9 @@ +namespace ProductsService.OutboxProcessing; + +public interface IEventPublisher +{ + Task PublishEventAsync(string pubSubName, + string topicName, + T eventPayload, + CancellationToken cancellationToken = default); +} diff --git a/src/ProductsService/OutboxProcessing/IOutboxProcessor.cs b/src/ProductsService/OutboxProcessing/IOutboxProcessor.cs new file mode 100644 index 0000000..b021848 --- /dev/null +++ b/src/ProductsService/OutboxProcessing/IOutboxProcessor.cs @@ -0,0 +1,6 @@ +namespace ProductsService.OutboxProcessing; + +public interface IOutboxProcessor +{ + void StartProcessingIfNecessary(); +} diff --git a/src/ProductsService/OutboxProcessing/IOutboxUnitOfWork.cs b/src/ProductsService/OutboxProcessing/IOutboxUnitOfWork.cs new file mode 100644 index 0000000..c07ba94 --- /dev/null +++ b/src/ProductsService/OutboxProcessing/IOutboxUnitOfWork.cs @@ -0,0 +1,11 @@ +using ProductsService.Data.Entities; +using ProductsService.Data.UnitOfWork; + +namespace ProductsService.OutboxProcessing; + +public interface IOutboxUnitOfWork : IAsyncReadOnlyUnitOfWork +{ + Task> GetNextOutboxItemsAsync(CancellationToken cancellationToken = default); + + Task DeleteOutboxItemAsync(OutboxItem outboxItem, CancellationToken cancellationToken = default); +} diff --git a/src/ProductsService/OutboxProcessing/InMemoryOutboxUnitOfWork.cs b/src/ProductsService/OutboxProcessing/InMemoryOutboxUnitOfWork.cs new file mode 100644 index 0000000..22e9f0a --- /dev/null +++ b/src/ProductsService/OutboxProcessing/InMemoryOutboxUnitOfWork.cs @@ -0,0 +1,24 @@ +using ProductsService.Data.Entities; +using ProductsService.Data.Repositories; +using ProductsService.Data.UnitOfWork.InMemory; + +namespace ProductsService.OutboxProcessing; + +public sealed class InMemoryOutboxUnitOfWork : InMemoryAsyncReadOnlyUnitOfWork, IOutboxUnitOfWork +{ + public InMemoryOutboxUnitOfWork(InMemoryProductsRepository repository) + { + Repository = repository; + } + + private InMemoryProductsRepository Repository { get; } + + public Task> GetNextOutboxItemsAsync(CancellationToken cancellationToken) => + Task.FromResult(Repository.GetNextOutboxItems()); + + public Task DeleteOutboxItemAsync(OutboxItem outboxItem, CancellationToken cancellationToken) + { + Repository.RemoveOutboxItem(outboxItem); + return Task.CompletedTask; + } +} diff --git a/src/ProductsService/OutboxProcessing/NullOutboxProcessor.cs b/src/ProductsService/OutboxProcessing/NullOutboxProcessor.cs new file mode 100644 index 0000000..c87a734 --- /dev/null +++ b/src/ProductsService/OutboxProcessing/NullOutboxProcessor.cs @@ -0,0 +1,6 @@ +namespace ProductsService.OutboxProcessing; + +public sealed class NullOutboxProcessor : IOutboxProcessor +{ + public void StartProcessingIfNecessary() { } +} diff --git a/src/ProductsService/OutboxProcessing/OutboxProcessor.cs b/src/ProductsService/OutboxProcessing/OutboxProcessor.cs new file mode 100644 index 0000000..acd1036 --- /dev/null +++ b/src/ProductsService/OutboxProcessing/OutboxProcessor.cs @@ -0,0 +1,164 @@ +using System.Text.Json; +using Dapr; +using ProductsService.Configuration; +using ProductsService.Controllers; +using ProductsService.Data.Entities; +using ProductsService.Data.UnitOfWork; + +namespace ProductsService.OutboxProcessing; + +// Initially, I thought that Dapr supports at-least-once semantics out of the box, but they +// only support it when delivering events to subscribers. When sending events, they do not +// persist events and try resending them if e.g. the message broker is down. See +// https://github.com/dapr/dapr/issues/4233 for details. +// In a productive environment, the outbox processor should be a separate process, especially +// when several replicas of the service are running. There should only be one +// process sending messages from the outbox at any given time. +// Currently, we can use the app settings to toggle if the Outbox Processor +// is active in this process or not using the EnableOutboxProcessing setting. If it is +// set to false, the NullOutboxProcessor is used instead (see Composition Root). +public sealed class OutboxProcessor : IHostedService, IOutboxProcessor +{ + public OutboxProcessor(IAsyncFactory unitOfWorkFactory, + IEventPublisher eventPublisher, + ProductsServiceConfiguration configuration, + ILogger logger) + { + UnitOfWorkFactory = unitOfWorkFactory; + EventPublisher = eventPublisher; + Configuration = configuration; + Logger = logger; + } + + private IAsyncFactory UnitOfWorkFactory { get; } + private IEventPublisher EventPublisher { get; } + private ProductsServiceConfiguration Configuration { get; } + private ILogger Logger { get; } + + private object LockObject { get; } = new (); + private Context? CurrentContext { get; set; } + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) + { + CurrentContext?.CancellationTokenSource.Cancel(); + return Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + + public async void StartProcessingIfNecessary() + { + // We use a simple double-check lock to see if there already is + // processing ongoing + if (CurrentContext is not null) + return; + + Context context; + lock (LockObject) + { + if (CurrentContext is not null) + return; + + // If we end up here, then we need to start processing + var cancellationTokenSource = new CancellationTokenSource(); + var task = ProcessOutboxAsync(cancellationTokenSource.Token); + context = new (task, cancellationTokenSource); + CurrentContext = context; + } + + // Here, we await the processing task asynchronously. + // As this method has a return type of void (instead of Task), the continuation + // will be scheduled on another Thread pool thread (unless the underlying unit of work + // finishes synchronously). Thus once a caller ended up here, it can return and finish its request. + try + { + await context.Task; + } + catch (Exception exception) + { + Logger.LogError(exception, "An error occurred while awaiting the outbox processing task"); + } + finally + { + context.CancellationTokenSource.Dispose(); + lock (LockObject) + { + CurrentContext = null; + } + } + } + + private async Task ProcessOutboxAsync(CancellationToken cancellationToken) + { + // This is the important part of the outbox processor. + // It loads a batch of outbox items and tries to send them via the + // event publisher. If this succeeds, the outbox item is deleted from + // the database. This leads to a at-least-once behavior for publishing + // messages to a broker (instead of at-most-once like in other examples in this repo). + var random = new Random(); + while (true) + { + try + { + if (cancellationToken.IsCancellationRequested) + return; + + // We load not one, but up to 50 outbox items from the database to minimize + // I/O between this service and the database. We do not incorporate any explicit + // transaction, which is why we use a AsyncReadOnlyUnitOfWork here (when a command + // is executed against the database, it will run within an implicit + // transaction on MS SQL Server (I think its default isolation level is read-committed?). + // As we try again on errors, this does not create problems. + await using var unitOfWork = await UnitOfWorkFactory.CreateAsync(cancellationToken); + var outboxBatch = await unitOfWork.GetNextOutboxItemsAsync(cancellationToken); + if (outboxBatch.Count == 0) + return; + + foreach (var outboxItem in outboxBatch) + { + if (cancellationToken.IsCancellationRequested) + return; + + // Instead of switching between the outbox item types here, it would probably be better + // to implement some type of handlers. This would reduce bloat here and follow + // Bertrand Meyer's Open-Closed Principle. + switch (outboxItem.Type) + { + case OutboxItemTypes.PriceDrop: + var data = JsonSerializer.Deserialize(outboxItem.Data)!; + var @event = new CloudEvent(data) + { + Type = "com.thinktecture/price-drop-notification" + }; + await EventPublisher.PublishEventAsync(Configuration.PriceDropsPubSubName, + Configuration.PriceDropsTopicName, + @event, + cancellationToken); + Logger.LogInformation("Price drop notification {Event} was sent", @event); + break; + default: + Logger.LogError("Cannot process outbox item with unknown type {OutboxItem}", outboxItem); + break; + } + + await unitOfWork.DeleteOutboxItemAsync(outboxItem, cancellationToken); + } + } + catch (Exception exception) + { + Logger.LogError(exception, "An error occurred while processing the outbox"); + + if (cancellationToken.IsCancellationRequested) + return; + + // If any of the third-party systems has a problem, we'll wait for a randomized time before + // we try the next loop run. + var waitTimeInMilliseconds = random.Next(1000, 3000); + // ReSharper disable once MethodSupportsCancellation -- we do not want to throw here + await Task.Delay(waitTimeInMilliseconds); + } + } + } + + private sealed record Context(Task Task, CancellationTokenSource CancellationTokenSource); +} diff --git a/src/ProductsService/OutboxProcessing/SqlOutboxUnitOfWork.cs b/src/ProductsService/OutboxProcessing/SqlOutboxUnitOfWork.cs new file mode 100644 index 0000000..7f2540f --- /dev/null +++ b/src/ProductsService/OutboxProcessing/SqlOutboxUnitOfWork.cs @@ -0,0 +1,27 @@ +using System.Data; +using System.Data.SqlClient; +using System.Diagnostics; +using ProductsService.Data.Entities; +using ProductsService.Data.UnitOfWork.Sql; + +namespace ProductsService.OutboxProcessing; + +public sealed class SqlOutboxUnitOfWork : SqlAsyncReadOnlyUnitOfWork, IOutboxUnitOfWork +{ + public SqlOutboxUnitOfWork(SqlConnection connection) : base(connection) { } + + public async Task> GetNextOutboxItemsAsync(CancellationToken cancellationToken = default) + { + await using var command = CreateCommand("SELECT TOP 50 * FROM Outbox ORDER BY CreatedAtUtc;"); + await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SingleResult, cancellationToken); + return await reader.DeserializeOutboxItemsAsync(cancellationToken); + } + + public async Task DeleteOutboxItemAsync(OutboxItem outboxItem, CancellationToken cancellationToken) + { + await using var command = CreateCommand("DELETE FROM Outbox WHERE Id = @OutboxItemId"); + command.Parameters.Add("@OutboxItemId", SqlDbType.BigInt).Value = outboxItem.Id; + var result = await command.ExecuteNonQueryAsync(cancellationToken); + Debug.Assert(result == 1); + } +} diff --git a/src/ProductsService/ProductsService.csproj b/src/ProductsService/ProductsService.csproj index 90f05b7..2ccc018 100644 --- a/src/ProductsService/ProductsService.csproj +++ b/src/ProductsService/ProductsService.csproj @@ -20,16 +20,7 @@ + - - - - - - - - - - diff --git a/src/ProductsService/Program.cs b/src/ProductsService/Program.cs index fc62e13..35f6b66 100644 --- a/src/ProductsService/Program.cs +++ b/src/ProductsService/Program.cs @@ -1,18 +1,16 @@ -using Microsoft.Extensions.Logging.Console; -using Microsoft.OpenApi.Models; -using OpenTelemetry.Metrics; -using OpenTelemetry.Resources; -using OpenTelemetry.Trace; +using System.Data.SqlClient; using ProductsService.Configuration; +using ProductsService.Controllers; using ProductsService.Data.Repositories; -using ProductsService.Migrations; +using ProductsService.Data.UnitOfWork; +using ProductsService.OutboxProcessing; var builder = WebApplication.CreateBuilder(args); var cfg = new ProductsServiceConfiguration(); var cfgSection = builder.Configuration.GetSection(ProductsServiceConfiguration.SectionName); -if (cfgSection == null || !cfgSection.Exists()) +if (!cfgSection.Exists()) { throw new ApplicationException( $"Could not find service config. Please provide a '{ProductsServiceConfiguration.SectionName}' config section"); @@ -36,15 +34,52 @@ // Configure AuthZ builder.ConfigureAuthZ(cfg); -builder.Services.AddScoped(services => +// Configure data access layer +if (string.IsNullOrWhiteSpace(cfg.ConnectionString)) { - var cfg = services.GetRequiredService(); - if (string.IsNullOrWhiteSpace(cfg.ConnectionString)) - { - return new InMemoryProductsRepository(services.GetRequiredService>()); - } - return new ProductsRepository(cfg, services.GetRequiredService>()); -}); + builder.Services + .AddSingleton() + .AddSingleton(c => c.GetRequiredService()) + .AddUnitOfWorkWithFactory() + .AddUnitOfWorkWithFactory( + unitOfWorkLifetime: ServiceLifetime.Singleton, + factoryLifetime: ServiceLifetime.Singleton + ); +} +else +{ + builder.Services + .AddScoped() + .AddTransient(_ => new SqlConnection(cfg.ConnectionString)) + .AddUnitOfWorkWithFactory() + .AddUnitOfWorkWithFactory( + unitOfWorkLifetime: ServiceLifetime.Transient, + factoryLifetime: ServiceLifetime.Singleton + ); +} + +// Configure Transactional Outbox +if (cfg.EnableOutboxProcessing) + builder.Services + .AddSingleton() + .AddHostedService(serviceProvider => serviceProvider.GetRequiredService()) + .AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); +else + builder.Services + .AddSingleton(); + +// Configure Dapr PubSub +if (cfg.UseFakeEventPublisher) +{ + builder.Services + .AddSingleton(); +} +else +{ + builder.Services + .AddSingleton() + .AddDaprClient(); +} builder.Services.AddHealthChecks(); builder.Services.AddControllers(); @@ -53,18 +88,19 @@ builder.Services.AddSwaggerGen(c => { c.EnableAnnotations(); - c.SwaggerDoc("v1", new OpenApiInfo() - { - Version = "v1", - Title = "Products Service", - Description = "Fairly simple .NET API to interact with product data", - Contact = new OpenApiContact - { - Name = "Thinktecture AG", - Email = "info@thinktecture.com", - Url = new Uri("https://thinktecture.com") - } - }); + c.SwaggerDoc("v1", + new() + { + Version = "v1", + Title = "Products Service", + Description = "Fairly simple .NET API to interact with product data", + Contact = new() + { + Name = "Thinktecture AG", + Email = "info@thinktecture.com", + Url = new ("https://thinktecture.com") + } + }); }); var app = builder.Build(); @@ -75,8 +111,7 @@ app.UseAuthentication(); app.UseAuthorization(); -app.MapControllers() - .RequireAuthorization("RequiresApiScope"); +app.MapControllers(); if (cfg.ExposePrometheusMetrics) { diff --git a/src/ProductsService/appsettings.json b/src/ProductsService/appsettings.json index 10f68b8..b4ca588 100644 --- a/src/ProductsService/appsettings.json +++ b/src/ProductsService/appsettings.json @@ -5,5 +5,10 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "ProductsService": { + "ConnectionString": "", + "EnableOutboxProcessing": true, + "UseFakeEventPublisher": false + } }