From e77a31b3a11e568cc65b87342ceccc11c637d52d Mon Sep 17 00:00:00 2001 From: Billy Mumby Date: Fri, 11 Mar 2022 15:41:37 +0000 Subject: [PATCH] Job list es sample app (#230) * Started new event sourcing app. * Added first endpoint * Updated example with another state change * Formatting * Added CompleteJob API * Update using * Formatting * Added some projections and read APIs * Refactored into IJobTrackerReadService.cs * Get all jobs lists for a user * Refactored into Endpoints extensions. * Renaming * nits --- Microsoft.Azure.CosmosRepository.sln | 7 + samples/BasicEventSourcingSample/Program.cs | 2 +- .../Projections/ShipInformationProjections.cs | 16 +-- .../API/DTOs/JobDto.cs | 15 +++ .../API/DTOs/JobsListDto.cs | 9 ++ .../API/Requests/CompleteJob.cs | 8 ++ .../API/Requests/CreateJob.cs | 9 ++ .../API/Requests/CreateJobList.cs | 9 ++ .../Infrastructure/IJobListRepository.cs | 13 ++ .../Services/DefaultJobTrackerReadService.cs | 63 +++++++++ .../Services/DefaultJobsTrackerService.cs | 50 +++++++ .../Services/IJobTrackerReadService.cs | 16 +++ .../Services/IJobsTrackerService.cs | 21 +++ .../Core/Aggregates/JobList.cs | 127 ++++++++++++++++++ .../Core/Entities/Job.cs | 28 ++++ .../Core/Events/JobAddedEvent.cs | 13 ++ .../Core/Events/JobCompletedEvent.cs | 12 ++ .../Core/Events/JobListCreatedEvent.cs | 12 ++ .../Core/ValueObjects/JobListInfo.cs | 10 ++ .../Endpoints/JobEndpoints.cs | 71 ++++++++++ .../Endpoints/JobsListsEndpoints.cs | 73 ++++++++++ .../EventSourcingJobsTracker.csproj | 21 +++ .../Infrastructure/Items/JobItem.cs | 35 +++++ .../Infrastructure/Items/JobsListEventItem.cs | 27 ++++ .../Infrastructure/Items/JobsListReadItem.cs | 30 +++++ .../Projections/JobsListJobAddedProjection.cs | 36 +++++ .../JobsListJobCompletedProjection.cs | 39 ++++++ .../Projections/UsersJobsListProjection.cs | 35 +++++ .../Repositories/DefaultJobListRepository.cs | 51 +++++++ samples/EventSourcingJobsTracker/Program.cs | 64 +++++++++ .../appsettings.Development.json | 11 ++ .../EventSourcingJobsTracker/appsettings.json | 9 ++ .../DefaultCosmosEventSourcingBuilder.cs | 2 +- .../Builders/ICosmosEventSourcingBuilder.cs | 2 +- .../IDomainEventProjectionBuilder.cs | 4 +- 35 files changed, 937 insertions(+), 13 deletions(-) create mode 100644 samples/EventSourcingJobsTracker/API/DTOs/JobDto.cs create mode 100644 samples/EventSourcingJobsTracker/API/DTOs/JobsListDto.cs create mode 100644 samples/EventSourcingJobsTracker/API/Requests/CompleteJob.cs create mode 100644 samples/EventSourcingJobsTracker/API/Requests/CreateJob.cs create mode 100644 samples/EventSourcingJobsTracker/API/Requests/CreateJobList.cs create mode 100644 samples/EventSourcingJobsTracker/Application/Infrastructure/IJobListRepository.cs create mode 100644 samples/EventSourcingJobsTracker/Application/Services/DefaultJobTrackerReadService.cs create mode 100644 samples/EventSourcingJobsTracker/Application/Services/DefaultJobsTrackerService.cs create mode 100644 samples/EventSourcingJobsTracker/Application/Services/IJobTrackerReadService.cs create mode 100644 samples/EventSourcingJobsTracker/Application/Services/IJobsTrackerService.cs create mode 100644 samples/EventSourcingJobsTracker/Core/Aggregates/JobList.cs create mode 100644 samples/EventSourcingJobsTracker/Core/Entities/Job.cs create mode 100644 samples/EventSourcingJobsTracker/Core/Events/JobAddedEvent.cs create mode 100644 samples/EventSourcingJobsTracker/Core/Events/JobCompletedEvent.cs create mode 100644 samples/EventSourcingJobsTracker/Core/Events/JobListCreatedEvent.cs create mode 100644 samples/EventSourcingJobsTracker/Core/ValueObjects/JobListInfo.cs create mode 100644 samples/EventSourcingJobsTracker/Endpoints/JobEndpoints.cs create mode 100644 samples/EventSourcingJobsTracker/Endpoints/JobsListsEndpoints.cs create mode 100644 samples/EventSourcingJobsTracker/EventSourcingJobsTracker.csproj create mode 100644 samples/EventSourcingJobsTracker/Infrastructure/Items/JobItem.cs create mode 100644 samples/EventSourcingJobsTracker/Infrastructure/Items/JobsListEventItem.cs create mode 100644 samples/EventSourcingJobsTracker/Infrastructure/Items/JobsListReadItem.cs create mode 100644 samples/EventSourcingJobsTracker/Infrastructure/Projections/JobsListJobAddedProjection.cs create mode 100644 samples/EventSourcingJobsTracker/Infrastructure/Projections/JobsListJobCompletedProjection.cs create mode 100644 samples/EventSourcingJobsTracker/Infrastructure/Projections/UsersJobsListProjection.cs create mode 100644 samples/EventSourcingJobsTracker/Infrastructure/Repositories/DefaultJobListRepository.cs create mode 100644 samples/EventSourcingJobsTracker/Program.cs create mode 100644 samples/EventSourcingJobsTracker/appsettings.Development.json create mode 100644 samples/EventSourcingJobsTracker/appsettings.json diff --git a/Microsoft.Azure.CosmosRepository.sln b/Microsoft.Azure.CosmosRepository.sln index d26021017..21440596c 100644 --- a/Microsoft.Azure.CosmosRepository.sln +++ b/Microsoft.Azure.CosmosRepository.sln @@ -75,6 +75,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AspNetCore", "AspNetCore", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Azure.CosmosEventSourcingTests", "tests\Microsoft.Azure.CosmosEventSourcingTests\Microsoft.Azure.CosmosEventSourcingTests.csproj", "{FEB072AC-B573-48BF-948D-B5FFF9102303}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventSourcingJobsTracker", "samples\EventSourcingJobsTracker\EventSourcingJobsTracker.csproj", "{D276C752-4DB7-4380-AF76-91492E8F8554}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -161,6 +163,10 @@ Global {FEB072AC-B573-48BF-948D-B5FFF9102303}.Debug|Any CPU.Build.0 = Debug|Any CPU {FEB072AC-B573-48BF-948D-B5FFF9102303}.Release|Any CPU.ActiveCfg = Release|Any CPU {FEB072AC-B573-48BF-948D-B5FFF9102303}.Release|Any CPU.Build.0 = Release|Any CPU + {D276C752-4DB7-4380-AF76-91492E8F8554}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D276C752-4DB7-4380-AF76-91492E8F8554}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D276C752-4DB7-4380-AF76-91492E8F8554}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D276C752-4DB7-4380-AF76-91492E8F8554}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -188,6 +194,7 @@ Global {A57B9AC9-8CAF-47AA-9F70-EDF9B48C12A7} = {3D8EDE92-2014-4AD5-8EDD-69A2DEA33928} {CDF603BA-5474-45E9-BBA1-2D33CAD12062} = {3D8EDE92-2014-4AD5-8EDD-69A2DEA33928} {FEB072AC-B573-48BF-948D-B5FFF9102303} = {F8ED6752-5ED3-4EA1-89F0-363C40F8D8E0} + {D276C752-4DB7-4380-AF76-91492E8F8554} = {8F8738AD-EBC3-4C24-882B-5D9FAC427E80} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6AAE7641-B62C-48BA-8FE6-0F819E5B45EF} diff --git a/samples/BasicEventSourcingSample/Program.cs b/samples/BasicEventSourcingSample/Program.cs index 54106011d..a4d654c21 100644 --- a/samples/BasicEventSourcingSample/Program.cs +++ b/samples/BasicEventSourcingSample/Program.cs @@ -26,7 +26,7 @@ eventSourcingBuilder.AddDomainEventTypes(); eventSourcingBuilder.AddDomainEventProjectionHandlers(); - eventSourcingBuilder.AddEventItemProjectionBuilder(options => + eventSourcingBuilder.AddDefaultDomainEventProjectionBuilder(options => { options.ProcessorName = "shipping-demo"; options.InstanceName = Environment.MachineName; diff --git a/samples/BasicEventSourcingSample/Projections/ShipInformationProjections.cs b/samples/BasicEventSourcingSample/Projections/ShipInformationProjections.cs index 31d1a6422..b13fa0183 100644 --- a/samples/BasicEventSourcingSample/Projections/ShipInformationProjections.cs +++ b/samples/BasicEventSourcingSample/Projections/ShipInformationProjections.cs @@ -19,14 +19,14 @@ public ShipCreatedBuilder(IRepository repository) => _repository = repository; public async ValueTask HandleAsync( - ShipEvents.ShipCreated shipCreated, + ShipEvents.ShipCreated domainEvent, ShipEventItem eventItem, CancellationToken cancellationToken = default) { ShipInformation info = new( - shipCreated.Name, - shipCreated.Commissioned, - shipCreated.OccuredUtc); + domainEvent.Name, + domainEvent.Commissioned, + domainEvent.OccuredUtc); await _repository.UpdateAsync(info, cancellationToken); } @@ -40,11 +40,11 @@ public ShipDockedBuilder(IRepository repository) => _repository = repository; public async ValueTask HandleAsync( - ShipEvents.DockedInPort dockedInPort, + ShipEvents.DockedInPort domainEvent, ShipEventItem eventItem, CancellationToken cancellationToken = default) { - (string name, string port) = dockedInPort; + (string name, string port) = domainEvent; ShipInformation shipInfo = await _repository.GetAsync( name, @@ -65,11 +65,11 @@ public ShipLoadedBuilder(IRepository repository) => _repository = repository; public async ValueTask HandleAsync( - ShipEvents.Loaded loaded, + ShipEvents.Loaded domainEvent, ShipEventItem eventItem, CancellationToken cancellationToken = default) { - (string name, string port, double cargoWeight) = loaded; + (string name, string port, double cargoWeight) = domainEvent; ShipInformation shipInfo = await _repository.GetAsync( name, diff --git a/samples/EventSourcingJobsTracker/API/DTOs/JobDto.cs b/samples/EventSourcingJobsTracker/API/DTOs/JobDto.cs new file mode 100644 index 000000000..3fc9be075 --- /dev/null +++ b/samples/EventSourcingJobsTracker/API/DTOs/JobDto.cs @@ -0,0 +1,15 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +namespace EventSourcingJobsTracker.API.DTOs; + +public record JobDto( + string Id, + string Title, + DateTime Due, + DateTime? CompletedAt = null) + +{ + public bool IsComplete => + CompletedAt is not null; +} \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/API/DTOs/JobsListDto.cs b/samples/EventSourcingJobsTracker/API/DTOs/JobsListDto.cs new file mode 100644 index 000000000..6d50e1cfb --- /dev/null +++ b/samples/EventSourcingJobsTracker/API/DTOs/JobsListDto.cs @@ -0,0 +1,9 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +namespace EventSourcingJobsTracker.API.DTOs; + +public record JobsListDto(string Id, + string Username, + string Category, + DateTime Created); \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/API/Requests/CompleteJob.cs b/samples/EventSourcingJobsTracker/API/Requests/CompleteJob.cs new file mode 100644 index 000000000..0b65c3db8 --- /dev/null +++ b/samples/EventSourcingJobsTracker/API/Requests/CompleteJob.cs @@ -0,0 +1,8 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +namespace EventSourcingJobsTracker.API.Requests; + +public record CompleteJob( + Guid JobListId, + Guid JobId); \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/API/Requests/CreateJob.cs b/samples/EventSourcingJobsTracker/API/Requests/CreateJob.cs new file mode 100644 index 000000000..e580fc26a --- /dev/null +++ b/samples/EventSourcingJobsTracker/API/Requests/CreateJob.cs @@ -0,0 +1,9 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +namespace EventSourcingJobsTracker.API.Requests; + +public record CreateJob( + Guid JobListId, + string Title, + DateTime Due); \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/API/Requests/CreateJobList.cs b/samples/EventSourcingJobsTracker/API/Requests/CreateJobList.cs new file mode 100644 index 000000000..308b765b8 --- /dev/null +++ b/samples/EventSourcingJobsTracker/API/Requests/CreateJobList.cs @@ -0,0 +1,9 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +namespace EventSourcingJobsTracker.API.Requests; + +public record CreateJobList( + string Name, + string Category, + string Username); \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Application/Infrastructure/IJobListRepository.cs b/samples/EventSourcingJobsTracker/Application/Infrastructure/IJobListRepository.cs new file mode 100644 index 000000000..563510612 --- /dev/null +++ b/samples/EventSourcingJobsTracker/Application/Infrastructure/IJobListRepository.cs @@ -0,0 +1,13 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +using EventSourcingJobsTracker.Core.Aggregates; + +namespace EventSourcingJobsTracker.Application.Infrastructure; + +public interface IJobListRepository +{ + ValueTask SaveAsync(JobsList jobList); + + ValueTask ReadAsync(Guid jobListId); +} \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Application/Services/DefaultJobTrackerReadService.cs b/samples/EventSourcingJobsTracker/Application/Services/DefaultJobTrackerReadService.cs new file mode 100644 index 000000000..1c4bb734a --- /dev/null +++ b/samples/EventSourcingJobsTracker/Application/Services/DefaultJobTrackerReadService.cs @@ -0,0 +1,63 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +using EventSourcingJobsTracker.API.DTOs; +using EventSourcingJobsTracker.Infrastructure.Items; +using Microsoft.Azure.CosmosRepository; + +namespace EventSourcingJobsTracker.Application.Services; + +public class DefaultJobTrackerReadService : IJobTrackerReadService +{ + private readonly IReadOnlyRepository _jobsListRepository; + private readonly IReadOnlyRepository _jobsRepository; + + public DefaultJobTrackerReadService( + IReadOnlyRepository jobsListRepository, + IReadOnlyRepository jobsRepository) + { + _jobsListRepository = jobsListRepository; + _jobsRepository = jobsRepository; + } + + public async ValueTask FindJobsListAsync( + Guid id, + string username) + { + JobsListReadItem? jobsList = await _jobsListRepository.TryGetAsync( + id.ToString(), + username); + + return jobsList is null + ? null + : new JobsListDto( + jobsList.Id, + jobsList.Username, + jobsList.Category, + jobsList.CreatedTimeUtc!.Value); + } + + public async ValueTask> FindJobsForJobsListAsync(Guid jobListId) + { + IEnumerable jobs = await _jobsRepository.GetAsync(x => + x.PartitionKey == jobListId.ToString()); + + return jobs.Select(x => new JobDto( + x.Id, + x.Title, + x.Due, + x.CompletedAt)); + } + + public async Task> FindJobsListAsync(string username) + { + IEnumerable jobLists = await _jobsListRepository.GetAsync(x => + x.PartitionKey == username); + + return jobLists.Select(x => new JobsListDto( + x.Id, + x.Username, + x.Category, + x.CreatedTimeUtc!.Value)); + } +} \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Application/Services/DefaultJobsTrackerService.cs b/samples/EventSourcingJobsTracker/Application/Services/DefaultJobsTrackerService.cs new file mode 100644 index 000000000..8bb8e3f8a --- /dev/null +++ b/samples/EventSourcingJobsTracker/Application/Services/DefaultJobsTrackerService.cs @@ -0,0 +1,50 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +using EventSourcingJobsTracker.Application.Infrastructure; +using EventSourcingJobsTracker.Core.Aggregates; + +namespace EventSourcingJobsTracker.Application.Services; + +public class DefaultJobsTrackerService : IJobsTrackerService +{ + private readonly IJobListRepository _jobListRepository; + + public DefaultJobsTrackerService(IJobListRepository jobListRepository) => + _jobListRepository = jobListRepository; + + public async ValueTask CreateJobList( + string name, + string category, + string username) + { + JobsList jobList = new(name, category, username); + + await _jobListRepository.SaveAsync(jobList); + + return jobList.Id; + } + + public async ValueTask AddJob( + Guid jobListId, + string title, + DateTime due) + { + JobsList jobsList = await _jobListRepository.ReadAsync(jobListId); + + jobsList.AddJob(title, due); + + await _jobListRepository.SaveAsync(jobsList); + } + + public async ValueTask CompleteJob( + Guid jobListId, + Guid jobId) + { + JobsList jobsList = await _jobListRepository.ReadAsync(jobListId); + + jobsList.CompleteJob(jobId); + + await _jobListRepository.SaveAsync(jobsList); + } +} \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Application/Services/IJobTrackerReadService.cs b/samples/EventSourcingJobsTracker/Application/Services/IJobTrackerReadService.cs new file mode 100644 index 000000000..eb190b3f3 --- /dev/null +++ b/samples/EventSourcingJobsTracker/Application/Services/IJobTrackerReadService.cs @@ -0,0 +1,16 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +using EventSourcingJobsTracker.API.DTOs; + +namespace EventSourcingJobsTracker.Application.Services; + +public interface IJobTrackerReadService +{ + ValueTask FindJobsListAsync( + Guid id, + string username); + + ValueTask> FindJobsForJobsListAsync(Guid jobListId); + Task> FindJobsListAsync(string username); +} \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Application/Services/IJobsTrackerService.cs b/samples/EventSourcingJobsTracker/Application/Services/IJobsTrackerService.cs new file mode 100644 index 000000000..4fd5b5d6f --- /dev/null +++ b/samples/EventSourcingJobsTracker/Application/Services/IJobsTrackerService.cs @@ -0,0 +1,21 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +namespace EventSourcingJobsTracker.Application.Services; + +public interface IJobsTrackerService +{ + ValueTask CreateJobList( + string name, + string category, + string username); + + ValueTask AddJob( + Guid jobListId, + string title, + DateTime due); + + ValueTask CompleteJob( + Guid jobListId, + Guid jobId); +} \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Core/Aggregates/JobList.cs b/samples/EventSourcingJobsTracker/Core/Aggregates/JobList.cs new file mode 100644 index 000000000..c041bb26a --- /dev/null +++ b/samples/EventSourcingJobsTracker/Core/Aggregates/JobList.cs @@ -0,0 +1,127 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +using CleanArchitecture.Exceptions; +using EventSourcingJobsTracker.Core.Entities; +using EventSourcingJobsTracker.Core.Events; +using EventSourcingJobsTracker.Core.ValueObjects; +using Microsoft.Azure.CosmosEventSourcing.Aggregates; +using Microsoft.Azure.CosmosEventSourcing.Events; + +namespace EventSourcingJobsTracker.Core.Aggregates; + +public class JobsList : AggregateRoot +{ + private readonly List _jobs = new(); + + public Guid Id { get; private set; } + + public string Name { get; private set; } = null!; + + public string Category { get; private set; } = null!; + + public string Username { get; private set; } = null!; + + public JobsList(string name, string category, string username) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new DomainException( + "You must provide a name for this jobs list"); + } + + if (string.IsNullOrWhiteSpace(category)) + { + throw new DomainException( + "You must provide a category for this jobs list"); + } + + if (string.IsNullOrWhiteSpace(username)) + { + throw new DomainException( + "You must provide a username for this jobs list"); + } + + AddEvent(new JobListCreatedEvent(Guid.NewGuid(), name, category, username)); + } + + public void AddJob(string title, DateTime due) + { + if (string.IsNullOrWhiteSpace(title)) + { + throw new DomainException( + "You must provide a title when creating a job"); + } + + AddEvent(new JobAddedEvent(Guid.NewGuid(), title, due, JobListInfo)); + } + + public void CompleteJob(Guid id) + { + Job? job = _jobs.FirstOrDefault(x => x.Id == id); + + if (job is null) + { + throw new ResourceNotFoundException( + $"There is no job with the ID {id}"); + } + + if (job.IsComplete) + { + throw new DomainException($"The job with ID {id} was completed at {job.CompletedAt}"); + } + + AddEvent(new JobCompletedEvent(id, job.Title, JobListInfo)); + } + + private JobListInfo JobListInfo => + new(Id, Name, Category, Username); + + private void Apply(JobListCreatedEvent evt) + { + Id = evt.Id; + Name = evt.Name; + Category = evt.Category; + Username = evt.Username; + } + + private void Apply(JobAddedEvent evt) => + _jobs.Add(new Job(evt.Id, evt.Title, evt.Due)); + + private void Apply(JobCompletedEvent evt) => + _jobs + .First(x => x.Id == evt.Id) + .Complete(evt.OccuredUtc); + + protected override void Apply(DomainEvent domainEvent) + { + switch (domainEvent) + { + case JobListCreatedEvent created: + Apply(created); + break; + case JobAddedEvent jobAdded: + Apply(jobAdded); + break; + case JobCompletedEvent jobCompleted: + Apply(jobCompleted); + break; + default: + throw new ArgumentOutOfRangeException( + nameof(domainEvent), + $"There is no {nameof(Apply)} method for domain event of type {domainEvent.GetType().Name}"); + } + } + + public static JobsList Replay(List domainEvents) + { + JobsList jobList = new(); + jobList.Apply(domainEvents); + return jobList; + } + + private JobsList() + { + + } +} \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Core/Entities/Job.cs b/samples/EventSourcingJobsTracker/Core/Entities/Job.cs new file mode 100644 index 000000000..e474f5c4f --- /dev/null +++ b/samples/EventSourcingJobsTracker/Core/Entities/Job.cs @@ -0,0 +1,28 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +namespace EventSourcingJobsTracker.Core.Entities; + +public class Job +{ + public Guid Id { get; } + public string Title { get; } + public DateTime Due { get; } + + public DateTime? CompletedAt { get; private set; } + + public bool IsComplete => + CompletedAt is not null; + + public Job(Guid id, string title, DateTime due) + { + Id = id; + Title = title; + Due = due; + } + + public void Complete(DateTime at) + { + CompletedAt = at; + } +} \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Core/Events/JobAddedEvent.cs b/samples/EventSourcingJobsTracker/Core/Events/JobAddedEvent.cs new file mode 100644 index 000000000..7d1bd9ffc --- /dev/null +++ b/samples/EventSourcingJobsTracker/Core/Events/JobAddedEvent.cs @@ -0,0 +1,13 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +using EventSourcingJobsTracker.Core.ValueObjects; +using Microsoft.Azure.CosmosEventSourcing.Events; + +namespace EventSourcingJobsTracker.Core.Events; + +public record JobAddedEvent( + Guid Id, + string Title, + DateTime Due, + JobListInfo JobListInfo) : DomainEvent; \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Core/Events/JobCompletedEvent.cs b/samples/EventSourcingJobsTracker/Core/Events/JobCompletedEvent.cs new file mode 100644 index 000000000..71c9702ff --- /dev/null +++ b/samples/EventSourcingJobsTracker/Core/Events/JobCompletedEvent.cs @@ -0,0 +1,12 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +using EventSourcingJobsTracker.Core.ValueObjects; +using Microsoft.Azure.CosmosEventSourcing.Events; + +namespace EventSourcingJobsTracker.Core.Events; + +public record JobCompletedEvent( + Guid Id, + string Title, + JobListInfo JobListInfo) : DomainEvent; \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Core/Events/JobListCreatedEvent.cs b/samples/EventSourcingJobsTracker/Core/Events/JobListCreatedEvent.cs new file mode 100644 index 000000000..172677ff7 --- /dev/null +++ b/samples/EventSourcingJobsTracker/Core/Events/JobListCreatedEvent.cs @@ -0,0 +1,12 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Azure.CosmosEventSourcing.Events; + +namespace EventSourcingJobsTracker.Core.Events; + +public record JobListCreatedEvent( + Guid Id, + string Name, + string Category, + string Username) : DomainEvent; \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Core/ValueObjects/JobListInfo.cs b/samples/EventSourcingJobsTracker/Core/ValueObjects/JobListInfo.cs new file mode 100644 index 000000000..cfeb5e545 --- /dev/null +++ b/samples/EventSourcingJobsTracker/Core/ValueObjects/JobListInfo.cs @@ -0,0 +1,10 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +namespace EventSourcingJobsTracker.Core.ValueObjects; + +public record JobListInfo( + Guid Id, + string Name, + string Category, + string Username); \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Endpoints/JobEndpoints.cs b/samples/EventSourcingJobsTracker/Endpoints/JobEndpoints.cs new file mode 100644 index 000000000..3ad609e7d --- /dev/null +++ b/samples/EventSourcingJobsTracker/Endpoints/JobEndpoints.cs @@ -0,0 +1,71 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +using CleanArchitecture.Exceptions.AspNetCore; +using EventSourcingJobsTracker.API.DTOs; +using EventSourcingJobsTracker.API.Requests; +using EventSourcingJobsTracker.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace EventSourcingJobsTracker.Endpoints; + +public static class JobEndpoints +{ + public const string Tag = "Jobs"; + public static IEndpointRouteBuilder MapJobEndpoints(this IEndpointRouteBuilder builder) + { + builder.MapPost( + "/api/jobs-list/jobs/", + async ( + CreateJob request, + IJobsTrackerService service) => + { + (Guid jobListId, string? title, DateTime due) = request; + + await service.AddJob(jobListId, title, due); + + return Results.Ok(); + }) + .Accepts("application/json") + .Produces(200) + .Produces(400) + .Produces(404) + .WithTags(Tag); + + builder.MapPut( + "/api/jobs-list/jobs/complete", + async ( + CompleteJob request, + IJobsTrackerService service) => + { + (Guid jobListId, Guid jobId) = request; + + await service.CompleteJob(jobListId, jobId); + + return Results.Ok(); + }) + .Accepts("application/json") + .Produces(200) + .Produces(400) + .Produces(404) + .WithTags(Tag); + + builder.MapGet( + "/api/jobs-list/jobs/", + async ( + Guid jobListId, + [FromServices] IJobTrackerReadService readService) => + { + IEnumerable jobs = await readService.FindJobsForJobsListAsync(jobListId); + + return jobs.Any() + ? Results.Ok(jobs) + : Results.NoContent(); + }) + .Produces(200) + .Produces(204) + .WithTags(Tag); + + return builder; + } +} \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Endpoints/JobsListsEndpoints.cs b/samples/EventSourcingJobsTracker/Endpoints/JobsListsEndpoints.cs new file mode 100644 index 000000000..cdff7d763 --- /dev/null +++ b/samples/EventSourcingJobsTracker/Endpoints/JobsListsEndpoints.cs @@ -0,0 +1,73 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +using CleanArchitecture.Exceptions.AspNetCore; +using EventSourcingJobsTracker.API.DTOs; +using EventSourcingJobsTracker.API.Requests; +using EventSourcingJobsTracker.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace EventSourcingJobsTracker.Endpoints; + +public static class JobsListsEndpoints +{ + public const string Tag = "Jobs Lists"; + + public static IEndpointRouteBuilder MapJobsListsEndpoints(this IEndpointRouteBuilder builder) + { + builder.MapPost( + "/api/jobs-list/", + async ( + CreateJobList request, + IJobsTrackerService service) => + { + (string name, string category, string username) = request; + + Guid id = await service.CreateJobList( + name, + category, + username); + + return Results.Created($"api/jobs-list/{id}", id); + }) + .Accepts("application/json") + .Produces(201) + .Produces(400) + .WithTags(Tag); + + builder.MapGet( + "/api/jobs-list/{id}", + async ( + Guid id, + string username, + [FromServices] IJobTrackerReadService readService) => + { + JobsListDto? jobsList = await readService.FindJobsListAsync(id, username); + + return jobsList is null + ? Results.NoContent() + : Results.Ok(jobsList); + }) + .Produces(200) + .Produces(204) + .WithTags(Tag); + + builder.MapGet( + "/api/jobs-list/", + async ( + string username, + [FromServices] IJobTrackerReadService readService) => + { + IEnumerable jobLists = await readService.FindJobsListAsync(username); + + return jobLists.Any() + ? Results.Ok(jobLists) + : Results.NoContent(); + }) + .Produces(200) + .Produces(204) + .WithTags(Tag); + + return builder; + } +} \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/EventSourcingJobsTracker.csproj b/samples/EventSourcingJobsTracker/EventSourcingJobsTracker.csproj new file mode 100644 index 000000000..af44d4a86 --- /dev/null +++ b/samples/EventSourcingJobsTracker/EventSourcingJobsTracker.csproj @@ -0,0 +1,21 @@ + + + + net6.0 + enable + enable + ce605ed2-3eba-45c1-8970-76b34c1c2837 + + + + + + + + + + + + + + diff --git a/samples/EventSourcingJobsTracker/Infrastructure/Items/JobItem.cs b/samples/EventSourcingJobsTracker/Infrastructure/Items/JobItem.cs new file mode 100644 index 000000000..c1a2c6b5a --- /dev/null +++ b/samples/EventSourcingJobsTracker/Infrastructure/Items/JobItem.cs @@ -0,0 +1,35 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Azure.CosmosRepository; + +namespace EventSourcingJobsTracker.Infrastructure.Items; + +public class JobItem : FullItem +{ + public string Title { get; } + public DateTime Due { get; } + public DateTime? CompletedAt { get; private set; } + + public string PartitionKey { get; set; } + + protected override string GetPartitionKeyValue() => + PartitionKey; + + public JobItem( + string id, + string jobListId, + string title, + DateTime due, + DateTime? completedAt = null) + { + Id = id; + Title = title; + Due = due; + CompletedAt = completedAt; + PartitionKey = jobListId; + } + + public void Complete(DateTime at) => + CompletedAt = at; +} \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Infrastructure/Items/JobsListEventItem.cs b/samples/EventSourcingJobsTracker/Infrastructure/Items/JobsListEventItem.cs new file mode 100644 index 000000000..4b4a29db8 --- /dev/null +++ b/samples/EventSourcingJobsTracker/Infrastructure/Items/JobsListEventItem.cs @@ -0,0 +1,27 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Azure.CosmosEventSourcing.Events; +using Microsoft.Azure.CosmosEventSourcing.Items; +using Newtonsoft.Json; + +namespace EventSourcingJobsTracker.Infrastructure.Items; + +public class JobsListEventItem : DefaultEventItem +{ + public JobsListEventItem( + IDomainEvent domainEvent, + Guid id) : + base(domainEvent, id.ToString()) + { + + } + + [JsonConstructor] + private JobsListEventItem( + IDomainEvent eventPayload, + string partitionKey) : base(eventPayload, partitionKey) + { + + } +} \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Infrastructure/Items/JobsListReadItem.cs b/samples/EventSourcingJobsTracker/Infrastructure/Items/JobsListReadItem.cs new file mode 100644 index 000000000..70daf3bf2 --- /dev/null +++ b/samples/EventSourcingJobsTracker/Infrastructure/Items/JobsListReadItem.cs @@ -0,0 +1,30 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Azure.CosmosRepository; + +namespace EventSourcingJobsTracker.Infrastructure.Items; + +public class JobsListReadItem : FullItem +{ + public string Name { get; } + public string Username { get; } + public string Category { get; } + public string PartitionKey { get; set; } + + protected override string GetPartitionKeyValue() => + PartitionKey; + + public JobsListReadItem( + string id, + string name, + string username, + string category) + { + Id = id; + Name = name; + Username = username; + Category = category; + PartitionKey = username; + } +} \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Infrastructure/Projections/JobsListJobAddedProjection.cs b/samples/EventSourcingJobsTracker/Infrastructure/Projections/JobsListJobAddedProjection.cs new file mode 100644 index 000000000..b3f2b8b18 --- /dev/null +++ b/samples/EventSourcingJobsTracker/Infrastructure/Projections/JobsListJobAddedProjection.cs @@ -0,0 +1,36 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +using EventSourcingJobsTracker.Core.Events; +using EventSourcingJobsTracker.Core.ValueObjects; +using EventSourcingJobsTracker.Infrastructure.Items; +using Microsoft.Azure.CosmosEventSourcing.Projections; +using Microsoft.Azure.CosmosRepository; + +namespace EventSourcingJobsTracker.Infrastructure.Projections; + +public class JobsListJobAddedProjection : IDomainEventProjectionBuilder +{ + private readonly IWriteOnlyRepository _repository; + + public JobsListJobAddedProjection(IWriteOnlyRepository repository) => + _repository = repository; + + public async ValueTask HandleAsync( + JobAddedEvent domainEvent, + JobsListEventItem eventSource, + CancellationToken cancellationToken = default) + { + (Guid guid, string? title, DateTime due, JobListInfo? jobListInfo) = domainEvent; + + JobItem item = new( + guid.ToString(), + jobListInfo.Id.ToString(), + title, + due); + + await _repository.CreateAsync( + item, + cancellationToken); + } +} \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Infrastructure/Projections/JobsListJobCompletedProjection.cs b/samples/EventSourcingJobsTracker/Infrastructure/Projections/JobsListJobCompletedProjection.cs new file mode 100644 index 000000000..bbe006f7b --- /dev/null +++ b/samples/EventSourcingJobsTracker/Infrastructure/Projections/JobsListJobCompletedProjection.cs @@ -0,0 +1,39 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +using EventSourcingJobsTracker.Core.Events; +using EventSourcingJobsTracker.Core.ValueObjects; +using EventSourcingJobsTracker.Infrastructure.Items; +using Microsoft.Azure.CosmosEventSourcing.Projections; +using Microsoft.Azure.CosmosRepository; + +namespace EventSourcingJobsTracker.Infrastructure.Projections; + +public class JobsListJobCompletedProjection : IDomainEventProjectionBuilder +{ + private readonly IRepository _repository; + + public JobsListJobCompletedProjection(IRepository repository) + { + _repository = repository; + } + + public async ValueTask HandleAsync( + JobCompletedEvent domainEvent, + JobsListEventItem eventSource, + CancellationToken cancellationToken = default) + { + (Guid id, string? _, JobListInfo? jobListInfo) = domainEvent; + + JobItem item = await _repository.GetAsync( + id.ToString(), + jobListInfo.Id.ToString(), + cancellationToken); + + item.Complete(domainEvent.OccuredUtc); + + await _repository.UpdateAsync( + item, + cancellationToken); + } +} \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Infrastructure/Projections/UsersJobsListProjection.cs b/samples/EventSourcingJobsTracker/Infrastructure/Projections/UsersJobsListProjection.cs new file mode 100644 index 000000000..cb7865126 --- /dev/null +++ b/samples/EventSourcingJobsTracker/Infrastructure/Projections/UsersJobsListProjection.cs @@ -0,0 +1,35 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +using EventSourcingJobsTracker.Core.Events; +using EventSourcingJobsTracker.Infrastructure.Items; +using Microsoft.Azure.CosmosEventSourcing.Projections; +using Microsoft.Azure.CosmosRepository; + +namespace EventSourcingJobsTracker.Infrastructure.Projections; + +public class UsersJobsListProjection : IDomainEventProjectionBuilder +{ + private readonly IWriteOnlyRepository _repository; + + public UsersJobsListProjection(IWriteOnlyRepository repository) => + _repository = repository; + + public async ValueTask HandleAsync( + JobListCreatedEvent domainEvent, + JobsListEventItem eventSource, + CancellationToken cancellationToken = default) + { + (Guid guid, string? name, string? category, string? username) = domainEvent; + + JobsListReadItem readItem = new( + guid.ToString(), + name, + username, + category); + + await _repository.CreateAsync( + readItem, + cancellationToken); + } +} \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Infrastructure/Repositories/DefaultJobListRepository.cs b/samples/EventSourcingJobsTracker/Infrastructure/Repositories/DefaultJobListRepository.cs new file mode 100644 index 000000000..19602dd20 --- /dev/null +++ b/samples/EventSourcingJobsTracker/Infrastructure/Repositories/DefaultJobListRepository.cs @@ -0,0 +1,51 @@ +// Copyright (c) IEvangelist. All rights reserved. +// Licensed under the MIT License. + +using CleanArchitecture.Exceptions; +using EventSourcingJobsTracker.Application.Infrastructure; +using EventSourcingJobsTracker.Core.Aggregates; +using EventSourcingJobsTracker.Infrastructure.Items; +using Microsoft.Azure.CosmosEventSourcing.Stores; +using Microsoft.Azure.CosmosRepository.Extensions; + +namespace EventSourcingJobsTracker.Infrastructure.Repositories; + +public class DefaultJobListRepository : IJobListRepository +{ + private readonly IEventStore _eventStore; + + public DefaultJobListRepository(IEventStore eventStore) => + _eventStore = eventStore; + + public async ValueTask SaveAsync(JobsList jobList) + { + List eventItems = jobList + .NewEvents + .Select(evt => + new JobsListEventItem( + evt, + jobList.Id)) + .ToList(); + + eventItems.Add(new JobsListEventItem( + jobList.AtomicEvent, + jobList.Id)); + + await _eventStore.PersistAsync(eventItems); + } + + public async ValueTask ReadAsync(Guid jobListId) + { + List events = await _eventStore + .ReadAsync(jobListId.ToString()) + .ToListAsync(); + + if (events is {Count: 0}) + { + throw new ResourceNotFoundException( + $"There is no job list with the ID {jobListId}"); + } + + return JobsList.Replay(events.Select(x => x.DomainEventPayload).ToList()); + } +} \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/Program.cs b/samples/EventSourcingJobsTracker/Program.cs new file mode 100644 index 000000000..31b5a6c8e --- /dev/null +++ b/samples/EventSourcingJobsTracker/Program.cs @@ -0,0 +1,64 @@ +using CleanArchitecture.Exceptions.AspNetCore; +using EventSourcingJobsTracker.Application.Infrastructure; +using EventSourcingJobsTracker.Application.Services; +using EventSourcingJobsTracker.Core.Aggregates; +using EventSourcingJobsTracker.Endpoints; +using EventSourcingJobsTracker.Infrastructure.Items; +using EventSourcingJobsTracker.Infrastructure.Repositories; +using Microsoft.Azure.CosmosEventSourcing.Extensions; +using Microsoft.Azure.CosmosRepository.AspNetCore.Extensions; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +string appName = typeof(Program).Assembly.FullName!; + +builder.Services + .AddCleanArchitectureExceptionsHandler(options => options.ApplicationName = appName) + .AddEndpointsApiExplorer() + .AddSwaggerGen(); + +builder.Services.AddCosmosEventSourcing(eventSourcingBuilder => +{ + eventSourcingBuilder.AddCosmosRepository(cosmosOptions => + { + cosmosOptions.DatabaseId = "jobs-list-db"; + cosmosOptions.ContainerBuilder + .ConfigureEventItemStore("jobs-list-events") + .ConfigureProjectionStore("projections") + .ConfigureProjectionStore("projections"); + }); + + eventSourcingBuilder.AddDomainEventTypes(typeof(JobsList).Assembly); + eventSourcingBuilder.AddDomainEventProjectionHandlers(typeof(JobsList).Assembly); + + eventSourcingBuilder.AddDefaultDomainEventProjectionBuilder(options => + { + options.InstanceName = appName; + options.ProcessorName = Environment.MachineName; + options.PollInterval = TimeSpan.FromSeconds(1); + }); +}); + +builder.Services.AddCosmosRepositoryChangeFeedHostedService(); + +builder.Services + .AddSingleton() + .AddSingleton() + .AddSingleton(); + + +WebApplication app = builder.Build(); + +app + .UseCleanArchitectureExceptionsHandler() + .UseSwagger() + .UseSwaggerUI(); + +app + .MapGet("/", () => Results.Redirect("/swagger")) + .ExcludeFromDescription(); + +app.MapJobEndpoints(); +app.MapJobsListsEndpoints(); + +app.Run(); \ No newline at end of file diff --git a/samples/EventSourcingJobsTracker/appsettings.Development.json b/samples/EventSourcingJobsTracker/appsettings.Development.json new file mode 100644 index 000000000..52e9aad93 --- /dev/null +++ b/samples/EventSourcingJobsTracker/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "System.Net.Http": "Error", + "Microsoft.Azure.CosmosRepository": "Debug", + "Microsoft.Azure.CosmosEventSourcing": "Debug" + } + } +} diff --git a/samples/EventSourcingJobsTracker/appsettings.json b/samples/EventSourcingJobsTracker/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/samples/EventSourcingJobsTracker/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Microsoft.Azure.CosmosEventSourcing/Builders/DefaultCosmosEventSourcingBuilder.cs b/src/Microsoft.Azure.CosmosEventSourcing/Builders/DefaultCosmosEventSourcingBuilder.cs index 4d3832ab3..9506debb7 100644 --- a/src/Microsoft.Azure.CosmosEventSourcing/Builders/DefaultCosmosEventSourcingBuilder.cs +++ b/src/Microsoft.Azure.CosmosEventSourcing/Builders/DefaultCosmosEventSourcingBuilder.cs @@ -34,7 +34,7 @@ public ICosmosEventSourcingBuilder AddEventItemProjectionBuilder( + public ICosmosEventSourcingBuilder AddDefaultDomainEventProjectionBuilder( Action>? optionsAction = null) where TEventItem : EventItem { diff --git a/src/Microsoft.Azure.CosmosEventSourcing/Builders/ICosmosEventSourcingBuilder.cs b/src/Microsoft.Azure.CosmosEventSourcing/Builders/ICosmosEventSourcingBuilder.cs index 89eda4aff..a43a79bb7 100644 --- a/src/Microsoft.Azure.CosmosEventSourcing/Builders/ICosmosEventSourcingBuilder.cs +++ b/src/Microsoft.Azure.CosmosEventSourcing/Builders/ICosmosEventSourcingBuilder.cs @@ -34,7 +34,7 @@ public ICosmosEventSourcingBuilder AddEventItemProjectionBuilderThe used to configure the processor. /// The /// - public ICosmosEventSourcingBuilder AddEventItemProjectionBuilder( + public ICosmosEventSourcingBuilder AddDefaultDomainEventProjectionBuilder( Action>? optionsAction = null) where TEventItem : EventItem; diff --git a/src/Microsoft.Azure.CosmosEventSourcing/Projections/IDomainEventProjectionBuilder.cs b/src/Microsoft.Azure.CosmosEventSourcing/Projections/IDomainEventProjectionBuilder.cs index 9e6559c6c..f187d2229 100644 --- a/src/Microsoft.Azure.CosmosEventSourcing/Projections/IDomainEventProjectionBuilder.cs +++ b/src/Microsoft.Azure.CosmosEventSourcing/Projections/IDomainEventProjectionBuilder.cs @@ -19,12 +19,12 @@ public interface IDomainEventProjectionBuilder /// A method to process a new event after it has been saved into Cosmos. /// /// This is invoked off the back fo the change feed processor library. - /// The event that was written. + /// The event that was written. /// The event source with all it's properties /// A token used to cancel the async operation. /// A that represents the async operation ValueTask HandleAsync( - TEvent persistedEvent, + TEvent domainEvent, TEventItem eventSource, CancellationToken cancellationToken = default); } \ No newline at end of file