From 100e9ed085b17bda649c344b2b499bbf1abc4db9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matija=20Ko=C5=BEelj?= Date: Wed, 7 Dec 2022 14:05:43 +0100 Subject: [PATCH] Initial commit --- Common.Model/Common - Backup.Model.csproj | 14 + Common.Model/Common.Model.csproj | 15 ++ Common.Model/CommonRoutes.cs | 8 + Common.Model/Documents/DocumentArgs.cs | 41 +++ Common.Model/Documents/IDocument.cs | 42 +++ Common.Model/Documents/IDocumentService.cs | 9 + Common.Model/Units.cs | 6 + Common.Notes.Model/Boot.cs | 3 + Common.Notes.Model/Common.Notes.Model.csproj | 15 ++ Common.Notes.Model/INote.cs | 9 + Common.Notes.Model/INoteSearch.cs | 9 + Common.Notes.Model/INoteService.cs | 24 ++ Common.Notes.Model/INoteText.cs | 10 + Common.Notes.Model/NoteArgs.cs | 44 ++++ Common.Notes.Model/NoteUrls.cs | 5 + Common.Notes/Bootstrap.cs | 3 + Common.Notes/Common.Notes.csproj | 16 ++ Common.Notes/Note.cs | 17 ++ Common.Notes/NoteOps.cs | 240 ++++++++++++++++++ Common.Notes/NoteSearch.cs | 17 ++ Common.Notes/NoteService.cs | 61 +++++ Common.Notes/NoteText.cs | 20 ++ .../Common.Numbering.Middleware.csproj | 15 ++ .../INumberingProvider.cs | 16 ++ .../Common.Numbering.Model.csproj | 14 + Common.Numbering.Model/INumbering.cs | 8 + Common.Numbering.Model/INumberingService.cs | 17 ++ Common.Numbering.Model/NumberingArgs.cs | 15 ++ Common.Numbering.Model/NumberingUrls.cs | 5 + Common.Numbering/Common.Numbering.csproj | 16 ++ Common.Numbering/DefaultProvider.cs | 23 ++ Common.Numbering/Numbering.cs | 15 ++ Common.Numbering/NumberingOps.cs | 137 ++++++++++ Common.Numbering/NumberingService.cs | 31 +++ Common.sln | 121 +++++++++ Common/Collections/MessageJob.cs | 25 ++ Common/Collections/QueueCache.cs | 19 ++ Common/Collections/QueueClientService.cs | 53 ++++ Common/Collections/QueueMessage.cs | 49 ++++ Common/Collections/QueueMessageDispatcher.cs | 10 + Common/Collections/QueueOps.cs | 98 +++++++ Common/Collections/QueueService.cs | 40 +++ Common/Common.csproj | 36 +++ Common/CommonSchemas.cs | 6 + Common/CommonStartup.cs | 39 +++ Common/Definitions.cs | 19 ++ Common/Distributed/DistributedLock.cs | 22 ++ Common/Distributed/DistributedLockArgs.cs | 19 ++ Common/Distributed/DistributedLockOps.cs | 126 +++++++++ .../Distributed/DistributedLockProtector.cs | 73 ++++++ Common/Distributed/DistributedLockService.cs | 32 +++ Common/Distributed/IDistributedLock.cs | 9 + Common/Distributed/IDistributedLockService.cs | 12 + Common/Documents/Document.cs | 32 +++ Common/Documents/DocumentListener.cs | 60 +++++ Common/Documents/DocumentLocker.cs | 96 +++++++ Common/Documents/DocumentService.cs | 15 ++ Common/Documents/IDocumentLocker.cs | 13 + Common/Documents/Validators.cs | 61 +++++ Common/Globalization/GlobalizationService.cs | 105 ++++++++ Common/Globalization/Language.cs | 31 +++ Common/Globalization/LanguageCache.cs | 77 ++++++ Common/Globalization/LanguageOps.cs | 234 +++++++++++++++++ Common/Globalization/LanguageService.cs | 66 +++++ Common/Net/Endpoint.cs | 29 +++ Common/Net/EndpointCache.cs | 11 + Common/Net/EndpointOps.cs | 43 ++++ Common/Net/EndpointService.cs | 27 ++ Common/SR.Designer.cs | 99 ++++++++ Common/SR.resx | 132 ++++++++++ Common/Security/CommonClaims.cs | 9 + Common/Security/Identity/IdentityService.cs | 17 ++ Common/Security/Identity/Role.cs | 16 ++ Common/Security/Identity/RoleCache.cs | 40 +++ Common/Security/Identity/RoleOps.cs | 183 +++++++++++++ Common/Security/Identity/RoleService.cs | 57 +++++ Common/Security/Identity/User.cs | 62 +++++ Common/Security/Identity/UserCache.cs | 11 + Common/Security/Identity/UserOps.cs | 237 +++++++++++++++++ Common/Security/Identity/UserService.cs | 63 +++++ Common/Security/Membership/Membership.cs | 20 ++ Common/Security/Membership/MembershipCache.cs | 11 + Common/Security/Membership/MembershipOps.cs | 116 +++++++++ .../Security/Membership/MembershipService.cs | 45 ++++ Common/Security/Permissions/Permission.cs | 36 +++ .../Security/Permissions/PermissionCache.cs | 11 + Common/Security/Permissions/PermissionOps.cs | 150 +++++++++++ .../Security/Permissions/PermissionService.cs | 51 ++++ Common/Settings/SettingsService.cs | 37 +++ 89 files changed, 4051 insertions(+) create mode 100644 Common.Model/Common - Backup.Model.csproj create mode 100644 Common.Model/Common.Model.csproj create mode 100644 Common.Model/CommonRoutes.cs create mode 100644 Common.Model/Documents/DocumentArgs.cs create mode 100644 Common.Model/Documents/IDocument.cs create mode 100644 Common.Model/Documents/IDocumentService.cs create mode 100644 Common.Model/Units.cs create mode 100644 Common.Notes.Model/Boot.cs create mode 100644 Common.Notes.Model/Common.Notes.Model.csproj create mode 100644 Common.Notes.Model/INote.cs create mode 100644 Common.Notes.Model/INoteSearch.cs create mode 100644 Common.Notes.Model/INoteService.cs create mode 100644 Common.Notes.Model/INoteText.cs create mode 100644 Common.Notes.Model/NoteArgs.cs create mode 100644 Common.Notes.Model/NoteUrls.cs create mode 100644 Common.Notes/Bootstrap.cs create mode 100644 Common.Notes/Common.Notes.csproj create mode 100644 Common.Notes/Note.cs create mode 100644 Common.Notes/NoteOps.cs create mode 100644 Common.Notes/NoteSearch.cs create mode 100644 Common.Notes/NoteService.cs create mode 100644 Common.Notes/NoteText.cs create mode 100644 Common.Numbering.Middleware/Common.Numbering.Middleware.csproj create mode 100644 Common.Numbering.Middleware/INumberingProvider.cs create mode 100644 Common.Numbering.Model/Common.Numbering.Model.csproj create mode 100644 Common.Numbering.Model/INumbering.cs create mode 100644 Common.Numbering.Model/INumberingService.cs create mode 100644 Common.Numbering.Model/NumberingArgs.cs create mode 100644 Common.Numbering.Model/NumberingUrls.cs create mode 100644 Common.Numbering/Common.Numbering.csproj create mode 100644 Common.Numbering/DefaultProvider.cs create mode 100644 Common.Numbering/Numbering.cs create mode 100644 Common.Numbering/NumberingOps.cs create mode 100644 Common.Numbering/NumberingService.cs create mode 100644 Common.sln create mode 100644 Common/Collections/MessageJob.cs create mode 100644 Common/Collections/QueueCache.cs create mode 100644 Common/Collections/QueueClientService.cs create mode 100644 Common/Collections/QueueMessage.cs create mode 100644 Common/Collections/QueueMessageDispatcher.cs create mode 100644 Common/Collections/QueueOps.cs create mode 100644 Common/Collections/QueueService.cs create mode 100644 Common/Common.csproj create mode 100644 Common/CommonSchemas.cs create mode 100644 Common/CommonStartup.cs create mode 100644 Common/Definitions.cs create mode 100644 Common/Distributed/DistributedLock.cs create mode 100644 Common/Distributed/DistributedLockArgs.cs create mode 100644 Common/Distributed/DistributedLockOps.cs create mode 100644 Common/Distributed/DistributedLockProtector.cs create mode 100644 Common/Distributed/DistributedLockService.cs create mode 100644 Common/Distributed/IDistributedLock.cs create mode 100644 Common/Distributed/IDistributedLockService.cs create mode 100644 Common/Documents/Document.cs create mode 100644 Common/Documents/DocumentListener.cs create mode 100644 Common/Documents/DocumentLocker.cs create mode 100644 Common/Documents/DocumentService.cs create mode 100644 Common/Documents/IDocumentLocker.cs create mode 100644 Common/Documents/Validators.cs create mode 100644 Common/Globalization/GlobalizationService.cs create mode 100644 Common/Globalization/Language.cs create mode 100644 Common/Globalization/LanguageCache.cs create mode 100644 Common/Globalization/LanguageOps.cs create mode 100644 Common/Globalization/LanguageService.cs create mode 100644 Common/Net/Endpoint.cs create mode 100644 Common/Net/EndpointCache.cs create mode 100644 Common/Net/EndpointOps.cs create mode 100644 Common/Net/EndpointService.cs create mode 100644 Common/SR.Designer.cs create mode 100644 Common/SR.resx create mode 100644 Common/Security/CommonClaims.cs create mode 100644 Common/Security/Identity/IdentityService.cs create mode 100644 Common/Security/Identity/Role.cs create mode 100644 Common/Security/Identity/RoleCache.cs create mode 100644 Common/Security/Identity/RoleOps.cs create mode 100644 Common/Security/Identity/RoleService.cs create mode 100644 Common/Security/Identity/User.cs create mode 100644 Common/Security/Identity/UserCache.cs create mode 100644 Common/Security/Identity/UserOps.cs create mode 100644 Common/Security/Identity/UserService.cs create mode 100644 Common/Security/Membership/Membership.cs create mode 100644 Common/Security/Membership/MembershipCache.cs create mode 100644 Common/Security/Membership/MembershipOps.cs create mode 100644 Common/Security/Membership/MembershipService.cs create mode 100644 Common/Security/Permissions/Permission.cs create mode 100644 Common/Security/Permissions/PermissionCache.cs create mode 100644 Common/Security/Permissions/PermissionOps.cs create mode 100644 Common/Security/Permissions/PermissionService.cs create mode 100644 Common/Settings/SettingsService.cs diff --git a/Common.Model/Common - Backup.Model.csproj b/Common.Model/Common - Backup.Model.csproj new file mode 100644 index 0000000..5d211e6 --- /dev/null +++ b/Common.Model/Common - Backup.Model.csproj @@ -0,0 +1,14 @@ + + + + net7.0 + enable + enable + $(MSBuildProjectName) + + + + + + + diff --git a/Common.Model/Common.Model.csproj b/Common.Model/Common.Model.csproj new file mode 100644 index 0000000..ec9d83a --- /dev/null +++ b/Common.Model/Common.Model.csproj @@ -0,0 +1,15 @@ + + + + net7.0 + enable + enable + $(MSBuildProjectName) + Common + + + + + + + diff --git a/Common.Model/CommonRoutes.cs b/Common.Model/CommonRoutes.cs new file mode 100644 index 0000000..e833d89 --- /dev/null +++ b/Common.Model/CommonRoutes.cs @@ -0,0 +1,8 @@ +namespace Common; + +public static class CommonRoutes +{ + public const string Common = "/common"; + public const string Management = "/management"; + public const string Documents = "/documents"; +} diff --git a/Common.Model/Documents/DocumentArgs.cs b/Common.Model/Documents/DocumentArgs.cs new file mode 100644 index 0000000..1e90daf --- /dev/null +++ b/Common.Model/Documents/DocumentArgs.cs @@ -0,0 +1,41 @@ +using Connected.Annotations; +using Connected.ServiceModel; +using System.ComponentModel.DataAnnotations; + +namespace Common.Documents; + +public class DocumentArgs : Dto +{ + [MaxLength(32)] + public string? Code { get; set; } = default!; +} + +public abstract class InsertDocumentArgs : DocumentArgs +{ + public int? Author { get; set; } = default!; + + public DateTimeOffset? Created { get; set; } +} + +public abstract class UpdateDocumentArgs : DocumentArgs + where TPrimaryKey : notnull +{ + [MinValue(1)] + public TPrimaryKey Id { get; set; } = default!; + + public DateTimeOffset? Modified { get; set; } + + public int? Owner { get; set; } +} + +public sealed class SelectDocumentArgs : Dto +{ + [Required, MaxLength(32)] + public string Domain { get; set; } = default!; + + [Required, MaxLength(32)] + public string Type { get; set; } = default!; + + [Required, MaxLength(32)] + public string Code { set; get; } = default!; +} \ No newline at end of file diff --git a/Common.Model/Documents/IDocument.cs b/Common.Model/Documents/IDocument.cs new file mode 100644 index 0000000..c057869 --- /dev/null +++ b/Common.Model/Documents/IDocument.cs @@ -0,0 +1,42 @@ +using Connected.Data; + +namespace Common.Documents +{ + /// + /// Represents the base entity for all documents. + /// + /// + /// Document is primary entity of the business processes. It provides + /// schema which is used in a business process lifecycle. Documents, apart from + /// some basic validation, do not provide any specifiec business logic. Business + /// processes are entirely responsible for the business logic. + /// + public interface IDocument : IPrimaryKey + where TPrimaryKey : notnull + { + /// + /// The date when document was created. + /// + DateTimeOffset Created { get; init; } + /// + /// The date when document was last updated. + /// + DateTimeOffset? Modified { get; init; } + /// + /// The unique identifier of the document. This is + /// usually set by a customer specific numbering system. + /// + string? Code { get; init; } + /// + /// The user which created the document. Can be null if document was created by the system. + /// + int? Author { get; init; } + /// + /// The user last modified the document. Once user modifies the document it becomes the Owner. + /// + /// + /// This behavior could be overriden in documents implementation. + /// + int? Owner { get; init; } + } +} diff --git a/Common.Model/Documents/IDocumentService.cs b/Common.Model/Documents/IDocumentService.cs new file mode 100644 index 0000000..1e54eaf --- /dev/null +++ b/Common.Model/Documents/IDocumentService.cs @@ -0,0 +1,9 @@ +using Connected.Notifications; + +namespace Common.Documents; +public interface IDocumentService : IServiceNotifications +{ + event ServiceEventHandler>? ItemInserted; + event ServiceEventHandler>? ItemUpdated; + event ServiceEventHandler>? ItemDeleted; +} diff --git a/Common.Model/Units.cs b/Common.Model/Units.cs new file mode 100644 index 0000000..cf97585 --- /dev/null +++ b/Common.Model/Units.cs @@ -0,0 +1,6 @@ +namespace Common; + +public static class Units +{ + public const int MB = 1024 * 1024; +} diff --git a/Common.Notes.Model/Boot.cs b/Common.Notes.Model/Boot.cs new file mode 100644 index 0000000..99159b5 --- /dev/null +++ b/Common.Notes.Model/Boot.cs @@ -0,0 +1,3 @@ +using Connected.Annotations; + +[assembly: MicroService(MicroServiceType.Contract)] \ No newline at end of file diff --git a/Common.Notes.Model/Common.Notes.Model.csproj b/Common.Notes.Model/Common.Notes.Model.csproj new file mode 100644 index 0000000..1379137 --- /dev/null +++ b/Common.Notes.Model/Common.Notes.Model.csproj @@ -0,0 +1,15 @@ + + + + net7.0 + enable + enable + Common.Notes + + + + + + + + diff --git a/Common.Notes.Model/INote.cs b/Common.Notes.Model/INote.cs new file mode 100644 index 0000000..606a783 --- /dev/null +++ b/Common.Notes.Model/INote.cs @@ -0,0 +1,9 @@ +using Connected.Data; + +namespace Common.Notes; + +public interface INote : IEntityContainer +{ + int Author { get; init; } + DateTimeOffset Created { get; init; } +} diff --git a/Common.Notes.Model/INoteSearch.cs b/Common.Notes.Model/INoteSearch.cs new file mode 100644 index 0000000..40efb8a --- /dev/null +++ b/Common.Notes.Model/INoteSearch.cs @@ -0,0 +1,9 @@ +using Connected.Data; + +namespace Common.Notes; +public interface INoteSearch : IEntityContainer +{ + int Author { get; init; } + string Text { get; init; } + DateTimeOffset Created { get; init; } +} diff --git a/Common.Notes.Model/INoteService.cs b/Common.Notes.Model/INoteService.cs new file mode 100644 index 0000000..3d63544 --- /dev/null +++ b/Common.Notes.Model/INoteService.cs @@ -0,0 +1,24 @@ +using System.Collections.Immutable; +using Connected.Annotations; +using Connected.ServiceModel; +using Connected.ServiceModel.Search; + +namespace Common.Notes; + +[Service] +[ServiceUrl(NoteUrls.Notes)] +public interface INoteService +{ + Task> Query(NoteArgs args); + Task Select(PrimaryKeyArgs args); + + Task Insert(InsertNoteArgs args); + Task Update(UpdateNoteArgs args); + Task Delete(PrimaryKeyArgs args); + + Task> QueryText(QueryNoteTextArgs args); + [ServiceMethod(ServiceMethodVerbs.Get)] + Task SelectText(SelectNoteTextArgs args); + + Task> Search(SearchArgs args); +} diff --git a/Common.Notes.Model/INoteText.cs b/Common.Notes.Model/INoteText.cs new file mode 100644 index 0000000..75e2df3 --- /dev/null +++ b/Common.Notes.Model/INoteText.cs @@ -0,0 +1,10 @@ +using Connected.Data; + +namespace Common.Notes; + +public interface INoteText : IPrimaryKey +{ + string Entity { get; init; } + string EntityId { get; init; } + string? Text { get; init; } +} diff --git a/Common.Notes.Model/NoteArgs.cs b/Common.Notes.Model/NoteArgs.cs new file mode 100644 index 0000000..c2a65fb --- /dev/null +++ b/Common.Notes.Model/NoteArgs.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; +using Connected.ServiceModel; + +namespace Common.Notes; +public class NoteArgs : Dto +{ + [Required, MaxLength(128)] + public string Entity { get; set; } = default!; + + [Required, MaxLength(128)] + public string PrimaryKey { get; set; } = default!; +} + +public class InsertNoteArgs : NoteArgs +{ + [Range(1, int.MaxValue)] + public int Author { get; set; } + + public DateTimeOffset? Created { get; set; } + + [Required, MaxLength(1024 * 1024)] + public string Text { get; set; } = default!; +} + +public sealed class UpdateNoteArgs : PrimaryKeyArgs +{ + [Required, MaxLength(Units.MB)] + public string Text { get; set; } = default!; +} + +public sealed class QueryNoteTextArgs : PrimaryKeyListArgs +{ + [Required, MaxLength(128)] + public string Entity { get; set; } = default!; +} + +public sealed class SelectNoteTextArgs : PrimaryKeyArgs +{ + [Required, MaxLength(128)] + public string Entity { get; set; } = default!; + + [Required, MaxLength(128)] + public string EntityId { get; set; } = default!; +} \ No newline at end of file diff --git a/Common.Notes.Model/NoteUrls.cs b/Common.Notes.Model/NoteUrls.cs new file mode 100644 index 0000000..92e218b --- /dev/null +++ b/Common.Notes.Model/NoteUrls.cs @@ -0,0 +1,5 @@ +namespace Common.Notes; +public static class NoteUrls +{ + public const string Notes = $"{CommonRoutes.Common}/notes"; +} diff --git a/Common.Notes/Bootstrap.cs b/Common.Notes/Bootstrap.cs new file mode 100644 index 0000000..0fe0937 --- /dev/null +++ b/Common.Notes/Bootstrap.cs @@ -0,0 +1,3 @@ +using Connected.Annotations; + +[assembly: MicroService(MicroServiceType.Sys)] \ No newline at end of file diff --git a/Common.Notes/Common.Notes.csproj b/Common.Notes/Common.Notes.csproj new file mode 100644 index 0000000..d5d620f --- /dev/null +++ b/Common.Notes/Common.Notes.csproj @@ -0,0 +1,16 @@ + + + + net7.0 + enable + enable + + + + + + + + + + diff --git a/Common.Notes/Note.cs b/Common.Notes/Note.cs new file mode 100644 index 0000000..5e8eb95 --- /dev/null +++ b/Common.Notes/Note.cs @@ -0,0 +1,17 @@ +using Connected.Annotations; +using Connected.Entities.Annotations; +using Connected.Entities.Containers; + +namespace Common.Notes; + +[Table(Schema = Constants.CommonSchema)] +public sealed record Note : ContainerEntity, INote +{ + public const string EntityKey = $"{Constants.CommonSchema}.{nameof(Note)}"; + + [Ordinal(0)] + public int Author { get; init; } + + [Ordinal(1)] + public DateTimeOffset Created { get; init; } +} diff --git a/Common.Notes/NoteOps.cs b/Common.Notes/NoteOps.cs new file mode 100644 index 0000000..71b8c8c --- /dev/null +++ b/Common.Notes/NoteOps.cs @@ -0,0 +1,240 @@ +using System.Collections.Immutable; +using Connected.Caching; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Notifications; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Connected.ServiceModel.Search; +using Connected.Services; + +namespace Common.Notes; +internal static class NoteOps +{ + /// + /// Deletes the note entity, its text and search entity from the storage. + /// + public class Delete : ServiceAction> + { + /// + /// Create a new instance. + /// + public Delete(IStorageProvider storage, IEventService events) + { + Storage = storage; + Events = events; + } + + private IStorageProvider Storage { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + /* + * First delete the entity. + */ + await Storage.Open().Update(Arguments.AsEntity(State.Deleted)); + /* + * Delete the note's text. + */ + await Storage.Open().Update(Arguments.AsEntity(State.Deleted)); + /* + * Delete search entry. + */ + await Storage.Open().Update(Arguments.AsEntity(State.Deleted)); + } + + protected override async Task OnCommitted() + { + /* + * We are triggering notify only for the entity, not for text and search transactions. + * Note that we don't cache text and search entries. They are read from the storage every time + * it is requested. + */ + await Events.Enqueue(this, typeof(NoteService), nameof(IServiceNotifications.Deleted), Arguments); + } + } + /// + /// Inserts a new note entity into storage. + /// + /// + /// This class inserts a new entity into storage, then inserts text + /// into table storage and the creates a search index entry. + /// + public sealed class Insert : ServiceFunction + { + public Insert(IStorageProvider storage, IEventService events) + { + Storage = storage; + Events = events; + } + + private IStorageProvider Storage { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + /* + * This call inserts a new record into storage (database) and returns its id. + */ + var result = await Storage.Open().Update(Arguments.AsEntity(State.New)); + /* + * We'll be using a newly inserted id to create a new record in the table + * storage for text. + */ + await Storage.Open().Update(Arguments.AsEntity(State.New, new { result.Id })); + /* + * And create entry in the search index. + */ + //await Storage.Open().Update(Arguments.AsEntity(State.New, new { result.Id })); + + return result.Id; + } + + protected override async Task OnCommitted() + { + await Events.Enqueue(this, typeof(NoteService), nameof(IServiceNotifications.Inserted), new PrimaryKeyArgs { Id = Result }); + } + } + + public sealed class Update : ServiceAction + { + public Update(IStorageProvider storage, ICacheContext cache, IEventService events, INoteService notes) + { + Storage = storage; + Cache = cache; + Events = events; + Notes = notes; + } + + private IStorageProvider Storage { get; } + private ICacheContext Cache { get; } + private IEventService Events { get; } + private INoteService Notes { get; } + + protected override async Task OnInvoke() + { + if (await Notes.Select(Arguments.Id) is not INote entity) + return; + + await Storage.Open().Update(Arguments.AsEntity(State.Default), Arguments, async () => + { + await Cache.Remove(Note.EntityKey, Arguments.Id); + + return (await Notes.Select(Arguments.Id)) as Note; + }); + + await Storage.Open().Update(Arguments.AsEntity(State.Default), Arguments, async () => + { + await Cache.Remove(NoteText.EntityKey, Arguments.Id); + + return (await Notes.SelectText(new SelectNoteTextArgs + { + Entity = entity.Entity, + EntityId = entity.EntityId, + Id = Arguments.Id + })) as NoteText; + }); + + await Storage.Open().Update(Arguments.AsEntity(State.Default)); + } + + protected override async Task OnCommitted() + { + await Cache.Remove(Note.EntityKey, Arguments.Id); + await Cache.Remove(NoteText.EntityKey, Arguments.Id); + + await Events.Enqueue(this, typeof(NoteService), nameof(IServiceNotifications.Updated), new PrimaryKeyArgs { Id = Arguments.Id }); + } + } + + public sealed class Query : ServiceFunction> + { + public Query(IStorageProvider provider) + { + Provider = provider; + } + + private IStorageProvider Provider { get; } + + protected override async Task> OnInvoke() + { + return await (from dc in Provider.Open() select dc).AsEntities(); + } + } + + public sealed class Select : ServiceFunction, INote> + { + public Select(IStorageProvider provider, ICacheContext cache) + { + Provider = provider; + Cache = cache; + } + + private IStorageProvider Provider { get; } + private ICacheContext Cache { get; } + + protected override Task OnInvoke() + { + return Cache.Get(Note.EntityKey, Arguments.Id, + async (f) => + { + return await (from dc in Provider.Open() where dc.Id == Arguments.Id select dc).AsEntity(); + }); + } + } + + public sealed class Search : ServiceFunction> + { + public Search(IStorageProvider storage) + { + Storage = storage; + } + + private IStorageProvider Storage { get; } + + protected override async Task?> OnInvoke() + { + return await (from dc in Storage.Open() + where dc.Text.Contains(Arguments.Text) + select dc).AsEntities(); + } + } + + public sealed class QueryText : ServiceFunction> + { + public QueryText(IStorageProvider storage) + { + Storage = storage; + } + + private IStorageProvider Storage { get; } + + protected override async Task?> OnInvoke() + { + return await (from dc in Storage.Open() + where string.Equals(dc.Entity, Arguments.Entity, StringComparison.Ordinal) + && Arguments.IdList.Any(f => f == dc.Id) + select dc).AsEntities(); + } + } + + public sealed class SelectText : ServiceFunction + { + public SelectText(IStorageProvider provider) + { + Provider = provider; + } + + private IStorageProvider Provider { get; } + + protected override async Task OnInvoke() + { + return await (from dc in Provider.Open() + where string.Equals(dc.Entity, Arguments.Entity) + && string.Equals(dc.EntityId, Arguments.EntityId) + && dc.Id == Arguments.Id + select dc).AsEntity(); + } + } +} diff --git a/Common.Notes/NoteSearch.cs b/Common.Notes/NoteSearch.cs new file mode 100644 index 0000000..8eac90c --- /dev/null +++ b/Common.Notes/NoteSearch.cs @@ -0,0 +1,17 @@ +using Connected.Annotations; +using Connected.Entities.Annotations; +using Connected.Entities.Containers; + +namespace Common.Notes; +[Persistence(Persistence = ColumnPersistence.InMemory)] +public record NoteSearch : ContainerEntity, INoteSearch +{ + [Ordinal(0)] + public int Author { get; init; } + + [Ordinal(1), Length(Units.MB)] + public string Text { get; init; } = default!; + + [Ordinal(2)] + public DateTimeOffset Created { get; init; } +} diff --git a/Common.Notes/NoteService.cs b/Common.Notes/NoteService.cs new file mode 100644 index 0000000..885bbc5 --- /dev/null +++ b/Common.Notes/NoteService.cs @@ -0,0 +1,61 @@ +using System.Collections.Immutable; +using Connected.ServiceModel; +using Connected.ServiceModel.Search; +using Connected.Services; + +namespace Common.Notes; +internal sealed class NoteService : Service, INoteService +{ + public NoteService(IContext context) : base(context) + { + } + + public async Task Delete(PrimaryKeyArgs args) + { + await Invoke(GetOperation(), args); + } + + public async Task Insert(InsertNoteArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task> Query(NoteArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task> QueryText(QueryNoteTextArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task> Search(SearchArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task Select(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task SelectText(SelectNoteTextArgs args) + { + await Invoke(GetOperation(), new InsertNoteArgs + { + Entity = "Entity", + PrimaryKey = "10", + Author = 10, + Created = DateTime.UtcNow, + Text = "Note text" + }, "Insert"); + + return await Invoke(GetOperation(), args); + } + + public async Task Update(UpdateNoteArgs args) + { + await Invoke(GetOperation(), args); + } +} \ No newline at end of file diff --git a/Common.Notes/NoteText.cs b/Common.Notes/NoteText.cs new file mode 100644 index 0000000..bb917c7 --- /dev/null +++ b/Common.Notes/NoteText.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using Connected.Annotations; +using Connected.Entities.Annotations; +using Connected.ServiceModel.Annotations; +using Connected.ServiceModel.Data; + +namespace Common.Notes; +public sealed record NoteText : TableEntity, INoteText +{ + public const string EntityKey = $"{Constants.CommonSchema}.{nameof(NoteText)}"; + + [Ordinal(-50000), PartitionKey, MaxLength(128)] + public string Entity { get; init; } = default!; + + [Ordinal(-49000), PrimaryKey, MaxLength(128)] + public string EntityId { get; init; } = default!; + + [Ordinal(0), Length(Units.MB), Nullable] + public string? Text { get; init; } +} diff --git a/Common.Numbering.Middleware/Common.Numbering.Middleware.csproj b/Common.Numbering.Middleware/Common.Numbering.Middleware.csproj new file mode 100644 index 0000000..2d0affe --- /dev/null +++ b/Common.Numbering.Middleware/Common.Numbering.Middleware.csproj @@ -0,0 +1,15 @@ + + + + net7.0 + enable + enable + Common.Numbering + + + + + + + + diff --git a/Common.Numbering.Middleware/INumberingProvider.cs b/Common.Numbering.Middleware/INumberingProvider.cs new file mode 100644 index 0000000..32a2969 --- /dev/null +++ b/Common.Numbering.Middleware/INumberingProvider.cs @@ -0,0 +1,16 @@ +using Connected; + +namespace Common.Numbering; +/// +/// Provides middleware for providing a numbering algorithm. +/// +public interface INumberingProvider : IMiddleware +{ + /// + /// Creates a new value based on the specified arguments. + /// + /// The arguments providing information about the entity for which + /// value need to be provided. + /// A new value if the numbering is supported by the middleware, null otherwise. + Task Invoke(NumberingCalculateArgs args); +} diff --git a/Common.Numbering.Model/Common.Numbering.Model.csproj b/Common.Numbering.Model/Common.Numbering.Model.csproj new file mode 100644 index 0000000..a7c8f5d --- /dev/null +++ b/Common.Numbering.Model/Common.Numbering.Model.csproj @@ -0,0 +1,14 @@ + + + + net7.0 + enable + enable + Common.Numbering + + + + + + + diff --git a/Common.Numbering.Model/INumbering.cs b/Common.Numbering.Model/INumbering.cs new file mode 100644 index 0000000..d27cde2 --- /dev/null +++ b/Common.Numbering.Model/INumbering.cs @@ -0,0 +1,8 @@ +using Connected.Data; + +namespace Common.Numbering; +public interface INumbering : IPrimaryKey +{ + string Entity { get; init; } + string Value { get; init; } +} diff --git a/Common.Numbering.Model/INumberingService.cs b/Common.Numbering.Model/INumberingService.cs new file mode 100644 index 0000000..c541e0a --- /dev/null +++ b/Common.Numbering.Model/INumberingService.cs @@ -0,0 +1,17 @@ +using Connected.Annotations; +using Connected.ServiceModel; + +namespace Common.Numbering; +[Service] +[ServiceUrl(NumberingUrls.Numbering)] +public interface INumberingService +{ + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Calculate(NumberingCalculateArgs args); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Select(NumberingSelectArgs args); + + [ServiceMethod(ServiceMethodVerbs.Get | ServiceMethodVerbs.Post)] + Task Select(PrimaryKeyArgs args); +} diff --git a/Common.Numbering.Model/NumberingArgs.cs b/Common.Numbering.Model/NumberingArgs.cs new file mode 100644 index 0000000..6a9160d --- /dev/null +++ b/Common.Numbering.Model/NumberingArgs.cs @@ -0,0 +1,15 @@ +using Connected.ServiceModel; +using System.ComponentModel.DataAnnotations; + +namespace Common.Numbering; +public sealed class NumberingCalculateArgs : Dto +{ + [Required, MaxLength(128)] + public string Entity { get; set; } = default!; +} + +public sealed class NumberingSelectArgs : Dto +{ + [Required, MaxLength(128)] + public string Entity { get; set; } = default!; +} diff --git a/Common.Numbering.Model/NumberingUrls.cs b/Common.Numbering.Model/NumberingUrls.cs new file mode 100644 index 0000000..a6c0de3 --- /dev/null +++ b/Common.Numbering.Model/NumberingUrls.cs @@ -0,0 +1,5 @@ +namespace Common.Numbering; +public static class NumberingUrls +{ + public const string Numbering = "common/numbering"; +} diff --git a/Common.Numbering/Common.Numbering.csproj b/Common.Numbering/Common.Numbering.csproj new file mode 100644 index 0000000..a128285 --- /dev/null +++ b/Common.Numbering/Common.Numbering.csproj @@ -0,0 +1,16 @@ + + + + net7.0 + enable + enable + + + + + + + + + + diff --git a/Common.Numbering/DefaultProvider.cs b/Common.Numbering/DefaultProvider.cs new file mode 100644 index 0000000..af2fa24 --- /dev/null +++ b/Common.Numbering/DefaultProvider.cs @@ -0,0 +1,23 @@ +using Connected.Annotations; +using Connected.Middleware; + +namespace Common.Numbering; + +[Priority(0)] +internal sealed class DefaultProvider : MiddlewareComponent, INumberingProvider +{ + public DefaultProvider(INumberingService numbering) + { + Numbering = numbering; + } + + private INumberingService Numbering { get; } + + public async Task Invoke(NumberingCalculateArgs args) + { + if (Numbering is not NumberingService service) + throw new InvalidCastException(nameof(NumberingService)); + + return await service.NextValue(args); + } +} diff --git a/Common.Numbering/Numbering.cs b/Common.Numbering/Numbering.cs new file mode 100644 index 0000000..c27df6a --- /dev/null +++ b/Common.Numbering/Numbering.cs @@ -0,0 +1,15 @@ +using Connected.Annotations; +using Connected.Entities.Annotations; +using Connected.Entities.Consistency; +using System.ComponentModel.DataAnnotations; + +namespace Common.Numbering; +[Table(Schema = CommonSchemas.CommonSchema)] +internal sealed record Numbering : ConsistentEntity, INumbering +{ + [Ordinal(0), MaxLength(128), Index(Name = $"ui_{CommonSchemas.CommonSchema}_numbering_entity")] + public string Entity { get; init; } = default!; + + [Ordinal(1), MaxLength(128)] + public string Value { get; init; } = default!; +} diff --git a/Common.Numbering/NumberingOps.cs b/Common.Numbering/NumberingOps.cs new file mode 100644 index 0000000..78fc6db --- /dev/null +++ b/Common.Numbering/NumberingOps.cs @@ -0,0 +1,137 @@ +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Interop; +using Connected.Middleware; +using Connected.ServiceModel; +using Connected.Services; + +namespace Common.Numbering; +internal sealed class NumberingOps +{ + public sealed class Calculate : ServiceFunction + { + public Calculate(IMiddlewareService middleware) + { + Middleware = middleware; + } + + private IMiddlewareService Middleware { get; } + + protected override async Task OnInvoke() + { + foreach (var middleware in await Middleware.Query()) + { + if (await middleware.Invoke(Arguments) is string result && !string.IsNullOrEmpty(result)) + return result; + } + + return null; + } + } + + public sealed class NextValue : ServiceFunction + { + public NextValue(IStorageProvider storage, INumberingService numbering) + { + Storage = storage; + Numbering = numbering; + } + + private IStorageProvider Storage { get; } + private INumberingService Numbering { get; } + + protected override async Task OnInvoke() + { + var result = await Prepare(); + + await Storage.Open().Update(result, Arguments, + async () => + { + result = await Prepare(); + + return result; + }, + async (f) => + { + await Task.CompletedTask; + + return f; + }); + + return result.Value; + } + + private async Task Prepare() + { + var current = await Numbering.Select(Arguments.AsArguments()); + + if (current is null) + { + var id = await TryInsert(); + + if (id == 0) + current = await Numbering.Select(Arguments.AsArguments()); + else + current = await Numbering.Select(id); + } + + if (current is null) + throw new NullReferenceException(nameof(INumbering)); + + var newValue = string.Empty; + + if (TypeConversion.TryConvert(current.Value, out long existingValue)) + newValue = existingValue++.ToString(); + else + newValue = current.Value; + + return (Numbering)current with { Value = newValue }; + } + + private async Task TryInsert() + { + try + { + return (await Storage.Open().Update(Arguments.AsEntity(State.New))).Id; + } + catch + { + return 0; + } + } + } + + public sealed class SelectByEntity : ServiceFunction + { + public SelectByEntity(IStorageProvider storage) + { + Storage = storage; + } + + private IStorageProvider Storage { get; } + + protected override async Task OnInvoke() + { + return await (from e in Storage.Open() + where string.Equals(e.Entity, Arguments.Entity, StringComparison.OrdinalIgnoreCase) + select e).AsEntity(); + } + } + + public sealed class Select : ServiceFunction, INumbering> + { + public Select(IStorageProvider storage) + { + Storage = storage; + } + + private IStorageProvider Storage { get; } + + protected override async Task OnInvoke() + { + return await (from e in Storage.Open() + where e.Id == Arguments.Id + select e).AsEntity(); + } + } +} diff --git a/Common.Numbering/NumberingService.cs b/Common.Numbering/NumberingService.cs new file mode 100644 index 0000000..aa392dd --- /dev/null +++ b/Common.Numbering/NumberingService.cs @@ -0,0 +1,31 @@ +using Connected.ServiceModel; +using Connected.Services; +using Ops = Common.Numbering.NumberingOps; + +namespace Common.Numbering; +internal sealed class NumberingService : Service, INumberingService +{ + public NumberingService(IContext context) : base(context) + { + } + + public async Task Calculate(NumberingCalculateArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task Select(NumberingSelectArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task Select(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + + internal async Task NextValue(NumberingCalculateArgs args) + { + return await Invoke(GetOperation(), args); + } +} diff --git a/Common.sln b/Common.sln new file mode 100644 index 0000000..cbe9e29 --- /dev/null +++ b/Common.sln @@ -0,0 +1,121 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.32916.344 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Dependencies", "Dependencies", "{75ED46E6-38CD-4948-9F3C-5167A9FFD7FA}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common", "Common\Common.csproj", "{CB582FC6-7A9A-46D1-BA75-4A103E096674}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Globalization", "..\Framework\Connected.Globalization\Connected.Globalization.csproj", "{B9438432-CD2B-4570-B96C-C1B47A1CF5BB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Net", "..\Framework\Connected.Net\Connected.Net.csproj", "{664BD509-4D4A-45D4-8B82-5E54094A95E6}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Services", "..\Framework\Connected.Services\Connected.Services.csproj", "{A4616625-88A0-434B-8433-EC8693E1E53B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Validation", "..\Framework\Connected.Validation\Connected.Validation.csproj", "{71403C62-2D04-4E0B-9FAB-1ED23B0FB6B7}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected", "..\Connected\Connected\Connected.csproj", "{B2AE8588-0786-4556-9563-0D941A145C7C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.Model", "Common.Model\Common.Model.csproj", "{ABF6BF35-ED9F-43A9-8581-23CBD9701E94}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.Notes", "Common.Notes\Common.Notes.csproj", "{05B6148F-467C-4090-8FEA-8EA16A4D9956}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.Notes.Model", "Common.Notes.Model\Common.Notes.Model.csproj", "{DF77BAAB-8223-4BEA-B7BA-0C087D9C6750}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Entities", "..\Framework\Connected.Entities\Connected.Entities.csproj", "{0D2BE8CB-1C1B-4C74-9940-8F1BA5B1ED42}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.ServiceModel", "..\Framework.ServiceModel\Connected.ServiceModel\Connected.ServiceModel.csproj", "{3F224810-2034-45BB-BB53-DE5F6E83A07B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Numbering.Model", "Common.Numbering.Model\Common.Numbering.Model.csproj", "{83F83DE6-28BF-4AB7-902E-42D38A54A578}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Numbering", "Common.Numbering\Common.Numbering.csproj", "{4E810676-E37E-4C25-95FC-747BB32E5A69}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Numbering.Middleware", "Common.Numbering.Middleware\Common.Numbering.Middleware.csproj", "{424C1CBE-8490-431C-A0A8-1ACB22A65864}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Connected.Middleware", "..\Framework\Connected.Middleware\Connected.Middleware.csproj", "{085068A9-6739-423D-957F-DFA22564B574}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CB582FC6-7A9A-46D1-BA75-4A103E096674}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CB582FC6-7A9A-46D1-BA75-4A103E096674}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CB582FC6-7A9A-46D1-BA75-4A103E096674}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CB582FC6-7A9A-46D1-BA75-4A103E096674}.Release|Any CPU.Build.0 = Release|Any CPU + {B9438432-CD2B-4570-B96C-C1B47A1CF5BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9438432-CD2B-4570-B96C-C1B47A1CF5BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9438432-CD2B-4570-B96C-C1B47A1CF5BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9438432-CD2B-4570-B96C-C1B47A1CF5BB}.Release|Any CPU.Build.0 = Release|Any CPU + {664BD509-4D4A-45D4-8B82-5E54094A95E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {664BD509-4D4A-45D4-8B82-5E54094A95E6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {664BD509-4D4A-45D4-8B82-5E54094A95E6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {664BD509-4D4A-45D4-8B82-5E54094A95E6}.Release|Any CPU.Build.0 = Release|Any CPU + {A4616625-88A0-434B-8433-EC8693E1E53B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4616625-88A0-434B-8433-EC8693E1E53B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4616625-88A0-434B-8433-EC8693E1E53B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4616625-88A0-434B-8433-EC8693E1E53B}.Release|Any CPU.Build.0 = Release|Any CPU + {71403C62-2D04-4E0B-9FAB-1ED23B0FB6B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {71403C62-2D04-4E0B-9FAB-1ED23B0FB6B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {71403C62-2D04-4E0B-9FAB-1ED23B0FB6B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {71403C62-2D04-4E0B-9FAB-1ED23B0FB6B7}.Release|Any CPU.Build.0 = Release|Any CPU + {B2AE8588-0786-4556-9563-0D941A145C7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2AE8588-0786-4556-9563-0D941A145C7C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2AE8588-0786-4556-9563-0D941A145C7C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2AE8588-0786-4556-9563-0D941A145C7C}.Release|Any CPU.Build.0 = Release|Any CPU + {ABF6BF35-ED9F-43A9-8581-23CBD9701E94}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ABF6BF35-ED9F-43A9-8581-23CBD9701E94}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ABF6BF35-ED9F-43A9-8581-23CBD9701E94}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ABF6BF35-ED9F-43A9-8581-23CBD9701E94}.Release|Any CPU.Build.0 = Release|Any CPU + {05B6148F-467C-4090-8FEA-8EA16A4D9956}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {05B6148F-467C-4090-8FEA-8EA16A4D9956}.Debug|Any CPU.Build.0 = Debug|Any CPU + {05B6148F-467C-4090-8FEA-8EA16A4D9956}.Release|Any CPU.ActiveCfg = Release|Any CPU + {05B6148F-467C-4090-8FEA-8EA16A4D9956}.Release|Any CPU.Build.0 = Release|Any CPU + {DF77BAAB-8223-4BEA-B7BA-0C087D9C6750}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DF77BAAB-8223-4BEA-B7BA-0C087D9C6750}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DF77BAAB-8223-4BEA-B7BA-0C087D9C6750}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DF77BAAB-8223-4BEA-B7BA-0C087D9C6750}.Release|Any CPU.Build.0 = Release|Any CPU + {0D2BE8CB-1C1B-4C74-9940-8F1BA5B1ED42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0D2BE8CB-1C1B-4C74-9940-8F1BA5B1ED42}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0D2BE8CB-1C1B-4C74-9940-8F1BA5B1ED42}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0D2BE8CB-1C1B-4C74-9940-8F1BA5B1ED42}.Release|Any CPU.Build.0 = Release|Any CPU + {3F224810-2034-45BB-BB53-DE5F6E83A07B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3F224810-2034-45BB-BB53-DE5F6E83A07B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3F224810-2034-45BB-BB53-DE5F6E83A07B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3F224810-2034-45BB-BB53-DE5F6E83A07B}.Release|Any CPU.Build.0 = Release|Any CPU + {83F83DE6-28BF-4AB7-902E-42D38A54A578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {83F83DE6-28BF-4AB7-902E-42D38A54A578}.Debug|Any CPU.Build.0 = Debug|Any CPU + {83F83DE6-28BF-4AB7-902E-42D38A54A578}.Release|Any CPU.ActiveCfg = Release|Any CPU + {83F83DE6-28BF-4AB7-902E-42D38A54A578}.Release|Any CPU.Build.0 = Release|Any CPU + {4E810676-E37E-4C25-95FC-747BB32E5A69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4E810676-E37E-4C25-95FC-747BB32E5A69}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4E810676-E37E-4C25-95FC-747BB32E5A69}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4E810676-E37E-4C25-95FC-747BB32E5A69}.Release|Any CPU.Build.0 = Release|Any CPU + {424C1CBE-8490-431C-A0A8-1ACB22A65864}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {424C1CBE-8490-431C-A0A8-1ACB22A65864}.Debug|Any CPU.Build.0 = Debug|Any CPU + {424C1CBE-8490-431C-A0A8-1ACB22A65864}.Release|Any CPU.ActiveCfg = Release|Any CPU + {424C1CBE-8490-431C-A0A8-1ACB22A65864}.Release|Any CPU.Build.0 = Release|Any CPU + {085068A9-6739-423D-957F-DFA22564B574}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {085068A9-6739-423D-957F-DFA22564B574}.Debug|Any CPU.Build.0 = Debug|Any CPU + {085068A9-6739-423D-957F-DFA22564B574}.Release|Any CPU.ActiveCfg = Release|Any CPU + {085068A9-6739-423D-957F-DFA22564B574}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {B9438432-CD2B-4570-B96C-C1B47A1CF5BB} = {75ED46E6-38CD-4948-9F3C-5167A9FFD7FA} + {664BD509-4D4A-45D4-8B82-5E54094A95E6} = {75ED46E6-38CD-4948-9F3C-5167A9FFD7FA} + {A4616625-88A0-434B-8433-EC8693E1E53B} = {75ED46E6-38CD-4948-9F3C-5167A9FFD7FA} + {71403C62-2D04-4E0B-9FAB-1ED23B0FB6B7} = {75ED46E6-38CD-4948-9F3C-5167A9FFD7FA} + {B2AE8588-0786-4556-9563-0D941A145C7C} = {75ED46E6-38CD-4948-9F3C-5167A9FFD7FA} + {0D2BE8CB-1C1B-4C74-9940-8F1BA5B1ED42} = {75ED46E6-38CD-4948-9F3C-5167A9FFD7FA} + {3F224810-2034-45BB-BB53-DE5F6E83A07B} = {75ED46E6-38CD-4948-9F3C-5167A9FFD7FA} + {085068A9-6739-423D-957F-DFA22564B574} = {75ED46E6-38CD-4948-9F3C-5167A9FFD7FA} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {23EF0531-5260-4441-876C-7569138BD7FA} + EndGlobalSection +EndGlobal diff --git a/Common/Collections/MessageJob.cs b/Common/Collections/MessageJob.cs new file mode 100644 index 0000000..6f1f0e5 --- /dev/null +++ b/Common/Collections/MessageJob.cs @@ -0,0 +1,25 @@ +using Connected.Collections.Concurrent; +using Connected.Collections.Queues; +using Connected.ServiceModel; + +namespace Common.Collections; +internal sealed class MessageJob : DispatcherJob +{ + public MessageJob(IContextProvider provider) + { + Provider = provider; + } + + public IContextProvider Provider { get; } + + protected override async Task OnInvoke(IQueueMessage args, CancellationToken cancellationToken) + { + //TODO: need to do proper conversions + using var ctx = Provider.Create(); + var type = Type.GetType(args.Queue); + var client = ctx.GetService(type) as IQueueClient; + + await client.Initialize(); + await client.Invoke(args, args.Arguments); + } +} diff --git a/Common/Collections/QueueCache.cs b/Common/Collections/QueueCache.cs new file mode 100644 index 0000000..75717ad --- /dev/null +++ b/Common/Collections/QueueCache.cs @@ -0,0 +1,19 @@ +using Connected.Entities.Caching; + +namespace Common.Collections; + +internal interface IQueueCache : IEntityCacheClient +{ + void Update(QueueMessage message); +} +internal class QueueCache : EntityCacheClient, IQueueCache +{ + public QueueCache(IEntityCacheContext context) : base(context, QueueMessage.CacheKey) + { + } + + public void Update(QueueMessage message) + { + Set(message.Id, message, TimeSpan.Zero); + } +} diff --git a/Common/Collections/QueueClientService.cs b/Common/Collections/QueueClientService.cs new file mode 100644 index 0000000..19a9266 --- /dev/null +++ b/Common/Collections/QueueClientService.cs @@ -0,0 +1,53 @@ +using Connected.Collections.Queues; +using Connected.Hosting.Workers; +using Connected.Middleware; +using Connected.ServiceModel; + +namespace Common.Collections; +internal sealed class QueueClientService : ScheduledWorker +{ + public QueueClientService(IContextProvider provider) + { + Dispatcher = new(); + Timer = TimeSpan.FromMilliseconds(500); + Queues = new(); + Provider = provider; + } + + private IContextProvider Provider { get; } + private QueueMessageDispatcher Dispatcher { get; } + + private List Queues { get; } + + public override async Task StartAsync(CancellationToken cancellationToken) + { + using var ctx = Provider.Create(); + + if (ctx.GetService() is not IMiddlewareService middleware) + return; + + foreach (var m in await middleware.Query>()) + { + if (m.GetType().FullName is string fullName) + Queues.Add(fullName); + } + } + + protected override async Task OnInvoke(CancellationToken cancellationToken) + { + using var ctx = Provider.Create(); + + if (ctx.GetService() is not IQueueService queue) + return; + + var messages = await queue.Dequeue(new DequeueArgs + { + MaxCount = Dispatcher.Available, + NextVisible = TimeSpan.FromSeconds(30), + Queues = Queues + }); + + foreach (var message in messages) + Dispatcher.Enqueue(message); + } +} diff --git a/Common/Collections/QueueMessage.cs b/Common/Collections/QueueMessage.cs new file mode 100644 index 0000000..8f21b5e --- /dev/null +++ b/Common/Collections/QueueMessage.cs @@ -0,0 +1,49 @@ +using Connected.Annotations; +using Connected.Collections.Queues; +using Connected.Data; +using Connected.Entities.Annotations; +using Connected.Entities.Concurrency; + +namespace Common.Collections; +/// +[Table(Schema = SchemaAttribute.SysSchema)] +internal sealed record QueueMessage : ConcurrentEntity, IQueueMessage +{ + public const string CacheKey = $"{SchemaAttribute.SysSchema}.{nameof(QueueMessage)}"; + /// + [Ordinal(0), Date(Kind = DateKind.DateTime)] + public DateTime Created { get; init; } + /// + [Ordinal(2)] + public int DequeueCount { get; init; } + /// + [Ordinal(3), Date(Kind = DateKind.DateTime)] + public DateTime? DequeueTimestamp { get; init; } + /// + [Ordinal(4), Length(32)] + public string Queue { get; init; } = default!; + /// + /// The serialized value of the arguments if specified. + /// + /// + /// If the queue message must be persisted the arguments get serialized + /// and stored in this property. When selected from the storage, the + /// Arguments object is recreated from this property. + /// Note that queue serialized Arguments must be less that 1024 characters long. + /// + [Ordinal(8), Date(Kind = DateKind.DateTime)] + public DateTime NextVisible { get; init; } + /// + [Ordinal(9)] + public Guid? PopReceipt { get; init; } + /// + /// + /// This property is persisted through the + /// property. + /// + [Persistence(Persistence = ColumnPersistence.InMemory)] + public QueueArgs Arguments { get; init; } +} diff --git a/Common/Collections/QueueMessageDispatcher.cs b/Common/Collections/QueueMessageDispatcher.cs new file mode 100644 index 0000000..2db7ed4 --- /dev/null +++ b/Common/Collections/QueueMessageDispatcher.cs @@ -0,0 +1,10 @@ +using Connected.Collections.Concurrent; +using Connected.Collections.Queues; + +namespace Common.Collections; +internal sealed class QueueMessageDispatcher : Dispatcher +{ + public QueueMessageDispatcher() : base(128) + { + } +} diff --git a/Common/Collections/QueueOps.cs b/Common/Collections/QueueOps.cs new file mode 100644 index 0000000..3a4a9f4 --- /dev/null +++ b/Common/Collections/QueueOps.cs @@ -0,0 +1,98 @@ +using System.Collections.Immutable; +using Connected.Collections.Queues; +using Connected.Entities; +using Connected.Interop; +using Connected.Services; + +namespace Common.Collections; + +internal sealed class QueueOps +{ + public sealed class Dequeue : ServiceFunction> + { + public Dequeue(IQueueCache cache) + { + Cache = cache; + } + + private IQueueCache Cache { get; } + + protected override async Task?> OnInvoke() + { + var targets = await SelectTargets(); + var result = new List(); + + if (!targets.Any()) + return ImmutableList.Empty; + + foreach (var message in targets) + { + var modified = new QueueMessage + { + DequeueTimestamp = DateTime.UtcNow, + Arguments = message.Arguments, + Created = message.Created, + DequeueCount = message.DequeueCount + 1, + Id = message.Id, + NextVisible = DateTime.UtcNow.Add(Arguments.NextVisible), + PopReceipt = Guid.NewGuid(), + Queue = message.Queue, + State = State.Default, + Sync = message.Sync, + ETag = message.ETag + }; + + try + { + Cache.Update(modified); + result.Add(modified); + } + catch + { + //concurrent exception, someone was faster + } + } + + return result.ToImmutableList(); + } + + private async Task> SelectTargets() + { + var targets = new List(); + + var items = await (from dc in Cache + where dc.NextVisible <= DateTime.UtcNow + && dc.Arguments.Options.Expire > DateTime.UtcNow + && Arguments.Queues.Any(f => string.Equals(f, dc.Queue, StringComparison.OrdinalIgnoreCase)) + select dc).AsEntities(); + + if (!items.Any()) + return ImmutableList.Empty; + + var ordered = targets.OrderBy(f => f.NextVisible).ThenBy(f => f.Id); + + if (ordered.Count() <= Arguments.MaxCount) + return ordered.ToImmutableList(); + + return ordered.Take(Arguments.MaxCount).ToImmutableList(); + } + } + public sealed class Enqueue : ServiceAction + { + public Enqueue(IQueueCache cache) + { + Cache = cache; + } + + private IQueueCache Cache { get; } + + protected override Task OnInvoke() + { + var message = Serializer.Serialize(Arguments); + + Cache.Update(Arguments.AsEntity(State.New, new { Arguments, SerializedMessage = message, Queue = typeof(TClient).FullName })); + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/Common/Collections/QueueService.cs b/Common/Collections/QueueService.cs new file mode 100644 index 0000000..3e27f37 --- /dev/null +++ b/Common/Collections/QueueService.cs @@ -0,0 +1,40 @@ +using System.Collections.Immutable; +using Connected.Collections; +using Connected.Collections.Queues; +using Connected.Net; +using Connected.ServiceModel; +using Connected.Services; +using Ops = Common.Collections.QueueOps; + +namespace Common.Collections; + +internal sealed class QueueService : DistributedService, IQueueService +{ + public QueueService(IContext context) : base(context) + { + } + + public async Task> Dequeue(DequeueArgs args) + { + if (await IsServer()) + return await Invoke(GetOperation(), args) ?? ImmutableList.Empty; + + if (await Http.Get>(await ParseUrl(CollectionRoutes.Queue), args) is List result) + return result.ToImmutableList(); + + return ImmutableList.Empty; + } + + public async Task Enqueue(TArgs args) + where TClient : IQueueClient + where TArgs : QueueArgs + { + if (await IsServer()) + { + await Invoke(GetOperation>(), args); + return; + } + + await Http.Post>(await ParseUrl(CollectionRoutes.Queue), new object[] { typeof(TClient), args }); + } +} diff --git a/Common/Common.csproj b/Common/Common.csproj new file mode 100644 index 0000000..4f9d9e2 --- /dev/null +++ b/Common/Common.csproj @@ -0,0 +1,36 @@ + + + + net7.0 + enable + enable + $(MSBuildProjectName) + + + + + + + + + + + + + + + True + True + SR.resx + + + + + + ResXFileCodeGenerator + SR.Designer.cs + + + + + diff --git a/Common/CommonSchemas.cs b/Common/CommonSchemas.cs new file mode 100644 index 0000000..ea061b8 --- /dev/null +++ b/Common/CommonSchemas.cs @@ -0,0 +1,6 @@ +namespace Common; +public static class CommonSchemas +{ + public const string DocumentSchema = "dcm"; + public const string CommonSchema = "cmn"; +} diff --git a/Common/CommonStartup.cs b/Common/CommonStartup.cs new file mode 100644 index 0000000..a8617a6 --- /dev/null +++ b/Common/CommonStartup.cs @@ -0,0 +1,39 @@ +using Common.Documents; +using Common.Globalization; +using Common.Security.Identity; +using Connected; +using Connected.Annotations; +using Connected.Globalization; +using Connected.Net.Endpoints; +using Connected.Net.Server; +using Connected.Security.Identity; +using Connected.ServiceModel; +using Microsoft.Extensions.DependencyInjection; + +[assembly: MicroService(MicroServiceType.Service)] + +namespace Common +{ + internal class CommonStartup : Startup + { + protected override void OnConfigureServices(IServiceCollection services) + { + services.AddScoped(typeof(IGlobalizationService), typeof(GlobalizationService)); + services.AddScoped(typeof(IIdentityService), typeof(IdentityService)); + services.AddTransient(typeof(IDocumentLocker<,>), typeof(DocumentLocker<,>)); + } + + protected override async Task OnInitialize(Dictionary args) + { + if (Services is null || Services.GetService() is not IContextProvider provider) + return; + + using var ctx = provider.Create(); + + if (ctx.GetService() is not IEndpointService endpoints || ctx.GetService() is not IEndpointServer server) + return; + + await server.Initialize(await endpoints.Query(), ctx.CancellationToken); + } + } +} diff --git a/Common/Definitions.cs b/Common/Definitions.cs new file mode 100644 index 0000000..a754b20 --- /dev/null +++ b/Common/Definitions.cs @@ -0,0 +1,19 @@ +using Connected.Data; + +namespace Common; + +public static class RecordStatusLocalizer +{ + public static string Localize(Status status) => + status switch + { + Status.Enabled => SR.RecordStatusEnabled, + Status.Disabled => SR.RecordStatusDisabled, + _ => status.ToString() + }; +} + +public static class Constants +{ + public const string CommonSchema = "cmn"; +} diff --git a/Common/Distributed/DistributedLock.cs b/Common/Distributed/DistributedLock.cs new file mode 100644 index 0000000..6e8fae0 --- /dev/null +++ b/Common/Distributed/DistributedLock.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using Connected.Annotations; +using Connected.Entities.Annotations; +using Connected.ServiceModel.Annotations; +using Connected.ServiceModel.Data; + +namespace Common.Distributed; + +[Table(Schema = CommonSchemas.CommonSchema)] +internal sealed record DistributedLock : TableEntity, IDistributedLock +{ + public const string EntityKey = $"{SchemaAttribute.SysSchema}.{nameof(DistributedLock)}"; + + [Ordinal(-50000), PartitionKey, MaxLength(128)] + public string Entity { get; init; } = default!; + + [Ordinal(-49000), PrimaryKey, MaxLength(128)] + public string EntityId { get; init; } = default!; + + [Ordinal(1)] + public DateTimeOffset Expiration { get; init; } +} diff --git a/Common/Distributed/DistributedLockArgs.cs b/Common/Distributed/DistributedLockArgs.cs new file mode 100644 index 0000000..4e719cd --- /dev/null +++ b/Common/Distributed/DistributedLockArgs.cs @@ -0,0 +1,19 @@ +using Connected.ServiceModel; +using System.ComponentModel.DataAnnotations; + +namespace Common.Distributed; +public sealed class DistributedLockArgs : Dto +{ + [Required, MaxLength(128)] + public string Entity { get; set; } = default!; + + [Required, MaxLength(128)] + public string EntityId { get; set; } = default!; + + public TimeSpan? Duration { get; set; } +} + +public sealed class DistributedLockPingArgs : PrimaryKeyArgs +{ + public TimeSpan? Duration { get; set; } +} \ No newline at end of file diff --git a/Common/Distributed/DistributedLockOps.cs b/Common/Distributed/DistributedLockOps.cs new file mode 100644 index 0000000..cfb5317 --- /dev/null +++ b/Common/Distributed/DistributedLockOps.cs @@ -0,0 +1,126 @@ +using Connected.Caching; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.ServiceModel; +using Connected.Services; + +namespace Common.Distributed; +internal sealed class DistributedLockOps +{ + public class Lock : ServiceFunction + { + static Lock() + { + SynchronizationState = new(); + } + private static HashSet SynchronizationState { get; } + + public Lock(IStorageProvider storage, ICacheContext cache) + { + Cache = cache; + Storage = storage; + } + + private ICacheContext Cache { get; } + private IStorageProvider Storage { get; } + private IDistributedLock Entity { get; set; } + protected override async Task OnInvoke() + { + /* + * We must ensure that only one lock request is performed at a time for each entity. This is + * because we must guarantee that one and only one entry exist for each entity and its primary key. + */ + var key = $"{Arguments.Entity}.{Arguments.EntityId}".ToLowerInvariant(); + /* + * If the hashset already holds the key it means someone else was faster than we and we are not able to + * perform a lock. + */ + if (!SynchronizationState.Add(key)) + throw new SynchronizationLockException($"{SR.ValLock} ({key})"); + + try + { + Entity = await Storage.Open().Update(Arguments.AsEntity(State.New, new + { + Id = Guid.NewGuid(), + Expiration = DateTime.UtcNow.Add(Arguments.Duration ?? TimeSpan.FromSeconds(5)) + })); + + return Entity.Id; + } + finally + { + /* + * Free to remove the hash lock. Any subsequent lock requests will fail at data protection level. + */ + SynchronizationState.Remove(key); + } + } + + protected override async Task OnCommitted() + { + /* + * Put the lock it the local cache. + */ + Cache.Set(DistributedLock.EntityKey, Entity.Id, Entity, TimeSpan.Zero); + + await Task.CompletedTask; + } + } + + public sealed class Unlock : ServiceAction> + { + public Unlock(IStorageProvider storage, ICacheContext cache) + { + Storage = storage; + Cache = cache; + } + + private IStorageProvider Storage { get; } + private ICacheContext Cache { get; } + + protected override async Task OnInvoke() + { + await Storage.Open().Update(Arguments.AsEntity(State.Deleted)); + } + + protected override async Task OnCommitted() + { + await Cache.Remove(DistributedLock.EntityKey, Arguments.Id); + } + } + + public sealed class Ping : ServiceAction + { + public Ping(IStorageProvider storage, ICacheContext cache) + { + Storage = storage; + Cache = cache; + } + + private IStorageProvider Storage { get; } + private ICacheContext Cache { get; } + + protected override async Task OnInvoke() + { + var entity = await Cache.Get(DistributedLock.EntityKey, Arguments.Id, + async (f) => + { + return await (from dc in Storage.Open() where dc.Id == Arguments.Id select dc).AsEntity(); + }); + + if (entity is not null) + { + await Storage.Open().Update(Arguments.AsEntity(State.Default, new + { + Expiration = DateTime.UtcNow.Add(Arguments.Duration ?? TimeSpan.FromSeconds(5)) + })); + } + } + + protected override async Task OnCommitted() + { + await Cache.Remove(DistributedLock.EntityKey, Arguments.Id); + } + } +} diff --git a/Common/Distributed/DistributedLockProtector.cs b/Common/Distributed/DistributedLockProtector.cs new file mode 100644 index 0000000..702722d --- /dev/null +++ b/Common/Distributed/DistributedLockProtector.cs @@ -0,0 +1,73 @@ +using Connected.Caching; +using Connected.Data.DataProtection; +using Connected.Data.EntityProtection; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Middleware; + +namespace Common.Distributed; +internal sealed class DistributedLockProtector : MiddlewareComponent, IEntityProtector +{ + public DistributedLockProtector(IStorageProvider storage, ICacheContext cache) + { + Storage = storage; + Cache = cache; + } + + private IStorageProvider Storage { get; } + private ICacheContext Cache { get; } + + public async Task Invoke(EntityProtectionArgs args) + { + /* + * We don't care for deleted entities. + */ + if (args.State == State.Deleted) + return; + /* + * The most important thing is to prevent duplicate inserts. If the lock + * already exists we will reject the transaction. Each entity record can have + * only one entry in the distributed lock table. + */ + if (args.State == State.New) + { + /* + * First we'll look into the memory cache. + */ + var existing = Cache.Get(DistributedLock.EntityKey, + f => string.Equals(f.Entity, args.Entity.Entity, StringComparison.OrdinalIgnoreCase) + && string.Equals(f.EntityId, args.Entity.EntityId, StringComparison.OrdinalIgnoreCase)); + /* + * If the cache entry exists and holds a valid lock, we simply reject the transaction. + */ + if (existing is not null && existing.Expiration > DateTime.UtcNow) + throw new InvalidOperationException($"{SR.ValLock} ({args.Entity.Entity}, {args.Entity.EntityId})"); + /* + * Entry doesn't exist in the cache. Let's look in the storage. Note that this scenarios is unusual because + * locks tend to be short and there are only two possible schenarios that lock exists in the storage but not + * in the cache: + * - the process has rebooted + * - the master has changed. It this case all distributed services are redirected to the new master which must + * load record by record from the cache. In this case it's most probably that all locks already expired but we + * must check that anyway + */ + var entry = await (from dc in Storage.Open() + where string.Equals(dc.Entity, args.Entity.Entity, StringComparison.OrdinalIgnoreCase) + && string.Equals(dc.EntityId, args.Entity.EntityId, StringComparison.OrdinalIgnoreCase) + select dc).AsEntity(); + /* + * It exists, we must perform additional checks. + */ + if (entry is not null) + { + /* + * Set it in the cache so it gets deleted by the recycling service. + */ + Cache.Set(DistributedLock.EntityKey, entry.Id, entry, TimeSpan.Zero); + + if (entry.Expiration > DateTime.UtcNow) + throw new InvalidOperationException($"{SR.ValLock} ({args.Entity.Entity}, {args.Entity.EntityId})"); + } + } + } +} diff --git a/Common/Distributed/DistributedLockService.cs b/Common/Distributed/DistributedLockService.cs new file mode 100644 index 0000000..6e99be2 --- /dev/null +++ b/Common/Distributed/DistributedLockService.cs @@ -0,0 +1,32 @@ +using Connected.ServiceModel; +using Connected.Services; +using Ops = Common.Distributed.DistributedLockOps; + +namespace Common.Distributed; +internal sealed class DistributedLockService : DistributedService, IDistributedLockService +{ + public DistributedLockService(IContext context) : base(context) + { + } + /// + /// This method performs a distribubuted lock on an entity. If the lock + /// cannot be obtained the exception is thrown. + /// + /// The arguments containing the entity on which + /// a distributed lock will be performed. + /// The key of a newly acquired lock. + public async Task Lock(DistributedLockArgs args) + { + return await Invoke(GetOperation(), args); + } + + public async Task Ping(DistributedLockPingArgs args) + { + await Invoke(GetOperation(), args); + } + + public async Task Unlock(PrimaryKeyArgs args) + { + await Invoke(GetOperation(), args); + } +} diff --git a/Common/Distributed/IDistributedLock.cs b/Common/Distributed/IDistributedLock.cs new file mode 100644 index 0000000..20b518a --- /dev/null +++ b/Common/Distributed/IDistributedLock.cs @@ -0,0 +1,9 @@ +using Connected.Data; + +namespace Common.Distributed; +public interface IDistributedLock : IPrimaryKey +{ + string Entity { get; init; } + string EntityId { get; init; } + DateTimeOffset Expiration { get; init; } +} diff --git a/Common/Distributed/IDistributedLockService.cs b/Common/Distributed/IDistributedLockService.cs new file mode 100644 index 0000000..37c661e --- /dev/null +++ b/Common/Distributed/IDistributedLockService.cs @@ -0,0 +1,12 @@ +using Connected.Annotations; +using Connected.ServiceModel; + +namespace Common.Distributed; + +[Service] +public interface IDistributedLockService +{ + Task Lock(DistributedLockArgs args); + Task Unlock(PrimaryKeyArgs args); + Task Ping(DistributedLockPingArgs args); +} diff --git a/Common/Documents/Document.cs b/Common/Documents/Document.cs new file mode 100644 index 0000000..6069d84 --- /dev/null +++ b/Common/Documents/Document.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using Connected.Annotations; +using Connected.Entities.Annotations; +using Connected.Entities.Consistency; + +namespace Common.Documents; +/// +/// Default implementation of the interface. +/// +/// The type of the primary key used by document. +/// This is usually int or long. +/// +[Table(Schema = CommonSchemas.DocumentSchema)] +public abstract record Document : ConsistentEntity, IDocument + where TPrimaryKey : notnull +{ + /// + [Ordinal(-1007), Date(Kind = DateKind.SmallDateTime)] + public DateTimeOffset Created { get; init; } + /// + [Ordinal(-1006), Nullable, Date(Kind = DateKind.SmallDateTime)] + public DateTimeOffset? Modified { get; init; } + /// + [Ordinal(-1005), MaxLength(32), Nullable] + public string? Code { get; init; } = default!; + /// + [Ordinal(-1004), Nullable] + public int? Author { get; init; } + /// + [Ordinal(-1003), Nullable] + public int? Owner { get; init; } +} diff --git a/Common/Documents/DocumentListener.cs b/Common/Documents/DocumentListener.cs new file mode 100644 index 0000000..6678b1b --- /dev/null +++ b/Common/Documents/DocumentListener.cs @@ -0,0 +1,60 @@ +using Connected; +using Connected.Notifications.Events; + +namespace Common.Documents; +public abstract class DocumentListener : EventListener + where TArgs : IDto + where TDocument : IDocument + where TPrimaryKey : notnull +{ + public DocumentListener(IDocumentLocker locker) + { + Locker = locker; + } + + private IDocumentLocker Locker { get; } + protected abstract TDocument Document { get; } + protected override async Task OnInvoke() + { + await OnPreparing(); + + if (Document is null) + return; + + Locker.Expired += async (s, e) => { await OnException(new TimeoutException()); }; + + try + { + await Locker.Lock(Document); + await OnInvoking(); + await Locker.Unlock(); + } + catch (Exception ex) + { + await Locker.Unlock(); + await OnException(ex); + } + } + + protected virtual async Task OnPreparing() + { + await Task.CompletedTask; + + return false; + } + + protected virtual async Task OnInvoking() + { + await Task.CompletedTask; + } + + protected virtual async Task OnException(Exception ex) + { + await Task.CompletedTask; + } + + protected override void OnDisposing() + { + Locker.Dispose(); + } +} diff --git a/Common/Documents/DocumentLocker.cs b/Common/Documents/DocumentLocker.cs new file mode 100644 index 0000000..ddf3770 --- /dev/null +++ b/Common/Documents/DocumentLocker.cs @@ -0,0 +1,96 @@ +using Common.Distributed; +using Connected; +using Connected.Interop; +using Connected.ServiceModel; +using Connected.Threading; + +namespace Common.Documents; +internal sealed class DocumentLocker : IDocumentLocker + where TDocument : IDocument + where TPrimaryKey : notnull + +{ + public event EventHandler? Expired; + public DocumentLocker(IDistributedLockService locking) + { + Locking = locking; + Cancel = new(); + } + + private bool IsDisposed { get; set; } + public Guid Key { get; private set; } + private IDistributedLockService Locking { get; } + + public TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(2); + public TimeSpan Timeout { get; set; } = TimeSpan.FromMilliseconds(1500); + public TimeSpan Lifetime { get; set; } = TimeSpan.FromSeconds(30); + private CancellationTokenSource Cancel { get; } + public async Task Lock(TDocument document) + { + var id = TypeConversion.Convert(document.Id); + + if (string.IsNullOrEmpty(id)) + throw new NullReferenceException(nameof(document.Id)); + /* + * Obtain the lock on the document to ensure no other process + * will change it while we are updating the stock. If the lock + * could not be obtained the exception will be thrown. + */ + Key = await Locking.Lock(new DistributedLockArgs + { + Duration = LockTimeout, + Entity = typeof(TDocument).Name, + EntityId = id + }); + /* + * Make sure we don't get outdated by using the scheduled + * task which will ping the lock before it expires. + */ + using var timeout = new ScheduledTask(async () => + { + await Locking.Ping(new DistributedLockPingArgs + { + Id = Key, + Duration = LockTimeout + }); + }, async () => + { + await Unlock(); + + Expired?.Invoke(this, EventArgs.Empty); + }, Timeout, Lifetime, Cancel.Token); + + timeout.Start(); + } + + public async Task Unlock() + { + /* + * Finally release the lock on the document so it becomes + * updatable again. + */ + await Locking.Unlock(new PrimaryKeyArgs { Id = Key }); + } + + private void Dispose(bool disposing) + { + if (!IsDisposed) + { + if (disposing) + { + AsyncUtils.RunSync(Unlock); + + Cancel.Cancel(); + Cancel.Dispose(); + } + + IsDisposed = true; + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } +} diff --git a/Common/Documents/DocumentService.cs b/Common/Documents/DocumentService.cs new file mode 100644 index 0000000..a9c55df --- /dev/null +++ b/Common/Documents/DocumentService.cs @@ -0,0 +1,15 @@ +using Connected.Notifications; +using Connected.ServiceModel; +using Connected.Services; + +namespace Common.Documents; +public abstract class DocumentService : EntityService, IDocumentService +{ + protected DocumentService(IContext context) : base(context) + { + } + + public event ServiceEventHandler>? ItemInserted; + public event ServiceEventHandler>? ItemUpdated; + public event ServiceEventHandler>? ItemDeleted; +} diff --git a/Common/Documents/IDocumentLocker.cs b/Common/Documents/IDocumentLocker.cs new file mode 100644 index 0000000..3167428 --- /dev/null +++ b/Common/Documents/IDocumentLocker.cs @@ -0,0 +1,13 @@ +namespace Common.Documents; +public interface IDocumentLocker : IDisposable + where TDocument : IDocument + where TPrimaryKey : notnull +{ + event EventHandler? Expired; + Guid Key { get; } + TimeSpan LockTimeout { get; set; } + TimeSpan Timeout { get; set; } + TimeSpan Lifetime { get; set; } + Task Lock(TDocument document); + Task Unlock(); +} diff --git a/Common/Documents/Validators.cs b/Common/Documents/Validators.cs new file mode 100644 index 0000000..4c21332 --- /dev/null +++ b/Common/Documents/Validators.cs @@ -0,0 +1,61 @@ +using Connected.Middleware; +using Connected.Security.Identity; +using Connected.Validation; + +namespace Common.Documents; +public abstract class InsertDocumentValidator : MiddlewareComponent, IValidator + where TDocumentArgs : InsertDocumentArgs +{ + public InsertDocumentValidator(IUserService users) + { + Users = users; + } + protected IUserService Users { get; } + protected TDocumentArgs Arguments { get; private set; } + public async Task Validate(TDocumentArgs args) + { + Arguments = args; + + await ValidateAuthor(); + } + + protected virtual async Task OnValidating() + { + await Task.CompletedTask; + } + + private async Task ValidateAuthor() + { + if (Arguments.Author is null) + return; + + if (await Users.Select(Arguments.Author) is null) + throw new ArgumentException(nameof(Arguments.Author)); + } +} + +public abstract class UpdateDocumentValidator : MiddlewareComponent, IValidator + where TDocumentArgs : UpdateDocumentArgs + where TPrimaryKey : notnull +{ + public UpdateDocumentValidator(IUserService users) + { + Users = users; + } + + protected IUserService Users { get; } + + public async Task Validate(TDocumentArgs args) + { + await ValidateOwner(args); + } + + private async Task ValidateOwner(TDocumentArgs args) + { + if (args.Owner is null) + return; + + if (await Users.Select(args.Owner) is null) + throw new ArgumentException(nameof(args.Owner)); + } +} \ No newline at end of file diff --git a/Common/Globalization/GlobalizationService.cs b/Common/Globalization/GlobalizationService.cs new file mode 100644 index 0000000..8fbc187 --- /dev/null +++ b/Common/Globalization/GlobalizationService.cs @@ -0,0 +1,105 @@ +using System.Globalization; +using Connected; +using Connected.Data; +using Connected.Globalization; +using Connected.Globalization.Languages; +using Connected.Security.Identity; +using Connected.ServiceModel; + +namespace Common.Globalization; + +internal class GlobalizationService : IGlobalizationService +{ + private ILanguage _language; + private CultureInfo _culture; + public GlobalizationService(IIdentityService identityService, ILanguageService languageService) + { + IdentityService = identityService; + LanguageService = languageService; + } + + private IIdentityService IdentityService { get; } + private ILanguageService LanguageService { get; } + + public TimeZoneInfo TimeZone + { + get + { + if (!IdentityService.IsAuthenticated || IdentityService.CurrentUser is null) + return TimeZoneInfo.Utc; + else + { + try + { + if (TimeZoneInfo.FindSystemTimeZoneById(IdentityService.CurrentUser.TimeZone) is not TimeZoneInfo timezone) + return TimeZoneInfo.Utc; + else + return timezone; + } + catch + { + return TimeZoneInfo.Utc; + } + } + + } + } + + public DateTimeOffset Now => FromUtc(DateTimeOffset.UtcNow); + + public async Task GetCurrentLanguage() + { + if (_language is null) + { + if (IdentityService.CurrentUser is not null && IdentityService.CurrentUser.Language > 0) + _language = await LanguageService.Select(new PrimaryKeyArgs() { Id = IdentityService.CurrentUser.Language }); + + if (_language is not null && _language.Status == Status.Disabled) + _language = null; + } + + return _language; + } + public async Task GetCurrentCulture() + { + if (_culture is null) + { + if (await GetCurrentLanguage() is not ILanguage language) + _culture = Thread.CurrentThread.CurrentUICulture; + else + { + try + { + if (CultureInfo.GetCultureInfo(language.Lcid) is not CultureInfo culture) + _culture = Thread.CurrentThread.CurrentUICulture; + } + catch + { + _culture = Thread.CurrentThread.CurrentUICulture; + } + } + } + + return _culture; + } + + public DateTime FromUtc(DateTime value) + { + return DateExtensions.FromUtc(value, TimeZone); + } + + public DateTimeOffset FromUtc(DateTimeOffset value) + { + return DateExtensions.FromUtc(value, TimeZone); + } + + public DateTime ToUtc(DateTime value) + { + return DateExtensions.ToUtc(value, TimeZone); + } + + public DateTimeOffset ToUtc(DateTimeOffset value) + { + return DateExtensions.ToUtc(value, TimeZone); + } +} diff --git a/Common/Globalization/Language.cs b/Common/Globalization/Language.cs new file mode 100644 index 0000000..cfa9499 --- /dev/null +++ b/Common/Globalization/Language.cs @@ -0,0 +1,31 @@ +using Connected.Annotations; +using Connected.Data; +using Connected.Entities.Annotations; +using Connected.Entities.Consistency; +using Connected.Globalization.Languages; + +namespace Common.Globalization; + +/// +/// The implementation of the entity. +/// +[Table(Schema = SchemaAttribute.SysSchema)] +internal record Language : ConsistentEntity, ILanguage +{ + public const string CacheKey = $"{SchemaAttribute.SysSchema}.{nameof(Language)}"; + + [Length(128)] + [Ordinal(0)] + public string? Name { get; init; } + + [Ordinal(1)] + public int Lcid { get; init; } + + [Default(Status.Enabled)] + [Ordinal(2)] + public Status Status { get; init; } + + [Length(128)] + [Ordinal(3)] + public string? Mappings { get; init; } +} diff --git a/Common/Globalization/LanguageCache.cs b/Common/Globalization/LanguageCache.cs new file mode 100644 index 0000000..61d4a74 --- /dev/null +++ b/Common/Globalization/LanguageCache.cs @@ -0,0 +1,77 @@ +using System.Collections.Immutable; +using Connected.Entities.Caching; +using Connected.ServiceModel; + +namespace Common.Globalization; + +internal interface ILanguageCache : IEntityCacheClient +{ + Language? Select(string mappings); +} +/// +/// Represents stateful cache of the entities. +/// +internal class LanguageCache : EntityCacheClient, ILanguageCache +{ + private readonly Dictionary _mappings; + public LanguageCache(IEntityCacheContext context) : base(context, Language.CacheKey) + { + _mappings = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private Dictionary Mappings => _mappings; + protected override async Task> OnInitializing(IContext context) + { + var result = await base.OnInitializing(context); + + await ResetMappings(); + + return result; + } + + protected override async Task OnInvalidating(IContext context, int id) + { + var result = await base.OnInvalidating(context, id); + + await ResetMappings(); + + return result; + } + + public Language? Select(string mappings) + { + if (string.IsNullOrWhiteSpace(mappings)) + return null; + + var tokens = mappings.Split(',', StringSplitOptions.RemoveEmptyEntries); + + foreach (var token in tokens) + { + if (Mappings.TryGetValue(token.Trim(), out Language? language)) + return language; + } + + return null; + } + + private async Task ResetMappings() + { + Mappings.Clear(); + + foreach (var language in await All()) + { + if (string.IsNullOrWhiteSpace(language.Mappings)) + continue; + + var tokens = language.Mappings.Split(',', StringSplitOptions.RemoveEmptyEntries); + + foreach (var token in tokens) + { + if (Mappings.TryGetValue(token.Trim(), out _)) + continue; + + Mappings.Add(token.Trim(), language); + } + } + } +} diff --git a/Common/Globalization/LanguageOps.cs b/Common/Globalization/LanguageOps.cs new file mode 100644 index 0000000..69fe4e2 --- /dev/null +++ b/Common/Globalization/LanguageOps.cs @@ -0,0 +1,234 @@ +using System.Collections.Immutable; +using Connected; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Globalization.Languages; +using Connected.Notifications.Events; +using Connected.ServiceModel; +using Connected.Services; + +namespace Common.Globalization; + +/// +/// Queries all records except those marked as deleting. +/// +internal sealed class QueryLanguages : ServiceFunction?> +{ + public QueryLanguages(ILanguageCache cache) + { + Cache = cache; + } + + private ILanguageCache Cache { get; } + protected override async Task?> OnInvoke() + { + /* + * Filter records to return only currently valid record. Those with + * deleting state should never be returned to any client. + */ + return await (from dc in Cache + select dc).AsEntities(); + } +} +/// +/// Queries the records for the specified set of ids. +/// +internal sealed class LookupLanguages : ServiceFunction, ImmutableList> +{ + public LookupLanguages(ILanguageCache cache) + { + Cache = cache; + } + + private ILanguageCache Cache { get; } + protected override async Task?> OnInvoke() + { + if (Arguments?.IdList is null) + return default; + + return await (from dc in Cache + where Arguments.IdList.Any(f => f == dc.Id) + select dc).AsEntities(); + } +} +/// +/// Returns first with matches the provided mapping. +/// +internal sealed class ResolveLanguage : ServiceFunction +{ + public ResolveLanguage(ILanguageCache cache) + { + Cache = cache; + } + + private ILanguageCache Cache { get; } + protected override Task OnInvoke() + { + return Task.FromResult(Cache.Select(Arguments.Mapping)); + } +} +/// +/// Returns with the specified id or null +/// if the record for the specified id does not exist. +/// +internal sealed class SelectLanguage : ServiceFunction, ILanguage?> +{ + public SelectLanguage(ILanguageCache cache) + { + Cache = cache; + } + + private ILanguageCache Cache { get; } + protected override async Task OnInvoke() + { + return await (from dc in Cache + where dc.Id == Arguments.Id + select dc).AsEntity(); + } +} +/// +/// Returns with the specified name and rate or null if +/// the record with the specified arguments does not exist. +/// +internal sealed class SelectLanguageByName : ServiceFunction +{ + public SelectLanguageByName(ILanguageCache cache) + { + Cache = cache; + } + + private ILanguageCache Cache { get; } + + protected override async Task OnInvoke() + { + return await (from dc in Cache + where string.Equals(dc.Name, Arguments.Name, StringComparison.OrdinalIgnoreCase) + select dc).AsEntity(); + } +} +/// +/// Inserts a new and returns its Id. +/// +internal sealed class InsertLanguage : ServiceFunction +{ + public InsertLanguage(ILanguageService languageService, IStorageProvider storage, IEventService events, ILanguageCache cache) + { + LanguageService = languageService; + Storage = storage; + Events = events; + Cache = cache; + } + /// + /// We need this service to call a distribute event. + /// + private ILanguageService LanguageService { get; } + private IStorageProvider Storage { get; } + private IEventService Events { get; } + private ILanguageCache Cache { get; } + + protected override async Task OnInvoke() + { + /* + * First, create a new entity from the passed arguments and mark its state as new. This will + * signal the DatabaseContext to perform an insert operation when calling the Update. + */ + var entity = Arguments.AsEntity(State.New); + /* + * Call update on the DatabaseContext. This call will return a new ILanguage of the inserted + * entity. + */ + var result = await Storage.Open().Update(entity); + /* + * Return a newly inserted id to the caller. + */ + return result.Id; + } + + protected override async Task OnCommitted() + { + await Cache.Refresh(Result); + /* + * If ILanguageServer is our implementation (and should be) it's a IServerNotificationsTriggers + * for sure. + */ + await Events.Enqueue(this, LanguageService, ServiceEvents.Inserted, Result); + } +} +internal sealed class DeleteLanguage : ServiceAction> +{ + public DeleteLanguage(ILanguageService languageService, IStorageProvider storage, ILanguageCache cache, IEventService events) + { + LanguageService = languageService; + Storage = storage; + Cache = cache; + Events = events; + } + + private ILanguageService LanguageService { get; } + private IStorageProvider Storage { get; } + private ILanguageCache Cache { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + var entity = new Language { Id = Arguments.Id, State = State.Deleted }; + + await Storage.Open().Update(entity); + } + + protected override async Task OnCommitted() + { + await Cache.Remove(Arguments.Id); + await Events.Enqueue(this, LanguageService, ServiceEvents.Deleted, Arguments.Id); + } +} +/// +/// Updates entity. +/// +internal sealed class UpdateLanguage : ServiceAction +{ + public UpdateLanguage(ILanguageService languageService, IStorageProvider storage, ILanguageCache cache, IEventService events) + { + LanguageService = languageService; + Storage = storage; + Cache = cache; + Events = events; + } + private ILanguageService LanguageService { get; } + private IStorageProvider Storage { get; } + private ILanguageCache Cache { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + /* + * Updating Concurrency entity requires a bit more logic. Since Concurrency entity + * guarantees data consistency we must use a retry logic in case of + * Concurrency failure. We'll call Update method with reload lambda function. + */ + await Storage.Open().Update(await Load(), Arguments, async () => + { + /* + * Remove entry from the cache to ensure it will be loaded from the database + * next time. + */ + await Cache.Refresh(Arguments.Id); + + return await Load(); + }); + } + + private async Task Load() => await (from dc in Cache where dc.Id == Arguments.Id select dc).AsEntity(); + protected override async Task OnCommitted() + { + /* + * Once the update is complete remove the entity from the cache because its concurrency + * state is not valid enymore. + */ + await Cache.Remove(Arguments.Id); + /* + * Now trigger the distributed event notifying the update has completed. + */ + await Events.Enqueue(this, LanguageService, ServiceEvents.Updated, Arguments.Id); + } +} diff --git a/Common/Globalization/LanguageService.cs b/Common/Globalization/LanguageService.cs new file mode 100644 index 0000000..dd28d99 --- /dev/null +++ b/Common/Globalization/LanguageService.cs @@ -0,0 +1,66 @@ +using System.Collections.Immutable; +using Common.Security; +using Connected.Globalization.Languages; +using Connected.ServiceModel; +using Connected.Services; +using Connected.Services.Annotations; + +namespace Common.Globalization; + +/// +/// The implementation of the service. +/// +internal class LanguageService : EntityService, ILanguageService +{ + public LanguageService(IContext context) : base(context) + { + } + + [ServiceAuthorization(CommonClaims.CommonDelete)] + public async Task Delete(PrimaryKeyArgs args) + { + await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(CommonClaims.CommonAdd)] + public async Task Insert(LanguageInsertArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(CommonClaims.CommonRead)] + public async Task?> Query() + { + return await Invoke(GetOperation(), Dto.Empty); + } + + [ServiceAuthorization(CommonClaims.CommonRead)] + public async Task?> Query(PrimaryKeyListArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(CommonClaims.CommonRead)] + public async Task Resolve(LanguageResolveArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(CommonClaims.CommonRead)] + public async Task Select(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(CommonClaims.CommonRead)] + public async Task Select(NameArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(CommonClaims.CommonModify)] + public async Task Update(LanguageUpdateArgs args) + { + await Invoke(GetOperation(), args); + } +} diff --git a/Common/Net/Endpoint.cs b/Common/Net/Endpoint.cs new file mode 100644 index 0000000..077fdd5 --- /dev/null +++ b/Common/Net/Endpoint.cs @@ -0,0 +1,29 @@ +using Connected.Annotations; +using Connected.Data; +using Connected.Entities.Annotations; +using Connected.Entities.Consistency; +using Connected.Net.Endpoints; + +namespace Common.Net; + +[Table(Schema = SchemaAttribute.DefaultSchema)] +internal record Endpoint : ConsistentEntity, IEndpoint +{ + public const string CacheKey = $"{SchemaAttribute.SysSchema}.{nameof(Endpoint)}"; + + [Ordinal(0)] + [Length(128)] + public string? Name { get; init; } + + [Length(128)] + [Ordinal(1)] + public string? Address { get; init; } + + [Default(Status.Enabled)] + [Ordinal(2)] + [Length(128)] + public string? AuthenticationToken { get; init; } + + [Ordinal(3)] + public Status Status { get; init; } +} diff --git a/Common/Net/EndpointCache.cs b/Common/Net/EndpointCache.cs new file mode 100644 index 0000000..70b3a03 --- /dev/null +++ b/Common/Net/EndpointCache.cs @@ -0,0 +1,11 @@ +using Connected.Entities.Caching; + +namespace Common.Net; + +internal interface IEndpointCache : IEntityCacheClient { } +internal class EndpointCache : EntityCacheClient, IEndpointCache +{ + public EndpointCache(IEntityCacheContext cachingService) : base(cachingService, Endpoint.CacheKey) + { + } +} diff --git a/Common/Net/EndpointOps.cs b/Common/Net/EndpointOps.cs new file mode 100644 index 0000000..f7736a9 --- /dev/null +++ b/Common/Net/EndpointOps.cs @@ -0,0 +1,43 @@ +using System.Collections.Immutable; +using Connected; +using Connected.Entities; +using Connected.Net.Endpoints; +using Connected.ServiceModel; +using Connected.Services; + +namespace Common.Net; + +/// +/// Endpoints are singleton but their service is scoped so we must use Isolated database connections for all methods. +/// +internal sealed class QueryEndpoints : ServiceFunction?> +{ + public QueryEndpoints(IEndpointCache cache) + { + Cache = cache; + } + + private IEndpointCache Cache { get; } + + protected override async Task?> OnInvoke() + { + return await (from dc in Cache + select dc).AsEntities(); + } +} +internal sealed class SelectEndpoint : ServiceFunction, IEndpoint?> +{ + public SelectEndpoint(IEndpointCache cache) + { + Cache = cache; + } + + private IEndpointCache Cache { get; } + + protected override async Task OnInvoke() + { + return await (from dc in Cache + where dc.Id == Arguments.Id + select dc).AsEntity(); + } +} diff --git a/Common/Net/EndpointService.cs b/Common/Net/EndpointService.cs new file mode 100644 index 0000000..4e76201 --- /dev/null +++ b/Common/Net/EndpointService.cs @@ -0,0 +1,27 @@ +using System.Collections.Immutable; +using Connected.Net; +using Connected.Net.Endpoints; +using Connected.ServiceModel; +using Connected.Services; +using Connected.Services.Annotations; + +namespace Common.Net; + +internal sealed class EndpointService : EntityService, IEndpointService +{ + public EndpointService(IContext context) : base(context) + { + } + + [ServiceAuthorization(NetClaims.NetDiscovery)] + public async Task?> Query() + { + return await Invoke(GetOperation(), Dto.Empty); + } + + [ServiceAuthorization(NetClaims.NetDiscovery)] + public async Task Select(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } +} diff --git a/Common/SR.Designer.cs b/Common/SR.Designer.cs new file mode 100644 index 0000000..098e405 --- /dev/null +++ b/Common/SR.Designer.cs @@ -0,0 +1,99 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Common { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class SR { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal SR() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Common.SR", typeof(SR).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to System roles are read only. + /// + internal static string ErrSysRole { + get { + return ResourceManager.GetString("ErrSysRole", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Disabled. + /// + internal static string RecordStatusDisabled { + get { + return ResourceManager.GetString("RecordStatusDisabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enabled. + /// + internal static string RecordStatusEnabled { + get { + return ResourceManager.GetString("RecordStatusEnabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cannot acquire distributed lock. + /// + internal static string ValLock { + get { + return ResourceManager.GetString("ValLock", resourceCulture); + } + } + } +} diff --git a/Common/SR.resx b/Common/SR.resx new file mode 100644 index 0000000..208e866 --- /dev/null +++ b/Common/SR.resx @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System roles are read only + + + Disabled + + + Enabled + + + Cannot acquire distributed lock + + \ No newline at end of file diff --git a/Common/Security/CommonClaims.cs b/Common/Security/CommonClaims.cs new file mode 100644 index 0000000..e5430bb --- /dev/null +++ b/Common/Security/CommonClaims.cs @@ -0,0 +1,9 @@ +namespace Common.Security; + +public static class CommonClaims +{ + public const string CommonDelete = "Common Delete"; + public const string CommonRead = "Common Read"; + public const string CommonAdd = "Common Add"; + public const string CommonModify = "Common Modify"; +} diff --git a/Common/Security/Identity/IdentityService.cs b/Common/Security/Identity/IdentityService.cs new file mode 100644 index 0000000..addc8fe --- /dev/null +++ b/Common/Security/Identity/IdentityService.cs @@ -0,0 +1,17 @@ +using Connected.Security.Identity; +using Microsoft.AspNetCore.Http; + +namespace Common.Security.Identity; + +internal class IdentityService : IIdentityService +{ + public IdentityService(IHttpContextAccessor contextAccessor) + { + HttpContext = contextAccessor.HttpContext; + } + + private HttpContext? HttpContext { get; } + public IUser? CurrentUser => Identity?.User; + public bool IsAuthenticated => Identity is not null && Identity.IsAuthenticated; + private UserIdentity? Identity => HttpContext?.User?.Identity as UserIdentity; +} diff --git a/Common/Security/Identity/Role.cs b/Common/Security/Identity/Role.cs new file mode 100644 index 0000000..3a04679 --- /dev/null +++ b/Common/Security/Identity/Role.cs @@ -0,0 +1,16 @@ +using Connected.Annotations; +using Connected.Entities.Annotations; +using Connected.Entities.Consistency; +using Connected.Security.Identity; + +namespace Common.Security.Identity; + +[Table(Schema = SchemaAttribute.SysSchema)] +internal record Role : ConsistentEntity, IRole +{ + public const string CacheKey = $"{SchemaAttribute.SysSchema}.{nameof(Role)}"; + + [Length(128)] + [Ordinal(0)] + public string? Name { get; init; } +} diff --git a/Common/Security/Identity/RoleCache.cs b/Common/Security/Identity/RoleCache.cs new file mode 100644 index 0000000..f022777 --- /dev/null +++ b/Common/Security/Identity/RoleCache.cs @@ -0,0 +1,40 @@ +using Connected.Entities.Caching; +using Connected.Security.Identity; + +namespace Common.Security.Identity; + +internal interface IRoleCache : IEntityCacheClient { } +internal sealed class RoleCache : EntityCacheClient, IRoleCache +{ + public RoleCache(IEntityCacheContext context) : base(context, Role.CacheKey) + { + } + + protected override Task OnInitialized() + { + /* + * Register system or predefined roles. This roles cannot be changed. They differ + * from other roles in that they have a negative ids. + */ + + /* + * Full control role. This role passed all authorization policies. + */ + Set(-1, new Role { Id = -1, Name = Roles.FullControl }, TimeSpan.Zero); + /* + * Implicit role assigned to every authenticated user. + */ + Set(-2, new Role { Id = -2, Name = Roles.Authenticated }, TimeSpan.Zero); + /* + * Implicit role assigned to non authenticated user. + */ + Set(-3, new Role { Id = -3, Name = Roles.Anonymous }, TimeSpan.Zero); + /* + * Implicit role assigned to every user + * regardless if it's authenticated or not. + */ + Set(-4, new Role { Id = -4, Name = Roles.Everyone }, TimeSpan.Zero); + + return Task.CompletedTask; + } +} diff --git a/Common/Security/Identity/RoleOps.cs b/Common/Security/Identity/RoleOps.cs new file mode 100644 index 0000000..ad3007f --- /dev/null +++ b/Common/Security/Identity/RoleOps.cs @@ -0,0 +1,183 @@ +using System.Collections.Immutable; +using Connected; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Notifications; +using Connected.Notifications.Events; +using Connected.Security.Identity; +using Connected.ServiceModel; +using Connected.Services; +using Connected.Validation; + +namespace Common.Security.Identity; + +internal sealed class QueryRoles : ServiceFunction> +{ + public QueryRoles(IRoleCache cache) + { + Cache = cache; + } + + private IRoleCache Cache { get; } + protected override async Task?> OnInvoke() + { + return await (from dc in Cache + select dc).AsEntities(); + } +} + +internal sealed class SelectRole : ServiceFunction, IRole?> +{ + public SelectRole(IRoleCache cache) + { + Cache = cache; + } + + private IRoleCache Cache { get; } + protected override async Task OnInvoke() + { + return await (from dc in Cache + where dc.Id == Arguments.Id + select dc).AsEntity(); + } +} + +internal sealed class SelectRoleByName : ServiceFunction +{ + public SelectRoleByName(IRoleCache cache) + { + Cache = cache; + } + + private IRoleCache Cache { get; } + protected override async Task OnInvoke() + { + return await (from dc in Cache + where string.Equals(dc.Name, Arguments.Name, StringComparison.OrdinalIgnoreCase) + select dc).AsEntity(); + } +} + +internal sealed class LookupRoles : ServiceFunction, ImmutableList?> +{ + public LookupRoles(IRoleCache cache) + { + Cache = cache; + } + + private IRoleCache Cache { get; } + protected override async Task?> OnInvoke() + { + if (Arguments.IdList is null) + return default; + + return await (from dc in Cache + where Arguments.IdList.Any(f => f == dc.Id) + select dc).AsEntities(); + } +} + +internal sealed class InsertRole : ServiceFunction +{ + public InsertRole(IRoleService roleService, IRoleCache cache, IStorageProvider storage, IEventService events) + { + RoleService = roleService; + Cache = cache; + Storage = storage; + Events = events; + } + + private IStorageProvider Storage { get; } + private IEventService Events { get; } + private IRoleCache Cache { get; } + private IRoleService RoleService { get; } + + private IRole? Entity { get; set; } + protected override async Task OnInvoke() + { + if (await RoleService.Select(new NameArgs { Name = Arguments.Name }) is not null) + throw ValidationExceptions.ValueExists(nameof(Arguments.Name), Arguments.Name); + + if (Arguments.AsEntity(State.New) is not Role entity) + throw EntityExceptions.EntityCastException(Arguments.GetType(), typeof(Role)); + + Entity = await Storage.Open().Update(entity); + + return Entity.Id; + } + + protected override async Task OnCommitted() + { + await Cache.Refresh(Entity.Id); + await Events.Enqueue(this, RoleService, nameof(IServiceNotifications.Inserted), Result); + } + + protected override async Task OnRolledBack() + { + await Cache.Refresh(Result); + } +} + +internal sealed class DeleteRole : ServiceAction> +{ + public DeleteRole(IRoleService roleService, IStorageProvider storage, IRoleCache cache, IEventService events) + { + RoleService = roleService; + Storage = storage; + Cache = cache; + Events = events; + } + + private IRoleService RoleService { get; } + private IStorageProvider Storage { get; } + private IRoleCache Cache { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + if (await RoleService.Select(Arguments.Id) is IRole existing && existing.Id < 1) + throw new AccessViolationException($"{SR.ErrSysRole} ({existing.Name})"); + + await Storage.Open().Update(new Role { Id = Arguments.Id, State = State.Deleted }); + } + + protected override async Task OnCommitted() + { + await Cache.Remove(Arguments.Id); + await Events.Enqueue(this, RoleService, ServiceEvents.Deleted, Arguments.Id); + } +} + +internal sealed class UpdateRole : ServiceAction +{ + public UpdateRole(IRoleService roleService, IStorageProvider storage, IRoleCache cache, IEventService events) + { + RoleService = roleService; + Storage = storage; + Cache = cache; + Events = events; + } + + private IStorageProvider Storage { get; } + private IRoleCache Cache { get; } + private IEventService Events { get; } + private IRoleService RoleService { get; } + + protected override async Task OnInvoke() + { + await Storage.Open().Update(await Load(), Arguments, async () => + { + await Cache.Refresh(Arguments.Id); + + return await Load(); + }); + + await Cache.Refresh(Arguments.Id); + } + + private async Task Load() => await (from dc in Cache where dc.Id == Arguments.Id select dc).AsEntity(); + protected override async Task OnCommitted() + { + await Events.Enqueue(this, RoleService, ServiceEvents.Updated, Arguments.Id); + } +} \ No newline at end of file diff --git a/Common/Security/Identity/RoleService.cs b/Common/Security/Identity/RoleService.cs new file mode 100644 index 0000000..c9b12fe --- /dev/null +++ b/Common/Security/Identity/RoleService.cs @@ -0,0 +1,57 @@ +using System.Collections.Immutable; +using Connected.Security; +using Connected.Security.Identity; +using Connected.ServiceModel; +using Connected.Services; +using Connected.Services.Annotations; + +namespace Common.Security.Identity; + +internal class RoleService : EntityService, IRoleService +{ + public RoleService(IContext context) : base(context) + { + } + + [ServiceAuthorization(SecurityClaims.SecurityDelete)] + public async Task Delete(PrimaryKeyArgs args) + { + await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(SecurityClaims.SecurityAdd)] + public async Task Insert(RoleArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(SecurityClaims.SecurityRead)] + public async Task?> Query() + { + return await Invoke(GetOperation(), Dto.Empty); + } + + [ServiceAuthorization(SecurityClaims.SecurityRead)] + public async Task?> Query(PrimaryKeyListArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(SecurityClaims.SecurityRead)] + public async Task Select(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(SecurityClaims.SecurityRead)] + public async Task Select(NameArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(SecurityClaims.SecurityModify)] + public async Task Update(RoleUpdateArgs args) + { + await Invoke(GetOperation(), args); + } +} diff --git a/Common/Security/Identity/User.cs b/Common/Security/Identity/User.cs new file mode 100644 index 0000000..63fce9c --- /dev/null +++ b/Common/Security/Identity/User.cs @@ -0,0 +1,62 @@ +using Connected.Annotations; +using Connected.Entities.Annotations; +using Connected.Entities.Consistency; +using Connected.Security.Identity; + +namespace Common.Security.Identity; + +[Table(Schema = SchemaAttribute.SysSchema)] +internal record class User : ConsistentEntity, IUser, IUserPassport +{ + public const string CacheKey = $"{SchemaAttribute.SysSchema}.{nameof(User)}"; + + [Length(32)] + [Ordinal(0)] + [Nullable] + public string? FirstName { get; init; } + + [Length(64)] + [Ordinal(1)] + [Nullable] + public string? LastName { get; init; } + + [Length(128)] + [Ordinal(2)] + [Nullable] + public string? LoginName { get; init; } + + [Length(256)] + [Ordinal(3)] + [Nullable] + public string? Email { get; init; } + + [Length(256)] + [Ordinal(4)] + [Nullable] + public string? TimeZone { get; init; } + + [Ordinal(5)] + [Nullable] + public int Language { get; init; } + + [Ordinal(6)] + [Nullable] + [Length(256)] + public byte[] Password { get; init; } + + [Ordinal(7)] + [Nullable] + [Length(32)] + public byte[] Pin { get; init; } + + [Ordinal(8)] + public UserStatus Status { get; init; } + + [Nullable] + [Ordinal(9)] + public DateTime PasswordExpiration { get; init; } + + [Nullable] + [Ordinal(10)] + public Guid AuthenticationToken { get; init; } +} diff --git a/Common/Security/Identity/UserCache.cs b/Common/Security/Identity/UserCache.cs new file mode 100644 index 0000000..ef5c1a7 --- /dev/null +++ b/Common/Security/Identity/UserCache.cs @@ -0,0 +1,11 @@ +using Connected.Entities.Caching; + +namespace Common.Security.Identity; + +internal interface IUserCache : IEntityCacheClient { } +internal sealed class UserCache : EntityCacheClient, IUserCache +{ + public UserCache(IEntityCacheContext context) : base(context, User.CacheKey) + { + } +} diff --git a/Common/Security/Identity/UserOps.cs b/Common/Security/Identity/UserOps.cs new file mode 100644 index 0000000..4d6d0d2 --- /dev/null +++ b/Common/Security/Identity/UserOps.cs @@ -0,0 +1,237 @@ +using System.Collections.Immutable; +using Connected; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Notifications.Events; +using Connected.Security.Cryptography; +using Connected.Security.Identity; +using Connected.ServiceModel; +using Connected.Services; + +namespace Common.Security.Identity; + +internal sealed class QueryUsers : ServiceFunction?> +{ + public QueryUsers(IUserCache cache) + { + Cache = cache; + } + + private IUserCache Cache { get; } + protected override async Task?> OnInvoke() + { + return await (from dc in Cache select dc).AsEntities(); + } +} + +internal sealed class SelectUser : ServiceFunction, IUser?> +{ + public SelectUser(IUserCache cache) + { + Cache = cache; + } + + private IUserCache Cache { get; } + protected override async Task OnInvoke() + { + return await (from dc in Cache + where dc.Id == Arguments.Id + select dc).AsEntity(); + } +} +/// +/// Resolves user by a specified criteria string +/// +internal sealed class ResolveUser : ServiceFunction +{ + public ResolveUser(IUserCache cache) + { + Cache = cache; + } + + private IUserCache Cache { get; } + protected override async Task OnInvoke() + { + /* + * First, try to resolve by login name + */ + if (await (from dc in Cache where string.Equals(dc.LoginName, Arguments.Criteria, StringComparison.OrdinalIgnoreCase) select dc).AsEntity() is IUser user) + return user; + /* + * Next, try by authentication token + */ + if (Guid.TryParse(Arguments.Criteria, out Guid authenticationToken)) + { + if (await (from dc in Cache where dc.AuthenticationToken == authenticationToken select dc).AsEntity() is IUser authUser) + return authUser; + } + /* + * Next, try by email + */ + if (Arguments.Criteria?.Contains('@') == true) + { + if (await (from dc in Cache where string.Equals(dc.Email, Arguments.Criteria, StringComparison.OrdinalIgnoreCase) select dc).AsEntity() is IUser emailUser) + return emailUser; + } + /* + * Now by id + */ + if (int.TryParse(Arguments.Criteria, out int id)) + { + if (await (from dc in Cache where dc.Id == id select dc).AsEntity() is IUser idUser) + return idUser; + } + /* + * Doesn't exist. + */ + return null; + } +} + +internal sealed class LookupUsers : ServiceFunction, ImmutableList?> +{ + public LookupUsers(IUserCache cache) + { + Cache = cache; + } + + private IUserCache Cache { get; } + protected override async Task?> OnInvoke() + { + if (Arguments.IdList is null) + return default; + + return await (from dc in Cache + where Arguments.IdList.Contains(dc.Id) + select dc).AsEntities(); + } +} + +internal sealed class InsertUser : ServiceFunction +{ + public InsertUser(IUserService userService, IUserCache cache, IStorageProvider storage, IEventService events) + { + UserService = userService; + Cache = cache; + Storage = storage; + Events = events; + } + + private IUserCache Cache { get; } + private IStorageProvider Storage { get; } + private IEventService Events { get; } + private IUserService UserService { get; } + + protected override async Task OnInvoke() + { + if (Arguments.AsEntity(State.New) is not User entity) + throw EntityExceptions.EntityCastException(Arguments.GetType(), typeof(User)); + + var result = await Storage.Open().Update(entity); + + return result.Id; + } + + protected override async Task OnCommitted() + { + await Cache.Refresh(Result); + await Events.Enqueue(this, UserService, ServiceEvents.Inserted, Result); + } +} + +internal sealed class DeleteUser : ServiceAction> +{ + public DeleteUser(IUserService userService, IStorageProvider storage, IUserCache cache, IEventService events) + { + UserService = userService; + Storage = storage; + Cache = cache; + Events = events; + } + + private IUserService UserService { get; } + private IStorageProvider Storage { get; } + private IUserCache Cache { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + await Storage.Open().Update(new User { Id = Arguments.Id, State = State.Deleted }); + } + + protected override async Task OnCommitted() + { + await Cache.Remove(Arguments.Id); + await Events.Enqueue(this, UserService, ServiceEvents.Deleted, Arguments.Id); + } +} + +internal sealed class UpdateUser : ServiceAction +{ + public UpdateUser(IUserService userService, IUserCache cache, IStorageProvider storage, IEventService events) + { + UserService = userService; + Cache = cache; + Storage = storage; + Events = events; + } + + private IUserService UserService { get; } + private IUserCache Cache { get; } + private IStorageProvider Storage { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + await Storage.Open().Update(await Load(), Arguments, async () => + { + await Cache.Refresh(Arguments.Id); + + return await Load(); + }); + } + + private async Task Load() => await (from dc in Cache where dc.Id == Arguments.Id select dc).AsEntity(); + + protected override async Task OnCommitted() + { + await Cache.Refresh(Arguments.Id); + await Events.Enqueue(this, UserService, ServiceEvents.Updated, Arguments.Id); + } +} + +internal sealed class UserUpdatePassword : ServiceAction +{ + public UserUpdatePassword(IUserService userService, ICryptographyService cryptographyService, IStorageProvider storage, IUserCache cache, IEventService events) + { + UserService = userService; + CryptographyService = cryptographyService; + Storage = storage; + Cache = cache; + Events = events; + } + + private IUserService UserService { get; } + private ICryptographyService CryptographyService { get; } + private IStorageProvider Storage { get; } + public IUserCache Cache { get; } + public IEventService Events { get; } + + protected override async Task OnInvoke() + { + await Storage.Open().Update(await Load(), Arguments, async () => + { + await Cache.Refresh(Arguments.Id); + + return await Load(); + }); + } + + private async Task Load() => await (from dc in Cache where dc.Id == Arguments.Id select dc).AsEntity(); + + protected override async Task OnCommitted() + { + await Cache.Refresh(Arguments.Id); + await Events.Enqueue(this, UserService, ServiceEvents.Updated, Arguments.Id); + } +} \ No newline at end of file diff --git a/Common/Security/Identity/UserService.cs b/Common/Security/Identity/UserService.cs new file mode 100644 index 0000000..f0e19e2 --- /dev/null +++ b/Common/Security/Identity/UserService.cs @@ -0,0 +1,63 @@ +using System.Collections.Immutable; +using Connected.Security; +using Connected.Security.Identity; +using Connected.ServiceModel; +using Connected.Services; +using Connected.Services.Annotations; + +namespace Common.Security.Identity; + +internal class UserService : EntityService, IUserService +{ + public UserService(IContext context) : base(context) + { + } + + [ServiceAuthorization(SecurityClaims.SecurityDelete)] + public async Task Delete(PrimaryKeyArgs args) + { + await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(SecurityClaims.SecurityAdd)] + public async Task Insert(UserInsertArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(SecurityClaims.SecurityRead)] + public async Task?> Query() + { + return await Invoke(GetOperation(), Dto.Empty); + } + + [ServiceAuthorization(SecurityClaims.SecurityRead)] + public async Task?> Query(PrimaryKeyListArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(SecurityClaims.SecurityRead)] + public async Task Resolve(UserResolveArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(SecurityClaims.SecurityRead)] + public async Task Select(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(SecurityClaims.SecurityModify, SecurityClaims.SecurityModifySelf)] + public async Task Update(UserUpdateArgs args) + { + await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(SecurityClaims.SecurityModify, SecurityClaims.SecurityModifySelf)] + public async Task UpdatePassword(UserPasswordArgs args) + { + await Invoke(GetOperation(), args); + } +} diff --git a/Common/Security/Membership/Membership.cs b/Common/Security/Membership/Membership.cs new file mode 100644 index 0000000..69c848b --- /dev/null +++ b/Common/Security/Membership/Membership.cs @@ -0,0 +1,20 @@ +using Connected.Annotations; +using Connected.Entities; +using Connected.Entities.Annotations; +using Connected.Security.Membership; + +namespace Common.Security.Membership; + +[Table(Schema = SchemaAttribute.SysSchema)] +internal sealed record Membership : Entity, IMembership +{ + public const string CacheKey = $"{SchemaAttribute.SysSchema}.{nameof(Membership)}"; + + [Ordinal(0)] + [Index(Name = $"idx_{SchemaAttribute.SysSchema}_{nameof(User)}_{nameof(Role)}", Unique = true)] + public int User { get; init; } + + [Ordinal(1)] + [Index(Name = $"idx_{SchemaAttribute.SysSchema}_{nameof(User)}_{nameof(Role)}", Unique = true)] + public int Role { get; init; } +} diff --git a/Common/Security/Membership/MembershipCache.cs b/Common/Security/Membership/MembershipCache.cs new file mode 100644 index 0000000..dab0117 --- /dev/null +++ b/Common/Security/Membership/MembershipCache.cs @@ -0,0 +1,11 @@ +using Connected.Entities.Caching; + +namespace Common.Security.Membership; + +internal interface IMembershipCache : IEntityCacheClient { } +internal class MembershipCache : EntityCacheClient, IMembershipCache +{ + public MembershipCache(IEntityCacheContext context) : base(context, Membership.CacheKey) + { + } +} diff --git a/Common/Security/Membership/MembershipOps.cs b/Common/Security/Membership/MembershipOps.cs new file mode 100644 index 0000000..dd462ef --- /dev/null +++ b/Common/Security/Membership/MembershipOps.cs @@ -0,0 +1,116 @@ +using System.Collections.Immutable; +using Connected; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Notifications.Events; +using Connected.Security.Membership; +using Connected.ServiceModel; +using Connected.Services; + +namespace Common.Security.Membership; + +internal sealed class DeleteMembership : ServiceAction> +{ + public DeleteMembership(IMembershipService membershipService, IStorageProvider storage, IMembershipCache cache, IEventService events) + { + MembershipService = membershipService; + Storage = storage; + Cache = cache; + Events = events; + } + + private IMembershipService MembershipService { get; } + private IStorageProvider Storage { get; } + private IMembershipCache Cache { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + await Storage.Open().Update(new Membership { Id = Arguments.Id, State = State.Deleted }); + } + + protected override async Task OnCommitted() + { + await Cache.Refresh(Arguments.Id); + await Events.Enqueue(this, MembershipService, ServiceEvents.Deleted, Arguments.Id); + } +} + +internal sealed class QueryMembership : ServiceFunction> +{ + public QueryMembership(IMembershipCache membershipCache) + { + MembershipCache = membershipCache; + } + + private IMembershipCache MembershipCache { get; } + protected override async Task?> OnInvoke() + { + return await (from dc in MembershipCache + select dc).AsEntities(); + } +} + +internal sealed class SearchMembership : ServiceFunction?> +{ + public SearchMembership(IMembershipCache membershipCache) + { + MembershipCache = membershipCache; + } + + private IMembershipCache MembershipCache { get; } + protected override async Task?> OnInvoke() + { + return await (from dc in MembershipCache + where (Arguments.User == 0 || (Arguments.User == dc.User)) + && (Arguments.Role == 0 || (Arguments.Role == dc.Role)) + select dc).AsEntities(); + } +} + +internal sealed class SelectMembership : ServiceFunction, IMembership?> +{ + public SelectMembership(IMembershipCache membershipCache) + { + MembershipCache = membershipCache; + } + + private IMembershipCache MembershipCache { get; } + protected override async Task OnInvoke() + { + return await (from dc in MembershipCache + where dc.Id == Arguments.Id + select dc).AsEntity(); + } +} + +internal sealed class InsertMembership : ServiceFunction +{ + public InsertMembership(IMembershipService membershipService, IMembershipCache cache, IStorageProvider storage, IEventService events) + { + MembershipService = membershipService; + Cache = cache; + Storage = storage; + Events = events; + } + + private IMembershipService MembershipService { get; } + private IMembershipCache Cache { get; } + private IStorageProvider Storage { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + var entity = Arguments.AsEntity(State.New); + var result = await Storage.Open().Update(entity); + + return result.Id; + } + + protected override async Task OnCommitted() + { + await Cache.Refresh(Result); + await Events.Enqueue(this, MembershipService, ServiceEvents.Inserted, Result); + } +} + diff --git a/Common/Security/Membership/MembershipService.cs b/Common/Security/Membership/MembershipService.cs new file mode 100644 index 0000000..25da857 --- /dev/null +++ b/Common/Security/Membership/MembershipService.cs @@ -0,0 +1,45 @@ +using System.Collections.Immutable; +using Connected.Security; +using Connected.Security.Membership; +using Connected.ServiceModel; +using Connected.Services; +using Connected.Services.Annotations; + +namespace Common.Security.Membership; + +internal class MembershipService : EntityService, IMembershipService +{ + public MembershipService(IContext context) : base(context) + { + } + + [ServiceAuthorization(SecurityClaims.SecurityDelete)] + public async Task Delete(PrimaryKeyArgs args) + { + await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(SecurityClaims.SecurityRead)] + public async Task Insert(MembershipArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(SecurityClaims.SecurityRead)] + public async Task?> Query() + { + return await Invoke(GetOperation(), Dto.Empty); + } + + [ServiceAuthorization(SecurityClaims.SecurityRead)] + public async Task?> Query(MembershipQueryArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(SecurityClaims.SecurityRead)] + public async Task Select(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } +} diff --git a/Common/Security/Permissions/Permission.cs b/Common/Security/Permissions/Permission.cs new file mode 100644 index 0000000..d6b2c2a --- /dev/null +++ b/Common/Security/Permissions/Permission.cs @@ -0,0 +1,36 @@ +using Connected.Annotations; +using Connected.Entities.Annotations; +using Connected.Entities.Consistency; +using Connected.Security.Permissions; + +namespace Common.Security.Permissions; + +[Table(Schema = SchemaAttribute.SysSchema)] +internal sealed record Permission : ConsistentEntity, IPermission +{ + public const string CacheKey = $"{SchemaAttribute.SysSchema}.{nameof(Permission)}"; + + [Ordinal(0), Length(32)] + public string Evidence { get; init; } = default!; + + [Ordinal(1), Length(32)] + public string Schema { get; init; } = default!; + + [Ordinal(2), Length(32)] + public string Claim { get; init; } = default!; + + [Ordinal(3), Length(256), Nullable] + public string? PrimaryKey { get; init; } + + [Ordinal(4), Length(256), Nullable] + public string? Entity { get; init; } + + [Ordinal(5)] + public PermissionValue Value { get; init; } + + [Ordinal(6), Length(256), Nullable] + public string? Component { get; init; } + + [Ordinal(7), Length(256), Nullable] + public string? Method { get; init; } +} diff --git a/Common/Security/Permissions/PermissionCache.cs b/Common/Security/Permissions/PermissionCache.cs new file mode 100644 index 0000000..96904a3 --- /dev/null +++ b/Common/Security/Permissions/PermissionCache.cs @@ -0,0 +1,11 @@ +using Connected.Entities.Caching; + +namespace Common.Security.Permissions; + +internal interface IPermissionCache : IEntityCacheClient { } +internal class PermissionCache : EntityCacheClient, IPermissionCache +{ + public PermissionCache(IEntityCacheContext context) : base(context, Permission.CacheKey) + { + } +} diff --git a/Common/Security/Permissions/PermissionOps.cs b/Common/Security/Permissions/PermissionOps.cs new file mode 100644 index 0000000..33d386b --- /dev/null +++ b/Common/Security/Permissions/PermissionOps.cs @@ -0,0 +1,150 @@ +using System.Collections.Immutable; +using Connected; +using Connected.Entities; +using Connected.Entities.Storage; +using Connected.Notifications.Events; +using Connected.Security.Permissions; +using Connected.ServiceModel; +using Connected.Services; + +namespace Common.Security.Permissions; + +internal sealed class DeletePermission : ServiceAction> +{ + public DeletePermission(IPermissionService permissionService, IStorageProvider storage, IPermissionCache cache, IEventService events) + { + PermissionService = permissionService; + Storage = storage; + Cache = cache; + Events = events; + } + + private IPermissionService PermissionService { get; } + private IStorageProvider Storage { get; } + private IPermissionCache Cache { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + await Storage.Open().Update(new Permission { Id = Arguments.Id, State = State.Deleted }); + } + + protected override async Task OnCommitted() + { + await Cache.Refresh(Arguments.Id); + await Events.Enqueue(this, PermissionService, ServiceEvents.Deleted, Arguments.Id); + } +} + +internal sealed class QueryPermissions : ServiceFunction?> +{ + public QueryPermissions(IPermissionCache permissionCache) + { + PermissionCache = permissionCache; + } + + private IPermissionCache PermissionCache { get; } + protected override async Task?> OnInvoke() + { + return await (from dc in PermissionCache + select dc).AsEntities(); + } +} + +internal sealed class SearchPermissions : ServiceFunction?> +{ + public SearchPermissions(IPermissionCache permissionCache) + { + PermissionCache = permissionCache; + } + + private IPermissionCache PermissionCache { get; } + protected override async Task?> OnInvoke() + { + return await (from dc in PermissionCache + where (string.IsNullOrEmpty(Arguments.Entity) || string.Equals(Arguments.Entity, dc.Entity, StringComparison.OrdinalIgnoreCase)) + && (string.IsNullOrEmpty(Arguments.Claim) || string.Equals(Arguments.Claim, dc.Claim, StringComparison.OrdinalIgnoreCase)) + && (string.IsNullOrEmpty(Arguments.PrimaryKey) || string.Equals(Arguments.PrimaryKey, dc.PrimaryKey, StringComparison.OrdinalIgnoreCase)) + select dc).AsEntities(); + } +} + +internal sealed class SelectPermission : ServiceFunction, IPermission?> +{ + public SelectPermission(IPermissionCache permissionCache) + { + PermissionCache = permissionCache; + } + + private IPermissionCache PermissionCache { get; } + protected override async Task OnInvoke() + { + return await (from dc in PermissionCache + where dc.Id == Arguments.Id + select dc).AsEntity(); + } +} + +internal sealed class InsertPermission : ServiceFunction +{ + public InsertPermission(IPermissionService permissionService, IStorageProvider storage, IEventService events, IPermissionCache cache) + { + PermissionService = permissionService; + Storage = storage; + Events = events; + Cache = cache; + } + + private IPermissionService PermissionService { get; } + private IStorageProvider Storage { get; } + private IEventService Events { get; } + private IPermissionCache Cache { get; } + + protected override async Task OnInvoke() + { + var entity = Arguments.AsEntity(State.New); + var result = await Storage.Open().Update(entity); + + return result.Id; + } + + protected override async Task OnCommitted() + { + await Cache.Refresh(Result); + await Events.Enqueue(this, PermissionService, ServiceEvents.Inserted, Result); + } +} + +internal sealed class UpdatePermission : ServiceAction +{ + public UpdatePermission(IPermissionService permissionService, IStorageProvider storage, IPermissionCache cache, IEventService events) + { + PermissionService = permissionService; + Storage = storage; + Cache = cache; + Events = events; + } + + private IPermissionService PermissionService { get; } + private IStorageProvider Storage { get; } + private IPermissionCache Cache { get; } + private IEventService Events { get; } + + protected override async Task OnInvoke() + { + await Storage.Open().Update(await Load(), Arguments, async () => + { + await Cache.Refresh(Arguments.Id); + + return await Load(); + }); + } + + private async Task Load() => await (from dc in Cache where dc.Id == Arguments.Id select dc).AsEntity(); + + protected override async Task OnCommitted() + { + await Cache.Refresh(Arguments.Id); + await Events.Enqueue(this, PermissionService, ServiceEvents.Updated, Arguments.Id); + } +} \ No newline at end of file diff --git a/Common/Security/Permissions/PermissionService.cs b/Common/Security/Permissions/PermissionService.cs new file mode 100644 index 0000000..763a4b1 --- /dev/null +++ b/Common/Security/Permissions/PermissionService.cs @@ -0,0 +1,51 @@ +using System.Collections.Immutable; +using Connected.Security; +using Connected.Security.Permissions; +using Connected.ServiceModel; +using Connected.Services; +using Connected.Services.Annotations; + +namespace Common.Security.Permissions; + +internal class PermissionService : EntityService, IPermissionService +{ + public PermissionService(IContext context) : base(context) + { + } + + [ServiceAuthorization(SecurityClaims.SecurityDelete)] + public async Task Delete(PrimaryKeyArgs args) + { + await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(SecurityClaims.SecurityAdd)] + public async Task Insert(PermissionArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(SecurityClaims.SecurityRead, Stage = AuthorizationStage.Result)] + public async Task?> Query() + { + return await Invoke(GetOperation(), Dto.Empty); + } + + [ServiceAuthorization(SecurityClaims.SecurityRead, Stage = AuthorizationStage.Result)] + public async Task?> Query(PermissionSearchArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(SecurityClaims.SecurityRead, Stage = AuthorizationStage.Result)] + public async Task Select(PrimaryKeyArgs args) + { + return await Invoke(GetOperation(), args); + } + + [ServiceAuthorization(SecurityClaims.SecurityModify)] + public async Task Update(PermissionUpdateArgs args) + { + await Invoke(GetOperation(), args); + } +} diff --git a/Common/Settings/SettingsService.cs b/Common/Settings/SettingsService.cs new file mode 100644 index 0000000..d066324 --- /dev/null +++ b/Common/Settings/SettingsService.cs @@ -0,0 +1,37 @@ +using System.Collections.Immutable; +using Connected.Configuration.Settings; +using Connected.ServiceModel; +using Connected.Services; + +namespace Common.Settings; +internal class SettingsService : EntityService, ISettingsService +{ + public SettingsService(IContext context) : base(context) + { + } + + public Task Delete(PrimaryKeyArgs args) + { + throw new NotImplementedException(); + } + + public Task> Query() + { + throw new NotImplementedException(); + } + + public Task Select(PrimaryKeyArgs args) + { + throw new NotImplementedException(); + } + + public Task Select(NameArgs args) + { + throw new NotImplementedException(); + } + + public Task Update(SettingsArgs args) + { + throw new NotImplementedException(); + } +}