diff --git a/zero.Core/Unimplemented/PreviewApi.cs b/zero.Core/Unimplemented/PreviewApi.cs deleted file mode 100644 index 7363049e..00000000 --- a/zero.Core/Unimplemented/PreviewApi.cs +++ /dev/null @@ -1,75 +0,0 @@ -//using System; -//using System.Threading.Tasks; -//using zero.Core.Collections; -//using zero.Core.Entities; -//using zero.Core.Extensions; -//using Rc = Raven.Client; - -//namespace zero.Core.Api -//{ -// public class PreviewApi : BackofficeApi, IPreviewApi -// { -// Preview Blueprint; - -// public PreviewApi(IStoreContext store, Preview blueprint) : base(store) -// { -// Blueprint = blueprint; -// } - - -// /// -// public async Task> Add(TEntity model) where TEntity : ZeroEntity -// { -// return await Update(null, model); -// } - - -// /// -// public async Task> Update(string id, TEntity model) where TEntity : ZeroEntity -// { -// Preview entity = id == null ? await GetById(id) : Blueprint.Clone(); -// entity.Content = model; -// entity.OriginalId = model.Id; -// entity.Name = model.Name; - -// return await SaveModel(entity, meta: x => -// { -// x[Rc.Constants.Documents.Metadata.Expires] = GetExpiry(model); -// }); -// } - - -// /// -// public async Task GetById(string id) -// { -// return await GetById(id); -// } - - -// /// -// /// Get preview expiration for a document -// /// -// DateTime GetExpiry(ZeroEntity model) -// { -// return DateTime.Now.AddHours(1); -// } -// } - -// public interface IPreviewApi -// { -// /// -// /// Adds an entity to the preview collection. This will generate a preview with an id which can be used for the preview view. -// /// -// Task> Add(TEntity model) where TEntity : ZeroEntity; - -// /// -// /// Updates an entity in the preview collection. This will generate a preview with an id which can be used for the preview view. -// /// -// Task> Update(string id, TEntity model) where TEntity : ZeroEntity; - -// /// -// /// Get preview entity by Id -// /// -// Task GetById(string id); -// } -//} diff --git a/zero.sln b/zero.sln index 57a79e97..9aaf0b4b 100644 --- a/zero.sln +++ b/zero.sln @@ -13,6 +13,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "plugins", "plugins", "{6FAA EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "zero.Core", "zero.Core\zero.Core.csproj", "{CED2A7FA-8D35-4A2A-AF57-8B95307547CB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "zero", "zero\zero.csproj", "{33CD6E46-CD81-42C3-9019-C0EAD557EE99}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "legacy", "legacy", "{93A1CCEB-A323-445E-8116-4575C5939B46}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -35,6 +39,10 @@ Global {CED2A7FA-8D35-4A2A-AF57-8B95307547CB}.Debug|Any CPU.Build.0 = Debug|Any CPU {CED2A7FA-8D35-4A2A-AF57-8B95307547CB}.Release|Any CPU.ActiveCfg = Release|Any CPU {CED2A7FA-8D35-4A2A-AF57-8B95307547CB}.Release|Any CPU.Build.0 = Release|Any CPU + {33CD6E46-CD81-42C3-9019-C0EAD557EE99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {33CD6E46-CD81-42C3-9019-C0EAD557EE99}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33CD6E46-CD81-42C3-9019-C0EAD557EE99}.Release|Any CPU.ActiveCfg = Release|Any CPU + {33CD6E46-CD81-42C3-9019-C0EAD557EE99}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -43,6 +51,7 @@ Global {63A8AD15-15F0-4555-BD62-C38B4465FFC1} = {6FAA92A8-CA1A-48EA-9857-C9092971B4D1} {C23CF90A-DB90-427F-816C-0E2FE20E29D0} = {6FAA92A8-CA1A-48EA-9857-C9092971B4D1} {A8E5F34F-5400-4F92-96C6-7F91645BC220} = {6FAA92A8-CA1A-48EA-9857-C9092971B4D1} + {CED2A7FA-8D35-4A2A-AF57-8B95307547CB} = {93A1CCEB-A323-445E-8116-4575C5939B46} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AD3F31B9-8BBB-4334-9571-F556B9E632C9} diff --git a/zero/ApplicationBuilderExtensions.cs b/zero/ApplicationBuilderExtensions.cs new file mode 100644 index 00000000..748ecebd --- /dev/null +++ b/zero/ApplicationBuilderExtensions.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Builder; + +namespace zero; + +public static class ApplicationBuilderExtensions +{ + public static IZeroApplicationBuilder UseZero(this IApplicationBuilder app) => new ZeroApplicationBuilder(app); +} diff --git a/zero/Architecture/AssemblyDiscovery/AssemblyDiscovery.cs b/zero/Architecture/AssemblyDiscovery/AssemblyDiscovery.cs new file mode 100644 index 00000000..c1ffceaf --- /dev/null +++ b/zero/Architecture/AssemblyDiscovery/AssemblyDiscovery.cs @@ -0,0 +1,161 @@ +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyModel; +using System.Reflection; + +namespace zero.Architecture; + +public class AssemblyDiscovery : IAssemblyDiscovery +{ + public static IAssemblyDiscovery Current; + + AssemblyDiscoveryContext Context; + + IMvcBuilder Mvc; + + + + public AssemblyDiscovery(IMvcBuilder mvc) + { + Mvc = mvc; + Context = new AssemblyDiscoveryContext(); + Current = this; + } + + + /// + public void Execute(IEnumerable rules) + { + List assemblies = new List(); + DependencyContext dependencyContext = DependencyContext.Load(Context.EntryAssembly); + + if (dependencyContext == null) + { + return; + } + + string[] existingLibs = Mvc.PartManager.ApplicationParts.OfType().Select(p => p.Name).ToArray(); + + IEnumerable libraries = dependencyContext.RuntimeLibraries.Where(lib => !existingLibs.Contains(lib.Name, StringComparer.OrdinalIgnoreCase)); + + foreach (RuntimeLibrary library in libraries) + { + if (rules.Any(rule => rule.IsValid(library, Context))) + { + IEnumerable libraryAssemblies = library.GetDefaultAssemblyNames(dependencyContext).Select(name => Assembly.Load(name)); + + foreach (Assembly assembly in libraryAssemblies) + { + Mvc.AddApplicationPart(assembly); + } + } + } + } + + + /// + public void AddAssembly(Assembly assembly) + { + string[] existingLibs = Mvc.PartManager.ApplicationParts.OfType().Select(p => p.Name).ToArray(); + + if (!existingLibs.Contains(assembly.GetName().Name)) + { + Mvc.AddApplicationPart(assembly); + } + } + + + /// + public IEnumerable GetTypes(bool allowGenerics = false) => GetTypes(typeof(TService), allowGenerics); + + /// + public IEnumerable GetTypes(IEnumerable parts, bool allowGenerics = false) => GetTypes(typeof(TService), parts, allowGenerics); + + /// + public IEnumerable GetTypes(Type serviceType, bool allowGenerics = false) => GetAllClassTypes(allowGenerics).Where(t => serviceType.GetTypeInfo().IsAssignableFrom(t) && t.AsType() != serviceType); + + /// + public IEnumerable GetTypes(Type serviceType, IEnumerable parts, bool allowGenerics = false) => GetAllClassTypes(parts, allowGenerics).Where(t => serviceType.GetTypeInfo().IsAssignableFrom(t) && t.AsType() != serviceType); + + /// + public IEnumerable GetAllClassTypes(bool allowGenerics = false) => GetAllTypes().Where(t => t.IsClass && !t.IsAbstract && (allowGenerics || !t.ContainsGenericParameters)); + + /// + public IEnumerable GetAllClassTypes(IEnumerable parts, bool allowGenerics = false) => GetAllTypes(parts).Where(t => t.IsClass && !t.IsAbstract && (allowGenerics || !t.ContainsGenericParameters)); + + /// + public IEnumerable GetAssemblies() => Mvc.PartManager.ApplicationParts.OfType().Select(p => p.Assembly); + + /// + public IEnumerable GetAssemblies(IEnumerable parts) => parts.OfType().Select(p => p.Assembly); + + /// + public IEnumerable GetAllTypes() => Mvc.PartManager.ApplicationParts.OfType().SelectMany(p => p.Types); + + /// + public IEnumerable GetAllTypes(IEnumerable parts) => parts.OfType().SelectMany(p => p.Types); +} + + +public interface IAssemblyDiscovery +{ + /// + /// Discovers runtime assemblies based on the given rules + /// + void Execute(IEnumerable rules); + + /// + /// Manually add an assembly + /// + void AddAssembly(Assembly assembly); + + /// + /// Get all discovered types which implement a certain service + /// + IEnumerable GetTypes(bool allowGenerics = false); + + /// + /// Get all discovered types which implement a certain service + /// + IEnumerable GetTypes(IEnumerable parts, bool allowGenerics = false); + + /// + /// Get all discovered types which implement a certain service + /// + IEnumerable GetTypes(Type serviceType, bool allowGenerics = false); + + /// + /// Get all discovered types which implement a certain service + /// + IEnumerable GetTypes(Type serviceType, IEnumerable parts, bool allowGenerics = false); + + /// + /// Get all registered types + /// + IEnumerable GetAllTypes(); + + /// + /// Get all registered types + /// + IEnumerable GetAllTypes(IEnumerable parts); + + /// + /// Get all registered types + /// + IEnumerable GetAllClassTypes(bool allowGenerics = false); + + /// + /// Get all registered types + /// + IEnumerable GetAllClassTypes(IEnumerable parts, bool allowGenerics = false); + + /// + /// Get all registered assemblies + /// + IEnumerable GetAssemblies(); + + /// + /// Get all registered assemblies + /// + IEnumerable GetAssemblies(IEnumerable parts); +} diff --git a/zero/Architecture/AssemblyDiscovery/AssemblyDiscoveryContext.cs b/zero/Architecture/AssemblyDiscovery/AssemblyDiscoveryContext.cs new file mode 100644 index 00000000..33cd0836 --- /dev/null +++ b/zero/Architecture/AssemblyDiscovery/AssemblyDiscoveryContext.cs @@ -0,0 +1,26 @@ +using System.Reflection; + +namespace zero.Architecture; + +public class AssemblyDiscoveryContext +{ + public Assembly EntryAssembly { get; private set; } + + public string EntryAssemblyName { get; private set; } + + public bool HasEntryAssembly => EntryAssembly != null; + + + public AssemblyDiscoveryContext() + { + EntryAssembly = Assembly.GetEntryAssembly(); + EntryAssemblyName = EntryAssembly?.GetName().Name; + } + + + public AssemblyDiscoveryContext(Assembly entryAssembly) + { + EntryAssembly = entryAssembly; + EntryAssemblyName = entryAssembly.GetName().Name; + } +} diff --git a/zero/Architecture/AssemblyDiscovery/IAssemblyDiscoveryRule.cs b/zero/Architecture/AssemblyDiscovery/IAssemblyDiscoveryRule.cs new file mode 100644 index 00000000..d2f4a5ab --- /dev/null +++ b/zero/Architecture/AssemblyDiscovery/IAssemblyDiscoveryRule.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.DependencyModel; + +namespace zero.Architecture; + +public interface IAssemblyDiscoveryRule +{ + /// + /// Returns true if the specified runtime library should be added to + /// the ApplicationPartManager; otherwise false. + /// + bool IsValid(RuntimeLibrary library, AssemblyDiscoveryContext context); +} \ No newline at end of file diff --git a/zero/Architecture/AssemblyDiscovery/ZeroAssemblyDiscoveryRule.cs b/zero/Architecture/AssemblyDiscovery/ZeroAssemblyDiscoveryRule.cs new file mode 100644 index 00000000..be1f7ff7 --- /dev/null +++ b/zero/Architecture/AssemblyDiscovery/ZeroAssemblyDiscoveryRule.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyModel; + +namespace zero.Architecture; + +public class ZeroAssemblyDiscoveryRule : IAssemblyDiscoveryRule +{ + const string ZERO_PREFIX = "zero."; + + /// + public bool IsValid(RuntimeLibrary library, AssemblyDiscoveryContext context) + { + StringComparison casing = StringComparison.OrdinalIgnoreCase; + // TODO we need to auto-add assemblies and discover their types which have implementations of IZeroPlugin + return library.Name.StartsWith(ZERO_PREFIX, casing) || (context.HasEntryAssembly && library.Name.Contains(context.EntryAssemblyName, casing)); + } +} \ No newline at end of file diff --git a/zero/Architecture/ZeroModule.cs b/zero/Architecture/ZeroModule.cs new file mode 100644 index 00000000..959cdea8 --- /dev/null +++ b/zero/Architecture/ZeroModule.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace zero.Architecture; + +public abstract class ZeroModule : IZeroModule +{ + /// + public virtual int Order { get; } = 0; + + /// + public virtual int ConfigureOrder => Order; + + /// + public virtual void ConfigureServices(IServiceCollection services, IConfiguration configuration) { } + + /// + public virtual void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) { } +} + + +public interface IZeroModule +{ + /// + /// Get the value to use to order startups to configure services. The default is 0. + /// + int Order { get; } + + /// + /// Get the value to use to order startups to build the pipeline. The default is the 'Order' property. + /// + int ConfigureOrder { get; } + + /// + /// This method gets called by the runtime. Use this method to add services to the container. + /// For more information on how to configure your application, visit http://go.microsoft.com/fwlink/?LinkID=398940 + /// + /// The collection of service descriptors. + void ConfigureServices(IServiceCollection services, IConfiguration configuration); + + /// + /// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + /// + /// + /// + /// + void Configure(IApplicationBuilder builder, IEndpointRouteBuilder routes, IServiceProvider serviceProvider); +} \ No newline at end of file diff --git a/zero/Architecture/ZeroModuleCollection.cs b/zero/Architecture/ZeroModuleCollection.cs new file mode 100644 index 00000000..8ae3278b --- /dev/null +++ b/zero/Architecture/ZeroModuleCollection.cs @@ -0,0 +1,70 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Concurrent; + +namespace zero.Architecture; + +public class ZeroModuleCollection : ZeroModule +{ + ConcurrentDictionary _modules = new(); + + + /// + /// Get all registered modules + /// + public IEnumerable GetAll() => _modules.Values; + + + /// + /// Adds a zero module + /// + public void Add() where T : class, IZeroModule, new() + { + Add(new T()); + } + + + /// + /// Adds a zero module + /// + public void Add(T moduleInstance) where T : IZeroModule + { + Add(typeof(T), moduleInstance); + } + + + /// + /// Adds a zero module + /// + public void Add(Type moduleType, IZeroModule moduleInstance) + { + if (_modules.ContainsKey(moduleType)) + { + return; + } + + _modules.TryAdd(moduleType, moduleInstance); + } + + + /// + public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + foreach (var module in _modules.OrderBy(x => x.Value.Order)) + { + module.Value.ConfigureServices(services, configuration); + } + } + + + /// + public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + { + foreach (var module in _modules.OrderBy(x => x.Value.ConfigureOrder)) + { + module.Value.Configure(app, routes, serviceProvider); + } + } +} \ No newline at end of file diff --git a/zero/Communication/Handlers/IHandler.cs b/zero/Communication/Handlers/IHandler.cs new file mode 100644 index 00000000..c2ea5d02 --- /dev/null +++ b/zero/Communication/Handlers/IHandler.cs @@ -0,0 +1,5 @@ +namespace zero.Communication; + +public interface IHandler +{ +} diff --git a/zero/Communication/Handlers/IHandlerHolder.cs b/zero/Communication/Handlers/IHandlerHolder.cs new file mode 100644 index 00000000..096c88ef --- /dev/null +++ b/zero/Communication/Handlers/IHandlerHolder.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace zero.Communication; + +public class HandlerHolder : IHandlerHolder +{ + IServiceProvider Services { get; } + + public HandlerHolder(IServiceProvider services) + { + Services = services; + } + + + public T Get() where T : IHandler + { + return Services.GetService(); + } + + + public IEnumerable GetAll() where T : IHandler + { + return Services.GetService>(); + } +} + +public interface IHandlerHolder +{ + T Get() where T : IHandler; + + IEnumerable GetAll() where T : IHandler; +} \ No newline at end of file diff --git a/zero/Communication/Interceptors/Interceptor.cs b/zero/Communication/Interceptors/Interceptor.cs new file mode 100644 index 00000000..f47498c3 --- /dev/null +++ b/zero/Communication/Interceptors/Interceptor.cs @@ -0,0 +1,207 @@ +namespace zero.Communication; + + +public abstract partial class Interceptor : Interceptor, IInterceptor where T : ZeroIdEntity +{ + /// + public Type ModelType { get; protected set; } + + + public Interceptor() + { + Gravity = 0; + ModelType = typeof(T); + Name = ModelType.Name; + } + + + /// + public override bool CanHandle(InterceptorParameters args, Type modelType) => ModelType.IsAssignableFrom(modelType); + + /// + public virtual Task> Creating(InterceptorParameters args, T model) => Task.FromResult>(default); + + /// + public sealed override async Task> Creating(InterceptorParameters args, ZeroIdEntity model) => Convert(await Creating(args, model as T)); + + /// + public virtual Task> Updating(InterceptorParameters args, T model) => Task.FromResult>(default); + + /// + public sealed override async Task> Updating(InterceptorParameters args, ZeroIdEntity model) => Convert(await Updating(args, model as T)); + + /// + public virtual Task> Deleting(InterceptorParameters args, T model) => Task.FromResult>(default); + + /// + public sealed override async Task> Deleting(InterceptorParameters args, ZeroIdEntity model) => Convert(await Deleting(args, model as T)); + + /// + public virtual Task Created(InterceptorParameters args, T model) => Task.CompletedTask; + + /// + public sealed override Task Created(InterceptorParameters args, ZeroIdEntity model) => Created(args, model as T); + + /// + public virtual Task Updated(InterceptorParameters args, T model) => Task.CompletedTask; + + /// + public sealed override Task Updated(InterceptorParameters args, ZeroIdEntity model) => Updated(args, model as T); + + /// + public virtual Task Deleted(InterceptorParameters args, T model) => Task.CompletedTask; + + /// + public sealed override Task Deleted(InterceptorParameters args, ZeroIdEntity model) => Deleted(args, model as T); + + + InterceptorResult Convert(InterceptorResult result) + { + if (result == default) + { + return default; + } + + return new InterceptorResult() + { + Continue = result.Continue, + InterceptorHash = result.InterceptorHash, + Result = result.Result != null ? result.Result.ConvertTo(result.Result.Model) : null + }; + } +} + + +public abstract partial class Interceptor : IInterceptor +{ + /// + public int Gravity { get; protected set; } + + /// + public string Name { get; protected set; } + + /// + public string Hash { get; protected set; } + + + public Interceptor() + { + Gravity = 0; + Hash = IdGenerator.Create(); + } + + + /// + public virtual bool CanHandle(InterceptorParameters args, Type modelType) => true; + + /// + public virtual Task> Creating(InterceptorParameters args, ZeroIdEntity model) => Task.FromResult>(default); + + /// + public virtual Task> Updating(InterceptorParameters args, ZeroIdEntity model) => Task.FromResult>(default); + + /// + public virtual Task> Deleting(InterceptorParameters args, ZeroIdEntity model) => Task.FromResult>(default); + + /// + public virtual Task Created(InterceptorParameters args, ZeroIdEntity model) => Task.CompletedTask; + + /// + public virtual Task Updated(InterceptorParameters args, ZeroIdEntity model) => Task.CompletedTask; + + /// + public virtual Task Deleted(InterceptorParameters args, ZeroIdEntity model) => Task.CompletedTask; +} + + +public interface IInterceptor : IInterceptor where T : ZeroIdEntity +{ + /// + /// Type of the associated model + /// + Type ModelType { get; } + + /// + /// Called after an entity has been stored but before the session has saved its changes + /// + Task Created(InterceptorParameters args, T model); + + /// + /// Called before an entity is stored and validated + /// + Task> Creating(InterceptorParameters args, T model); + + /// + /// Called after an entity has been deleted but before the session has saved its changes + /// + Task Deleted(InterceptorParameters args, T model); + + /// + /// Called before an entity is deleted + /// + Task> Deleting(InterceptorParameters args, T model); + + /// + /// Called after an entity has been updated but before the session has saved its changes + /// + Task Updated(InterceptorParameters args, T model); + + /// + /// Called before an entity is stored and validated + /// + Task> Updating(InterceptorParameters args, T model); +} + + +public interface IInterceptor +{ + /// + /// Interceptors with a higher gravity will run before any with lower gravity + /// + int Gravity { get; } + + /// + /// Readable name for this interceptor + /// + string Name { get; } + + /// + /// Hash which is used for this interceptor to be able to pass results from Start to Complete methods + /// + string Hash { get; } + + /// + /// Whether any of the interceptor methods is allowed to run based on the parameters + /// + bool CanHandle(InterceptorParameters args, Type modelType); + + /// + /// Called after an entity has been stored but before the session has saved its changes + /// + Task Created(InterceptorParameters args, ZeroIdEntity model); + + /// + /// Called before an entity is stored and validated + /// + Task> Creating(InterceptorParameters args, ZeroIdEntity model); + + /// + /// Called after an entity has been deleted but before the session has saved its changes + /// + Task Deleted(InterceptorParameters args, ZeroIdEntity model); + + /// + /// Called before an entity is deleted + /// + Task> Deleting(InterceptorParameters args, ZeroIdEntity model); + + /// + /// Called after an entity has been updated but before the session has saved its changes + /// + Task Updated(InterceptorParameters args, ZeroIdEntity model); + + /// + /// Called before an entity is stored and validated + /// + Task> Updating(InterceptorParameters args, ZeroIdEntity model); +} \ No newline at end of file diff --git a/zero/Communication/Interceptors/InterceptorInstruction.cs b/zero/Communication/Interceptors/InterceptorInstruction.cs new file mode 100644 index 00000000..9b88512d --- /dev/null +++ b/zero/Communication/Interceptors/InterceptorInstruction.cs @@ -0,0 +1,148 @@ +using Microsoft.Extensions.Logging; + +namespace zero.Communication; + +public class InterceptorInstruction where T : ZeroIdEntity, new() +{ + public Guid Guid { get; private set; } + + public InterceptorRunType Runtype { get; private set; } + + public T Model { get; private set; } + + public T PreviousModel { get; private set; } + + public Type ModelType { get; private set; } + + public Result Result { get; private set; } + + protected IZeroContext Context { get; private set; } + + protected Lazy> Interceptors { get; private set; } + + protected Dictionary InterceptorCache { get; private set; } = new(); + + protected IInterceptors InterceptorHandler { get; private set; } + + protected ILogger Logger { get; private set; } + + protected Func InterceptorFilter { get; private set; } = x => true; + + + internal InterceptorInstruction(IInterceptors interceptors, IZeroContext context, Lazy> registrations, ILogger logger, InterceptorRunType runtype, T model, T previousModel = null) + { + InterceptorHandler = interceptors; + Context = context; + Guid = Guid.NewGuid(); + Logger = logger; + Runtype = runtype; + Model = model; + PreviousModel = previousModel; + Interceptors = registrations; + + ModelType = model.GetType(); + } + + + /// + /// Custom interceptor filter for this instruction (the CanHandle() method for each interceptor is still activated) + /// + public void Filter(Func predicate) + { + InterceptorFilter = predicate; + } + + + /// + /// Run all interceptors (in order) which can handle the given type. + /// Depending on the action any of the following methods on the interceptor is called: Creating(), Updating(), Deleting(). + /// If any of the interceptors returns a result the operation is cancelled and this result is returned. + /// + public async Task Start(IStoreOperations operations) + { + foreach (IInterceptor interceptor in GetInterceptors()) + { + InterceptorParameters parameters = new() + { + Context = Context, + Store = Context.Store, + Properties = new(), + Interceptors = InterceptorHandler, + Operations = operations, + PreviousModel = PreviousModel + }; + + if (!interceptor.CanHandle(parameters, ModelType)) + { + continue; + } + + + Logger.LogDebug("Run interceptor {interceptor} for {type}:{operation}", interceptor.Name, ModelType, Runtype); + + InterceptorResult result = (await HandleBefore(interceptor, parameters)) ?? new(); + result.InterceptorHash = IdGenerator.Create(32); + + InterceptorCache.Add(interceptor, parameters); + + // we cancel all further interceptors if a result is available and return this instead + if (result.Result != null) + { + Result = result.Result.ConvertTo(result.Result.Model as T); + return false; + } + + // the Continue task will cancel all further interceptors + if (!result.Continue) + { + break; + } + } + + return true; + } + + + /// + /// Run all interceptors (in order) which can handle the given type. + /// Depending on the action any of the following methods on the interceptor is called: Created(), Updated(), Deleted(). + /// The parameters which are returned from the Start() operation are passed to the methods. + /// + public async Task Complete() + { + foreach ((IInterceptor interceptor, InterceptorParameters parameters) in InterceptorCache) + { + await HandleAfter(interceptor, parameters); + } + } + + + protected IEnumerable GetInterceptors() + { + return Interceptors.Value.Where(InterceptorFilter).OrderByDescending(x => x.Gravity); + } + + + /// + /// Proxy for handling methods on an interceptor + /// + protected Task> HandleBefore(IInterceptor interceptor, InterceptorParameters parameters) => Runtype switch + { + InterceptorRunType.Create => interceptor.Creating(parameters, Model), + InterceptorRunType.Update => interceptor.Updating(parameters, Model), + InterceptorRunType.Delete => interceptor.Deleting(parameters, Model), + _ => throw new NotImplementedException() + }; + + + /// + /// Proxy for handling methods on an interceptor + /// + protected Task HandleAfter(IInterceptor interceptor, InterceptorParameters parameters) => Runtype switch + { + InterceptorRunType.Create => interceptor.Created(parameters, Model), + InterceptorRunType.Update => interceptor.Updated(parameters, Model), + InterceptorRunType.Delete => interceptor.Deleted(parameters, Model), + _ => throw new NotImplementedException() + }; +} \ No newline at end of file diff --git a/zero/Communication/Interceptors/InterceptorParameters.cs b/zero/Communication/Interceptors/InterceptorParameters.cs new file mode 100644 index 00000000..2e36a170 --- /dev/null +++ b/zero/Communication/Interceptors/InterceptorParameters.cs @@ -0,0 +1,44 @@ +namespace zero.Communication; + +public class InterceptorParameters +{ + /// + /// The current zero context + /// + public IZeroContext Context { get; set; } + + /// + /// Raven document store + /// + public IZeroStore Store { get; set; } + + /// + /// Access to other interceptor methods + /// + public IInterceptors Interceptors { get; internal set; } + + /// + /// Access to operations + /// + public IStoreOperations Operations { get; internal set; } + + /// + /// Parameters from the interceptor which ran on before the operation (only available for completed operations) + /// + public Dictionary Properties { get; set; } = new(); + + /// + /// Get a typed property + /// + public TProp Property(string key) => Properties.GetValueOrDefault(key); + + /// + /// Get a typed property + /// + public bool TryGetProperty(string key, out TProp property) => Properties.TryGetValue(key, out property); + + /// + /// Holds a reference to the previously existing model when an update happens (can be null) + /// + public object PreviousModel { get; set; } +} \ No newline at end of file diff --git a/zero/Communication/Interceptors/InterceptorResult.cs b/zero/Communication/Interceptors/InterceptorResult.cs new file mode 100644 index 00000000..735ae3ba --- /dev/null +++ b/zero/Communication/Interceptors/InterceptorResult.cs @@ -0,0 +1,19 @@ +namespace zero.Communication; + +public class InterceptorResult +{ + /// + /// Autoset. Hash used to match interceptors in order to correctly pass parameters + /// + internal string InterceptorHash { get; set; } + + /// + /// Prevent further interceptors from running for this operation (only valid for the current interception type, e.g. Update/Created/Purge/...) + /// + public bool Continue { get; set; } = true; + + /// + /// Set a result. This will prevent further execution of the operation and cancels all subsequent interceptors + /// + public Result Result { get; set; } +} \ No newline at end of file diff --git a/zero/Communication/Interceptors/InterceptorRunType.cs b/zero/Communication/Interceptors/InterceptorRunType.cs new file mode 100644 index 00000000..82be301e --- /dev/null +++ b/zero/Communication/Interceptors/InterceptorRunType.cs @@ -0,0 +1,8 @@ +namespace zero.Communication; + +public enum InterceptorRunType +{ + Create = 1, + Update = 2, + Delete = 3 +} \ No newline at end of file diff --git a/zero/Communication/Interceptors/Interceptors.cs b/zero/Communication/Interceptors/Interceptors.cs new file mode 100644 index 00000000..297f1676 --- /dev/null +++ b/zero/Communication/Interceptors/Interceptors.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Logging; + +namespace zero.Communication; + +public class Interceptors : IInterceptors +{ + protected IZeroContext Context { get; set; } + + protected Lazy> Registrations { get; set; } + + protected ILogger Logger { get; set; } + + + public Interceptors(IZeroContext context, Lazy> registrations, ILogger logger) + { + Context = context; + Registrations = registrations; + Logger = logger; + } + + + /// + public InterceptorInstruction ForCreate(T model) where T : ZeroIdEntity, new() => new(this, Context, Registrations, Logger, InterceptorRunType.Create, model); + + /// + public InterceptorInstruction ForUpdate(T model, T previousModel = null) where T : ZeroIdEntity, new() => new(this, Context, Registrations, Logger, InterceptorRunType.Update, model, previousModel); + + /// + public InterceptorInstruction ForDelete(T model) where T : ZeroIdEntity, new() => new(this, Context, Registrations, Logger, InterceptorRunType.Delete, model); +} + + +public interface IInterceptors +{ + /// + /// Instruction which can run interceptors before and after a creating an entity + /// + InterceptorInstruction ForCreate(T model) where T : ZeroIdEntity, new(); + + /// + /// Instruction which can run interceptors before and after updating an entity + /// + InterceptorInstruction ForUpdate(T model, T previousModel = null) where T : ZeroIdEntity, new(); + + /// + /// Instruction which can run interceptors before and after deleting an entity + /// + InterceptorInstruction ForDelete(T model) where T : ZeroIdEntity, new(); +} \ No newline at end of file diff --git a/zero/Communication/LazilyResolved.cs b/zero/Communication/LazilyResolved.cs new file mode 100644 index 00000000..0666dae4 --- /dev/null +++ b/zero/Communication/LazilyResolved.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace zero.Communication; + +public class LazilyResolved : Lazy +{ + public LazilyResolved(IServiceProvider serviceProvider): base(serviceProvider.GetRequiredService) + { + + } +} \ No newline at end of file diff --git a/zero/Communication/Messages/IMessage.cs b/zero/Communication/Messages/IMessage.cs new file mode 100644 index 00000000..4c03d107 --- /dev/null +++ b/zero/Communication/Messages/IMessage.cs @@ -0,0 +1,6 @@ + +namespace zero.Communication; + +public interface IMessage +{ +} diff --git a/zero/Communication/Messages/IMessageHandler.cs b/zero/Communication/Messages/IMessageHandler.cs new file mode 100644 index 00000000..a818e1de --- /dev/null +++ b/zero/Communication/Messages/IMessageHandler.cs @@ -0,0 +1,26 @@ +namespace zero.Communication; + +/// +/// Indicates a handler that can perform an action when a message of +/// a certain type is received. (could be an interface rather than concrete type) +/// +public interface IMessageHandler where TMessage : IMessage +{ + /// + /// Method to invoke when a message of type TMessage is published + /// + Task Handle(TMessage message); +} + + +/// +/// Indicates a handler that can perform an action when a message of +/// a certain type is received. (could be an interface rather than concrete type) +/// +public interface IBatchMessageHandler : IMessageHandler where TMessage : IMessage +{ + /// + /// Method to invoke when a batch of messages of type TMessage are published + /// + Task HandleBatchAsync(IReadOnlyCollection message); +} diff --git a/zero/Communication/Messages/MessageAggregator.cs b/zero/Communication/Messages/MessageAggregator.cs new file mode 100644 index 00000000..cd6a3f37 --- /dev/null +++ b/zero/Communication/Messages/MessageAggregator.cs @@ -0,0 +1,52 @@ +using System.Collections.Concurrent; + +namespace zero.Communication; + +public class MessageAggregator : IMessageAggregator +{ + readonly ConcurrentBag Subscription = new ConcurrentBag(); + readonly IServiceProvider ServiceProvider; + + + public MessageAggregator(IServiceProvider serviceProvider) + { + ServiceProvider = serviceProvider; + } + + + /// + public async Task Publish(TMessage message) where TMessage : class, IMessage + { + IEnumerable subscriptions = Subscription.Where(s => s.CanDeliver()); + + foreach (IMessageSubscription subscription in subscriptions) + { + await subscription.Deliver(ServiceProvider, message); + } + } + + + /// + public void Activate() + where TMessage : class, IMessage + where TMessageHandler : IMessageHandler + { + Subscription.Add(new MessageSubscription()); + } +} + + +public interface IMessageAggregator +{ + /// + /// Publishes the specified message, invoking any handlers subscribed to the message. + /// + Task Publish(TMessage message) where TMessage : class, IMessage; + + /// + /// Subscribes the specified handler to the spified message type + /// + void Activate() + where TMessage : class, IMessage + where TMessageHandler : IMessageHandler; +} diff --git a/zero/Communication/Messages/MessageSubscription.cs b/zero/Communication/Messages/MessageSubscription.cs new file mode 100644 index 00000000..3698f175 --- /dev/null +++ b/zero/Communication/Messages/MessageSubscription.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; + +namespace zero.Communication; + +internal class MessageSubscription : IMessageSubscription + where TMessage : class, IMessage + where TMessageHandler : IMessageHandler +{ + public bool CanDeliver() + { + return typeof(TMessage).GetTypeInfo().IsAssignableFrom(typeof(T)); + } + + public async Task Deliver(IServiceProvider serviceProvider, object message) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message)); + } + if (!(message is TMessage)) + { + throw new ArgumentException($"{ nameof(message) } must be of type '{typeof(TMessage).FullName}'"); + } + + var handlers = serviceProvider.GetServices(); + + foreach (var handler in handlers) + { + await handler.Handle((TMessage)message); + } + } +} + + +public interface IMessageSubscription +{ + bool CanDeliver(); + + Task Deliver(IServiceProvider serviceProvider, object message); +} diff --git a/zero/Communication/ZeroCommunicationModule.cs b/zero/Communication/ZeroCommunicationModule.cs new file mode 100644 index 00000000..bd3a05c4 --- /dev/null +++ b/zero/Communication/ZeroCommunicationModule.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace zero.Communication; + +internal class ZeroCommunicationModule : ZeroModule +{ + public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + services.AddScoped(); + services.AddTransient(); + services.AddTransient(typeof(Lazy<>), typeof(LazilyResolved<>)); + } +} \ No newline at end of file diff --git a/zero/Configuration/Constants.cs b/zero/Configuration/Constants.cs new file mode 100644 index 00000000..fa96aa14 --- /dev/null +++ b/zero/Configuration/Constants.cs @@ -0,0 +1,102 @@ +namespace zero.Configuration; + +public static partial class Constants +{ + public const string ErrorFieldNone = "__zero_no_field"; + + public static partial class Tabs + { + public const string General = "general"; + } + + public static partial class Auth + { + public const string SystemUser = "system"; + public const string DefaultScheme = "zeroScheme"; + public const string BackofficeDisplayName = "Zero Backoffice"; + public const string BackofficeScheme = "zeroBackoffice"; + public const string BackofficeCookieName = "zero.be.session"; + public const string DefaultCookieName = "zero.session"; + + public static partial class Claims + { + public const string IsZero = "zero.claim.iszero"; + public const string IsSuper = "zero.claim.issuper"; + public const string UserId = "zero.claim.userid"; + public const string UserName = "zero.claim.username"; + public const string Role = "zero.claim.rolealias"; + public const string SecurityStamp = "zero.claim.securitystamp"; + public const string Email = "zero.claim.email"; + public const string Permission = "zero.claim.permission"; + public const string DefaultAppId = "zero.claim.defaultAppId"; + public const string AppId = "zero.claim.appId"; + public const string TicketExpires = "zero.claim.ticketExpires"; + } + } + + + public static partial class Permissions + { + public static partial class Groups + { + public const string Sections = "zero.permissions.groups.sections"; + public const string Spaces = "zero.permissions.groups.spaces"; + public const string Settings = "zero.permissions.groups.settings"; + public const string Pages = "zero.permissions.groups.pages"; + public const string Modules = "zero.permissions.groups.modules"; + } + } + + + public static partial class Database + { + public const string ReservationPrefix = "zero."; + public const string Expires = Raven.Client.Constants.Documents.Metadata.Expires; + } + + public static partial class Sections + { + public const string Dashboard = "dashboard"; + public const string Pages = "pages"; + public const string Spaces = "spaces"; + public const string Media = "media"; + public const string Settings = "settings"; + } + + public static partial class Settings + { + public const string Updates = "updates"; + public const string Applications = "applications"; + public const string Users = "users"; + public const string Languages = "languages"; + public const string Translations = "translations"; + public const string Countries = "countries"; + public const string Mails = "mailTemplates"; + public const string Integrations = "integrations"; + public const string Logging = "logs"; + public const string Plugins = "plugins"; + public const string CreatePlugin = "createplugin"; + } + + public static partial class PermissionCollections + { + public const string Sections = "permissionCollectionSections"; + public const string Spaces = "permissionCollectionSpaces"; + public const string Settings = "permisssionCollectionSettings"; + public const string Modules = "permissionCollectionModules"; + } + + public static partial class Pages + { + public const string FolderAlias = "zero.folder"; + public const string DefaultRootPageTypeAlias = "root"; + public const string PageRouteProviderAlias = "zero.pages"; + } + + public static partial class Routing + { + public const string InternalRoutePrefix = "/__zero/"; + + public const string ErrorRoute = InternalRoutePrefix + "error"; + } +} diff --git a/zero/Configuration/IZeroCollectionOptions.cs b/zero/Configuration/IZeroCollectionOptions.cs new file mode 100644 index 00000000..1bd70b9e --- /dev/null +++ b/zero/Configuration/IZeroCollectionOptions.cs @@ -0,0 +1,6 @@ +namespace zero.Configuration; + +public interface IZeroCollectionOptions +{ + +} \ No newline at end of file diff --git a/zero/Configuration/OptionsType.cs b/zero/Configuration/OptionsType.cs new file mode 100644 index 00000000..44d73a4a --- /dev/null +++ b/zero/Configuration/OptionsType.cs @@ -0,0 +1,26 @@ +using FluentValidation; + +namespace zero.Configuration; + +public abstract class OptionsType +{ + /// + /// Type of the associated model class + /// + public Type ContentType { get; set; } + + /// + /// The alias + /// + public string Alias { get; set; } + + /// + /// The name of the options type + /// + public string Name { get; set; } + + /// + /// Set a validator for the editor + /// + public IValidator Validator { get; set; } +} \ No newline at end of file diff --git a/zero/Configuration/ZeroConfigurationModule.cs b/zero/Configuration/ZeroConfigurationModule.cs new file mode 100644 index 00000000..223c0047 --- /dev/null +++ b/zero/Configuration/ZeroConfigurationModule.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace zero.Configuration; + +internal class ZeroConfigurationModule : ZeroModule +{ + public override int Order => -1000; + + public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddOptions().Configure((opts, svc) => + { + opts.ServiceProvider = svc; + opts.Version = "1.0.0-alpha.1"; + opts.TokenExpiration = TimeSpan.FromHours(3); + }).Bind(configuration.GetSection("Zero")); + + services.AddTransient(factory => factory.GetService>().Value); + } +} \ No newline at end of file diff --git a/zero/Configuration/ZeroOptions.cs b/zero/Configuration/ZeroOptions.cs new file mode 100644 index 00000000..19e0b611 --- /dev/null +++ b/zero/Configuration/ZeroOptions.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using System.Collections.Concurrent; + +namespace zero.Configuration; + +public class ZeroOptions : IZeroOptions +{ + /// + public bool Initialized => !string.IsNullOrEmpty(For()?.Database); + + /// + public string Version { get; set; } + + /// + public TimeSpan TokenExpiration { get; set; } + + + internal IServiceProvider ServiceProvider { get; set; } + + protected ConcurrentDictionary OptionsCache { get; private set; } = new(); + + + /// + public TOptions For() where TOptions : class + { + Type type = typeof(TOptions); + + if (!OptionsCache.TryGetValue(type, out object value) && ServiceProvider != null) + { + IOptions options = ServiceProvider.GetService>(); + value = options.Value; + OptionsCache.TryAdd(type, value); + } + + return value as TOptions; + } +} + + +public interface IZeroOptions +{ + /// + /// The currently active version + /// This should not be set manually, as it is used for setup and migrations and incremented automatically + /// + string Version { get; set; } + + /// + /// Whether this zero instance is initialized (setup is completed) + /// + bool Initialized { get; } + + /// + /// Expiration of a generated change token for an entity + /// + TimeSpan TokenExpiration { get; set; } + + /// + /// Get typed options (proxy to IOptions) + /// + TOptions For() where TOptions : class; +} diff --git a/zero/Configuration/ZeroStartupOptions.cs b/zero/Configuration/ZeroStartupOptions.cs new file mode 100644 index 00000000..cc11bcb8 --- /dev/null +++ b/zero/Configuration/ZeroStartupOptions.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace zero.Configuration; + +public class ZeroStartupOptions : IZeroStartupOptions +{ + public IList AssemblyDiscoveryRules { get; private set; } = new List(); + + public IMvcBuilder Mvc { get; private set; } + + + public ZeroStartupOptions(IMvcBuilder mvc) + { + Mvc = mvc; + } +} + +public interface IZeroStartupOptions +{ + IList AssemblyDiscoveryRules { get; } + + IMvcBuilder Mvc { get; } +} \ No newline at end of file diff --git a/zero/Context/ZeroContext.cs b/zero/Context/ZeroContext.cs new file mode 100644 index 00000000..b7bfae84 --- /dev/null +++ b/zero/Context/ZeroContext.cs @@ -0,0 +1,113 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using System.Security.Claims; + +namespace zero.Context; + +public class ZeroContext : IZeroContext +{ + /// + public IZeroOptions Options { get; protected set; } + + /// + public IZeroStore Store { get; private set; } + + /// + public IServiceProvider Services { get; private set; } + + protected ICultureResolver CultureResolver { get; private set; } + + protected ILogger Logger { get; private set; } + + protected IHandlerHolder Handler { get; private set; } + + protected IHttpContextAccessor HttpContextAccessor { get; private set; } + + protected IPrimitiveTypeCollection ValueCollection { get; private set; } + + + bool _resolved = false; + + + public ZeroContext(IZeroOptions options, IHttpContextAccessor httpContextAccessor, ICultureResolver cultureResolver, + ILogger logger, IZeroStore store, IHandlerHolder handler, IServiceProvider services) + { + Options = options; + CultureResolver = cultureResolver; + Logger = logger; + Store = store; + Handler = handler; + ValueCollection = new PrimitiveTypeCollection(); + HttpContextAccessor = httpContextAccessor; + Services = services; + } + + + /// + public virtual async Task Resolve(HttpContext context) + { + if (_resolved) + { + return; + } + + _resolved = true; + + // set current culture + await CultureResolver.Resolve(this); + } + + + /// + public T Get() => ValueCollection.Get(); + + + /// + public void Set(T value) => ValueCollection.Set(value); + + + /// + public void Remove() => ValueCollection.Remove(); +} + + + +public interface IZeroContext +{ + /// + /// Global zero options + /// + IZeroOptions Options { get; } + + /// + /// Document store + /// + IZeroStore Store { get; } + + /// + /// Service container + /// + IServiceProvider Services { get; } + + /// + /// Resolves the current application (for backoffice + frontend requests) and + /// the currently active backoffice user, as users are not signed in with the default scheme and do therefore not populate HttpContext.User + /// + Task Resolve(HttpContext context); + + /// + /// Get a custom property from this scoped context + /// + T Get(); + + /// + /// Add a custom property to this scoped context + /// + void Set(T value); + + /// + /// Remove a custom property from this scoped context + /// + void Remove(); +} \ No newline at end of file diff --git a/zero/Context/ZeroContextMiddleware.cs b/zero/Context/ZeroContextMiddleware.cs new file mode 100644 index 00000000..c1325774 --- /dev/null +++ b/zero/Context/ZeroContextMiddleware.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Http; + +namespace zero.Context +{ + public class ZeroContextMiddleware + { + RequestDelegate _next; + + public ZeroContextMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task Invoke(HttpContext httpContext, IZeroContext zeroContext) + { + await zeroContext.Resolve(httpContext); + await _next(httpContext); + } + } +} diff --git a/zero/Context/ZeroContextModule.cs b/zero/Context/ZeroContextModule.cs new file mode 100644 index 00000000..63083b08 --- /dev/null +++ b/zero/Context/ZeroContextModule.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace zero.Context; + +internal class ZeroContextModule : ZeroModule +{ + public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + services.AddHttpContextAccessor(); + } +} \ No newline at end of file diff --git a/zero/Extensions/CharExtensions.cs b/zero/Extensions/CharExtensions.cs new file mode 100644 index 00000000..1560fc9b --- /dev/null +++ b/zero/Extensions/CharExtensions.cs @@ -0,0 +1,57 @@ +namespace zero.Extensions; + +public static class CharExtensions +{ + private static Dictionary accents { get; } = new Dictionary() + { + { 'ä', new char[2] { 'a', 'e' } }, + { 'á', new char[1] { 'a' } }, + { 'à', new char[1] { 'a' } }, + { 'ó', new char[1] { 'o' } }, + { 'ò', new char[1] { 'o' } }, + { 'é', new char[1] { 'e' } }, + { 'è', new char[1] { 'e' } }, + { 'ú', new char[1] { 'u' } }, + { 'ù', new char[1] { 'u' } }, + { 'í', new char[1] { 'i' } }, + { 'ì', new char[1] { 'i' } }, + { 'ö', new char[2] { 'o', 'e' } }, + { 'ü', new char[2] { 'u', 'e' } }, + { 'ß', new char[2] { 's', 's' } }, + { '&', new char[1] { '+' } } + }; + + + /// + /// Check if a character is from a-z, A-Z or 0-9 + /// + public static bool IsAZor09(this char value) + { + return (value >= 0x41 && value <= 0x5A) || (value >= 0x61 && value <= 0x7a) || (value >= 0x30 && value <= 0x39); + } + + + /// + /// Check if a character is in ASCII range + /// + public static bool IsASCII(this char value) + { + return value < 128; + } + + + /// + /// Replaces an accent or umlaut with the appropriate URL + file ready variant + /// + public static bool TryReplaceAccent(this char value, out char[] result) + { + if (!accents.ContainsKey(value)) + { + result = null; + return false; + } + + result = accents[value]; + return true; + } +} diff --git a/zero/Extensions/ColorExtensions.cs b/zero/Extensions/ColorExtensions.cs new file mode 100644 index 00000000..0d35f1d1 --- /dev/null +++ b/zero/Extensions/ColorExtensions.cs @@ -0,0 +1,58 @@ +using System.Drawing; + +namespace zero.Extensions; + +public static class ColorExtensions +{ + /// + /// Get color object from a hex string + /// + public static Color FromHex(this string color) + { + if (color.StartsWith("#")) + { + color = color[1..]; + } + + if (color.Length != 6) + { + throw new Exception("Hex color has to be in format #RRGGBB"); + } + + return Color.FromArgb( + int.Parse(color.Substring(0, 2), System.Globalization.NumberStyles.HexNumber), + int.Parse(color.Substring(2, 2), System.Globalization.NumberStyles.HexNumber), + int.Parse(color.Substring(4, 2), System.Globalization.NumberStyles.HexNumber) + ); + } + + + /// + /// Get contrast ratio between two color values + /// + /// + public static double GetContrastRatio(this Color color1, Color color2) + { + double lum1 = color1.GetLuminance(); + double lum2 = color2.GetLuminance(); + double brightest = Math.Max(lum1, lum2); + double darkest = Math.Min(lum1, lum2); + return (brightest + 0.05) / (darkest + 0.05); + } + + + /// + /// Get luminance value of a color based on WCAG-conform JS implementation + /// + /// + public static double GetLuminance(this Color color) + { + double[] a = new double[3] { color.R, color.G, color.B }.Select(v => + { + v /= 255; + return v <= 0.03928 ? v / 12.92 : Math.Pow((v + 0.055) / 1.055, 2.4); + }).ToArray(); + + return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; + } +} diff --git a/zero/Extensions/DictionaryExtensions.cs b/zero/Extensions/DictionaryExtensions.cs new file mode 100644 index 00000000..6575f3a8 --- /dev/null +++ b/zero/Extensions/DictionaryExtensions.cs @@ -0,0 +1,36 @@ +namespace zero.Extensions; + +public static class DictionaryExtensions +{ + public static bool TryGetValue(this Dictionary model, string key, out T value) + { + if (!model.TryGetValue(key, out object valueObj) || !(valueObj is T)) + { + value = default; + return false; + } + + value = (T)valueObj; + return true; + } + + + public static T GetValueOrDefault(this Dictionary model, string key) + { + object value = model.GetValueOrDefault(key); + return value == default || !(value is T) ? default : (T)value; + } + + + public static Dictionary ToDistinctDictionary(this IEnumerable source, Func keySelector, Func elementSelector) + { + Dictionary result = new(); + + foreach (TSource sourceElement in source) + { + result.TryAdd(keySelector(sourceElement), elementSelector(sourceElement)); + } + + return result; + } +} diff --git a/zero/Extensions/EnumerableExtensions.cs b/zero/Extensions/EnumerableExtensions.cs new file mode 100644 index 00000000..9dfd9d9f --- /dev/null +++ b/zero/Extensions/EnumerableExtensions.cs @@ -0,0 +1,36 @@ +namespace zero.Extensions; + +public static class EnumerableExtensions +{ + public static IEnumerable Paging(this IEnumerable source, int pageNumber, int pageSize) + { + pageNumber = pageNumber.Limit(1, 10_000_000); + pageSize = pageSize.Limit(1, 1_000); + + if (pageNumber <= 0 || pageSize <= 0) + { + throw new NotSupportedException("Both pageNumber and pageSize must be greater than zero"); + } + + return source.Skip((pageNumber - 1) * pageSize).Take(pageSize); + } + + + public static bool TryGet(this IEnumerable source, Func predicate, out T model) + { + model = source.FirstOrDefault(predicate); + return model != null; + } + + + public static bool TryAdd(this IList source, T model) where T : class + { + if (model == default) + { + return false; + } + + source.Add(model); + return true; + } +} \ No newline at end of file diff --git a/zero/Extensions/ExpressionExtensions.cs b/zero/Extensions/ExpressionExtensions.cs new file mode 100644 index 00000000..589fbc25 --- /dev/null +++ b/zero/Extensions/ExpressionExtensions.cs @@ -0,0 +1,48 @@ +using System.Linq.Expressions; +using System.Text; + +namespace zero.Extensions; + +public static class ExpressionExtensions +{ + internal static string GetPropertyPath(this Expression expr, out MemberExpression member) + { + StringBuilder path = new(); + MemberExpression memberExpression = GetMemberExpression(expr); + do + { + member = memberExpression; + + if (path.Length > 0) + { + path.Insert(0, "."); + } + path.Insert(0, memberExpression.Member.Name); + memberExpression = GetMemberExpression(memberExpression.Expression); + } + while (memberExpression != null); + + return path.ToString(); + } + + internal static MemberExpression GetMemberExpression(this Expression expression) + { + if (expression is MemberExpression) + { + return (MemberExpression)expression; + } + else if (expression is LambdaExpression) + { + var lambdaExpression = expression as LambdaExpression; + if (lambdaExpression.Body is MemberExpression) + { + return (MemberExpression)lambdaExpression.Body; + } + else if (lambdaExpression.Body is UnaryExpression) + { + return ((MemberExpression)((UnaryExpression)lambdaExpression.Body).Operand); + } + } + return null; + } +} diff --git a/zero/Extensions/HttpContextExtensions.cs b/zero/Extensions/HttpContextExtensions.cs new file mode 100644 index 00000000..eff39b0a --- /dev/null +++ b/zero/Extensions/HttpContextExtensions.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Http; + +namespace zero.Extensions; + +public static class HttpContextExtensions +{ + /// + /// Whether the current request is an AJAX request + /// + public static bool IsAjaxRequest(this HttpContext context) + { + if (context?.Request?.Headers == null) + { + return false; + } + + return context.Request.Headers["X-Requested-With"] == "XMLHttpRequest"; + } + + + /// + /// Whether the current request only accepts application/json + /// + public static bool IsJsonRequest(this HttpContext context) + { + if (context?.Request?.Headers == null) + { + return false; + } + + return context.Request.Headers["Accept"] == "application/json"; + } + + + /// + /// Get IP Address of the client + /// + public static string GetClientIpAddress(this HttpContext context) + { + return context.Connection.RemoteIpAddress?.ToString(); + //string ipAddress = context.GetServerVariable("HTTP_X_FORWARDED_FOR"); + //return !ipAddress.IsNullOrEmpty() ? ipAddress.Split(',')[0] : context.GetServerVariable("REMOTE_ADDR"); + } +} diff --git a/zero/Extensions/NumberExtensions.cs b/zero/Extensions/NumberExtensions.cs new file mode 100644 index 00000000..26f13f4f --- /dev/null +++ b/zero/Extensions/NumberExtensions.cs @@ -0,0 +1,47 @@ +namespace zero.Extensions; + +public static class NumberExtensions +{ + static Dictionary FileSizeUnits = new() + { + { FileSizeNotation.SI, new string[] { "B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB" } }, + { FileSizeNotation.IEC, new string[] { "B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB" } }, + { FileSizeNotation.JEDEC, new string[] { "B", "KB", "MB", "GB" } } + }; + + public static int Limit(this int input, int min, int max) + { + if (input < min) + { + return min; + } + if (input > max) + { + return max; + } + return input; + } + + + public static string GetFileSize(this long sizeInBytes, FileSizeNotation notation = FileSizeNotation.JEDEC) + { + if (!FileSizeUnits.ContainsKey(notation)) + { + throw new NotImplementedException($"The notation {notation} has no implementation for generating human-readable file sizes"); + } + + int power = notation == FileSizeNotation.SI ? 1000 : 1024; + + string[] units = FileSizeUnits[notation]; + + int order = 0; + + while (sizeInBytes >= power && order + 1 < units.Length) + { + order++; + sizeInBytes = sizeInBytes / power; + } + + return String.Format("{0:0.##} {1}", sizeInBytes, units[order]); + } +} diff --git a/zero/Extensions/ObjectExtensions.cs b/zero/Extensions/ObjectExtensions.cs new file mode 100644 index 00000000..4f10bbf0 --- /dev/null +++ b/zero/Extensions/ObjectExtensions.cs @@ -0,0 +1,30 @@ +//using Newtonsoft.Json; + +//namespace zero.Extensions; + +//[Obsolete("we don't want this for every object (use a Utils class instead)")] +//public static class ObjectExtensions +//{ +// public static bool Is(this Type type) +// { +// return type.IsAssignableFrom(typeof(T)); +// } + + +// public static bool Is(this object obj) +// { +// return obj.GetType().IsAssignableFrom(typeof(T)); +// } + + +// public static T Clone(this T obj) +// { +// Type type = obj.GetType(); +// return (T)JsonConvert.DeserializeObject(JsonConvert.SerializeObject(obj), type); +// } + +// public static T CloneLax(this object obj) +// { +// return JsonConvert.DeserializeObject(JsonConvert.SerializeObject(obj)); +// } +//} diff --git a/zero/Extensions/ResultExtensions.cs b/zero/Extensions/ResultExtensions.cs new file mode 100644 index 00000000..d726b0ab --- /dev/null +++ b/zero/Extensions/ResultExtensions.cs @@ -0,0 +1,48 @@ +namespace zero.Extensions; + +public static class ResultExtensions +{ + public static Result WithoutModel(this Result origin) + { + Result result = new(); + result.IsSuccess = origin.IsSuccess; + result.Errors = origin.Errors; + return result; + } + + public static Result ConvertTo(this Result origin, TTarget model) + { + Result result = new(); + result.IsSuccess = origin.IsSuccess; + result.Errors = origin.Errors; + result.Model = model; + return result; + } + + + public static Result AddError(this Result origin, string property, string message) + { + origin.IsSuccess = false; + origin.Errors.Add(new(property, message)); + return origin; + } + + + public static Result AddError(this Result origin, string message) + { + origin.IsSuccess = false; + origin.Errors.Add(new(Constants.ErrorFieldNone, message)); + return origin; + } + + + public static Result AppendErrors(this Result origin, Result with) + { + foreach (ResultError error in with.Errors) + { + origin.Errors.Add(error); + } + origin.IsSuccess = origin.Errors.Any(); + return origin; + } +} \ No newline at end of file diff --git a/zero/Extensions/ServiceCollectionExtensions.cs b/zero/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..9d7e0a5c --- /dev/null +++ b/zero/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,109 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using System.Reflection; + +namespace zero.Extensions; + +public static class ServiceCollectionExtensions +{ + /// + /// Adds all found implementations based on the service type and assembly discovery rules + /// + public static void AddAll(this IServiceCollection services, ServiceLifetime lifetime = ServiceLifetime.Transient, bool asSelf = false) => services.AddAll(typeof(TService), lifetime, asSelf); + + + /// + /// Adds all found implementations based on the service type and assembly discovery rules + /// + public static void AddAll(this IServiceCollection services, Type serviceType, ServiceLifetime lifetime = ServiceLifetime.Transient, bool asSelf = false) + { + if (AssemblyDiscovery.Current == null) + { + throw new Exception("services.AddAll() can only be run after mvcBuilder.AddZero()"); + } + + // add implementations with generic service types + if (serviceType.GetTypeInfo().IsGenericTypeDefinition) + { + IEnumerable<(Type, TypeInfo)> matches = AssemblyDiscovery.Current.GetAllClassTypes().SelectMany(type => + { + var interfaces = type.GetInterfaces().Select(x => x.GetTypeInfo()); + IEnumerable genericTypes = interfaces.Where(x => x.IsGenericType && x.GetGenericTypeDefinition() == serviceType); + if (genericTypes.Any()) + { + var genericTypeDefinitions = genericTypes.First(); + } + return genericTypes.Select(x => (x, type)); + }); + + foreach ((Type, Type) match in matches) + { + services.Add(new ServiceDescriptor(asSelf ? match.Item2 : match.Item1, match.Item2, lifetime)); + } + } + // add implementations with specific service types + else + { + foreach (Type type in AssemblyDiscovery.Current.GetTypes(serviceType)) + { + services.Add(new ServiceDescriptor(asSelf ? type : serviceType, type, lifetime)); + } + } + } + + + /// + /// Adds or overrides an implementation + /// + public static void Replace(this IServiceCollection services, ServiceLifetime lifetime = ServiceLifetime.Transient) + where TService : class + where TImplementation : class, TService + { + services.RemoveAll(); + services.Add(new ServiceDescriptor(typeof(TService), typeof(TImplementation), lifetime)); + } + + + /// + /// Adds or overrides an implementation + /// + public static void Replace(this IServiceCollection services, Func implementationFactory, ServiceLifetime lifetime = ServiceLifetime.Transient) + where TService : class + where TImplementation : class, TService + { + services.RemoveAll(); + services.Add(new ServiceDescriptor(typeof(TService), implementationFactory, lifetime)); + } + + + /// + /// Adds or overrides an implementation + /// + public static void Replace(this IServiceCollection services) + where TService : class + where TOldImplementation : class, TService + where TNewImplementation : class, TService + { + ServiceDescriptor oldDescriptor = services.FirstOrDefault(x => x.ImplementationType == typeof(TOldImplementation)); + if (oldDescriptor != null) + { + services.Remove(oldDescriptor); + services.Add(new ServiceDescriptor(typeof(TService), typeof(TNewImplementation), oldDescriptor.Lifetime)); + } + } + + + /// + /// Removes an implementation + /// + public static void Remove(this IServiceCollection services) + where TService : class + where TImplementation : class, TService + { + ServiceDescriptor oldDescriptor = services.FirstOrDefault(x => x.ImplementationType == typeof(TImplementation)); + if (oldDescriptor != null) + { + services.Remove(oldDescriptor); + } + } +} diff --git a/zero/Extensions/StringExtensions.cs b/zero/Extensions/StringExtensions.cs new file mode 100644 index 00000000..6c3b8da7 --- /dev/null +++ b/zero/Extensions/StringExtensions.cs @@ -0,0 +1,196 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace zero.Extensions; + +public static class StringExtensions +{ + const string SPACE = " "; + + static Regex replaceMultipleSpacesRegex { get; } = new Regex("[ ]{2,}", RegexOptions.None); + + static Regex newLineCharsRegex { get; } = new Regex("\t|\n|\r", RegexOptions.None); + + + public static string FullTrim(this string value) + { + if (String.IsNullOrEmpty(value)) + { + return value; + } + + return replaceMultipleSpacesRegex.Replace(value, SPACE).Trim(); + } + + + public static string EnsureStartsWith(this string input, string toStartWith) + { + if (input.StartsWith(toStartWith)) return input; + return toStartWith + input.TrimStart(toStartWith); + } + + + public static string EnsureStartsWith(this string input, char value) + { + return input.StartsWith(value.ToString(CultureInfo.InvariantCulture)) ? input : value + input; + } + + + public static string EnsureEndsWith(this string input, char value) + { + return input.EndsWith(value.ToString(CultureInfo.InvariantCulture)) ? input : input + value; + } + + + public static string EnsureEndsWith(this string input, string toEndWith) + { + return input.EndsWith(toEndWith.ToString(CultureInfo.InvariantCulture)) ? input : input + toEndWith; + } + + + public static string EnsureSurroundedWith(this string input, char toSurroundWith) + { + return input.EnsureStartsWith(toSurroundWith).EnsureEndsWith(toSurroundWith); + } + + + public static string Or(this string value, string fallback) + { + return String.IsNullOrWhiteSpace(value) ? fallback : value; + } + + + public static string TrimEnd(this string value, string forRemoving) + { + if (String.IsNullOrEmpty(value)) return value; + if (String.IsNullOrEmpty(forRemoving)) return value; + + while (value.EndsWith(forRemoving, StringComparison.InvariantCultureIgnoreCase)) + { + value = value.Remove(value.LastIndexOf(forRemoving, StringComparison.InvariantCultureIgnoreCase)); + } + return value; + } + + + public static string TrimStart(this string value, string forRemoving) + { + if (String.IsNullOrEmpty(value)) return value; + if (String.IsNullOrEmpty(forRemoving)) return value; + + while (value.StartsWith(forRemoving, StringComparison.InvariantCultureIgnoreCase)) + { + value = value.Substring(forRemoving.Length); + } + return value; + } + + + public static bool HasValue(this string input) + { + return !String.IsNullOrWhiteSpace(input); + } + + + public static bool IsNullOrEmpty(this string input) + { + return String.IsNullOrEmpty(input); + } + + + public static bool IsNullOrWhiteSpace(this string input) + { + return String.IsNullOrWhiteSpace(input); + } + + public static string ToCamelCase(this string input) + { + if (!String.IsNullOrEmpty(input) && input.Length > 1) + { + return Char.ToLowerInvariant(input[0]) + input.Substring(1); + } + return input; + } + + public static string ToPascalCase(this string input) + { + if (!String.IsNullOrEmpty(input) && input.Length > 1) + { + return Char.ToUpperInvariant(input[0]) + input.Substring(1); + } + return input; + } + + public static string ToCamelCaseId(this string input) + { + if (String.IsNullOrEmpty(input)) + { + return input; + } + + if (input.Length < 2) + { + return input.ToLowerInvariant(); + } + + string[] parts = input.Split('.'); + + return String.Join(".", parts.Select(x => x.ToCamelCase())); + } + + + public static string ToPascalCaseId(this string input) + { + if (String.IsNullOrEmpty(input)) + { + return input; + } + + if (input.Length < 2) + { + return input.ToUpperInvariant(); + } + + string[] parts = input.Split('.'); + + return String.Join(".", parts.Select(x => x.ToPascalCase())); + } + + + public static string RemoveNewLines(this string input) + { + if (String.IsNullOrEmpty(input)) + { + return input; + } + + return newLineCharsRegex.Replace(input, String.Empty).Trim(); + } + + + public static string Shorten(this string input, int maxLength) + { + if (maxLength < 4) + { + throw new ArgumentOutOfRangeException("maxLength", "Has to be at least 4 characters long"); + } + + if (String.IsNullOrEmpty(input) || input.Length <= maxLength) + { + return input; + } + + return input.Substring(0, maxLength - 3) + "..."; + } + + + public static string ReplaceFirst(this string text, string search, string replace) + { + int pos = text.IndexOf(search); + if (pos < 0) + { + return text; + } + return text.Substring(0, pos) + replace + text.Substring(pos + search.Length); + } +} diff --git a/zero/FileStorage/FileResult.cs b/zero/FileStorage/FileResult.cs new file mode 100644 index 00000000..d4bcc98b --- /dev/null +++ b/zero/FileStorage/FileResult.cs @@ -0,0 +1,10 @@ +namespace zero.FileStorage; + +public class FileResult +{ + public string Filename { get; set; } + + public string Path { get; set; } + + public string ContentType { get; set; } +} diff --git a/zero/FileStorage/FileSizeNotation.cs b/zero/FileStorage/FileSizeNotation.cs new file mode 100644 index 00000000..d85a36e0 --- /dev/null +++ b/zero/FileStorage/FileSizeNotation.cs @@ -0,0 +1,18 @@ +namespace zero.FileStorage; + +public enum FileSizeNotation +{ + /// + /// Multiply by 1.000, as specified by the International System of Units (e.g. kB, MB, GB) + /// + SI = 0, + /// + /// Binary notation with a multiplier of 1.024 (e.g. KiB, MiB, GiB) + /// + IEC = 1, + /// + /// Binary notation with a multiplier of 1.024 (e.g. KB, MB, GB). + /// Ends with GB, there is no TB, ... + /// + JEDEC = 2 +} diff --git a/zero/FileStorage/FileSystemException.cs b/zero/FileStorage/FileSystemException.cs new file mode 100644 index 00000000..fc9d39b0 --- /dev/null +++ b/zero/FileStorage/FileSystemException.cs @@ -0,0 +1,10 @@ +namespace zero.FileStorage; + +public class FileSystemException : Exception +{ + public FileSystemException() { } + + public FileSystemException(string message) : base(message) { } + + public FileSystemException(string message, Exception innerException) : base(message, innerException) { } +} \ No newline at end of file diff --git a/zero/FileStorage/FileSystemOptions.cs b/zero/FileStorage/FileSystemOptions.cs new file mode 100644 index 00000000..97c25f95 --- /dev/null +++ b/zero/FileStorage/FileSystemOptions.cs @@ -0,0 +1,6 @@ +namespace zero.FileStorage; + +public class FileSystemOptions +{ + public string ZeroAssetsPath { get; set; } +} \ No newline at end of file diff --git a/zero/FileStorage/IFileMeta.cs b/zero/FileStorage/IFileMeta.cs new file mode 100644 index 00000000..cd6b49dd --- /dev/null +++ b/zero/FileStorage/IFileMeta.cs @@ -0,0 +1,44 @@ +namespace zero.FileStorage; + +public interface IFileMeta +{ + /// + /// Full path of the item within the file system + /// + string Path { get; } + + /// + /// Absolute path of the item + /// + string AbsolutePath { get; } + + /// + /// Name of the item + /// + string Name { get; } + + /// + /// Full path to the directory containing the item + /// + string DirectoryPath { get; } + + /// + /// Length of the file in bytes + /// + long Length { get; } + + /// + /// Date when the item was last modified + /// + DateTimeOffset LastModifiedDate { get; } + + /// + /// Whether this item is directory or a file + /// + bool IsDirectory { get; } + + /// + /// Additional properties + /// + Dictionary Properties { get; } +} diff --git a/zero/FileStorage/IFileSystem.cs b/zero/FileStorage/IFileSystem.cs new file mode 100644 index 00000000..bd8c5238 --- /dev/null +++ b/zero/FileStorage/IFileSystem.cs @@ -0,0 +1,71 @@ +using System.IO; + +namespace zero.FileStorage; + +public interface IFileSystem +{ + /// + /// Map a relative path to an absolute internal path + /// + string Map(string path); + + /// + /// Map an internal path to a public accessible path + /// + string MapToPublicPath(string path); + + /// + /// Determines whether a file or directory at the given path already exists + /// + Task Exists(string path, CancellationToken cancellationToken = default); + + /// + /// Get information of a file + /// + Task GetFileInfo(string path, CancellationToken cancellationToken = default); + + /// + /// Returns a readable stream for a file + /// + Task StreamFile(string path, CancellationToken cancellationToken = default); + + /// + /// Creates a file at the given path + /// + Task CreateFile(string path, Stream fileStream, bool overwrite = false, CancellationToken cancellationToken = default); + + /// + /// Deletes a file at the given path. This method returns if the file does not exist. + /// + Task DeleteFile(string path, CancellationToken cancellationToken = default); + + /// + /// Moves a file to another path (can also be used to rename resources) + /// + Task MoveFile(string oldPath, string newPath, CancellationToken cancellationToken = default); + + /// + /// Copies a file to another path + /// + Task CopyFile(string oldPath, string newPath, CancellationToken cancellationToken = default); + + /// + /// Get information of a directory + /// + Task GetDirectoryInfo(string path, CancellationToken cancellationToken = default); + + /// + /// Get all items within a directory + /// + IAsyncEnumerable GetDirectoryContent(string path = null, bool recursive = false, CancellationToken cancellationToken = default); + + /// + /// Tries to create a directory at the given path. This method returns if the directory already exists. + /// + Task CreateDirectory(string path, CancellationToken cancellationToken = default); + + /// + /// Deletes a directory at the given path. This method returns if the directory does not exist. + /// + Task DeleteDirectory(string path, bool recursive = false, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/zero/FileStorage/Paths.cs b/zero/FileStorage/Paths.cs new file mode 100644 index 00000000..602ab9de --- /dev/null +++ b/zero/FileStorage/Paths.cs @@ -0,0 +1,217 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.StaticFiles; +using System.IO; +using System.Text; + +namespace zero.FileStorage; + +public class Paths : IPaths +{ + public string WebRoot { get; set; } + + public string ContentRoot { get; set; } + + public string SecureRoot { get; set; } + + public string Media { get; set; } + + protected IWebHostEnvironment Env { get; set; } + + public const string MEDIA_FOLDER = "Uploads"; + + public const char PATH_SEPARATOR = '/'; + + static char[] InvalidFilenameChars = null; + + const char REPLACEMENT_CHAR = '-'; + + FileExtensionContentTypeProvider FileExtensionContentTypeProvider { get; set; } + + static readonly Dictionary Replacements = new() + { + { 'ä', "ae" }, + { 'ü', "ue" }, + { 'ö', "oe" }, + { 'ß', "ss" } + }; + + + public Paths(IWebHostEnvironment env) + { + Env = env; + WebRoot = env.WebRootPath; + ContentRoot = env.ContentRootPath; + SecureRoot = Path.Combine(ContentRoot, "wwwroot.secure"); + Media = Path.Combine(WebRoot, MEDIA_FOLDER); + FileExtensionContentTypeProvider = new(); + } + + + /// + /// Combine a path + /// + public string Combine(params string[] paths) + { + return Path.Combine(paths); + } + + + /// + /// Map a path + /// + public string Map(string path) + { + return Path.Combine(WebRoot, path); + } + + + /// + /// Map a secure path + /// + public string MapSecure(string path) + { + return Path.Combine(SecureRoot, path); + } + + + /// + /// Map a path + /// + public string Map(params string[] paths) + { + return Path.Combine(WebRoot, Path.Combine(paths)); + } + + + /// + /// Map a secure path + /// + public string MapSecure(params string[] paths) + { + return Path.Combine(SecureRoot, Path.Combine(paths)); + } + + + /// + /// Create a directory if it does not exist yet + /// + public void Create(string directory) + { + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + } + + + /// + /// Get content type for a filename + /// + public string GetContentType(string filename, string fallback = "application/octet-stream") + { + if (filename == null || !FileExtensionContentTypeProvider.TryGetContentType(Path.GetFileName(filename), out string contentType)) + { + contentType = fallback; + } + return contentType; + } + + + /// + /// Normalizes a filename and removes invalid chars + /// + public string ToFilename(string value) + { + if (String.IsNullOrWhiteSpace(value)) + { + return value; + } + + // lowercase for string + value = value.ToLower(); + + StringBuilder sb = new(value.Length); + + var invalids = InvalidFilenameChars ??= Path.GetInvalidFileNameChars(); + bool changed = false; + + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + + if (Replacements.ContainsKey(c)) + { + changed = true; + sb.Append(Replacements[c]); + } + else if (invalids.Contains(c)) + { + changed = true; + sb.Append(REPLACEMENT_CHAR); + } + else + { + sb.Append(c); + } + } + + if (sb.Length == 0) + { + return "_"; + } + + return changed ? sb.ToString() : value; + } +} + + +public interface IPaths +{ + string ContentRoot { get; set; } + + string WebRoot { get; set; } + + string SecureRoot { get; set; } + + string Media { get; set; } + + /// + /// Combine a path + /// + string Combine(params string[] paths); + + /// + /// Map a path + /// + string Map(string path); + + /// + /// Map a secure path + /// + string MapSecure(string path); + + /// + /// Map a path + /// + string Map(params string[] paths); + + /// + /// Map a secure path + /// + string MapSecure(params string[] paths); + + /// + /// Create a directory if it does not exist yet + /// + void Create(string directory); + + /// + /// Get content type for a filename + /// + string GetContentType(string filename, string fallback = "application/octet-stream"); + + /// + /// Normalizes a filename and removes invalid chars + /// + string ToFilename(string value); +} \ No newline at end of file diff --git a/zero/FileStorage/PhysicalFileMeta.cs b/zero/FileStorage/PhysicalFileMeta.cs new file mode 100644 index 00000000..6f89c6dc --- /dev/null +++ b/zero/FileStorage/PhysicalFileMeta.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.FileProviders; + +namespace zero.FileStorage; + +public class PhysicalFileMeta : IFileMeta +{ + /// + public string Name { get; } + + /// + public string Path { get; } + + /// + public string AbsolutePath { get; } + + /// + public string DirectoryPath { get; } + + /// + public long Length { get; } + + /// + public DateTimeOffset LastModifiedDate { get; } + + /// + public bool IsDirectory { get; } + + /// + public Dictionary Properties { get; } + + + public PhysicalFileMeta(IFileInfo fileInfo, string path) + { + Path = path; + AbsolutePath = fileInfo.PhysicalPath; + Name = fileInfo.Name; + DirectoryPath = path.Substring(0, path.Length - fileInfo.Name.Length).TrimEnd('/'); + LastModifiedDate = fileInfo.LastModified; + Length = fileInfo.Length; + IsDirectory = fileInfo.IsDirectory; + Properties = new(); + } +} \ No newline at end of file diff --git a/zero/FileStorage/PhysicalFileSystem.cs b/zero/FileStorage/PhysicalFileSystem.cs new file mode 100644 index 00000000..c5b3cf33 --- /dev/null +++ b/zero/FileStorage/PhysicalFileSystem.cs @@ -0,0 +1,305 @@ +using Microsoft.Extensions.FileProviders.Physical; +using System.IO; + +namespace zero.FileStorage; + +public class PhysicalFileSystem : IFileSystem +{ + readonly string _root; + + + public PhysicalFileSystem(string root) + { + _root = root; + } + + + /// + public virtual string Map(string path) + { + return ResolvePath(path); + } + + + /// + public virtual string MapToPublicPath(string path) + { + return path.EnsureStartsWith('/'); + } + + + /// + public virtual Task Exists(string path, CancellationToken cancellationToken = default) + { + try + { + string resolvedPath = ResolvePath(path); + return Task.FromResult(File.Exists(resolvedPath) || Directory.Exists(resolvedPath)); + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not get file exists info for path '{path}'", ex); + } + } + + + /// + public virtual Task GetFileInfo(string path, CancellationToken cancellationToken = default) + { + try + { + string resolvedPath = ResolvePath(path); + + PhysicalFileInfo fileInfo = new(new FileInfo(resolvedPath)); + + if (!fileInfo.Exists) + { + return Task.FromResult(default); + } + + return Task.FromResult(new PhysicalFileMeta(fileInfo, path)); + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not get file info for path '{path}'", ex); + } + } + + + /// + public virtual Task StreamFile(string path, CancellationToken cancellationToken = default) + { + try + { + string resolvedPath = ResolvePath(path); + + if (resolvedPath.IsNullOrEmpty() || !File.Exists(resolvedPath)) + { + throw new FileSystemException($"The path '{path}' does not exist in the file system"); + } + + return Task.FromResult(File.OpenRead(resolvedPath)); + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not get file stream for path '{path}'", ex); + } + } + + + /// + public virtual async Task CreateFile(string path, Stream fileStream, bool overwrite = false, CancellationToken cancellationToken = default) + { + try + { + string resolvedPath = ResolvePath(path); + + if ((!overwrite && File.Exists(resolvedPath)) || Directory.Exists(resolvedPath)) + { + throw new FileSystemException($"A file/directory at the path '{path}' already existsin the file system"); + } + + string directoryPath = Path.GetDirectoryName(resolvedPath); + Directory.CreateDirectory(directoryPath); + + FileInfo fileInfo = new(resolvedPath); + using var outputStream = fileInfo.Create(); + await fileStream.CopyToAsync(outputStream, cancellationToken); + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not get file stream for path '{path}'", ex); + } + } + + + /// + public virtual Task DeleteFile(string path, CancellationToken cancellationToken = default) + { + try + { + string resolvedPath = ResolvePath(path); + File.Delete(resolvedPath); + return Task.CompletedTask; + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not delete file for path '{path}'", ex); + } + } + + + /// + public virtual Task MoveFile(string oldPath, string newPath, CancellationToken cancellationToken = default) + { + try + { + string resolvedOldPath = ResolvePath(oldPath); + string resolvedNewPath = ResolvePath(newPath); + + if (!File.Exists(resolvedOldPath)) + { + throw new FileSystemException($"The path '{oldPath}' does not exist in the file system"); + } + + if (File.Exists(resolvedNewPath) || Directory.Exists(resolvedNewPath)) + { + throw new FileSystemException($"A file/directory at the path '{newPath}' already exists in the file system"); + } + + File.Move(resolvedOldPath, resolvedNewPath); + return Task.CompletedTask; + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not move file from path '{oldPath}' to '{newPath}'", ex); + } + } + + + /// + public virtual Task CopyFile(string oldPath, string newPath, CancellationToken cancellationToken = default) + { + try + { + string resolvedOldPath = ResolvePath(oldPath); + string resolvedNewPath = ResolvePath(newPath); + + if (!File.Exists(resolvedOldPath)) + { + throw new FileSystemException($"The path '{oldPath}' does not exist in the file system"); + } + + if (File.Exists(resolvedNewPath) || Directory.Exists(resolvedNewPath)) + { + throw new FileSystemException($"A file/directory at the path '{newPath}' already exists in the file system"); + } + + File.Copy(resolvedOldPath, resolvedNewPath); + return Task.CompletedTask; + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not copy file from path '{oldPath}' to '{newPath}'", ex); + } + } + + + /// + public virtual Task GetDirectoryInfo(string path, CancellationToken cancellationToken = default) => GetFileInfo(path, cancellationToken); + + + /// + public virtual IAsyncEnumerable GetDirectoryContent(string path = null, bool recursive = false, CancellationToken cancellationToken = default) + { + try + { + string resolvedPath = ResolvePath(path); + List results = new(); + SearchOption searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + + if (!Directory.Exists(resolvedPath)) + { + return results.ToAsyncEnumerable(); + } + + results.AddRange(Directory.GetDirectories(resolvedPath, "*", searchOption).Select(f => + { + PhysicalDirectoryInfo fileSystemInfo = new(new DirectoryInfo(f)); + return new PhysicalFileMeta(fileSystemInfo, ResolvePath(f.Substring(_root.Length))); + })); + + results.AddRange(Directory.GetFiles(resolvedPath, "*", searchOption).Select(f => + { + PhysicalFileInfo fileSystemInfo = new(new FileInfo(f)); + return new PhysicalFileMeta(fileSystemInfo, ResolvePath(f.Substring(_root.Length))); + })); + + return results.ToAsyncEnumerable(); + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not get directory content for path '{path}'", ex); + } + } + + + /// + public virtual Task CreateDirectory(string path, CancellationToken cancellationToken = default) + { + try + { + string resolvedPath = ResolvePath(path); + + if (File.Exists(resolvedPath)) + { + throw new FileSystemException($"A file at the path '{path}' already exists in the file system"); + } + + if (!Directory.Exists(resolvedPath)) + { + Directory.CreateDirectory(resolvedPath); + } + + return Task.CompletedTask; + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not create directory at path '{path}'", ex); + } + } + + + /// + public virtual Task DeleteDirectory(string path, bool recursive = false, CancellationToken cancellationToken = default) + { + try + { + string resolvedPath = ResolvePath(path); + Directory.Delete(resolvedPath, recursive); + return Task.CompletedTask; + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not delete directory at path '{path}'", ex); + } + } + + + /// + /// Creates a fully-usable absolte path from the given path + /// + protected virtual string ResolvePath(string path) + { + try + { + if (path.IsNullOrWhiteSpace()) + { + return _root; + } + + // normalize path + path = path.Replace('\\', '/').FullTrim().Trim('/'); + + string physicalPath = Path.Combine(_root, path); + string rootPath = Path.GetFullPath(_root); + + // do not allow paths which are outside this file system + // the boundaries are set be the basePath which is assigned in the file system constructor + if (!Path.GetFullPath(physicalPath).StartsWith(rootPath, StringComparison.InvariantCultureIgnoreCase)) + { + throw new FileSystemException($"The path '{path}' is outside the file system"); + } + + return physicalPath; + } + catch (FileSystemException) + { + throw; + } + catch (Exception ex) + { + throw new FileSystemException($"Could not resolve path '{path}'", ex); + } + } +} diff --git a/zero/FileStorage/WebRootFileSystem.cs b/zero/FileStorage/WebRootFileSystem.cs new file mode 100644 index 00000000..2e09cead --- /dev/null +++ b/zero/FileStorage/WebRootFileSystem.cs @@ -0,0 +1,28 @@ +namespace zero.FileStorage; + +public class WebRootFileSystem : PhysicalFileSystem, IWebRootFileSystem +{ + protected string PublicPathPrefix { get; } + + + public WebRootFileSystem(string root, string publicPathPrefix) : base(root) + { + PublicPathPrefix = (publicPathPrefix ?? String.Empty).EnsureEndsWith('/'); + } + + + /// + public override string MapToPublicPath(string path) + { + if (path.IsNullOrEmpty()) + { + return null; + } + return PublicPathPrefix + path.TrimStart('/'); + } +} + + +public interface IWebRootFileSystem : IFileSystem +{ +} \ No newline at end of file diff --git a/zero/FileStorage/ZeroFileStorageModule.cs b/zero/FileStorage/ZeroFileStorageModule.cs new file mode 100644 index 00000000..f195cdc0 --- /dev/null +++ b/zero/FileStorage/ZeroFileStorageModule.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace zero.FileStorage; + +internal class ZeroFileStorageModule : ZeroModule +{ + public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(factory => new Paths(factory.GetService())); + + services.AddOptions().Bind(configuration.GetSection("Zero:FileSystem")).Configure(opts => + { + opts.ZeroAssetsPath = "zero"; + }); + + services.AddSingleton(svc => + { + IWebHostEnvironment env = svc.GetRequiredService(); + return new(env.WebRootPath, null); + }); + } +} \ No newline at end of file diff --git a/zero/Localization/CultureResolver.cs b/zero/Localization/CultureResolver.cs new file mode 100644 index 00000000..c1931c24 --- /dev/null +++ b/zero/Localization/CultureResolver.cs @@ -0,0 +1,59 @@ +using FluentValidation; +using Microsoft.Extensions.Logging; +using Raven.Client.Documents; +using Raven.Client.Documents.Session; +using System.Globalization; +using System.Security.Claims; + +namespace zero.Localization; + +public class CultureResolver : ICultureResolver +{ + protected ILogger Logger { get; private set; } + + + public CultureResolver(ILogger logger) + { + Logger = logger; + } + + + /// + public async Task Resolve(IZeroContext context) + { + //var session = context.Store.Session(); + //Language language = await session.Query().FirstOrDefaultAsync(); + string isoCode = "de-AT"; + + try + { + CultureInfo culture = CultureInfo.CreateSpecificCulture(isoCode); + + if (culture.ThreeLetterISOLanguageName.IsNullOrEmpty()) + { + throw new Exception("ThreeLetterISOLanguageName is empty"); + } + + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + ValidatorOptions.Global.LanguageManager.Culture = culture; + } + catch (Exception ex) + { + Logger.LogError(ex, "Could not create culture from Language code {code}", isoCode); + return CultureInfo.CurrentCulture; + } + + return CultureInfo.CurrentCulture; + } +} + + +public interface ICultureResolver +{ + /// + /// Resolves the current application from either the backoffice user (in case it is backoffice request) + /// or the domain (in case it is frontend request). + /// + Task Resolve(IZeroContext context); +} diff --git a/zero/Localization/CultureService.cs b/zero/Localization/CultureService.cs new file mode 100644 index 00000000..b1a111a4 --- /dev/null +++ b/zero/Localization/CultureService.cs @@ -0,0 +1,31 @@ +using System.Globalization; + +namespace zero.Localization; + +public class CultureService : ICultureService +{ + /// + public List GetAllCultures(params string[] codes) + { + return CultureInfo.GetCultures(CultureTypes.AllCultures) + .Where(x => !x.Name.IsNullOrWhiteSpace()) + .Select(x => new CultureInfo(x.Name)) + .Where(x => codes.Length == 0 || codes.Contains(x.Name, StringComparer.InvariantCultureIgnoreCase)) + .OrderBy(x => x.DisplayName) + .Select(x => new Culture() + { + Code = x.Name, + Name = x.DisplayName + }) + .ToList(); + } +} + + +public interface ICultureService +{ + /// + /// Get all available cultures + /// + List GetAllCultures(params string[] codes); +} \ No newline at end of file diff --git a/zero/Localization/LocalizeAttribute.cs b/zero/Localization/LocalizeAttribute.cs new file mode 100644 index 00000000..aa8c9de2 --- /dev/null +++ b/zero/Localization/LocalizeAttribute.cs @@ -0,0 +1,12 @@ +namespace zero.Localization; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Field, AllowMultiple = false)] +public class LocalizeAttribute : Attribute +{ + public string Key; + + public LocalizeAttribute(string key) + { + Key = key; + } +} diff --git a/zero/Localization/Localizer.cs b/zero/Localization/Localizer.cs new file mode 100644 index 00000000..5d89a598 --- /dev/null +++ b/zero/Localization/Localizer.cs @@ -0,0 +1,116 @@ +using System.Collections.Concurrent; +using System.Reflection; + +namespace zero.Localization; + +public class Localizer : ILocalizer +{ + protected ConcurrentDictionary Cache { get; private set; } = new(); + + protected IZeroStore Store { get; private set; } + + public Localizer(IZeroStore store) + { + Store = store; + } + + + /// + public string Text(string key) => Text(key, null); + + + /// + public string Text(string key, Dictionary tokens) + { + if (key.IsNullOrEmpty()) + { + return null; + } + + if (!Cache.TryGetValue(key, out string value)) + { + Translation translation = LoadTranslation(key); + + if (translation == null) + { + return null; + } + + value = translation.Value; + Cache.TryAdd(key, value); + } + + if (tokens != null) + { + value = TokenReplacement.Apply(value, tokens); + } + + return value; + } + + + /// + public string Text(T enumValue) where T : Enum => Text(enumValue, null); + + + /// + public string Text(T enumValue, Dictionary tokens) where T : Enum + { + Type type = enumValue.GetType(); + MemberInfo memInfo = type.GetMember(enumValue.ToString())[0]; + return Text(memInfo.GetCustomAttribute()?.Key, tokens); + } + + + /// + public string Maybe(string key) => Maybe(key, null); + + + /// + public string Maybe(string key, Dictionary tokens) + { + return key.IsNullOrEmpty() || !key.StartsWith("@") ? key : Text(key.Substring(1), tokens); + } + + + /// + /// Get translation from database or any other source + /// + protected virtual Translation LoadTranslation(string key) + { + return Store.Session().Synchronous.Query().FirstOrDefault(x => x.Key == key); + } +} + +public interface ILocalizer +{ + /// + /// + /// + string Text(string key); + + /// + /// + /// + string Text(string key, Dictionary tokens); + + /// + /// Get a text string from a [Localize] attribute + /// + string Text(T enumValue) where T : Enum; + + /// + /// Get a text string from a [Localize] attribute + /// + string Text(T enumValue, Dictionary tokens) where T : Enum; + + /// + /// Only tries to resolve the key when it is prefixed with an @ + /// + string Maybe(string key); + + /// + /// Only tries to resolve the key when it is prefixed with an @ + /// + string Maybe(string key, Dictionary tokens); +} diff --git a/zero/Localization/LocalizerExtensions.cs b/zero/Localization/LocalizerExtensions.cs new file mode 100644 index 00000000..256cd417 --- /dev/null +++ b/zero/Localization/LocalizerExtensions.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Html; + +namespace zero.Localization; + +public static class LocalizerExtensions +{ + public static IHtmlContent Html(this ILocalizer localizer, string key) + { + string value = localizer.Text(key); + + HtmlContentBuilder builder = new(); + builder.SetHtmlContent(value); + return builder; + } + + + public static IHtmlContent Html(this ILocalizer localizer, string key, Dictionary tokens) + { + string value = localizer.Text(key, tokens); + + HtmlContentBuilder builder = new(); + builder.SetHtmlContent(value); + return builder; + } +} \ No newline at end of file diff --git a/zero/Localization/Models/Culture.cs b/zero/Localization/Models/Culture.cs new file mode 100644 index 00000000..eed5ff88 --- /dev/null +++ b/zero/Localization/Models/Culture.cs @@ -0,0 +1,8 @@ +namespace zero.Localization; + +public class Culture +{ + public string Code { get; set; } + + public string Name { get; set; } +} diff --git a/zero/Localization/Models/Translation.cs b/zero/Localization/Models/Translation.cs new file mode 100644 index 00000000..0d893918 --- /dev/null +++ b/zero/Localization/Models/Translation.cs @@ -0,0 +1,27 @@ +namespace zero.Localization; + +[RavenCollection("Translations")] +public class Translation : ZeroEntity, IAlwaysActive +{ + public Translation() + { + IsActive = true; + } + + /// + /// Value of the translation + /// + public string Value { get; set; } + + /// + /// Display + input type + /// + public TranslationDisplay Display { get; set; } +} + + +public enum TranslationDisplay +{ + Text = 0, + HTML = 1 +} \ No newline at end of file diff --git a/zero/Localization/ZeroLocalizationModule.cs b/zero/Localization/ZeroLocalizationModule.cs new file mode 100644 index 00000000..1a6c7935 --- /dev/null +++ b/zero/Localization/ZeroLocalizationModule.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace zero.Localization; + +internal class ZeroLocalizationModule : ZeroModule +{ + public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } +} \ No newline at end of file diff --git a/zero/Models/IAlwaysActive.cs b/zero/Models/IAlwaysActive.cs new file mode 100644 index 00000000..34f7dbce --- /dev/null +++ b/zero/Models/IAlwaysActive.cs @@ -0,0 +1,12 @@ +namespace zero.Models; + +/// +/// Entities decorated with this interface are always set to IsActive=true +/// +public interface IAlwaysActive +{ + /// + /// Whether the entity is visible in the frontend + /// + bool IsActive { get; set; } +} \ No newline at end of file diff --git a/zero/Models/ISupportsFlavors.cs b/zero/Models/ISupportsFlavors.cs new file mode 100644 index 00000000..aac608cd --- /dev/null +++ b/zero/Models/ISupportsFlavors.cs @@ -0,0 +1,14 @@ +namespace zero.Models; + +public interface ISupportsFlavors +{ + /// + /// Id of the entity + /// + string Id { get; set; } + + /// + /// Alias of the used flavor + /// + string Flavor { get; set; } +} \ No newline at end of file diff --git a/zero/Models/ISupportsRouting.cs b/zero/Models/ISupportsRouting.cs new file mode 100644 index 00000000..4189bbb3 --- /dev/null +++ b/zero/Models/ISupportsRouting.cs @@ -0,0 +1,14 @@ +namespace zero.Models; + +public interface ISupportsRouting +{ + /// + /// Id of the entity + /// + string Id { get; set; } + + /// + /// Unique hash for this entity (primarily used for routing) + /// + string Hash { get; set; } +} \ No newline at end of file diff --git a/zero/Models/ISupportsSorting.cs b/zero/Models/ISupportsSorting.cs new file mode 100644 index 00000000..166998cc --- /dev/null +++ b/zero/Models/ISupportsSorting.cs @@ -0,0 +1,9 @@ +namespace zero.Models; + +public interface ISupportsSorting +{ + /// + /// Sort order + /// + uint Sort { get; set; } +} \ No newline at end of file diff --git a/zero/Models/ISupportsTrees.cs b/zero/Models/ISupportsTrees.cs new file mode 100644 index 00000000..437433ac --- /dev/null +++ b/zero/Models/ISupportsTrees.cs @@ -0,0 +1,19 @@ +namespace zero.Models; + +public interface ISupportsTrees +{ + /// + /// Id of the entity + /// + string Id { get; set; } + + /// + /// Parent id + /// + string ParentId { get; set; } + + /// + /// Sort order + /// + uint Sort { get; set; } +} \ No newline at end of file diff --git a/zero/Models/Results/Paged.cs b/zero/Models/Results/Paged.cs new file mode 100644 index 00000000..d78ed5ad --- /dev/null +++ b/zero/Models/Results/Paged.cs @@ -0,0 +1,66 @@ +namespace zero.Models; + +public class Paged : Paged +{ + public IList Items { get; set; } = new List(); + + public Paged(IList items, long totalItems, long pageNumber, long pageSize) : base(totalItems, pageNumber, pageSize) + { + Items = items; + } + + public Paged(long totalItems, long pageNumber, long pageSize) : base(totalItems, pageNumber, pageSize) { } + + public Paged MapTo(Func convertItem) + { + return new Paged(Items.Select(x => convertItem(x)).Where(x => x != null).ToList(), TotalItems, Page, PageSize); + } + + public override IEnumerable GetItems() => Items; +} + + +public abstract class Paged +{ + public long Page { get; protected set; } + + public long PageSize { get; protected set; } + + public long TotalPages { get; protected set; } + + public long TotalItems { get; protected set; } + + public bool HasMore { get; protected set; } + + public Dictionary Properties { get; set; } = new(); + + + public Paged(long totalItems, long page, long pageSize) + { + TotalItems = totalItems; + Page = page; + PageSize = pageSize; + + if (pageSize > 0) + { + TotalPages = (long)Math.Ceiling(totalItems / (decimal)pageSize); + } + else + { + TotalPages = 1; + } + + HasMore = TotalPages > Page; + } + + public int GetSkipSize() + { + if (Page > 0 && PageSize > 0) + { + return Convert.ToInt32((Page - 1) * PageSize); + } + return 0; + } + + public virtual IEnumerable GetItems() => Array.Empty(); +} \ No newline at end of file diff --git a/zero/Models/Results/Result.cs b/zero/Models/Results/Result.cs new file mode 100644 index 00000000..90efb299 --- /dev/null +++ b/zero/Models/Results/Result.cs @@ -0,0 +1,114 @@ +using FluentValidation.Results; +using System.Runtime.Serialization; + +namespace zero.Models; + + +[DataContract(Name = "result", Namespace = "")] +public class Result +{ + [DataMember(Name = "success")] + public bool IsSuccess { get; set; } + + [DataMember(Name = "errors")] + public List Errors { get; set; } = new(); + + public Result() { } + + public Result(ValidationResult validation) + { + IsSuccess = validation.IsValid; + Errors = validation.Errors.Select(x => new ResultError() + { + Property = x.PropertyName, + Message = x.ErrorMessage + }).ToList(); + } + + public virtual object GetModel() => null; + + public static Result Maybe(bool isSuccess) => isSuccess ? Success() : Fail(); + + public static Result Success() => new() { IsSuccess = true }; + + public static Result Fail() => new() { }; + + public static Result Fail(string property, string message) + { + Result result = new(); + result.Errors.Add(new(property, message)); + return result; + } + + public static Result Fail(string message) + { + Result result = new(); + result.AddError(message); + return result; + } + + public static Result Fail(ValidationResult validation) => new(validation); + + public static Result Fail(ResultError error) => Fail(error.Property, error.Message); + + public static Result Fail(IEnumerable errors) => new() { IsSuccess = !errors.Any(), Errors = errors.ToList() }; +} + + +public class Result : Result +{ + [DataMember(Name = "model")] + public T Model { get; set; } + + public Result() : base() { } + + public Result(ValidationResult validation) : base(validation) { } + + + public new static Result Success() => new() { IsSuccess = true }; + + public static Result Success(T model) => new() { IsSuccess = true, Model = model }; + + public new static Result Fail() => new() { }; + + public new static Result Fail(string property, string message) + { + Result result = new(); + result.Errors.Add(new(property, message)); + return result; + } + + public new static Result Fail(string message) + { + Result result = new(); + result.AddError(message); + return result; + } + + public new static Result Fail(ValidationResult validation) => new(validation); + + public new static Result Fail(ResultError error) => Fail(error.Property, error.Message); + + public new static Result Fail(IEnumerable errors) => new() { IsSuccess = !errors.Any(), Errors = errors.ToList() }; + + public override object GetModel() => Model; +} + + +[DataContract(Name = "resultError", Namespace = "")] +public class ResultError +{ + [DataMember(Name = "property")] + public string Property { get; set; } + + [DataMember(Name = "message")] + public string Message { get; set; } + + public ResultError() { } + + public ResultError(string property, string message) + { + Property = property; + Message = message; + } +} diff --git a/zero/Models/Results/UrlsResult.cs b/zero/Models/Results/UrlsResult.cs new file mode 100644 index 00000000..212c52ec --- /dev/null +++ b/zero/Models/Results/UrlsResult.cs @@ -0,0 +1,56 @@ +namespace zero.Models; + +public class UrlResult +{ + public string Url { get; set; } + + public string Domain { get; set; } + + public UrlResult(string domain, string url) + { + Domain = domain; + Url = url; + } + + public UrlResult(Uri domain, string url) + { + if (domain != null) + { + Domain = domain.Scheme + "://" + domain.Authority.TrimEnd("/"); + } + Url = url; + } +} + +public class UrlsResult +{ + public string[] Urls { get; set; } = Array.Empty(); + + public string Domain { get; set; } + + public UrlsResult(string domain, params string[] urls) + { + Domain = domain; + Urls = urls.Where(x => x.HasValue()).ToArray(); + } + + public UrlsResult(Uri domain, params string[] urls) + { + if (domain != null) + { + Domain = domain.Scheme + "://" + domain.Authority.TrimEnd("/"); + } + Urls = urls.Where(x => x.HasValue()).ToArray(); + } +} + + +public class PreviewUrlResult +{ + public string Url { get; set; } + + public PreviewUrlResult(string url) + { + Url = url; + } +} \ No newline at end of file diff --git a/zero/Models/ZeroEntity.cs b/zero/Models/ZeroEntity.cs new file mode 100644 index 00000000..7ce59ffb --- /dev/null +++ b/zero/Models/ZeroEntity.cs @@ -0,0 +1,76 @@ +using Newtonsoft.Json; +using System.Diagnostics; + +namespace zero.Models; + +[DebuggerDisplay("Id = {Id,nq}, Name = {Name}, Alias = {Alias}")] +public class ZeroEntity : ZeroIdEntity, ISupportsDbConventions, ISupportsRouting, ISupportsFlavors, ISupportsSorting +{ + /// + /// Full name of the entity + /// + public string Name { get; set; } + + /// + /// Alias (non-unique) which can be used in the frontend and URLs + /// + public string Alias { get; set; } + + /// + /// A key which can be used to query this entity in code + /// + public string Key { get; set; } + + /// + /// Sort order + /// + public uint Sort { get; set; } + + /// + /// Whether the entity is visible in the frontend + /// + public bool IsActive { get; set; } + + /// + /// Unique hash for this entity (primarily used for routing) + /// + public string Hash { get; set; } + + /// + /// Backoffice user who last modified this content + /// + public string LastModifiedById { get; set; } + + /// + /// Date of last modification + /// + public DateTimeOffset LastModifiedDate { get; set; } + + /// + /// Backoffice user who created this content + /// + public string CreatedById { get; set; } + + /// + /// Date of creation + /// + public DateTimeOffset CreatedDate { get; set; } + + /// + /// Alias of the used flavor (uses default if empty) + /// + public string Flavor { get; set; } + + /// + /// Additional properties for this entity + /// + public Dictionary Properties { get; set; } = new(); + + /// + /// [Warning] This field is always empty when bound to the database. + /// It is only filled in the app-code for routing. + /// + [JsonIgnore] + public string Url { get; set; } +} + \ No newline at end of file diff --git a/zero/Models/ZeroIdEntity.cs b/zero/Models/ZeroIdEntity.cs new file mode 100644 index 00000000..8dec8272 --- /dev/null +++ b/zero/Models/ZeroIdEntity.cs @@ -0,0 +1,9 @@ +namespace zero.Models; + +public class ZeroIdEntity +{ + /// + /// Id of the entity + /// + public string Id { get; set; } +} \ No newline at end of file diff --git a/zero/Models/ZeroReference.cs b/zero/Models/ZeroReference.cs new file mode 100644 index 00000000..a8808e26 --- /dev/null +++ b/zero/Models/ZeroReference.cs @@ -0,0 +1,30 @@ +namespace zero.Models; + +public class ZeroReference +{ + public ZeroReference() { } + + public ZeroReference(ZeroEntity entity) + { + Id = entity.Id; + Name = entity.Name; + } + + public static ZeroReference From(ZeroEntity entity) + { + return entity == null ? null : new ZeroReference(entity); + } + + public static ZeroReference From(T entity, Func transform) where T : ZeroEntity + { + return entity == null ? null : new ZeroReference() + { + Id = entity.Id, + Name = transform(entity) + }; + } + + public string Id { get; set; } + + public string Name { get; set; } +} diff --git a/zero/Persistence/GenerateIdAttribute.cs b/zero/Persistence/GenerateIdAttribute.cs new file mode 100644 index 00000000..8318aa43 --- /dev/null +++ b/zero/Persistence/GenerateIdAttribute.cs @@ -0,0 +1,17 @@ +namespace zero.Persistence; + +/// +/// Automatically generate ID with the specified length and insert it into this property on entity save +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class GenerateIdAttribute : Attribute +{ + public int? Length = null; + + public GenerateIdAttribute(int length) + { + Length = length; + } + + public GenerateIdAttribute() { } +} diff --git a/zero/Persistence/ISupportsDbConventions.cs b/zero/Persistence/ISupportsDbConventions.cs new file mode 100644 index 00000000..1ce4ec3b --- /dev/null +++ b/zero/Persistence/ISupportsDbConventions.cs @@ -0,0 +1,6 @@ +namespace zero.Persistence; + +/// +/// Triggers custom Raven conventions for database operations +/// +public interface ISupportsDbConventions { } \ No newline at end of file diff --git a/zero/Persistence/IdGenerator.cs b/zero/Persistence/IdGenerator.cs new file mode 100644 index 00000000..63483b0c --- /dev/null +++ b/zero/Persistence/IdGenerator.cs @@ -0,0 +1,112 @@ +using System.Text.Json; + +namespace zero.Persistence; + +public class IdGenerator +{ + const string CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"; + const string CHARS_COMPLEX = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-@#.:!?*"; + + private static Random random = new(); + + /// + /// Create a new unique Id + /// + public static string Create(int length = -1, Charset charset = Charset.az09) + { + if (length < 1) + { + length = 12; + } + + string chars = charset == Charset.az09 ? CHARS : CHARS_COMPLEX; + + return new string(Enumerable.Repeat(chars, length).Select(s => s[random.Next(s.Length)]).ToArray()); + + //if (length > 0) + //{ + //return Convert.ToBase64String(Guid.NewGuid().ToByteArray()) + // .Replace("/", String.Empty) + // .Replace("+", String.Empty) + // .Replace("-", String.Empty) + // .ToLowerInvariant() + // .Substring(0, length); + //} + + //return Guid.NewGuid().ToString(); + } + + + /// + /// Creates a simple hash from a string + /// + public static string HashString(string value) + { + return GetStableHashCode(value).ToString().Replace("-", String.Empty); + } + + + /// + /// Creates a simple hash from a string + /// + public static string HashObject(params object[] values) + { + return GetStableHashCode(JsonSerializer.Serialize(values)).ToString().Replace("-", String.Empty); + } + + + /// + /// Autofill IDs on an object with [GenerateId] attributes + /// + public static T Autofill(T model) + { + // find all Raven Ids + List> ravenIds = ObjectTraverser.FindAttribute(model); + + // set unset Raven Ids + foreach (ObjectTraverser.Result item in ravenIds) + { + string id = item.Property.GetValue(item.Parent, null) as string; + if (id.IsNullOrWhiteSpace()) + { + id = item.Item.Length.HasValue ? Create(item.Item.Length.Value) : Create(); + item.Property.SetValue(item.Parent, id); + } + } + + return model; + } + + + static int GetStableHashCode(string str) + { + unchecked + { + int hash1 = 5381; + int hash2 = hash1; + + for (int i = 0; i < str.Length && str[i] != '\0'; i += 2) + { + hash1 = ((hash1 << 5) + hash1) ^ str[i]; + if (i == str.Length - 1 || str[i + 1] == '\0') + break; + hash2 = ((hash2 << 5) + hash2) ^ str[i + 1]; + } + + return hash1 + (hash2 * 1566083941); + } + } + + + public enum Charset + { + /// + /// a-z, 0-9 + /// + az09 = 0, + /// + /// a-z, A-Z, 0-9, _-@#.:!?* + /// + azAZ09x = 1 + } +} \ No newline at end of file diff --git a/zero/Persistence/Indexes/ZeroIndex.cs b/zero/Persistence/Indexes/ZeroIndex.cs new file mode 100644 index 00000000..b86cee27 --- /dev/null +++ b/zero/Persistence/Indexes/ZeroIndex.cs @@ -0,0 +1,288 @@ +using Raven.Client.Documents; +using Raven.Client.Documents.Indexes; +using Raven.Client.Documents.Indexes.Spatial; +using Raven.Client.Documents.Operations.Attachments; +using System.Linq.Expressions; + +namespace zero.Persistence; + + +public abstract class ZeroJavascriptIndex : AbstractJavaScriptIndexCreationTask, IZeroIndexDefinition +{ + public ZeroJavascriptIndex() { Create(); } + protected virtual void Create() { } + + public virtual void Setup(IZeroOptions options, IDocumentStore store) { } + + public new string Reduce { get => base.Reduce; set => base.Reduce = value; } + public new string OutputReduceToCollection { get => base.OutputReduceToCollection; set => base.OutputReduceToCollection = value; } + public new string PatternReferencesCollectionName { get => base.PatternReferencesCollectionName; set => base.PatternReferencesCollectionName = value; } + public new string PatternForOutputReduceToCollectionReferences { get => base.PatternForOutputReduceToCollectionReferences; set => base.PatternForOutputReduceToCollectionReferences = value; } + + // AbstractIndexCreationTask + public new IJsonObject AsJson(object doc) => base.AsJson(doc); + public new IEnumerable AttachmentsFor(object doc) => base.AttachmentsFor(doc); + public new IEnumerable CounterNamesFor(object doc) => base.CounterNamesFor(doc); + public new IJsonObject.IMetadata MetadataFor(object doc) => base.MetadataFor(doc); + public new IEnumerable TimeSeriesNamesFor(object doc) => base.TimeSeriesNamesFor(doc); + + // AbstractIndexCreationTaskBase + public new object CreateField(string name, object value, CreateFieldOptions options) => base.CreateField(name, value, options); + public new object CreateField(string name, object value, bool stored, bool analyzed) => base.CreateField(name, value, stored, analyzed); + public new object CreateField(string name, object value) => base.CreateField(name, value); + + // AbstractCommonApiForIndexes + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new T? TryConvert(object value) where T : struct => base.TryConvert(value); +} + +public abstract class ZeroMultiMapIndex : AbstractMultiMapIndexCreationTask, IZeroIndexDefinition +{ + public ZeroMultiMapIndex() { Create(); } + protected virtual void Create() { } + + public virtual void Setup(IZeroOptions options, IDocumentStore store) { } + + // AbstractMultiMapIndexCreationTask + public new void AddMap(Expression, IEnumerable>> map) => base.AddMap(map); + public new void AddMapForAll(Expression, IEnumerable>> map) => base.AddMapForAll(map); + + // AbstractGenericIndexCreationTask + public new Expression, IEnumerable>> Reduce { get => base.Reduce; set => base.Reduce = value; } + public new string OutputReduceToCollection { get => base.OutputReduceToCollection; set => base.OutputReduceToCollection = value; } + public new IDictionary IndexesStrings { get => base.IndexesStrings; set => base.IndexesStrings = value; } + public new IDictionary>, FieldIndexing> Indexes { get => base.Indexes; set => base.Indexes = value; } + public new IDictionary SpatialIndexesStrings { get => base.SpatialIndexesStrings; set => base.SpatialIndexesStrings = value; } + public new IDictionary>, SpatialOptions> SpatialIndexes { get => base.SpatialIndexes; set => base.SpatialIndexes = value; } + public new IDictionary TermVectorsStrings { get => base.TermVectorsStrings; set => base.TermVectorsStrings = value; } + public new IDictionary>, FieldTermVector> TermVectors { get => base.TermVectors; set => base.TermVectors = value; } + public new IDictionary AnalyzersStrings { get => base.AnalyzersStrings; set => base.AnalyzersStrings = value; } + public new IDictionary>, string> Analyzers { get => base.Analyzers; set => base.Analyzers = value; } + public new ISet>> IndexSuggestions { get => base.IndexSuggestions; set => base.IndexSuggestions = value; } + public new IDictionary StoresStrings { get => base.StoresStrings; set => base.StoresStrings = value; } + public new IDictionary>, FieldStorage> Stores { get => base.Stores; set => base.Stores = value; } + public new string PatternReferencesCollectionName { get => base.PatternReferencesCollectionName; set => base.PatternReferencesCollectionName = value; } + public new Expression> PatternForOutputReduceToCollectionReferences { get => base.PatternForOutputReduceToCollectionReferences; set => base.PatternForOutputReduceToCollectionReferences = value; } + public new void AddAssembly(AdditionalAssembly assembly) => base.AddAssembly(assembly); + public new void Analyze(string field, string analyzer) => base.Analyze(field, analyzer); + public new void Analyze(Expression> field, string analyzer) => base.Analyze(field, analyzer); + public new void Index(Expression> field, FieldIndexing indexing) => base.Index(field, indexing); + public new void Index(string field, FieldIndexing indexing) => base.Index(field, indexing); + public new void Spatial(string field, Func indexing) => base.Spatial(field, indexing); + public new void Spatial(Expression> field, Func indexing) => base.Spatial(field, indexing); + public new void Store(string field, FieldStorage storage) => base.Store(field, storage); + public new void Store(Expression> field, FieldStorage storage) => base.Store(field, storage); + public new void StoreAllFields(FieldStorage storage) => base.StoreAllFields(storage); + public new void Suggestion(Expression> field) => base.Suggestion(field); + public new void TermVector(string field, FieldTermVector termVector) => base.TermVector(field, termVector); + public new void TermVector(Expression> field, FieldTermVector termVector) => base.TermVector(field, termVector); + + // AbstractIndexCreationTask + public new IJsonObject AsJson(object doc) => base.AsJson(doc); + public new IEnumerable AttachmentsFor(object doc) => base.AttachmentsFor(doc); + public new IEnumerable CounterNamesFor(object doc) => base.CounterNamesFor(doc); + public new IJsonObject.IMetadata MetadataFor(object doc) => base.MetadataFor(doc); + public new IEnumerable TimeSeriesNamesFor(object doc) => base.TimeSeriesNamesFor(doc); + + // AbstractIndexCreationTaskBase + public new object CreateField(string name, object value, CreateFieldOptions options) => base.CreateField(name, value, options); + public new object CreateField(string name, object value, bool stored, bool analyzed) => base.CreateField(name, value, stored, analyzed); + public new object CreateField(string name, object value) => base.CreateField(name, value); + + // AbstractCommonApiForIndexes + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new T? TryConvert(object value) where T : struct => base.TryConvert(value); +} + + +public abstract class ZeroMultiMapIndex : AbstractMultiMapIndexCreationTask, IZeroIndexDefinition +{ + public ZeroMultiMapIndex() { Create(); } + protected virtual void Create() { } + + public virtual void Setup(IZeroOptions options, IDocumentStore store) { } + + // AbstractMultiMapIndexCreationTask + public new void AddMap(Expression, IEnumerable>> map) => base.AddMap(map); + public new void AddMapForAll(Expression, IEnumerable>> map) => base.AddMapForAll(map); + + // AbstractGenericIndexCreationTask + public new Expression, IEnumerable>> Reduce { get => base.Reduce; set => base.Reduce = value; } + public new string OutputReduceToCollection { get => base.OutputReduceToCollection; set => base.OutputReduceToCollection = value; } + public new IDictionary IndexesStrings { get => base.IndexesStrings; set => base.IndexesStrings = value; } + public new IDictionary>, FieldIndexing> Indexes { get => base.Indexes; set => base.Indexes = value; } + public new IDictionary SpatialIndexesStrings { get => base.SpatialIndexesStrings; set => base.SpatialIndexesStrings = value; } + public new IDictionary>, SpatialOptions> SpatialIndexes { get => base.SpatialIndexes; set => base.SpatialIndexes = value; } + public new IDictionary TermVectorsStrings { get => base.TermVectorsStrings; set => base.TermVectorsStrings = value; } + public new IDictionary>, FieldTermVector> TermVectors { get => base.TermVectors; set => base.TermVectors = value; } + public new IDictionary AnalyzersStrings { get => base.AnalyzersStrings; set => base.AnalyzersStrings = value; } + public new IDictionary>, string> Analyzers { get => base.Analyzers; set => base.Analyzers = value; } + public new ISet>> IndexSuggestions { get => base.IndexSuggestions; set => base.IndexSuggestions = value; } + public new IDictionary StoresStrings { get => base.StoresStrings; set => base.StoresStrings = value; } + public new IDictionary>, FieldStorage> Stores { get => base.Stores; set => base.Stores = value; } + public new string PatternReferencesCollectionName { get => base.PatternReferencesCollectionName; set => base.PatternReferencesCollectionName = value; } + public new Expression> PatternForOutputReduceToCollectionReferences { get => base.PatternForOutputReduceToCollectionReferences; set => base.PatternForOutputReduceToCollectionReferences = value; } + public new void AddAssembly(AdditionalAssembly assembly) => base.AddAssembly(assembly); + public new void Analyze(string field, string analyzer) => base.Analyze(field, analyzer); + public new void Analyze(Expression> field, string analyzer) => base.Analyze(field, analyzer); + public new void Index(Expression> field, FieldIndexing indexing) => base.Index(field, indexing); + public new void Index(string field, FieldIndexing indexing) => base.Index(field, indexing); + public new void Spatial(string field, Func indexing) => base.Spatial(field, indexing); + public new void Spatial(Expression> field, Func indexing) => base.Spatial(field, indexing); + public new void Store(string field, FieldStorage storage) => base.Store(field, storage); + public new void Store(Expression> field, FieldStorage storage) => base.Store(field, storage); + public new void StoreAllFields(FieldStorage storage) => base.StoreAllFields(storage); + public new void Suggestion(Expression> field) => base.Suggestion(field); + public new void TermVector(string field, FieldTermVector termVector) => base.TermVector(field, termVector); + public new void TermVector(Expression> field, FieldTermVector termVector) => base.TermVector(field, termVector); + + // AbstractIndexCreationTask + public new IJsonObject AsJson(object doc) => base.AsJson(doc); + public new IEnumerable AttachmentsFor(object doc) => base.AttachmentsFor(doc); + public new IEnumerable CounterNamesFor(object doc) => base.CounterNamesFor(doc); + public new IJsonObject.IMetadata MetadataFor(object doc) => base.MetadataFor(doc); + public new IEnumerable TimeSeriesNamesFor(object doc) => base.TimeSeriesNamesFor(doc); + + // AbstractIndexCreationTaskBase + public new object CreateField(string name, object value, CreateFieldOptions options) => base.CreateField(name, value, options); + public new object CreateField(string name, object value, bool stored, bool analyzed) => base.CreateField(name, value, stored, analyzed); + public new object CreateField(string name, object value) => base.CreateField(name, value); + + // AbstractCommonApiForIndexes + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new T? TryConvert(object value) where T : struct => base.TryConvert(value); +} + + +public abstract class ZeroIndex : AbstractIndexCreationTask, IZeroIndexDefinition +{ + public ZeroIndex() { Create(); } + protected virtual void Create() { } + + public virtual void Setup(IZeroOptions options, IDocumentStore store) { } + + // AbstractIndexCreationTask + public new Expression, IEnumerable>> Map { get => base.Map; set => base.Map = value; } + + // AbstractGenericIndexCreationTask + public new Expression, IEnumerable>> Reduce { get => base.Reduce; set => base.Reduce = value; } + public new string OutputReduceToCollection { get => base.OutputReduceToCollection; set => base.OutputReduceToCollection = value; } + public new IDictionary IndexesStrings { get => base.IndexesStrings; set => base.IndexesStrings = value; } + public new IDictionary>, FieldIndexing> Indexes { get => base.Indexes; set => base.Indexes = value; } + public new IDictionary SpatialIndexesStrings { get => base.SpatialIndexesStrings; set => base.SpatialIndexesStrings = value; } + public new IDictionary>, SpatialOptions> SpatialIndexes { get => base.SpatialIndexes; set => base.SpatialIndexes = value; } + public new IDictionary TermVectorsStrings { get => base.TermVectorsStrings; set => base.TermVectorsStrings = value; } + public new IDictionary>, FieldTermVector> TermVectors { get => base.TermVectors; set => base.TermVectors = value; } + public new IDictionary AnalyzersStrings { get => base.AnalyzersStrings; set => base.AnalyzersStrings = value; } + public new IDictionary>, string> Analyzers { get => base.Analyzers; set => base.Analyzers = value; } + public new ISet>> IndexSuggestions { get => base.IndexSuggestions; set => base.IndexSuggestions = value; } + public new IDictionary StoresStrings { get => base.StoresStrings; set => base.StoresStrings = value; } + public new IDictionary>, FieldStorage> Stores { get => base.Stores; set => base.Stores = value; } + public new string PatternReferencesCollectionName { get => base.PatternReferencesCollectionName; set => base.PatternReferencesCollectionName = value; } + public new Expression> PatternForOutputReduceToCollectionReferences { get => base.PatternForOutputReduceToCollectionReferences; set => base.PatternForOutputReduceToCollectionReferences = value; } + public new void AddAssembly(AdditionalAssembly assembly) => base.AddAssembly(assembly); + public new void Analyze(string field, string analyzer) => base.Analyze(field, analyzer); + public new void Analyze(Expression> field, string analyzer) => base.Analyze(field, analyzer); + public new void Index(Expression> field, FieldIndexing indexing) => base.Index(field, indexing); + public new void Index(string field, FieldIndexing indexing) => base.Index(field, indexing); + public new void Spatial(string field, Func indexing) => base.Spatial(field, indexing); + public new void Spatial(Expression> field, Func indexing) => base.Spatial(field, indexing); + public new void Store(string field, FieldStorage storage) => base.Store(field, storage); + public new void Store(Expression> field, FieldStorage storage) => base.Store(field, storage); + public new void StoreAllFields(FieldStorage storage) => base.StoreAllFields(storage); + public new void Suggestion(Expression> field) => base.Suggestion(field); + public new void TermVector(string field, FieldTermVector termVector) => base.TermVector(field, termVector); + public new void TermVector(Expression> field, FieldTermVector termVector) => base.TermVector(field, termVector); + + // AbstractIndexCreationTask + public new IJsonObject AsJson(object doc) => base.AsJson(doc); + public new IEnumerable AttachmentsFor(object doc) => base.AttachmentsFor(doc); + public new IEnumerable CounterNamesFor(object doc) => base.CounterNamesFor(doc); + public new IJsonObject.IMetadata MetadataFor(object doc) => base.MetadataFor(doc); + public new IEnumerable TimeSeriesNamesFor(object doc) => base.TimeSeriesNamesFor(doc); + + // AbstractIndexCreationTaskBase + public new object CreateField(string name, object value, CreateFieldOptions options) => base.CreateField(name, value, options); + public new object CreateField(string name, object value, bool stored, bool analyzed) => base.CreateField(name, value, stored, analyzed); + public new object CreateField(string name, object value) => base.CreateField(name, value); + + // AbstractCommonApiForIndexes + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new T? TryConvert(object value) where T : struct => base.TryConvert(value); +} + + +public abstract class ZeroIndex : ZeroIndex +{ +} + + +public abstract class ZeroIndex : AbstractIndexCreationTask, IZeroIndexDefinition +{ + public ZeroIndex() { Create(); } + protected virtual void Create() { } + + public virtual void Setup(IZeroOptions options, IDocumentStore store) { } + + // AbstractIndexCreationTask + public new IJsonObject AsJson(object doc) => base.AsJson(doc); + public new IEnumerable AttachmentsFor(object doc) => base.AttachmentsFor(doc); + public new IEnumerable CounterNamesFor(object doc) => base.CounterNamesFor(doc); + public new IJsonObject.IMetadata MetadataFor(object doc) => base.MetadataFor(doc); + public new IEnumerable TimeSeriesNamesFor(object doc) => base.TimeSeriesNamesFor(doc); + + // AbstractIndexCreationTaskBase + public new object CreateField(string name, object value, CreateFieldOptions options) => base.CreateField(name, value, options); + public new object CreateField(string name, object value, bool stored, bool analyzed) => base.CreateField(name, value, stored, analyzed); + public new object CreateField(string name, object value) => base.CreateField(name, value); + + // AbstractCommonApiForIndexes + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func func) => base.Recurse(source, func); + public new IEnumerable Recurse(TSource source, Func> func) => base.Recurse(source, func); + public new T? TryConvert(object value) where T : struct => base.TryConvert(value); +} + + +public interface IZeroIndexDefinition : IAbstractIndexCreationTask +{ + void Setup(IZeroOptions options, IDocumentStore store); +} diff --git a/zero/Persistence/Indexes/ZeroIndexExtensions.cs b/zero/Persistence/Indexes/ZeroIndexExtensions.cs new file mode 100644 index 00000000..41ead020 --- /dev/null +++ b/zero/Persistence/Indexes/ZeroIndexExtensions.cs @@ -0,0 +1,15 @@ +namespace zero.Persistence; + +public static class ZeroIndexExtensions +{ + internal static void RunModifiers(this T index, RavenOptions options) where T : IZeroIndexDefinition + { + IEnumerable modifiers = options.Indexes.Modifiers.GetAllForType(index.GetType()); + + foreach (RavenIndexModifiersOptions.Modifier modifier in modifiers) + { + Action action = modifier.Modify.Compile(); + action.Invoke(index); + } + } +} diff --git a/zero/Persistence/Indexes/ZeroTreeHierachyIndex.cs b/zero/Persistence/Indexes/ZeroTreeHierachyIndex.cs new file mode 100644 index 00000000..f1f2d6b6 --- /dev/null +++ b/zero/Persistence/Indexes/ZeroTreeHierachyIndex.cs @@ -0,0 +1,28 @@ +using Raven.Client.Documents; +using Raven.Client.Documents.Indexes; + +namespace zero.Persistence; + +public class ZeroTreeHierarchyIndexResult : ZeroIdEntity, ISupportsDbConventions +{ + public List Path { get; set; } = new List(); +} + +public abstract class ZeroTreeHierarchyIndex : ZeroIndex where T : ZeroIdEntity, ISupportsTrees +{ + protected override void Create() + { + Map = items => items.Select(item => new ZeroTreeHierarchyIndexResult + { + Id = item.Id, + Path = Recurse(item, x => LoadDocument(x.ParentId)) + .Where(x => x != null && x.Id != null && x.Id != item.Id) + .Reverse() + .Select(current => current.Id) + .ToList() + }); + + StoreAllFields(FieldStorage.Yes); + //Index(x => x.ChannelId, FieldIndexing.Exact); + } +} \ No newline at end of file diff --git a/zero/Persistence/RavenCollectionAttribute.cs b/zero/Persistence/RavenCollectionAttribute.cs new file mode 100644 index 00000000..d45d9701 --- /dev/null +++ b/zero/Persistence/RavenCollectionAttribute.cs @@ -0,0 +1,15 @@ +namespace zero.Persistence; + +/// +/// This attribute will allow the usage of custom collection names for Raven collections +/// +[AttributeUsage(AttributeTargets.Interface | AttributeTargets.Class, AllowMultiple = false)] +public class RavenCollectionAttribute : Attribute +{ + public string Name { get; set; } + + public RavenCollectionAttribute(string name) + { + Name = name; + } +} diff --git a/zero/Persistence/RavenConstants.cs b/zero/Persistence/RavenConstants.cs new file mode 100644 index 00000000..698754c2 --- /dev/null +++ b/zero/Persistence/RavenConstants.cs @@ -0,0 +1,9 @@ +namespace zero.Persistence; + +public static partial class RavenConstants +{ + public static partial class Indexing + { + public const string ThrottlingTimeIntervalInMs = "Indexing.Throttling.TimeIntervalInMs"; + } +} \ No newline at end of file diff --git a/zero/Persistence/RavenIndexExtensions.cs b/zero/Persistence/RavenIndexExtensions.cs new file mode 100644 index 00000000..016894fc --- /dev/null +++ b/zero/Persistence/RavenIndexExtensions.cs @@ -0,0 +1,11 @@ +using Raven.Client.Documents.Indexes; + +namespace zero.Persistence; + +public static class RavenIndexExtensions +{ + public static void Throttle(this AbstractCommonApiForIndexes index, TimeSpan delay) + { + index.Configuration[RavenConstants.Indexing.ThrottlingTimeIntervalInMs] = delay.TotalMilliseconds.ToString(); + } +} diff --git a/zero/Persistence/RavenOptions.cs b/zero/Persistence/RavenOptions.cs new file mode 100644 index 00000000..9ff3d2de --- /dev/null +++ b/zero/Persistence/RavenOptions.cs @@ -0,0 +1,122 @@ +using Raven.Client.Documents; +using System.Linq.Expressions; + +namespace zero.Persistence; + +public class RavenOptions +{ + public string Url { get; set; } + + public string Database { get; set; } + + public bool DatabaseIsDefault { get; set; } = false; + + public string CollectionPrefix { get; set; } = String.Empty; + + public RavenIndexesOptions Indexes { get; set; } = new(); +} + + +public class RavenIndexesOptions : List +{ + public class Map + { + internal Type Type { get; set; } + + internal Expression> CreateIndex { get; set; } + + internal Map(Type type, Expression> create) + { + Type = type; + CreateIndex = create; + } + } + + + public RavenIndexModifiersOptions Modifiers { get; private set; } = new(); + + public void Add() where T : IZeroIndexDefinition, new() + { + base.Add(new Map(typeof(T), () => new T())); + } + + public void Add(Type indexType) + { + base.Add(new Map(indexType, () => (IZeroIndexDefinition)Activator.CreateInstance(indexType))); + } + + public void Add(T index) where T : IZeroIndexDefinition + { + base.Add(new Map(typeof(T), () => index)); + } + + public void AddRange(params Type[] indexes) + { + foreach (Type type in indexes) + { + Add(type); + } + } + + public void Replace() + where T : IZeroIndexDefinition, new() + where TReplaceWith : IZeroIndexDefinition, new() + { + Replace(typeof(T), typeof(TReplaceWith)); + } + + public void Replace(Type origin, Type replaceWith) + { + var item = this.FirstOrDefault(x => x.Type == origin); + if (item != null) + { + Remove(item); + } + Add(replaceWith); + } + + public IEnumerable BuildAll(IZeroOptions options, IDocumentStore store) + { + RavenOptions ravenOptions = options.For(); + + foreach (Map map in this) + { + IZeroIndexDefinition index = map.CreateIndex.Compile().Invoke(); + index.Setup(options, store); + index.RunModifiers(ravenOptions); + yield return index; + } + } +} + + +public class RavenIndexModifiersOptions : List +{ + public class Modifier + { + public Type Type { get; set; } + + public Expression> Modify { get; set; } + } + + public void Add(Action modify) where T : IZeroIndexDefinition, new() + { + Add(new() + { + Type = typeof(T), + Modify = x => modify((T)x) + }); + } + + + public IEnumerable GetAllForType() where T : IZeroIndexDefinition, new() => GetAllForType(typeof(T)); + + + public IEnumerable GetAllForType(Type type) + { + foreach (Modifier modifier in this.Where(x => x.Type.IsAssignableFrom(type))) + { + yield return modifier; + } + } +} diff --git a/zero/Persistence/RavenQueryableExtensions.cs b/zero/Persistence/RavenQueryableExtensions.cs new file mode 100644 index 00000000..332154e4 --- /dev/null +++ b/zero/Persistence/RavenQueryableExtensions.cs @@ -0,0 +1,118 @@ +using Raven.Client.Documents; +using Raven.Client.Documents.Linq; +using Raven.Client.Documents.Queries; +using Raven.Client.Documents.Session; +using System.Linq.Expressions; + +namespace zero.Persistence; + +public static class RavenQueryableExtensions +{ + public static IOrderedQueryable OrderBy(this IQueryable source, string path, bool isDescending, OrderingType type = OrderingType.String) + { + if (!String.IsNullOrEmpty(path)) + { + char[] a = path.ToCharArray(); + a[0] = char.ToUpper(a[0]); + path = new string(a); + } + + if (isDescending) + { + return source.OrderByDescending(path, type); + } + return source.OrderBy(path, type); + } + + + public static IOrderedQueryable ThenBy(this IOrderedQueryable source, string path, bool isDescending, OrderingType type = OrderingType.String) + { + if (!String.IsNullOrEmpty(path)) + { + char[] a = path.ToCharArray(); + a[0] = char.ToUpper(a[0]); + path = new string(a); + } + + if (isDescending) + { + return source.ThenByDescending(path, type); + } + return source.ThenBy(path, type); + } + + + public static IQueryable Paging(this IQueryable source, int pageNumber, int pageSize) + { + pageNumber = pageNumber.Limit(1, 10_000_000); + pageSize = pageSize.Limit(1, 1_000); + + if (pageNumber <= 0 || pageSize <= 0) + { + throw new NotSupportedException("Both pageNumber and pageSize must be greater than zero"); + } + + return source.Skip((pageNumber - 1) * pageSize).Take(pageSize); + } + + + public static IRavenQueryable WhereIf(this IRavenQueryable source, Expression> predicate, bool condition, Expression> elsePredicate = null) + { + if (!condition) + { + if (elsePredicate != null) + { + return source.Where(elsePredicate); + } + return source; + } + + return source.Where(predicate); + } + + + public static IQueryable WhereIf(this IQueryable source, Expression> predicate, bool condition, Expression> elsePredicate = null) + { + if (!condition) + { + if (elsePredicate != null) + { + return source.Where(elsePredicate); + } + return source; + } + + return source.Where(predicate); + } + + + public static IQueryable SearchIf(this IQueryable source, Expression> fieldSelector, string searchTerms, string suffix = null, string prefix = null, SearchOperator @operator = SearchOperator.Or) + { + if (String.IsNullOrWhiteSpace(searchTerms)) + { + return source; + } + + searchTerms = searchTerms.Trim(); + + string[] searchParts = searchTerms.Split(new[] { " " }, StringSplitOptions.RemoveEmptyEntries).Select(x => + { + if (suffix != null) + { + x += suffix; + } + if (prefix != null) + { + x = prefix + x; + } + return x; + }).ToArray(); + + if (searchTerms.StartsWith('"') && searchTerms.EndsWith('"')) + { + searchParts = new[] { searchTerms }; + } + + return source.Search(fieldSelector, searchParts, @operator: @operator); + } +} diff --git a/zero/Persistence/Safenames.cs b/zero/Persistence/Safenames.cs new file mode 100644 index 00000000..7d41297d --- /dev/null +++ b/zero/Persistence/Safenames.cs @@ -0,0 +1,143 @@ +using System.IO; +using System.Text; + +namespace zero.Persistence; + +public class Safenames +{ + public enum Scope + { + Url, + File + } + + const char HYPHEN = '-'; + + const char DOT = '.'; + + const char PLUS = '+'; + + const char AMPERSAND = '&'; + + + /// + /// Converts an untrusted to a safe filename + /// + public static string File(string value) + { + return Generate(Path.GetFileName(value), Scope.File); + } + + + /// + /// Converts a term to a safe alias (suitable for URLs) + /// + public static string Alias(string value) + { + return Generate(value, Scope.Url); + } + + + /// + /// Converts a term to a safe alias (suitable for URLs) + /// + public static string Alias(object value) + { + return Generate(value?.ToString(), Scope.Url); + } + + + /// + /// + /// + static string Generate(string value, Scope scope) + { + if (String.IsNullOrWhiteSpace(value)) + { + return String.Empty; + } + + char previous = default; + StringBuilder output = new(); + + for (int i = 0; i < value.Length; i++) + { + // get character in lower case + char character = char.ToLower(value[i]); + char target; + + // do not handle surrogates + if (char.IsSurrogate(character)) + { + continue; + } + + // special replacements accents + umlauts + if (character.TryReplaceAccent(out char[] replacement)) + { + if (replacement.Length > 1) + { + output.Append(replacement); + output.Remove(output.Length - 1, 1); + } + target = replacement[replacement.Length - 1]; + } + // append character a-z, 0-9 + else if (character.IsAZor09()) + { + target = character; + } + // + sign for + and & + else if (character == PLUS || character == AMPERSAND) + { + target = PLUS; + } + else if (scope == Scope.File && character == DOT) + { + target = DOT; + } + // add hyphen for all other characters + else + { + target = HYPHEN; + } + + // add default characters + if (target != HYPHEN && target != PLUS) + { + output.Append(target); + } + // add hyphen if it isn't first and previous char is not + or - + else if (target == HYPHEN && previous != default && previous != PLUS && previous != HYPHEN) + { + output.Append(target); + } + // add plus. do remove hyphen it is the previous character + else if (target == PLUS) + { + if (previous == HYPHEN) + { + output.Remove(output.Length - 1, 1); + } + output.Append(target); + } + + if (output.Length > 0) + { + previous = output[output.Length - 1]; + } + } + + if (output.Length > 0 && !output[output.Length - 1].IsAZor09()) + { + output.Remove(output.Length - 1, 1); + } + + if (output.Length == 0) + { + output.Append(HYPHEN); + } + + return output.ToString(); + } +} \ No newline at end of file diff --git a/zero/Persistence/Tokens/Rfc6238AuthenticationService.cs b/zero/Persistence/Tokens/Rfc6238AuthenticationService.cs new file mode 100644 index 00000000..8c576663 --- /dev/null +++ b/zero/Persistence/Tokens/Rfc6238AuthenticationService.cs @@ -0,0 +1,104 @@ +using System.Diagnostics; +using System.Net; +using System.Security.Cryptography; +using System.Text; + +namespace zero.Persistence; + +/// +/// Replicates the Rfc6238AuthenticationService from ASP.NET Core internals as it isn't part of the public API +/// (see: https://github.com/dotnet/aspnetcore/blob/release/5.0/src/Identity/Extensions.Core/src/Rfc6238AuthenticationService.cs) +/// +public static class Rfc6238AuthenticationService +{ + private static readonly TimeSpan _timestep = TimeSpan.FromMinutes(3); + private static readonly Encoding _encoding = new UTF8Encoding(false, true); + + // Generates a new 80-bit security token + public static byte[] GenerateRandomKey() + { + byte[] bytes = new byte[20]; + RandomNumberGenerator.Fill(bytes); + return bytes; + } + + internal static int ComputeTotp(HashAlgorithm hashAlgorithm, ulong timestepNumber, string modifier) + { + // # of 0's = length of pin + const int Mod = 1000000; + + // See https://tools.ietf.org/html/rfc4226 + // We can add an optional modifier + var timestepAsBytes = BitConverter.GetBytes(IPAddress.HostToNetworkOrder((long)timestepNumber)); + var hash = hashAlgorithm.ComputeHash(ApplyModifier(timestepAsBytes, modifier)); + + // Generate DT string + var offset = hash[^1] & 0xf; + Debug.Assert(offset + 4 < hash.Length); + var binaryCode = (hash[offset] & 0x7f) << 24 + | (hash[offset + 1] & 0xff) << 16 + | (hash[offset + 2] & 0xff) << 8 + | (hash[offset + 3] & 0xff); + + return binaryCode % Mod; + } + + private static byte[] ApplyModifier(byte[] input, string modifier) + { + if (String.IsNullOrEmpty(modifier)) + { + return input; + } + + var modifierBytes = _encoding.GetBytes(modifier); + var combined = new byte[checked(input.Length + modifierBytes.Length)]; + Buffer.BlockCopy(input, 0, combined, 0, input.Length); + Buffer.BlockCopy(modifierBytes, 0, combined, input.Length, modifierBytes.Length); + return combined; + } + + // More info: https://tools.ietf.org/html/rfc6238#section-4 + private static ulong GetCurrentTimeStepNumber() + { + var delta = DateTimeOffset.UtcNow - DateTimeOffset.UnixEpoch; + return (ulong)(delta.Ticks / _timestep.Ticks); + } + + public static int GenerateCode(byte[] securityToken, string modifier = null) + { + if (securityToken == null) + { + throw new ArgumentNullException(nameof(securityToken)); + } + + // Allow a variance of no greater than 9 minutes in either direction + var currentTimeStep = GetCurrentTimeStepNumber(); + using var hashAlgorithm = new HMACSHA1(securityToken); + return ComputeTotp(hashAlgorithm, currentTimeStep, modifier); + } + + public static bool ValidateCode(byte[] securityToken, int code, string modifier = null) + { + if (securityToken == null) + { + throw new ArgumentNullException(nameof(securityToken)); + } + + // Allow a variance of no greater than 9 minutes in either direction + var currentTimeStep = GetCurrentTimeStepNumber(); + using (var hashAlgorithm = new HMACSHA1(securityToken)) + { + for (var i = -2; i <= 2; i++) + { + var computedTotp = ComputeTotp(hashAlgorithm, (ulong)((long)currentTimeStep + i), modifier); + if (computedTotp == code) + { + return true; + } + } + } + + // No match + return false; + } +} \ No newline at end of file diff --git a/zero/Persistence/Tokens/Token.cs b/zero/Persistence/Tokens/Token.cs new file mode 100644 index 00000000..866b85fd --- /dev/null +++ b/zero/Persistence/Tokens/Token.cs @@ -0,0 +1,13 @@ +namespace zero.Persistence; + +[RavenCollection("Tokens")] +public class SecurityToken : ISupportsDbConventions +{ + public string Id { get; set; } + + public string Key { get; set; } + + public string Token { get; set; } + + public Dictionary Metadata { get; set; } = new(); +} \ No newline at end of file diff --git a/zero/Persistence/Tokens/ZeroTokenProvider.cs b/zero/Persistence/Tokens/ZeroTokenProvider.cs new file mode 100644 index 00000000..ff1e37a0 --- /dev/null +++ b/zero/Persistence/Tokens/ZeroTokenProvider.cs @@ -0,0 +1,295 @@ +using Microsoft.AspNetCore.Cryptography.KeyDerivation; +using Raven.Client.Documents.Session; +using System.Security.Cryptography; +using System.Text; + +namespace zero.Persistence; + +public class ZeroTokenProvider : IZeroTokenProvider +{ + readonly char[] chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-".ToCharArray(); + + readonly RandomNumberGenerator randonNumberGenerator; + + protected IZeroStore Store { get; private set; } + + + public ZeroTokenProvider(IZeroStore store) + { + Store = store; + randonNumberGenerator = RandomNumberGenerator.Create(); + } + + + /// + public virtual async Task Create(string key, TimeSpan expires, int length = 82, Dictionary metadata = default) + { + if (key.IsNullOrWhiteSpace()) + { + throw new ArgumentException("Key cannot be empty"); + } + if (length < 16) + { + throw new ArgumentOutOfRangeException("Use at least a length of 16 for the token"); + } + + string tokenKey = Random(length); + + SecurityToken securityToken = new() + { + Id = TokenToId(tokenKey), + Token = tokenKey, + Key = HashKey(key), + Metadata = metadata ?? new() + }; + + IZeroDocumentSession session = Store.Session(); + + // saves the token + await session.StoreAsync(securityToken); + + // set the expires flag for the token + session.Expires(securityToken, expires); + await session.SaveChangesAsync(); + + return tokenKey; + } + + + /// + public virtual async Task Verify(string key, string token) + { + if (token.IsNullOrWhiteSpace() || key.IsNullOrWhiteSpace()) + { + return false; + } + + IZeroDocumentSession session = Store.Session(); + + // try to find a valid token + SecurityToken securityToken = await session.LoadAsync(TokenToId(token)); + bool isValid = securityToken != null && VerifyKey(securityToken.Key, key); + + // remove token from DB if it is valid + if (isValid) + { + session.Delete(securityToken); + await session.SaveChangesAsync(); + } + + return isValid; + } + + + /// + public virtual async Task VerifyAndReturn(string key, string token) + { + if (token.IsNullOrWhiteSpace() || key.IsNullOrWhiteSpace()) + { + return null; + } + + IZeroDocumentSession session = Store.Session(); + + // try to find a valid token + SecurityToken securityToken = await session.LoadAsync(TokenToId(token)); + bool isValid = securityToken != null && VerifyKey(securityToken.Key, key); + + // remove token from DB if it is valid + if (isValid) + { + session.Delete(securityToken); + await session.SaveChangesAsync(); + return securityToken; + } + + return null; + } + + + /// + public string Random(int length) + { + byte[] data = new byte[4 * length]; + randonNumberGenerator.GetBytes(data); + + StringBuilder result = new(length); + + for (int i = 0; i < length; i++) + { + var rnd = BitConverter.ToUInt32(data, i * 4); + var idx = rnd % chars.Length; + + result.Append(chars[idx]); + } + + return result.ToString(); + } + + + /// + public virtual async Task Exists(string token) + { + if (token.IsNullOrWhiteSpace()) + { + return false; + } + + IZeroDocumentSession session = Store.Session(); + + // try to find a valid token + SecurityToken securityToken = await session.LoadAsync(TokenToId(token)); + return securityToken != null; + } + + + /// + /// Converts the token to a database id + /// + string TokenToId(string token) + { + string collection = Store.Raven.Conventions.GetCollectionName(typeof(SecurityToken)); + string idPrefix = Store.Raven.Conventions.TransformTypeCollectionNameToDocumentIdPrefix(collection); + return idPrefix + Store.Raven.Conventions.IdentityPartsSeparator + token; + } + + + /// + /// Creates a hash for the security token key. + /// Borrowed from the .NET Core PasswordHasher + /// (see: https://github.com/dotnet/aspnetcore/blob/release/5.0/src/Identity/Extensions.Core/src/PasswordHasher.cs) + /// + string HashKey(string key) + { + key = key.ToLowerInvariant(); + + KeyDerivationPrf prf = KeyDerivationPrf.HMACSHA256; + int iterationCount = 10000; + int saltSize = 128 / 8; + int numBytesRequested = 256 / 8; + + byte[] salt = new byte[saltSize]; + randonNumberGenerator.GetBytes(salt); + byte[] subkey = KeyDerivation.Pbkdf2(key, salt, prf, iterationCount, numBytesRequested); + + var outputBytes = new byte[13 + salt.Length + subkey.Length]; + outputBytes[0] = 0x01; + WriteNetworkByteOrder(outputBytes, 1, (uint)prf); + WriteNetworkByteOrder(outputBytes, 5, (uint)iterationCount); + WriteNetworkByteOrder(outputBytes, 9, (uint)saltSize); + Buffer.BlockCopy(salt, 0, outputBytes, 13, salt.Length); + Buffer.BlockCopy(subkey, 0, outputBytes, 13 + saltSize, subkey.Length); + return Convert.ToBase64String(outputBytes); + } + + + /// + /// Verifies the given key. + /// Borrowed from the .NET Core PasswordHasher + /// (see: https://github.com/dotnet/aspnetcore/blob/release/5.0/src/Identity/Extensions.Core/src/PasswordHasher.cs) + /// + bool VerifyKey(string storedKey, string key) + { + byte[] decodedStoredKey = Convert.FromBase64String(storedKey); + + try + { + KeyDerivationPrf prf = (KeyDerivationPrf)ReadNetworkByteOrder(decodedStoredKey, 1); + int embeddedIterCount = (int)ReadNetworkByteOrder(decodedStoredKey, 5); + int saltLength = (int)ReadNetworkByteOrder(decodedStoredKey, 9); + + // Read the salt: must be >= 128 bits + if (saltLength < 128 / 8) + { + return false; + } + + byte[] salt = new byte[saltLength]; + Buffer.BlockCopy(decodedStoredKey, 13, salt, 0, salt.Length); + + // Read the subkey (the rest of the payload): must be >= 128 bits + int subkeyLength = decodedStoredKey.Length - 13 - salt.Length; + if (subkeyLength < 128 / 8) + { + return false; + } + byte[] expectedSubkey = new byte[subkeyLength]; + Buffer.BlockCopy(decodedStoredKey, 13 + salt.Length, expectedSubkey, 0, expectedSubkey.Length); + + // Hash the incoming key and verify it + byte[] actualSubkey = KeyDerivation.Pbkdf2(key, salt, prf, embeddedIterCount, subkeyLength); + + return CryptographicOperations.FixedTimeEquals(actualSubkey, expectedSubkey); + } + catch + { + return false; + } + } + + + static void WriteNetworkByteOrder(byte[] buffer, int offset, uint value) + { + buffer[offset + 0] = (byte)(value >> 24); + buffer[offset + 1] = (byte)(value >> 16); + buffer[offset + 2] = (byte)(value >> 8); + buffer[offset + 3] = (byte)(value >> 0); + } + + static uint ReadNetworkByteOrder(byte[] buffer, int offset) + { + return ((uint)(buffer[offset + 0]) << 24) + | ((uint)(buffer[offset + 1]) << 16) + | ((uint)(buffer[offset + 2]) << 8) + | ((uint)(buffer[offset + 3])); + } +} + + +public interface IZeroTokenProvider +{ + /// + /// Generates a token for a with a specified lifespan. + /// + /// The purpose the token will be used for. The key must match when verifying the token and should always be hidden from the user. + /// The token will automatically expire after this timespan. + /// Length of the geneated token. + /// Additional metadata to store with the token. This data can be retrieved on verification with + /// + /// The generated token with the specified . + /// + Task Create(string key, TimeSpan expires, int length = 82, Dictionary metadata = default); + + /// + /// Validates the passed for the specified . + /// + /// The purpose the token was used for. + /// The previously generated token. + /// + /// False, if the token could not be found or it has already expired. + /// + Task Verify(string key, string token); + + /// + /// Validates the passed for the specified . + /// + /// The purpose the token was used for. + /// The previously generated token. + /// + /// Null, if the token could not be found or it has already expired. + /// + Task VerifyAndReturn(string key, string token); + + /// + /// Generates a random token with the specified using the RNGCryptoServiceProvider. + /// + /// + /// This method won't store the token in the database and can't be used for verification. Use instead. + /// + string Random(int length); + + /// + /// Determines whether a token exists in the database. + /// + Task Exists(string token); +} diff --git a/zero/Persistence/ZeroDocumentConventionsBuilder.cs b/zero/Persistence/ZeroDocumentConventionsBuilder.cs new file mode 100644 index 00000000..423a11e2 --- /dev/null +++ b/zero/Persistence/ZeroDocumentConventionsBuilder.cs @@ -0,0 +1,142 @@ +using Raven.Client.Documents.Conventions; +using System.Collections.Concurrent; +using System.Reflection; +using System.Text; + +namespace zero.Persistence; + +public class ZeroDocumentConventionsBuilder : IZeroDocumentConventionsBuilder +{ + protected HashSet PolymorphTypes { get; private set; } = new(); + + protected Type AcceptsZeroConventionsType { get; set; } = typeof(ISupportsDbConventions); + + protected char IdentityPartsSeparator { get; set; } = '.'; + + protected IZeroOptions Options { get; private set; } + + protected static ConcurrentDictionary CachedTypeCollectionNameMap = new(); + + + public ZeroDocumentConventionsBuilder(IZeroOptions options) + { + Options = options; + } + + + /// + public void Run(DocumentConventions conventions) + { + conventions.MaxNumberOfRequestsPerSession = 1000; + conventions.IdentityPartsSeparator = IdentityPartsSeparator; + conventions.TransformTypeCollectionNameToDocumentIdPrefix = TransformTypeCollectionNameToDocumentIdPrefix; + conventions.FindCollectionName = FindCollectionName; + conventions.RegisterAsyncIdConvention((_, entity) => GetDocumentId(conventions, entity)); + } + + + /// + /// Get a document ID from an entity + /// + protected virtual Task GetDocumentId(DocumentConventions conventions, T entity) + { + string collection = conventions.GetCollectionName(entity); + + StringBuilder documentId = new(); + documentId.Append(conventions.TransformTypeCollectionNameToDocumentIdPrefix(collection)); + documentId.Append(IdentityPartsSeparator); + documentId.Append(IdGenerator.Create()); + + return Task.FromResult(documentId.ToString()); + } + + + /// + /// Finds the collection name for a certain type based on internal rules + /// + protected virtual string FindCollectionName(Type originalType) + { + string collection = null; + + Type type = originalType; + + Func cache = name => + { + CachedTypeCollectionNameMap.TryAdd(type, name); + return name; + }; + + // get inner type for revisions + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Raven.Client.Documents.Subscriptions.Revision<>)) + { + type = type.GetGenericArguments().FirstOrDefault(); + } + + // try to resolve from cache + if (CachedTypeCollectionNameMap.TryGetValue(type, out collection)) + { + return collection; + } + + // do not alter non-internal entities + if (!AcceptsZeroConventionsType.IsAssignableFrom(type)) + { + return cache(DocumentConventions.DefaultGetCollectionName(type)); + } + + // use name from attribute if available + RavenCollectionAttribute collectionAttribute = type.GetCustomAttribute(true); + + if (collectionAttribute != null) + { + return cache(collectionAttribute.Name); + } + + // use base interface if available + Type interfaceBaseType = type.GetInterfaces().FirstOrDefault(x => x.IsInterface && AcceptsZeroConventionsType.IsAssignableFrom(x) && x.Name != AcceptsZeroConventionsType.Name); + + if (interfaceBaseType != null) + { + // use name from attribute if available + collectionAttribute = interfaceBaseType.GetCustomAttribute(true); + if (collectionAttribute != null) + { + return cache(collectionAttribute.Name); + } + } + + // use base type for polymorphism + Type polymorphBaseType = PolymorphTypes.FirstOrDefault(x => type.IsSubclassOf(x)); + + if (polymorphBaseType != null) + { + return cache(DocumentConventions.DefaultGetCollectionName(polymorphBaseType)); + } + + return cache(DocumentConventions.DefaultGetCollectionName(type)); + } + + + /// + /// Translates the types collection name to the document id prefix + /// + protected virtual string TransformTypeCollectionNameToDocumentIdPrefix(string name) + { + RavenOptions options = Options.For(); + if (options != null && !options.CollectionPrefix.IsNullOrWhiteSpace()) + { + name = options.CollectionPrefix.EnsureEndsWith(IdentityPartsSeparator) + name.TrimStart(options.CollectionPrefix); + } + + return name.ToCamelCaseId(); + } +} + + +public interface IZeroDocumentConventionsBuilder +{ + /// + /// Applies internal rules to the RavenDB document conventions + /// + void Run(DocumentConventions conventions); +} \ No newline at end of file diff --git a/zero/Persistence/ZeroDocumentSession.cs b/zero/Persistence/ZeroDocumentSession.cs new file mode 100644 index 00000000..20fda22f --- /dev/null +++ b/zero/Persistence/ZeroDocumentSession.cs @@ -0,0 +1,51 @@ +using Raven.Client.Documents; +using Raven.Client.Documents.Session; + +namespace zero.Persistence; + +public class ZeroDocumentSession : AsyncDocumentSession, IZeroDocumentSession +{ + public IZeroDocumentSession Core { get; private set; } + + public IDocumentSession Synchronous => _synchronous ?? (_synchronous = DocumentStore.OpenSession(DatabaseName)); + + IDocumentSession _synchronous; + + public ZeroDocumentSession(DocumentStore documentStore, Guid id, SessionOptions options, string coreDatabase = null) : base(documentStore, id, options) + { + if (coreDatabase.HasValue()) + { + Core = new ZeroDocumentSession(documentStore, id, new SessionOptions() + { + Database = coreDatabase, + DisableAtomicDocumentWritesInClusterWideTransaction = options.DisableAtomicDocumentWritesInClusterWideTransaction, + NoCaching = options.NoCaching, + NoTracking = options.NoTracking, + RequestExecutor = options.RequestExecutor, + TransactionMode = options.TransactionMode + }); + } + else + { + Core = this; + } + } + + public bool IsDisposed { get; set; } + + public override void Dispose() + { + _synchronous?.Dispose(); + base.Dispose(); + } +} + + +public interface IZeroDocumentSession : IAsyncDocumentSession +{ + IZeroDocumentSession Core { get; } + + IDocumentSession Synchronous { get; } + + bool IsDisposed { get; } +} diff --git a/zero/Persistence/ZeroDocumentSessionExtensions.cs b/zero/Persistence/ZeroDocumentSessionExtensions.cs new file mode 100644 index 00000000..bf923c30 --- /dev/null +++ b/zero/Persistence/ZeroDocumentSessionExtensions.cs @@ -0,0 +1,14 @@ +namespace zero.Persistence; + +public static class ZeroDocumentSessionExtensions +{ + public static void SetCollection(this IZeroDocumentSession session, T model, string collectionName) + { + session.Advanced.GetMetadataFor(model)[Raven.Client.Constants.Documents.Metadata.Collection] = collectionName; + } + + public static void Expires(this IZeroDocumentSession session, T model, TimeSpan expires) + { + session.Advanced.GetMetadataFor(model)[Raven.Client.Constants.Documents.Metadata.Expires] = DateTime.UtcNow.AddSeconds(expires.TotalSeconds); + } +} diff --git a/zero/Persistence/ZeroDocumentStore.cs b/zero/Persistence/ZeroDocumentStore.cs new file mode 100644 index 00000000..7abda123 --- /dev/null +++ b/zero/Persistence/ZeroDocumentStore.cs @@ -0,0 +1,147 @@ +using Raven.Client; +using Raven.Client.Documents; +using Raven.Client.Documents.BulkInsert; +using Raven.Client.Documents.Operations; +using Raven.Client.Documents.Queries; +using Raven.Client.Documents.Session; + +namespace zero.Persistence; + +public class ZeroDocumentStore : DocumentStore, IZeroDocumentStore +{ + public ZeroDocumentStore(IZeroOptions options) : base() + { + Options = options.For(); + Database = null; + } + + protected RavenOptions Options { get; set; } + + /// + public string ResolvedDatabase { get; set; } + + + /// + public OperationExecutor GetOperationExecutor(string database = null) + { + return Operations.ForDatabase(database ?? ResolvedDatabase); + } + + /// + public override IAsyncDocumentSession OpenAsyncSession(string database) + { + return OpenAsyncSession(new SessionOptions() + { + Database = database + }); + } + + /// + public override IAsyncDocumentSession OpenAsyncSession(SessionOptions options) + { + options.Database = options.Database ?? ResolvedDatabase; + + AssertInitialized(); + EnsureNotClosed(); + + var sessionId = Guid.NewGuid(); + var session = new ZeroDocumentSession(this, sessionId, options, Options.Database); + RegisterEvents(session); + AfterSessionCreated(session); + session.OnSessionDisposing += (sender, args) => + { + session.IsDisposed = true; + }; + + session.Advanced.WaitForIndexesAfterSaveChanges(TimeSpan.FromSeconds(0.5), throwOnTimeout: false); + session.Advanced.DocumentStore.AggressivelyCacheFor(TimeSpan.FromHours(1), options.Database); // TODO I guess this will not work in backoffice when we want to see changes immediately + // maybe use caching for frontend but not for backoffice? + + return session; + } + + /// + public override IAsyncDocumentSession OpenAsyncSession() + { + return OpenAsyncSession(new SessionOptions() + { + Database = ResolvedDatabase + }); + } + + /// + public override IDocumentSession OpenSession(SessionOptions options) + { + options.Database = options.Database ?? ResolvedDatabase; + + AssertInitialized(); + EnsureNotClosed(); + + var sessionId = Guid.NewGuid(); + var session = new DocumentSession(this, sessionId, options); + RegisterEvents(session); + AfterSessionCreated(session); + + session.Advanced.DocumentStore.AggressivelyCacheFor(TimeSpan.FromHours(1), options.Database); + + return session; + } + + /// + public override IDocumentSession OpenSession(string database) + { + return OpenSession(new SessionOptions() + { + Database = database + }); + } + + /// + public override IDocumentSession OpenSession() + { + return OpenSession(new SessionOptions() + { + Database = ResolvedDatabase + }); + } + + /// + public override BulkInsertOperation BulkInsert(string database = null, CancellationToken token = default) + { + return base.BulkInsert(database ?? ResolvedDatabase, token); + } + + /// + public async Task PurgeAsync(string database = null, string querySuffix = null, Parameters parameters = null) + { + var collectionName = Conventions.FindCollectionName(typeof(T)); + var operationQuery = new DeleteByQueryOperation(new IndexQuery() + { + Query = $"from {collectionName} c {querySuffix ?? String.Empty}", + QueryParameters = parameters + }, new QueryOperationOptions { AllowStale = true }); + + Operation operation = await GetOperationExecutor(database ?? ResolvedDatabase).SendAsync(operationQuery); + + await operation.WaitForCompletionAsync(); + } +} + + +public interface IZeroDocumentStore : IDocumentStore +{ + /// + /// The database which has been resolved from the current application + /// + string ResolvedDatabase { get; set; } + + /// + /// Get operation executor + /// + OperationExecutor GetOperationExecutor(string database = null); + + /// + /// Purges a collection + /// + Task PurgeAsync(string database = null, string querySuffix = null, Parameters parameters = null); +} \ No newline at end of file diff --git a/zero/Persistence/ZeroPersistenceModule.cs b/zero/Persistence/ZeroPersistenceModule.cs new file mode 100644 index 00000000..55f8fbd3 --- /dev/null +++ b/zero/Persistence/ZeroPersistenceModule.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Raven.Client.Documents; +using Raven.Client.Documents.Indexes; +using Raven.Client.Http; + +namespace zero.Persistence; + +internal class ZeroPersistenceModule : ZeroModule +{ + public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddSingleton(); + services.AddSingleton(CreateRavenStore); + services.AddScoped(); + services.AddScoped(); + + services.AddOptions().Bind(configuration.GetSection("Zero:Raven")); + } + + /// + /// Creates and configures the raven store + /// + protected ZeroDocumentStore CreateRavenStore(IServiceProvider services) + { + IZeroOptions options = services.GetService(); + RavenOptions ravenOptions = options.For(); + IZeroDocumentConventionsBuilder conventionsBuilder = services.GetService(); + + IZeroDocumentStore store = new ZeroDocumentStore(options) + { + Database = ravenOptions.DatabaseIsDefault ? ravenOptions.Database : null, + Urls = new string[1] { ravenOptions.Url }, + Conventions = + { + AggressiveCache = + { + Duration = TimeSpan.FromHours(1), + Mode = AggressiveCacheMode.TrackChanges + } + } + }; + + conventionsBuilder.Run(store.Conventions); + + IDocumentStore raven = store.Initialize(); + + // create all indexes + if (options.Initialized) + { + var indexes = ravenOptions.Indexes.BuildAll(options, store); + IndexCreation.CreateIndexes(indexes, store, database: ravenOptions.Database); + } + + return (ZeroDocumentStore)raven; + } +} \ No newline at end of file diff --git a/zero/Persistence/ZeroStore.cs b/zero/Persistence/ZeroStore.cs new file mode 100644 index 00000000..035e0807 --- /dev/null +++ b/zero/Persistence/ZeroStore.cs @@ -0,0 +1,103 @@ +using Raven.Client.Documents.Session; + +namespace zero.Persistence; + +public class ZeroStore : IZeroStore +{ + public ZeroStore(IZeroDocumentStore raven, IZeroOptions options) : base() + { + Options = options; + Raven = raven; + //Database = null; + } + + protected IZeroOptions Options { get; set; } + protected Dictionary ScopedSessions { get; set; } = new(); + + /// + string _resolvedDatabase = null; + public string ResolvedDatabase + { + get => _resolvedDatabase; + set + { + _resolvedDatabase = value; + Raven.ResolvedDatabase = value; + } + } + + + /// + public IZeroDocumentStore Raven { get; private set; } + + + /// + public IZeroDocumentSession Session(string database = null, ZeroSessionResolution resolution = ZeroSessionResolution.Reuse, SessionOptions options = null) + { + database ??= ResolvedDatabase; + options ??= new SessionOptions() { Database = database }; + + if (!database.HasValue()) + { + return Session(true, database, resolution, options); + } + + if (resolution == ZeroSessionResolution.Create) + { + return Raven.OpenAsyncSession(options) as IZeroDocumentSession; + } + + if (!ScopedSessions.TryGetValue(database, out IZeroDocumentSession session)) + { + session = Raven.OpenAsyncSession(options) as IZeroDocumentSession; + ScopedSessions.TryAdd(database, session); + } + + if (session.IsDisposed) + { + session = Raven.OpenAsyncSession(options) as IZeroDocumentSession; + ScopedSessions.Remove(database); + ScopedSessions.TryAdd(database, session); + } + + return session; + } + + + /// + public IZeroDocumentSession Session(bool global, string database = null, ZeroSessionResolution resolution = ZeroSessionResolution.Reuse, SessionOptions options = null) + { + return Session(global ? Options.For().Database : database, resolution, options); + } +} + + +public enum ZeroSessionResolution +{ + Reuse = 0, + Create = 1 +} + + +public interface IZeroStore +{ + /// + /// The database which has been resolved from the current application + /// + string ResolvedDatabase { get; set; } + + /// + /// Get underlying raven document store + /// + IZeroDocumentStore Raven { get; } + + /// + /// Use a specific session + /// + IZeroDocumentSession Session(string database = null, ZeroSessionResolution resolution = ZeroSessionResolution.Reuse, SessionOptions options = null); + + /// + /// Use a session for the core database + /// + IZeroDocumentSession Session(bool global, string database = null, ZeroSessionResolution resolution = ZeroSessionResolution.Reuse, SessionOptions options = null); +} \ No newline at end of file diff --git a/zero/Rendering/RazorRenderer.cs b/zero/Rendering/RazorRenderer.cs new file mode 100644 index 00000000..98b08fa1 --- /dev/null +++ b/zero/Rendering/RazorRenderer.cs @@ -0,0 +1,345 @@ +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using System.Diagnostics; +using System.IO; +using System.Text.Encodings.Web; + +namespace zero.Rendering; + +public class RazorRenderer : IRazorRenderer, IDisposable +{ + protected IRazorViewEngine ViewEngine { get; set; } + + protected IRazorPageActivator PageActivator { get; set; } + + protected ITempDataDictionaryFactory TempDataDictionaryFactory { get; set; } + + protected IModelMetadataProvider ModelMetadataProvider { get; set; } + + protected IHttpContextAccessor HttpContextAccessor { get; set; } + + protected IServiceScope ServiceScope { get; set; } + + protected HtmlHelperOptions HtmlHelperOptions { get; set; } + + + public RazorRenderer(IRazorViewEngine viewEngine, IRazorPageActivator pageActivator, IHttpContextAccessor httpContextAccessor, ITempDataDictionaryFactory tempDataDictionaryFactory, + IModelMetadataProvider modelMetadataProvider, IServiceProvider serviceProvider, IOptions mvcHelperOptions) + { + ViewEngine = viewEngine; + PageActivator = pageActivator; + HttpContextAccessor = httpContextAccessor; + TempDataDictionaryFactory = tempDataDictionaryFactory; + ModelMetadataProvider = modelMetadataProvider; + ServiceScope = serviceProvider.CreateScope(); + HtmlHelperOptions = mvcHelperOptions.Value.HtmlHelperOptions; + } + + + /// + /// Renders a razor component to a string + /// + public async Task ComponentAsync(object args = null) where T : ViewComponent + { + return await ComponentAsync(typeof(T), args); + } + + + /// + /// Renders a razor component to a string + /// + public async Task ComponentAsync(Type componentType, object args = null) + { + return await ComponentAsync(componentType, BuildActionContext(), args); + } + + + /// + /// Renders a razor component to a string + /// + public async Task ComponentAsync(string componentName, object args = null) + { + return await ComponentAsync(componentName, BuildActionContext(), args); + } + + + /// + /// Renders a razor component to a string + /// + public async Task ComponentAsync(ActionContext context, object args = null) where T : ViewComponent + { + return await ComponentAsync(typeof(T), context, args); + } + + + /// + /// Renders a razor component to a string + /// + public async Task ComponentAsync(Type componentType, ActionContext context, object args = null) + { + IViewComponentHelper viewComponentHelper = context.HttpContext.RequestServices.GetRequiredService(); + + using StringWriter stringWriter = new(); + + ViewContext viewContext = BuildViewContext(context, stringWriter); + + (viewComponentHelper as IViewContextAware)?.Contextualize(viewContext); + IHtmlContent result = await viewComponentHelper.InvokeAsync(componentType, args); + + result.WriteTo(stringWriter, HtmlEncoder.Default); + await stringWriter.FlushAsync(); + return stringWriter.ToString(); + } + + + /// + /// Renders a razor component to a string + /// + public async Task ComponentAsync(string componentName, ActionContext context, object args = null) + { + IViewComponentHelper viewComponentHelper = context.HttpContext.RequestServices.GetRequiredService(); + + using StringWriter stringWriter = new(); + + ViewContext viewContext = BuildViewContext(context, stringWriter); + + (viewComponentHelper as IViewContextAware)?.Contextualize(viewContext); + IHtmlContent result = await viewComponentHelper.InvokeAsync(componentName, args); + + result.WriteTo(stringWriter, HtmlEncoder.Default); + await stringWriter.FlushAsync(); + return stringWriter.ToString(); + } + + + /// + /// Renders a razor page to a string + /// + public async Task PageAsync(T model) where T : PageModel + { + IRazorPage pageResult = FindPage(model.PageContext, model.PageContext.ActionDescriptor.RelativePath); + + using StringWriter stringWriter = new(); + + RazorView view = new RazorView(ViewEngine, PageActivator, new List(), pageResult, HtmlEncoder.Default, new DiagnosticListener("ViewRenderService")); + + ViewContext viewContext = BuildViewContext(model.PageContext, stringWriter, view); + viewContext.RouteData = model.RouteData; + viewContext.ViewData = model.ViewData; + + Microsoft.AspNetCore.Mvc.RazorPages.Page razorPage = (Microsoft.AspNetCore.Mvc.RazorPages.Page)pageResult; + razorPage.PageContext = model.PageContext; + razorPage.ViewContext = viewContext; + + PageActivator.Activate(razorPage, viewContext); + + await razorPage.ExecuteAsync(); + await stringWriter.FlushAsync(); + + return stringWriter.ToString(); + } + + + /// + /// Renders a razor view to a string + /// + public async Task ViewAsync(string view, object model = null) + { + return await ViewAsync(BuildActionContext(), view, model); + } + + + /// + /// Renders a razor view to a string + /// + public async Task ViewAsync(ActionContext context, string view, object model = null) + { + IView viewResult = FindView(context, view); + + using StringWriter stringWriter = new(); + + ViewContext viewContext = BuildViewContext(context, stringWriter, viewResult); + viewContext.RouteData = context.RouteData; + viewContext.ViewData.Model = model; + + await viewResult.RenderAsync(viewContext); + await stringWriter.FlushAsync(); + + return stringWriter.ToString(); + } + + + /// + public void Dispose() + { + ServiceScope?.Dispose(); + } + + + /// + /// Build the view context + /// + protected virtual ViewContext BuildViewContext(ActionContext context, StringWriter writer, IView view = null) + { + ViewDataDictionary viewData = new(ModelMetadataProvider, context.ModelState); // result.ViewData ?? new ViewData...; + ITempDataDictionary tempData = TempDataDictionaryFactory.GetTempData(context.HttpContext); // result.TempData ?? TempData...; + + return new ViewContext(context, view ?? NullView.Instance, viewData, tempData, writer, HtmlHelperOptions); + } + + + /// + /// Builds a new view context + /// + protected virtual ActionContext BuildActionContext() + { + HttpContext context = GetHttpContext(); + RouteData routeData = context.GetRouteData(); + return new ActionContext(context, routeData, new ActionDescriptor()); + } + + + /// + /// Get HTTP context or mock one + /// + protected virtual HttpContext GetHttpContext() + { + HttpContext context = HttpContextAccessor.HttpContext; + context ??= new DefaultHttpContext() + { + RequestServices = ServiceScope.ServiceProvider + }; + + return context; + } + + + /// + /// Tries to find a view + /// + protected virtual IView FindView(ActionContext actionContext, string viewName) + { + ViewEngineResult getViewResult = ViewEngine.GetView(executingFilePath: null, viewPath: viewName, isMainPage: false); + if (getViewResult.Success) + { + return getViewResult.View; + } + + ViewEngineResult findViewResult = ViewEngine.FindView(actionContext, viewName, isMainPage: false); + if (findViewResult.Success) + { + return findViewResult.View; + } + + IEnumerable searchedLocations = getViewResult.SearchedLocations.Concat(findViewResult.SearchedLocations); + string errorMessage = String.Join(Environment.NewLine, new[] { $"Unable to find view '{viewName}'. The following locations were searched:" }.Concat(searchedLocations)); + + throw new InvalidOperationException(errorMessage); + } + + + /// + /// Tries to find a page + /// + protected virtual IRazorPage FindPage(ActionContext actionContext, string pageName) + { + RazorPageResult getPageResult = ViewEngine.GetPage(executingFilePath: null, pagePath: pageName); + if (getPageResult.Page != null) + { + return getPageResult.Page; + } + + RazorPageResult findPageResult = ViewEngine.FindPage(actionContext, pageName: pageName); + if (findPageResult.Page != null) + { + return findPageResult.Page; + } + + IEnumerable searchedLocations = getPageResult.SearchedLocations.Concat(findPageResult.SearchedLocations); + string errorMessage = String.Join(Environment.NewLine, new[] { $"Unable to find page '{pageName}'. The following locations were searched:" }.Concat(searchedLocations)); + + throw new InvalidOperationException(errorMessage); + } + + + class GenericController : ControllerBase { } + + + class NullView : IView + { + public static readonly NullView Instance = new NullView(); + + public string Path => string.Empty; + + public Task RenderAsync(ViewContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + return Task.CompletedTask; + } + } + +} + + +public interface IRazorRenderer +{ + /// + /// Renders a razor component to a string + /// + Task ComponentAsync(object args = null) where T : ViewComponent; + + /// + /// Renders a razor component to a string + /// + Task ComponentAsync(Type componentType, object args = null); + + /// + /// Renders a razor component to a string + /// + Task ComponentAsync(string componentName, object args = null); + + /// + /// Renders a razor component to a string + /// + Task ComponentAsync(ActionContext context, object args = null) where T : ViewComponent; + + /// + /// Renders a razor component to a string + /// + Task ComponentAsync(Type componentType, ActionContext context, object args = null); + + /// + /// Renders a razor component to a string + /// + Task ComponentAsync(string componentName, ActionContext context, object args = null); + + /// + /// Renders a razor page to a string + /// + Task PageAsync(T model) where T : PageModel; + + /// + /// Renders a razor view to a string + /// + Task ViewAsync(string view, object model = null); + + /// + /// Renders a razor view to a string + /// + Task ViewAsync(ActionContext context, string view, object model = null); +} \ No newline at end of file diff --git a/zero/Rendering/ZeroRenderingModule.cs b/zero/Rendering/ZeroRenderingModule.cs new file mode 100644 index 00000000..5d975059 --- /dev/null +++ b/zero/Rendering/ZeroRenderingModule.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace zero.Rendering; + +public class ZeroRenderingModule : ZeroModule +{ + public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + } +} \ No newline at end of file diff --git a/zero/ServiceCollectionExtensions.cs b/zero/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..f854e89b --- /dev/null +++ b/zero/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace zero; + +public static class ServiceCollectionExtensions +{ + public static ZeroBuilder AddZero(this IServiceCollection services, IConfiguration configuration) + { + return new ZeroBuilder(services, configuration, null); + } + + public static ZeroBuilder AddZero(this IServiceCollection services, IConfiguration configuration, Action setupAction) + { + return new ZeroBuilder(services, configuration, setupAction); + } +} \ No newline at end of file diff --git a/zero/Stores/Flavors/ConfigureFlavorJsonOptions.cs b/zero/Stores/Flavors/ConfigureFlavorJsonOptions.cs new file mode 100644 index 00000000..3f4af64a --- /dev/null +++ b/zero/Stores/Flavors/ConfigureFlavorJsonOptions.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace zero.Stores; + + +public class ConfigureFlavorJsonOptions : IConfigureOptions +{ + private readonly IZeroOptions _zeroOptions; + + public ConfigureFlavorJsonOptions(IZeroOptions options) + { + _zeroOptions = options; + } + + public void Configure(JsonOptions options) + { + options.JsonSerializerOptions.Converters.Add(new JsonFlavorVariantConverterFactory(_zeroOptions)); + } +} \ No newline at end of file diff --git a/zero/Stores/Flavors/FlavorConfig.cs b/zero/Stores/Flavors/FlavorConfig.cs new file mode 100644 index 00000000..a0139203 --- /dev/null +++ b/zero/Stores/Flavors/FlavorConfig.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; + +namespace zero.Stores; + +/// +/// A flavor config holds information about a flavor implementation +/// +public class FlavorConfig +{ + /// + /// Type of the associated entity + /// + [JsonIgnore] + public Type FlavorType { get; private set; } + + /// + /// Alias for querying + /// + public string Alias { get; set; } + + /// + /// Name of the flavor + /// + public string Name { get; set; } + + /// + /// Optional description + /// + public string Description { get; set; } + + /// + /// Icon of the flavor + /// + public string Icon { get; set; } + + + [JsonIgnore] + public Func Construct { get; set; } + + + public FlavorConfig(Type type) + { + FlavorType = type; + } +} \ No newline at end of file diff --git a/zero/Stores/Flavors/FlavorOptions.cs b/zero/Stores/Flavors/FlavorOptions.cs new file mode 100644 index 00000000..dfcb5521 --- /dev/null +++ b/zero/Stores/Flavors/FlavorOptions.cs @@ -0,0 +1,190 @@ +using System.Collections.Concurrent; + +namespace zero.Stores; + +public class FlavorOptions +{ + public ConcurrentDictionary Providers { get; private set; } = new(); + + public void Configure(Action> configure) where TEntity : class, ISupportsFlavors, new() + { + Type type = typeof(TEntity); + FlavorProvider provider = Providers.GetOrAdd(type, _ => CreateProvider()); + configure(new FlavorProviderOptions(this, provider)); + } + + + public bool CanUseWithoutFlavors() where TEntity : class, ISupportsFlavors, new() + { + return Providers.GetValueOrDefault(typeof(TEntity), new()).CanUseWithoutFlavors; + } + + public string DefaultFlavorFor() where TEntity : class, ISupportsFlavors, new() + { + FlavorProvider provider = Providers.GetValueOrDefault(typeof(TEntity), new()); + + if (!provider.Flavors.Any()) + { + return null; + } + + if (provider.DefaultFlavor.HasValue() && provider.Flavors.Any(x => x.Alias == provider.DefaultFlavor)) + { + return provider.DefaultFlavor; + } + + return null; + } + + + public TFlavor Construct(string alias = null) + where TEntity : class, ISupportsFlavors, new() + where TFlavor : class, TEntity, new() + { + // if no flavor provider is registered we return the base entity + if (!Providers.TryGetValue(typeof(TEntity), out FlavorProvider provider)) + { + return new TFlavor(); + } + + // throw if this entity is not allowed to be created without a flavor + if (alias.IsNullOrEmpty() && !provider.CanUseWithoutFlavors) + { + string defaultFlavorAlias = provider.DefaultFlavor; + + if (defaultFlavorAlias.IsNullOrEmpty() || !provider.Flavors.Any(x => x.Alias == defaultFlavorAlias)) + { + throw new ArgumentException("Can not create instance of an entity which is configured to to be only used as a flavor", nameof(alias)); + } + + alias = defaultFlavorAlias; + } + + // return default instance if no flavor is required + if (alias.IsNullOrEmpty()) + { + return provider.FlavorlessConstruct() as TFlavor; + } + + // try to load and construct a specific flavor + FlavorConfig config = provider.Flavors.FirstOrDefault(x => x.Alias == alias); + TFlavor result = config?.Construct(config) as TFlavor; + + if (result == null) + { + return default; + } + + result.Flavor = alias; + return result; + } + + + public IEnumerable GetAll() => GetAll(typeof(TEntity)); + + + public IEnumerable GetAll(Type type) => Providers.GetValueOrDefault(type, new()).Flavors; + + + public FlavorConfig Get() => Get(typeof(TEntity), typeof(TFlavor)); + + + public FlavorConfig Get(string alias) => Get(typeof(TEntity), typeof(TFlavor), alias); + + + public FlavorConfig Get(string alias) => Get(typeof(TEntity), alias); + + + public FlavorConfig Get(Type baseType, Type flavorType) + { + FlavorProvider provider = Providers.GetValueOrDefault(baseType, new()); + return provider.Flavors.FirstOrDefault(x => x.FlavorType == flavorType); + } + + + public FlavorConfig Get(Type baseType, Type flavorType, string alias) + { + FlavorProvider provider = Providers.GetValueOrDefault(baseType, new()); + return provider.Flavors.FirstOrDefault(x => x.FlavorType == flavorType && x.Alias == alias); + } + + + public FlavorConfig Get(Type baseType, string alias) + { + FlavorProvider provider = Providers.GetValueOrDefault(baseType, new()); + return provider.Flavors.FirstOrDefault(x => x.Alias == alias); + } + + + public bool Exists(string alias) + { + FlavorProvider provider = Providers.GetValueOrDefault(typeof(TEntity), new()); + return provider.Flavors.Any(x => x.Alias == alias); + } + + + public void Add(string alias, string name, string description = null, string icon = null) + where TEntity : class, ISupportsFlavors, new() + where TFlavor : TEntity, new() + { + Add(typeof(TFlavor), _ => new TFlavor(), alias, name, description, icon); + } + + + public void Add(Func construct, string alias, string name, string description = null, string icon = null) + where TEntity : class, ISupportsFlavors, new() + where TFlavor : TEntity, new() + { + Add(typeof(TFlavor), construct, alias, name, description, icon); + } + + + public void Add(Type flavorType, Func construct, string alias, string name, string description = null, string icon = null) + where TEntity : class, ISupportsFlavors, new() + where TFlavor : TEntity, new() + { + Add(new(flavorType) + { + Alias = alias, + Name = name, + Description = description, + Icon = icon, + Construct = _ => construct(_) + }); + } + + + public void Add(FlavorConfig config) + where TEntity : class, ISupportsFlavors, new() + where TFlavor : TEntity, new() + { + Type baseEntityType = typeof(TEntity); + FlavorProvider provider = Providers.GetOrAdd(baseEntityType, _ => CreateProvider()); + provider.Flavors.Add(config); + } + + + public void Implement() + where TEntity : class, ISupportsFlavors, new() + where TDefaultImplementation : TEntity, new() + { + Type baseEntityType = typeof(TEntity); + FlavorProvider provider = Providers.GetOrAdd(baseEntityType, _ => CreateProvider()); + + provider.FlavorlessConstruct = () => new TDefaultImplementation(); + provider.FlavorlessType = typeof(TDefaultImplementation); + } + + + FlavorProvider CreateProvider() + where TEntity : class, ISupportsFlavors, new() + { + return new FlavorProvider() + { + BaseType = typeof(TEntity), + FlavorlessConstruct = () => new TEntity(), + FlavorlessType = typeof(TEntity), + ConverterCreator = cfg => new JsonFlavorVariantConverter(cfg) + }; + } +} diff --git a/zero/Stores/Flavors/FlavorProvider.cs b/zero/Stores/Flavors/FlavorProvider.cs new file mode 100644 index 00000000..c8ace2b1 --- /dev/null +++ b/zero/Stores/Flavors/FlavorProvider.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace zero.Stores; + +/// +/// A flavor provider is attached to an entity (which has ISupportsFlavors) and contains all flavors +/// +public class FlavorProvider +{ + public bool CanUseWithoutFlavors { get; set; } = true; + + public string DefaultFlavor { get; set; } + + [JsonIgnore] + public Type BaseType { get; set; } + + [JsonIgnore] + public Type FlavorlessType { get; set; } + + [JsonIgnore] + public Func FlavorlessConstruct { get; set; } + + public List Flavors { get; set; } = new(); + + /// + /// Flavor discriminator converter + /// + [JsonIgnore] + public Func ConverterCreator { get; set; } +} \ No newline at end of file diff --git a/zero/Stores/Flavors/FlavorProviderOptions.cs b/zero/Stores/Flavors/FlavorProviderOptions.cs new file mode 100644 index 00000000..35686027 --- /dev/null +++ b/zero/Stores/Flavors/FlavorProviderOptions.cs @@ -0,0 +1,82 @@ +namespace zero.Stores; + +public class FlavorProviderOptions where TEntity : class, ISupportsFlavors, new() +{ + readonly Type _baseType; + readonly FlavorOptions _options; + readonly FlavorProvider _provider; + + internal FlavorProviderOptions(FlavorOptions options, FlavorProvider provider) + { + _baseType = typeof(TEntity); + _options = options; + _provider = provider; + } + + public bool CanUseWithoutFlavors + { + get => _provider.CanUseWithoutFlavors; + set => _provider.CanUseWithoutFlavors = value; + } + + public string DefaultFlavor + { + get => _provider.DefaultFlavor; + set => _provider.DefaultFlavor = value; + } + + public Type BaseType => _provider.BaseType; + + public Func FlavorlessConstruct + { + get => _provider.FlavorlessConstruct; + set => _provider.FlavorlessConstruct = value; + } + + public Type FlavorlessType + { + get => _provider.FlavorlessType; + set => _provider.FlavorlessType = value; + } + + public IEnumerable GetAll() => _options.GetAll(); + + public FlavorConfig Get() => _options.Get(typeof(TEntity), typeof(TFlavor)); + + public FlavorConfig Get(string alias) => _options.Get(typeof(TEntity), alias); + + public FlavorConfig Get(Type flavorType) => _options.Get(typeof(TEntity), flavorType); + + public FlavorConfig Get(string alias) => _options.Get(typeof(TEntity), alias); + + public bool Exists(string alias) => _options.Exists(alias); + + public void Add(string alias, string name, string description = null, string icon = null) + where TFlavor : TEntity, new() => _options.Add(alias, name, description, icon); + + public void Add(Func construct, string alias, string name, string description = null, string icon = null) + where TFlavor : TEntity, new() => _options.Add(construct, alias, name, description, icon); + + public void Add(Type flavorType, Func construct, string alias, string name, string description = null, string icon = null) + where TFlavor : TEntity, new() => _options.Add(flavorType, construct, alias, name, description, icon); + + public void Add(FlavorConfig config) + where TFlavor : TEntity, new() => _options.Add(config); + + public void Remove(string alias) + { + FlavorProvider provider = _options.Providers.GetValueOrDefault(_baseType, new()); + FlavorConfig flavor = provider.Flavors.FirstOrDefault(x => x.Alias == alias); + + if (flavor != null) + { + provider.Flavors.Remove(flavor); + } + } + + public void RemoveAll() + { + FlavorProvider provider = _options.Providers.GetValueOrDefault(_baseType, new()); + provider.Flavors.Clear(); + } +} diff --git a/zero/Stores/Flavors/JsonFlavorVariantConverter.cs b/zero/Stores/Flavors/JsonFlavorVariantConverter.cs new file mode 100644 index 00000000..7224a194 --- /dev/null +++ b/zero/Stores/Flavors/JsonFlavorVariantConverter.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace zero.Stores; + +internal class JsonFlavorVariantConverter : JsonDiscriminatorConverter where T : class, ISupportsFlavors, new() +{ + protected FlavorProvider Provider { get; private set; } + + Type _factoryType = typeof(JsonFlavorVariantConverterFactory); + + + public JsonFlavorVariantConverter(FlavorProvider provider) : base("flavor") + { + Provider = provider; + } + + + protected override Type GetTypeFromDiscriminator(Type requestedType, string discriminator) + { + FlavorConfig config = Provider.Flavors.FirstOrDefault(x => x.Alias.Equals(discriminator, StringComparison.InvariantCultureIgnoreCase)); + return config?.FlavorType ?? Provider.FlavorlessType ?? requestedType; + } + + + protected override JsonSerializerOptions CreateOptions(JsonSerializerOptions baseOptions) + { + JsonSerializerOptions newOptions = base.CreateOptions(baseOptions); + JsonConverter toRemove = newOptions.Converters.FirstOrDefault(x => x.GetType() == _factoryType); + newOptions.Converters.Remove(toRemove); + return newOptions; + } +} \ No newline at end of file diff --git a/zero/Stores/Flavors/JsonFlavorVariantConverterFactory.cs b/zero/Stores/Flavors/JsonFlavorVariantConverterFactory.cs new file mode 100644 index 00000000..10df7099 --- /dev/null +++ b/zero/Stores/Flavors/JsonFlavorVariantConverterFactory.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace zero.Stores; + +public class JsonFlavorVariantConverterFactory : JsonConverterFactory +{ + readonly IZeroOptions _options; + + public JsonFlavorVariantConverterFactory(IZeroOptions options) + { + _options = options; + } + + public override bool CanConvert(Type typeToConvert) + { + if (!typeof(ISupportsFlavors).IsAssignableFrom(typeToConvert)) + { + return false; + } + + if (!_options.For().Providers.TryGetValue(typeToConvert, out FlavorProvider provider) || provider.ConverterCreator == null) + { + return false; + } + + return true; + } + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + FlavorProvider provider = _options.For().Providers[typeToConvert]; + return provider.ConverterCreator(provider); + } +} \ No newline at end of file diff --git a/zero/Stores/StoreContext.cs b/zero/Stores/StoreContext.cs new file mode 100644 index 00000000..c2f5bd1e --- /dev/null +++ b/zero/Stores/StoreContext.cs @@ -0,0 +1,27 @@ +namespace zero.Stores; + +public class StoreContext +{ + public IZeroStore Store { get; private set; } + + public IZeroContext Context { get; private set; } + + public IZeroOptions Options { get; private set; } + + public IInterceptors Interceptors { get; private set; } + + public IServiceProvider Services { get; private set; } + + public IMessageAggregator Messages { get; private set; } + + + public StoreContext(IZeroContext context, IInterceptors interceptors, IServiceProvider serviceProvider, IMessageAggregator messages) + { + Store = context.Store; + Options = context.Options; + Context = context; + Interceptors = interceptors; + Services = serviceProvider; + Messages = messages; + } +} \ No newline at end of file diff --git a/zero/Stores/StoreOperations.Delete.cs b/zero/Stores/StoreOperations.Delete.cs new file mode 100644 index 00000000..465d23c8 --- /dev/null +++ b/zero/Stores/StoreOperations.Delete.cs @@ -0,0 +1,30 @@ +namespace zero.Stores; + +public partial class StoreOperations : IStoreOperations +{ + /// + public virtual async Task> Delete(T model) where T : ZeroIdEntity, new() + { + if (model == null) + { + return Result.Fail("@errors.ondelete.idnotfound"); + } + + InterceptorInstruction instruction = Interceptors.ForDelete(model); + + if (InterceptorBlocker == null && !await instruction.Start(this)) + { + return instruction.Result; + } + + Session.Delete(model); + await Session.SaveChangesAsync(); + if (InterceptorBlocker == null) + { + await instruction.Complete(); + } + await Session.SaveChangesAsync(); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/zero/Stores/StoreOperations.Empty.cs b/zero/Stores/StoreOperations.Empty.cs new file mode 100644 index 00000000..903d5e6c --- /dev/null +++ b/zero/Stores/StoreOperations.Empty.cs @@ -0,0 +1,16 @@ +namespace zero.Stores; + +public partial class StoreOperations : IStoreOperations +{ + /// + public virtual Task Empty(string flavorAlias = null) where T : ZeroIdEntity, ISupportsFlavors, new() => Empty(flavorAlias); + + + /// + public virtual Task Empty(string flavorAlias = null) + where T : ZeroIdEntity, ISupportsFlavors, new() + where TFlavor : T, new() + { + return Task.FromResult(Flavors.Construct(flavorAlias)); + } +} \ No newline at end of file diff --git a/zero/Stores/StoreOperations.Read.cs b/zero/Stores/StoreOperations.Read.cs new file mode 100644 index 00000000..e8c81090 --- /dev/null +++ b/zero/Stores/StoreOperations.Read.cs @@ -0,0 +1,121 @@ +using Raven.Client.Documents; +using Raven.Client.Documents.Indexes; +using Raven.Client.Documents.Linq; +using Raven.Client.Documents.Session; + +namespace zero.Stores; + +public partial class StoreOperations : IStoreOperations +{ + /// + public virtual async Task Load(string id, string changeVector = null) where T : ZeroIdEntity, new() + { + if (id.IsNullOrWhiteSpace()) + { + return default; + } + if (!changeVector.IsNullOrEmpty()) + { + //return WhenActive(await GetRevision(changeVector)); // TODO + } + + return WhenActive(await Session.LoadAsync(id)); + } + + + /// + public virtual async Task> Load(IEnumerable ids) where T : ZeroIdEntity, new() + { + ids = ids.Distinct().ToArray(); + + Dictionary models = await Session.LoadAsync(ids); + Dictionary result = new(); + + foreach (string id in ids) + { + models.TryGetValue(id, out T model); + result.Add(id, WhenActive(model)); + } + + return result; + } + + + /// + public virtual async Task Any(Func, IQueryable> querySelector = default) where T : ZeroIdEntity, new() + { + querySelector ??= x => x; + return await querySelector(Session.Query()).AnyAsync(); + } + + + /// + public virtual async Task> Load(int pageNumber, int pageSize, Func, IQueryable> querySelector = default) where T : ZeroIdEntity, new() + { + IRavenQueryable queryable = Session.Query().Statistics(out QueryStatistics statistics); + querySelector ??= x => x; + + List result = await querySelector(queryable).Paging(pageNumber, pageSize).ToListAsync(); + return new Paged(result, statistics.TotalResults, pageNumber, pageSize); + } + + + /// + public virtual async Task> Load(int pageNumber, int pageSize, Func, IQueryable> querySelector = default) + where T : ZeroIdEntity, new() + where TIndex : AbstractCommonApiForIndexes, new() + { + IRavenQueryable queryable = Session.Query().Statistics(out QueryStatistics statistics); + querySelector ??= x => x; + + List result = await querySelector(queryable).Paging(pageNumber, pageSize).ToListAsync(); + return new Paged(result, statistics.TotalResults, pageNumber, pageSize); + } + + + /// + public virtual async Task> LoadAll() where T : ZeroIdEntity, new() + { + List items = new(); + + await foreach (T item in Stream(null)) + { + items.Add(item); + } + + return items; + } + + + /// + public virtual async IAsyncEnumerable Stream(Func, IQueryable> expression) where T : ZeroIdEntity, new() + { + IRavenQueryable query = Session.Query(); + IQueryable queryable = query; + + if (expression != null) + { + queryable = expression(query); + } + + var stream = await Session.Advanced.StreamAsync(queryable); + + while (await stream.MoveNextAsync()) + { + if (WhenActive(stream.Current.Document) == default) + { + continue; + } + + yield return stream.Current.Document; + } + } + + + /// + public virtual string GetChangeToken(T model) where T : ZeroIdEntity, new() + { + string changeVector = Session.Advanced.GetChangeVectorFor(model); + return IdGenerator.HashString(changeVector); + } +} \ No newline at end of file diff --git a/zero/Stores/StoreOperations.Tree.cs b/zero/Stores/StoreOperations.Tree.cs new file mode 100644 index 00000000..666496b4 --- /dev/null +++ b/zero/Stores/StoreOperations.Tree.cs @@ -0,0 +1,185 @@ +using Raven.Client.Documents; +using Raven.Client.Documents.Indexes; +using Raven.Client.Documents.Linq; +using Raven.Client.Documents.Session; + +namespace zero.Stores; + +public partial class StoreOperations : IStoreOperations +{ + /// + public virtual Task IsAllowedAsChild(T model, string parentId) where T : ZeroIdEntity, ISupportsTrees, new() + { + return Task.FromResult(true); + } + + + /// + public async Task GetHierarchy(string id) + where T : ZeroIdEntity, ISupportsTrees, new() + where TIndex : ZeroTreeHierarchyIndex, new() + { + ZeroTreeHierarchyIndexResult result = await Session.Query() + .ProjectInto() + .Include(x => x.Path) + .Include(x => x.Id) + .FirstOrDefaultAsync(x => x.Id == id); + + if (result == null) + { + return Array.Empty(); + } + + List ids = result.Path ?? new(); + ids.Add(id); + + return (await Session.LoadAsync(ids)).Select(x => x.Value).ToArray(); + } + + + /// + public async Task> LoadChildren(string parentId, int pageNumber, int pageSize, Func, IQueryable> querySelector = default) where T : ZeroIdEntity, ISupportsTrees, new() + { + IRavenQueryable queryable = Session.Query().Where(x => x.ParentId == parentId).Statistics(out QueryStatistics statistics); + querySelector ??= x => x.OrderBy(x => x.Sort); + + List result = await querySelector(queryable).Paging(pageNumber, pageSize).ToListAsync(); + return new Paged(result, statistics.TotalResults, pageNumber, pageSize); + } + + + /// + public async Task> LoadChildren(string parentId, int pageNumber, int pageSize, Func, IQueryable> querySelector = default) + where T : ZeroIdEntity, ISupportsTrees, new() + where TIndex : AbstractCommonApiForIndexes, new() + { + IRavenQueryable queryable = Session.Query().Where(x => x.ParentId == parentId).Statistics(out QueryStatistics statistics); + querySelector ??= x => x.OrderBy(x => x.Sort); + + var query = querySelector(queryable).Paging(pageNumber, pageSize); + + List result = await query.ToListAsync(); + return new Paged(result, statistics.TotalResults, pageNumber, pageSize); + } + + + /// + public async Task> Move(string id, string newParentId, Func> isParentAllowed = null) where T : ZeroIdEntity, ISupportsTrees, new() + { + T model = await Load(id); + T parent = await Load(newParentId); + + if (model == null || (!newParentId.IsNullOrEmpty() && parent == null)) + { + return Result.Fail("@errors.idnotfound"); + } + + if (isParentAllowed != null && !await isParentAllowed(model, newParentId)) + { + return Result.Fail("@errors.treeentity.parentnotallowed"); + } + + model.ParentId = parent?.Id; + + return await Update(model); + } + + + /// + public Task> Copy(string id, string newParentId, Func> isParentAllowed = null) where T : ZeroIdEntity, ISupportsTrees, new() => Copy(id, newParentId, false, isParentAllowed); + + + /// + public Task> CopyWithDescendants(string id, string newParentId, Func> isParentAllowed = null) where T : ZeroIdEntity, ISupportsTrees, new() => Copy(id, newParentId, true, isParentAllowed); + + + /// + /// Copies an entity (with optional descendants) to a new location + /// + protected async Task> Copy(string id, string newParentId, bool includeDescendants, Func> isParentAllowed = null, bool isDescendant = false) where T : ZeroIdEntity, ISupportsTrees, new() + { + T originalModel = await Load(id); + T model = ObjectCopycat.Clone(originalModel); + T parent = await Load(newParentId); + + if (model == null || (!newParentId.IsNullOrEmpty() && parent == null)) + { + return Result.Fail("@errors.idnotfound"); + } + + string baseId = model.Id; + string originalParentId = model.ParentId; + + // update new page properties + model.Id = null; + model.ParentId = parent?.Id; + + if (model is ZeroEntity zeroEntity) + { + zeroEntity.IsActive = !isDescendant ? false : (originalModel as ZeroEntity).IsActive; + zeroEntity.CreatedDate = DateTime.Now; + zeroEntity.Hash = null; + } + + // check if new parent is allowed + if (isParentAllowed != null && !await isParentAllowed(model, newParentId)) + { + return Result.Fail("@errors.treeentity.parentnotallowed"); + } + + Result result = await Create(model); + + // recursive function to store descendants + if (includeDescendants) + { + List children = await Session.Query().Where(x => x.ParentId == baseId).ToListAsync(); + + foreach (T child in children) + { + await Copy(child.Id, model.Id, true, isParentAllowed, true); + } + } + + return result; + } + + + /// + public async Task> DeleteWithDescendants(T model) where T : ZeroIdEntity, ISupportsTrees, new() + { + List pages = await GetDescendantsAndSelf(model); + + if (pages.Count < 1) + { + return Result.Fail("@errors.ondelete.idnotfound"); + } + + await this.Delete(pages.ToArray()); + return Result.Success(pages.Select(x => x.Id).ToArray()); + } + + + /// + /// Get an entity with all its descendants + /// + async Task> GetDescendantsAndSelf(T model) where T : ZeroIdEntity, ISupportsTrees, new() + { + List items = new() { model }; + + async Task AddChildren(T parent) + { + string parentId = parent.Id; + List children = await Session.Query().Where(x => x.ParentId == parentId).ToListAsync(); + items.AddRange(children); + + foreach (T child in children) + { + await AddChildren(child); + } + } + + await AddChildren(model); + + return items; + } +} \ No newline at end of file diff --git a/zero/Stores/StoreOperations.Write.cs b/zero/Stores/StoreOperations.Write.cs new file mode 100644 index 00000000..9744c75e --- /dev/null +++ b/zero/Stores/StoreOperations.Write.cs @@ -0,0 +1,112 @@ +using FluentValidation.Results; + +namespace zero.Stores; + +public partial class StoreOperations : IStoreOperations +{ + /// + public virtual Task> Create(T model, Func> validate = null, Action onAfterStore = null) where T : ZeroIdEntity, new() => Save(model, validate, onAfterStore); + + /// + public virtual Task> Update(T model, Func> validate = null, Action onAfterStore = null) where T : ZeroIdEntity, new() => Save(model, validate, onAfterStore); + + /// + protected virtual async Task> Save(T model, Func> validate = null, Action onAfterStore = null) where T : ZeroIdEntity, new() + { + if (model == null) + { + return Result.Fail("@errors.onsave.empty"); + } + + T previousModel = null; + bool isUpdate = false; + + // check if the Id for a model already exists + if (!model.Id.IsNullOrEmpty()) + { + using (IZeroDocumentSession session = Context.Store.Session(resolution: ZeroSessionResolution.Create, options: new Raven.Client.Documents.Session.SessionOptions() { Database = Context.Store.ResolvedDatabase, NoCaching = true })) + { + previousModel = await session.LoadAsync(model.Id); + } + + if (previousModel == null) + { + return Result.Fail("@errors.onsave.noidmatch"); + } + + isUpdate = true; + } + + // validate flavor + if (model is ISupportsFlavors flavorModel && !flavorModel.Flavor.IsNullOrEmpty()) + { + if (!Flavors.Exists(flavorModel.Flavor)) + { + return Result.Fail("@errors.onsave.flavornotfound"); + } + } + + // prepare model + PrepareForSave(model); + + // run validator + if (validate != null) + { + ValidationResult validation = await validate(model); + + if (!validation.IsValid) + { + return Result.Fail(validation); + } + } + + // create ID before-hand so interceptors can use it + if (!isUpdate) + { + model.Id = await GenerateId(model); + } + + // run interceptor + InterceptorInstruction instruction = isUpdate ? Interceptors.ForUpdate(model, previousModel) : Interceptors.ForCreate(model); + + if (InterceptorBlocker == null && !await instruction.Start(this)) + { + return instruction.Result; + } + + // store our model + await Session.StoreAsync(model); + onAfterStore?.Invoke(Session); + await Session.SaveChangesAsync(); + if (InterceptorBlocker == null) + { + await instruction.Complete(); + } + await Session.SaveChangesAsync(); + + return Result.Success(model); + } + + + /// + public async Task>> Sort(string[] sortedIds) where T : ZeroIdEntity, ISupportsSorting, new() + { + Dictionary items = await Load(sortedIds); + uint index = 10; + + // contains multiple parents, therefore fail + if (typeof(ISupportsTrees).IsAssignableFrom(typeof(T)) && items.Select(x => (x.Value as ISupportsTrees)?.ParentId).Distinct().Count() > 1) + { + return Result>.Fail("@errors.treeentity.sortingmultipleparents"); + } + + foreach (var item in items) + { + item.Value.Sort = index; + index += 10; + await Update(item.Value); + } + + return Result>.Success(items.Select(x => x.Value).OrderByDescending(x => x.Sort)); + } +} \ No newline at end of file diff --git a/zero/Stores/StoreOperations.cs b/zero/Stores/StoreOperations.cs new file mode 100644 index 00000000..52d20748 --- /dev/null +++ b/zero/Stores/StoreOperations.cs @@ -0,0 +1,278 @@ +using FluentValidation.Results; +using Microsoft.Extensions.DependencyInjection; +using Raven.Client.Documents.Indexes; +using Raven.Client.Documents.Linq; +using System.Security.Claims; + +namespace zero.Stores; + +public partial class StoreOperations : IStoreOperations +{ + /// + public IZeroDocumentSession Session => Context.Store.Session(); + + protected record OperationOptions(bool IncludeInactive); + + protected IZeroContext Context { get; private set; } + + protected IInterceptors Interceptors { get; private set; } + + protected FlavorOptions Flavors { get; private set; } + + protected IServiceProvider Services { get; private set; } + + protected StoreInterceptorBlocker InterceptorBlocker { get; private set; } + + + public StoreOperations(StoreContext context) + { + Context = context.Context; + Interceptors = context.Interceptors; + Services = context.Services; + Flavors = context.Options.For(); + } + + + /// + public async Task GenerateId(T model) where T : ZeroIdEntity + { + IZeroDocumentSession session = Session; + return await session.Advanced.DocumentStore.Conventions.GenerateDocumentIdAsync(session.Advanced.DocumentStore.Database, model); + } + + + /// + public T AutoSetIds(T model) + { + return IdGenerator.Autofill(model); + } + + + /// + public T PrepareForSave(T model) where T : ZeroIdEntity + { + // set IDs + AutoSetIds(model); + + if (model is ZeroEntity zeroModel) + { + // get current user + string userId = null; + //string userId = Context.BackofficeUser.FindFirstValue(Constants.Auth.Claims.UserId).Or(Constants.Auth.SystemUser); + + // set default properties + if (zeroModel.CreatedDate == default) + { + zeroModel.CreatedDate = DateTimeOffset.Now; + } + if (zeroModel.CreatedById.IsNullOrEmpty()) + { + zeroModel.CreatedById = userId; + } + + // update name alias and last modified + zeroModel.Alias = Safenames.Alias(zeroModel.Name); + zeroModel.LastModifiedById = userId; + zeroModel.LastModifiedDate = DateTimeOffset.Now; + zeroModel.CreatedById ??= userId; + zeroModel.Hash ??= IdGenerator.Create(); + } + + if (model is IAlwaysActive activeModel) + { + activeModel.IsActive = true; + } + + return model; + } + + + /// + public async Task Validate(T model) where T : ZeroIdEntity, new() + { + IZeroMergedValidator validator = Services.GetService>(); + + if (validator == null) + { + return new(); + } + + return await validator.ValidateAsync(model); + } + + + /// + public StoreInterceptorBlocker WithoutInterceptors() + { + return InterceptorBlocker ?? (InterceptorBlocker = new(() => InterceptorBlocker = null)); + } + + + /// + public virtual T WhenActive(T model) where T : ZeroIdEntity, new() + { + return model != null && (model is IAlwaysActive || model is not ZeroEntity || (model as ZeroEntity).IsActive) ? model : default; + } +} + + +public class StoreInterceptorBlocker : IDisposable +{ + Action _onRelease; + + internal StoreInterceptorBlocker(Action onRelease) + { + _onRelease = onRelease; + } + + public void Dispose() + { + _onRelease(); + } +} + + +public interface IStoreOperations +{ + /// + /// Access to the current session + /// + IZeroDocumentSession Session { get; } + + /// + /// Get new instance of an entity (with an optional flavor) + /// + Task Empty(string flavorAlias = null) where T : ZeroIdEntity, ISupportsFlavors, new(); + + /// + /// Get new instance of an entity with a specific flavor + /// + /// Optional alias. If left out the default flavor is used (if configured) + Task Empty(string flavorAlias = null) + where T : ZeroIdEntity, ISupportsFlavors, new() + where TFlavor : T, new(); + + /// + /// Generate model Id by using configured document store conventions + /// + Task GenerateId(T model) where T : ZeroIdEntity; + + /// + /// Generate values for all properties (incl. nested) which contain the [GenerateId] attribute + /// + T AutoSetIds(T model); + + /// + /// Automatically fill base properties of a ZeroEntity + /// + T PrepareForSave(T model) where T : ZeroIdEntity; + + /// + /// Check if any items exist in this collection (with optional query) + /// + Task Any(Func, IQueryable> querySelector = default) where T : ZeroIdEntity, new(); + + /// + /// Get an entity by Id + /// + Task Load(string id, string changeVector = null) where T : ZeroIdEntity, new(); + + /// + /// Get entities by ids + /// + Task> Load(IEnumerable ids) where T : ZeroIdEntity, new(); + + /// + /// Get entities by query + /// + Task> Load(int pageNumber, int pageSize, Func, IQueryable> expression = default) where T : ZeroIdEntity, new(); + + /// + /// Get entities by query (by using the specified index) + /// + Task> Load(int pageNumber, int pageSize, Func, IQueryable> expression = default) where T : ZeroIdEntity, new() where TIndex : AbstractCommonApiForIndexes, new(); + + /// + /// Get all entities from this collection. + /// Warning: Don't use this method for large collections. Stream the results instead. + /// + Task> LoadAll() where T : ZeroIdEntity, new(); + + /// + /// Stream the collection + /// + IAsyncEnumerable Stream(Func, IQueryable> expression) where T : ZeroIdEntity, new(); + + /// + /// Get the change vector for a model + /// + string GetChangeToken(T model) where T : ZeroIdEntity, new(); + + /// + /// Validates an entity + /// + Task Validate(T model) where T : ZeroIdEntity, new(); + + /// + /// Do not run interceptors for create/update/delete operations while this disposable is active + /// + StoreInterceptorBlocker WithoutInterceptors(); + + /// + /// Do only return the model when it is set to active or inactive entities are included with IncludeInactive() + /// + T WhenActive(T model) where T : ZeroIdEntity, new(); + + /// + /// Creates an entity with an optional validator + /// + Task> Create(T model, Func> validate = null, Action onAfterStore = null) where T : ZeroIdEntity, new(); + + /// + /// Updates an entity with an optional validator + /// + Task> Update(T model, Func> validate = null, Action onAfterStore = null) where T : ZeroIdEntity, new(); + + /// + Task>> Sort(string[] sortedIds) where T : ZeroIdEntity, ISupportsSorting, new(); + + /// + /// Deletes an entity + /// + Task> Delete(T model) where T : ZeroIdEntity, new(); + + /// + /// Loads all children for an entity + /// + Task> LoadChildren(string parentId, int pageNumber, int pageSize, Func, IQueryable> querySelector = default) where T : ZeroIdEntity, ISupportsTrees, new(); + + /// + /// Get descendants by query (by using the specified index) + /// + Task> LoadChildren(string parentId, int pageNumber, int pageSize, Func, IQueryable> querySelector = default) where T : ZeroIdEntity, ISupportsTrees, new() where TIndex : AbstractCommonApiForIndexes, new(); + + /// + /// Get tree hierarchy for an entity + /// + Task GetHierarchy(string id) where T : ZeroIdEntity, ISupportsTrees, new() where TIndex : ZeroTreeHierarchyIndex, new(); + + /// + /// Move an entity to a new parent + /// + Task> Move(string id, string newParentId, Func> isParentAllowed = null) where T : ZeroIdEntity, ISupportsTrees, new(); + + /// + /// Copies an entity to a new location + /// + Task> Copy(string id, string newParentId, Func> isParentAllowed = null) where T : ZeroIdEntity, ISupportsTrees, new(); + + /// + /// Copies an entity with descendants to a new location + /// + Task> CopyWithDescendants(string id, string newParentId, Func> isParentAllowed = null) where T : ZeroIdEntity, ISupportsTrees, new(); + + /// + /// Deletes an entity with all descendents + /// + Task> DeleteWithDescendants(T model) where T : ZeroIdEntity, ISupportsTrees, new(); +} \ No newline at end of file diff --git a/zero/Stores/StoreOperationsExtensions.cs b/zero/Stores/StoreOperationsExtensions.cs new file mode 100644 index 00000000..5039d029 --- /dev/null +++ b/zero/Stores/StoreOperationsExtensions.cs @@ -0,0 +1,45 @@ +using Raven.Client.Documents.Linq; + +namespace zero.Stores; + +public static class StoreOperationsExtensions +{ + /// + /// Stream the collection + /// + public static IAsyncEnumerable Stream(this IStoreOperations ops) where T : ZeroIdEntity, new() => ops.Stream(null); + + + /// + /// Deletes an entity by Id + /// + public static async Task> Delete(this IStoreOperations ops, string id) where T : ZeroIdEntity, new() => await ops.Delete(await ops.Load(id)); + + + /// + /// Deletes entities by Id + /// + public static async Task Delete(this IStoreOperations ops, IEnumerable ids) where T : ZeroIdEntity, new() => await ops.Delete((await ops.Load(ids)).Select(x => x.Value)); + + + /// + /// Deletes entities + /// + public static async Task Delete(this IStoreOperations ops, IEnumerable models) where T : ZeroIdEntity, new() + { + int successCount = 0; + + foreach (T model in models) + { + Result result = await ops.Delete(model); + successCount += result.IsSuccess ? 1 : 0; + } + + return successCount; + } + + /// + /// Deletes an entity by Id with all descendents + /// + public static async Task> DeleteWithDescendants(this IStoreOperations ops, string id) where T : ZeroIdEntity, ISupportsTrees, new() => await ops.DeleteWithDescendants(await ops.Load(id)); +} \ No newline at end of file diff --git a/zero/Stores/ZeroStoreModule.cs b/zero/Stores/ZeroStoreModule.cs new file mode 100644 index 00000000..30a888dd --- /dev/null +++ b/zero/Stores/ZeroStoreModule.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace zero.Stores; + +internal class ZeroStoreModule : ZeroModule +{ + public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + services.AddTransient(); + + services.AddOptions(); + + services.ConfigureOptions(); + } +} \ No newline at end of file diff --git a/zero/Usings.cs b/zero/Usings.cs new file mode 100644 index 00000000..3155036f --- /dev/null +++ b/zero/Usings.cs @@ -0,0 +1,21 @@ + +global using System; +global using System.Collections; +global using System.Collections.Generic; +global using System.Linq; +global using System.Threading; +global using System.Threading.Tasks; + +global using zero.Persistence; +global using zero.Utils; +global using zero.Configuration; +global using zero.Extensions; +global using zero.FileStorage; +global using zero.Models; +global using zero.Architecture; +global using zero.Rendering; +global using zero.Validation; +global using zero.Localization; +global using zero.Context; +global using zero.Communication; +global using zero.Stores; \ No newline at end of file diff --git a/zero/Utils/DateRange.cs b/zero/Utils/DateRange.cs new file mode 100644 index 00000000..3650671f --- /dev/null +++ b/zero/Utils/DateRange.cs @@ -0,0 +1,40 @@ +namespace zero.Utils; + +public class DateRange +{ + public DateTimeOffset? From { get; set; } + + public DateTimeOffset? To { get; set; } + + + public bool IsWithin(DateTimeOffset date) + { + if (From.HasValue && date < From) + { + return false; + } + if (To.HasValue && date > To) + { + return false; + } + return true; + } + + + public string Format(string format) + { + if (!From.HasValue && !To.HasValue) + { + return null; + } + if (!From.HasValue && To.HasValue) + { + return "≤ " + To.Value.ToLocalTime().ToString(format); + } + if (From.HasValue && !To.HasValue) + { + return "≥ " + From.Value.ToLocalTime().ToString(format); + } + return From.Value.ToLocalTime().ToString(format) + " – " + To.Value.ToLocalTime().ToString(format); + } +} \ No newline at end of file diff --git a/zero/Utils/Gender.cs b/zero/Utils/Gender.cs new file mode 100644 index 00000000..acfb9b4c --- /dev/null +++ b/zero/Utils/Gender.cs @@ -0,0 +1,9 @@ +namespace zero.Utils; + +public enum Gender +{ + Undisclosed = 0, + Female = 1, + Male = 2, + NonBinary = 3 +} \ No newline at end of file diff --git a/zero/Utils/JsonDiscriminatorConverter.cs b/zero/Utils/JsonDiscriminatorConverter.cs new file mode 100644 index 00000000..935cc5e6 --- /dev/null +++ b/zero/Utils/JsonDiscriminatorConverter.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace zero.Stores; + +public abstract class JsonDiscriminatorConverter : JsonConverter where T : class, new() +{ + Type _type; + string _discriminatorPropertyName; + + public JsonDiscriminatorConverter(string discriminatorPropertyName) : base() + { + _discriminatorPropertyName = discriminatorPropertyName; + _type = GetType(); + } + + + protected abstract Type GetTypeFromDiscriminator(Type requestedType, string discriminator); + + + /// + public override bool CanConvert(Type typeToConvert) => typeof(T).IsAssignableFrom(typeToConvert); + + + /// + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + Utf8JsonReader variantReader = reader; + string discriminatorValue = null; + + while (variantReader.Read()) + { + if (variantReader.TokenType == JsonTokenType.PropertyName) + { + string property = variantReader.GetString(); + + if (property != null && property.Equals(_discriminatorPropertyName, StringComparison.InvariantCultureIgnoreCase)) + { + variantReader.Read(); + discriminatorValue = variantReader.GetString(); + break; + } + } + } + + Type childType = GetTypeFromDiscriminator(typeToConvert, discriminatorValue); + Type resolvedType = childType ?? typeToConvert; + + return JsonSerializer.Deserialize(ref reader, resolvedType, CreateOptions(options)) as T; + } + + + /// + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, value.GetType(), CreateOptions(options)); + } + + + protected virtual JsonSerializerOptions CreateOptions(JsonSerializerOptions baseOptions) + { + JsonSerializerOptions newOptions = new(baseOptions); + JsonConverter toRemove = newOptions.Converters.FirstOrDefault(x => x.GetType() == _type); + newOptions.Converters.Remove(toRemove); + return newOptions; + } +} diff --git a/zero/Utils/ObjectCopycat.cs b/zero/Utils/ObjectCopycat.cs new file mode 100644 index 00000000..cf13f02d --- /dev/null +++ b/zero/Utils/ObjectCopycat.cs @@ -0,0 +1,86 @@ +using Newtonsoft.Json; +using System.Collections.Concurrent; +using System.Reflection; + +namespace zero.Utils; + +public class ObjectCopycat +{ + const string SEPARATOR = "."; + + static readonly Type STRING_TYPE = typeof(string); + + static ConcurrentDictionary> PublicPropertiesPerType { get; set; } = new(); + + + public static T Clone(T obj) + { + Type type = obj.GetType(); + return (T)JsonConvert.DeserializeObject(JsonConvert.SerializeObject(obj), type); + } + + + public static T CloneSpecific(T obj) + { + Type type = obj.GetType(); + return JsonConvert.DeserializeObject(JsonConvert.SerializeObject(obj)); + } + + + public static bool ContentEquals(T obj1, T obj2) + { + return (obj1 == null && obj2 == null) || (obj1 != null && obj2 != null && JsonConvert.SerializeObject(obj1) == JsonConvert.SerializeObject(obj2)); + } + + + public static T CopyProperties(T source, T target, params string[] exceptions) + { + return CopyPropertiesInternal(source, target, null, exceptions); + } + + + static T CopyPropertiesInternal(T source, T target, string keyPrefix = null, params string[] exceptions) + { + Type type = source.GetType(); + + if (!PublicPropertiesPerType.TryGetValue(type.FullName, out IEnumerable properties)) + { + properties = type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).Where(x => x.CanWrite); + PublicPropertiesPerType.TryAdd(type.FullName, properties); + } + + foreach (PropertyInfo property in properties) + { + string propertyNameWithPrefix = CombineKey(SEPARATOR, keyPrefix, property.Name); + + if (exceptions.Contains(propertyNameWithPrefix, StringComparer.InvariantCultureIgnoreCase)) + { + continue; + } + + object propertyValue = property.GetValue(source, null); + + if (property.PropertyType.IsValueType || property.PropertyType.IsEnum || property.PropertyType.Equals(STRING_TYPE) || propertyValue == null || propertyValue is IEnumerable) + { + property.SetValue(target, propertyValue, null); + } + else if (property.PropertyType.IsClass) + { + string[] nestedExceptions = exceptions.Where(x => x.StartsWith(propertyNameWithPrefix, StringComparison.InvariantCultureIgnoreCase)).ToArray(); + property.SetValue(target, CopyPropertiesInternal(propertyValue, property.GetValue(target, null), propertyNameWithPrefix, nestedExceptions), null); + } + } + + return target; + } + + + private static string CombineKey(string sep, string key1, string key2) + { + if (String.IsNullOrEmpty(key1)) + { + return key2; + } + return String.Join(sep, key1, key2); + } +} diff --git a/zero/Utils/ObjectTraverser.cs b/zero/Utils/ObjectTraverser.cs new file mode 100644 index 00000000..f5de7b9c --- /dev/null +++ b/zero/Utils/ObjectTraverser.cs @@ -0,0 +1,273 @@ +using System.Reflection; + +namespace zero.Utils; + +public class ObjectTraverser +{ + private const string SEPARATOR = "."; + + private const string SQBKT_OPEN = "["; + + private const string SQBKT_CLOSE = "]"; + + private const string NEWTONSOFT_NS = "Newtonsoft.Json"; + + + /// + /// Find all instances of a given type in an object (features nested + enumerable content) + /// + public static List> Find(object value) where T : class + { + HashSet exploredObjects = new HashSet(); + List> found = new List>(); + + Find(value, null, String.Empty, null, exploredObjects, found); + + return found; + } + + + /// + /// Find all instances of a given type in an object (features nested + enumerable content) + /// + public static List Find(Type type, object value) + { + HashSet exploredObjects = new HashSet(); + List found = new List(); + + Find(type, value, null, String.Empty, null, exploredObjects, found); + + return found; + } + + + /// + /// Find all instances of a given type in an object (features nested + enumerable content) + /// + public static List> FindAttribute(object value) where T : Attribute + { + HashSet exploredObjects = new HashSet(); + List> found = new List>(); + + FindAttribute(value, null, String.Empty, null, exploredObjects, found); + + return found; + } + + + /// + /// Recursively ind all instances of a given type in an object + /// + private static void Find(object value, object parent, string key, PropertyInfo property, HashSet exploredObjects, List> found) where T : class + { + if (value == null || value is string || exploredObjects.Contains(value) || value.GetType().FullName.StartsWith(NEWTONSOFT_NS)) + { + return; + } + + exploredObjects.Add(value); + + System.Collections.ICollection enumerable = value as System.Collections.ICollection; + + // this property is a list + if (enumerable != null) + { + int idx = 0; + foreach (object item in enumerable) + { + Find(item, value, CombineKey(String.Empty, key, SQBKT_OPEN + idx + SQBKT_CLOSE), property, exploredObjects, found); + idx += 1; + } + return; + } + + // get type of value + Type type = typeof(T); + Type valueType = value.GetType(); + Type genericValueType = valueType.IsGenericType ? valueType.GetGenericTypeDefinition() : null; + + // check if the current property is our searched type + if (valueType == type || genericValueType == type || type.IsAssignableFrom(valueType) || (genericValueType != null && type.IsAssignableFrom(genericValueType))) + { + found.Add(new Result() + { + Path = key, + Item = value as T, + Parent = parent, + Property = property + }); + return; + } + + // check if our property is a primitive type + if (value == null || valueType.IsPrimitive || value is string || value is DateTime || value is DateTimeOffset || value is Uri) + { + return; + } + + // iterate over nested properties + PropertyInfo[] properties = valueType.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.GetProperty); + foreach (PropertyInfo nestedProperty in properties) + { + object propertyValue = nestedProperty.GetValue(value, null); + Find(propertyValue, value, CombineKey(SEPARATOR, key, nestedProperty.Name), nestedProperty, exploredObjects, found); + } + } + + + /// + /// Recursively ind all instances of a given type in an object + /// + private static void Find(Type type, object value, object parent, string key, PropertyInfo property, HashSet exploredObjects, List found) + { + if (value == null || value is string || exploredObjects.Contains(value) || value.GetType().FullName.StartsWith(NEWTONSOFT_NS)) + { + return; + } + + exploredObjects.Add(value); + + System.Collections.ICollection enumerable = value as System.Collections.ICollection; + + // this property is a list + if (enumerable != null) + { + int idx = 0; + foreach (object item in enumerable) + { + Find(type, item, value, CombineKey(String.Empty, key, SQBKT_OPEN + idx + SQBKT_CLOSE), property, exploredObjects, found); + idx += 1; + } + return; + } + + // get type of value + Type valueType = value.GetType(); + Type genericValueType = valueType.IsGenericType ? valueType.GetGenericTypeDefinition() : null; + + // check if the current property is our searched type + if (valueType == type || genericValueType == type || type.IsAssignableFrom(valueType) || (genericValueType != null && type.IsAssignableFrom(genericValueType))) + { + found.Add(new Result() + { + Path = key, + Item = value, + Type = valueType, + Parent = parent, + Property = property + }); + return; + } + + // check if our property is a primitive type + if (value == null || valueType.IsPrimitive || value is string || value is DateTime || value is DateTimeOffset || value is Uri) + { + return; + } + + // iterate over nested properties + PropertyInfo[] properties = valueType.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.GetProperty); + foreach (PropertyInfo nestedProperty in properties) + { + object propertyValue = nestedProperty.GetValue(value, null); + Find(type, propertyValue, value, CombineKey(SEPARATOR, key, nestedProperty.Name), nestedProperty, exploredObjects, found); + } + } + + + /// + /// Recursively find all instances with the attribute of a given type in an object + /// + private static void FindAttribute(object value, object parent, string key, PropertyInfo property, HashSet exploredObjects, List> found) where T : Attribute + { + if (exploredObjects.Contains(key) || (value != null && value.GetType().FullName.StartsWith(NEWTONSOFT_NS))) + { + return; + } + + exploredObjects.Add(key); + + // check if the current property contains the attribute + if (property != null) + { + T attribute = property.GetCustomAttribute(true); + + if (attribute != null) + { + found.Add(new Result() + { + Path = key, + Item = attribute, + Parent = parent, + Property = property //parent.GetType().GetProperty(key) + }); + return; + } + } + + System.Collections.ICollection enumerable = value as System.Collections.ICollection; + + // this property is a list + if (enumerable != null && !(value is string)) + { + int idx = 0; + foreach (object item in enumerable) + { + FindAttribute(item, value, CombineKey(String.Empty, key, SQBKT_OPEN + idx + SQBKT_CLOSE), null, exploredObjects, found); + idx += 1; + } + return; + } + + // check if our property is a primitive type + if (value == null || value.GetType().IsPrimitive || value is string || value is DateTime || value is DateTimeOffset || value is Uri || value is System.Text.Json.JsonElement) + { + return; + } + + // iterate over nested properties + PropertyInfo[] properties = value.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.GetProperty); + foreach (PropertyInfo nestedProperty in properties) + { + object propertyValue = nestedProperty.GetValue(value, null); + FindAttribute(propertyValue, value, CombineKey(SEPARATOR, key, nestedProperty.Name), nestedProperty, exploredObjects, found); + } + } + + + private static string CombineKey(string sep, string key1, string key2) + { + if (String.IsNullOrEmpty(key1)) + { + return key2; + } + return String.Join(sep, key1, key2); + } + + + public class Result + { + public string Path { get; set; } + + public PropertyInfo Property { get; set; } + + public object Parent { get; set; } + + public T Item { get; set; } + } + + public class Result + { + public string Path { get; set; } + + public PropertyInfo Property { get; set; } + + public object Parent { get; set; } + + public object Item { get; set; } + + public Type Type { get; set; } + + public int Index { get; set; } + } +} diff --git a/zero/Utils/PriceRange.cs b/zero/Utils/PriceRange.cs new file mode 100644 index 00000000..1d764d94 --- /dev/null +++ b/zero/Utils/PriceRange.cs @@ -0,0 +1,8 @@ +namespace zero.Utils; + +public class PriceRange +{ + public decimal? From { get; set; } + + public decimal? To { get; set; } +} diff --git a/zero/Utils/PrimitiveTypeCollection.cs b/zero/Utils/PrimitiveTypeCollection.cs new file mode 100644 index 00000000..05ae8f4b --- /dev/null +++ b/zero/Utils/PrimitiveTypeCollection.cs @@ -0,0 +1,48 @@ +using System.Collections.Concurrent; + +namespace zero.Utils; + +public class PrimitiveTypeCollection : ConcurrentDictionary, IPrimitiveTypeCollection +{ + /// + /// Initializes a new instance of . + /// + public PrimitiveTypeCollection() { } + + /// + public TValue Get() => (TValue)this[typeof(TValue)]; + + /// + public void Set(TValue instance) => this[typeof(TValue)] = instance; + + /// + public void Remove() => TryRemove(typeof(TValue), out _); +} + + +/// +/// Represents a simple collection based on type. +/// +public interface IPrimitiveTypeCollection : ICollection> +{ + /// + /// Retrieves the requested value from the collection. + /// + /// The value key. + /// The requested value, or null if it is not present. + TValue Get(); + + /// + /// Sets the given value in the collection. + /// + /// The value key. + /// The value value. + void Set(TValue instance); + + /// + /// Removes the given value from the collection. + /// + /// The value key. + /// The value value. + void Remove(); +} diff --git a/zero/Utils/TokenReplacement.cs b/zero/Utils/TokenReplacement.cs new file mode 100644 index 00000000..1b3a7e3f --- /dev/null +++ b/zero/Utils/TokenReplacement.cs @@ -0,0 +1,53 @@ +using System.Text.RegularExpressions; + +namespace zero.Utils; + +public class TokenReplacement +{ + static Regex TokenRegex; + + + static TokenReplacement() + { + TokenRegex = new Regex("{([\\w-_.]+)}", RegexOptions.IgnoreCase); + } + + + public static string Apply(string text, Dictionary tokens) + { + if (text.IsNullOrWhiteSpace()) + { + return text; + } + + MatchCollection matches = TokenRegex.Matches(text); + + foreach (Match match in matches) + { + if (!match.Success) + { + continue; + } + + string original = match.Value; + string token = match.Groups[1].Value; + + if (tokens.ContainsKey(token)) + { + text = text.Replace(original, tokens[token]); + } + } + + return text; + + //foreach ((string key, string value) in tokens) + //{ + // string tokenKey = key.EnsureStartsWith(BEGIN).EnsureEndsWith(END); + // string tokenValue = value; // TODO escape for HTML? + + // text = text.Replace(tokenKey, value); + //} + + //return text; + } +} diff --git a/zero/Validation/ValidatorCamelCasePropertyResolver.cs b/zero/Validation/ValidatorCamelCasePropertyResolver.cs new file mode 100644 index 00000000..52c87e72 --- /dev/null +++ b/zero/Validation/ValidatorCamelCasePropertyResolver.cs @@ -0,0 +1,29 @@ +using FluentValidation.Internal; +using System.Linq.Expressions; +using System.Reflection; + +namespace zero.Validation; + +public class ValidatorCamelCasePropertyResolver +{ + public static string ResolvePropertyName(Type type, MemberInfo memberInfo, LambdaExpression expression) + { + return DefaultPropertyNameResolver(type, memberInfo, expression).ToCamelCaseId(); + } + + private static string DefaultPropertyNameResolver(Type type, MemberInfo memberInfo, LambdaExpression expression) + { + if (expression != null) + { + var chain = PropertyChain.FromExpression(expression); + if (chain.Count > 0) return chain.ToString(); + } + + if (memberInfo != null) + { + return memberInfo.Name; + } + + return null; + } +} \ No newline at end of file diff --git a/zero/Validation/ValidatorExtensions.cs b/zero/Validation/ValidatorExtensions.cs new file mode 100644 index 00000000..68dfe4f3 --- /dev/null +++ b/zero/Validation/ValidatorExtensions.cs @@ -0,0 +1,177 @@ +using FluentValidation; +using Raven.Client.Documents; +using System.Globalization; + +namespace zero.Validation; + +public static class ValidatorExtensions +{ + private const char DOT = '.'; + + private const char KLAMMERAFFE = '@'; + + private static string HEX_REGEX = "(^$)|(#[0-9a-fA-F]{3,8})"; + + /// + /// Validate a color input as HEX (#aabbccdd or #aabbcc or #abc) + /// + public static IRuleBuilderOptions Hex(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.Matches(HEX_REGEX).WithMessage("@errors.forms.hex_format"); + } + + + /// + /// Validate an email + /// + public static IRuleBuilderOptions Url(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.Must((root, value, context) => + { + return value.IsNullOrWhiteSpace() || Uri.IsWellFormedUriString(value, UriKind.Absolute); + }).WithMessage("@errors.forms.url_format"); + } + + + /// + /// Validate an email + /// + public static IRuleBuilderOptions Email(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.Must((root, value, context) => + { + if (value.IsNullOrWhiteSpace()) + { + return true; + } + + int index = value.IndexOf(KLAMMERAFFE); + + if (index < 0 || index == value.Length - 1 || index != value.LastIndexOf(KLAMMERAFFE)) + { + return false; + } + + return true; + }).WithMessage("@errors.forms.email_invalid"); + } + + + /// + /// Validate one or multiple emails + /// + public static IRuleBuilderOptions Emails(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.Must((root, value, context) => + { + if (value.IsNullOrWhiteSpace()) + { + return true; + } + + string[] mails = value.Split(',', ';').Select(x => x.Trim()).Where(x => !x.IsNullOrWhiteSpace()).ToArray(); + + if (!mails.Any()) + { + return false; + } + + foreach (string mail in mails) + { + int index = mail.IndexOf(KLAMMERAFFE); + + if (index < 0 || index == mail.Length - 1 || index != mail.LastIndexOf(KLAMMERAFFE)) + { + return false; + } + } + + return true; + }).WithMessage("@errors.forms.emails_invalid"); + } + + + /// + /// Validates a culture identifier + /// + public static IRuleBuilderOptions Culture(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.Must((root, value, context) => + { + if (value.IsNullOrWhiteSpace()) + { + return true; + } + + try + { + CultureInfo info = CultureInfo.GetCultureInfo(value); + return info != null && !info.EnglishName.Equals(value, StringComparison.InvariantCultureIgnoreCase); + } + catch + { + return false; + } + }).WithMessage("@errors.forms.culture"); + } + + + + + /// + /// Check if this value is unique within a collection + /// + public static IRuleBuilderOptions Unique(this IRuleBuilder ruleBuilder, IZeroStore store) where T : ZeroEntity + { + return ruleBuilder.MustAsync(async (entity, value, context, cancellation) => + { + bool any = await store.Session().Advanced.AsyncDocumentQuery() + .WhereNotEquals(nameof(ZeroIdEntity.Id), entity.Id) + .WhereEquals(context.PropertyName.ToPascalCaseId(), value) + .AnyAsync(cancellation); + + return !any; + }).WithMessage("@errors.forms.not_unique"); + } + + + /// + /// Check if this value is at least set once to the expected value within a collection + /// + public static IRuleBuilderOptions ExpectAnyUnique(this IRuleBuilder ruleBuilder, IZeroStore store, TProperty expectedValue) where T : ZeroEntity + { + return ruleBuilder.MustAsync(async (entity, value, context, cancellation) => + { + return await store.Session().Advanced.AsyncDocumentQuery() + .WhereNotEquals(nameof(ZeroIdEntity.Id), entity.Id) + .WhereEquals(context.PropertyName.ToPascalCaseId(), expectedValue) + .AnyAsync(cancellation); + }).WithMessage("@errors.forms.not_unique_alone"); + } + + + /// + /// Check if this reference exists and is an entity which can be referenced (appId = shared for shareable entities or appId = current) + /// + public static IRuleBuilderOptions Exists(this IRuleBuilder ruleBuilder, IZeroStore store) where T : ZeroEntity + { + return ruleBuilder.Exists(store); + } + + + /// + /// Check if this reference exists and is an entity which can be referenced (appId = shared for shareable entities or appId = current) + /// + public static IRuleBuilderOptions Exists(this IRuleBuilder ruleBuilder, IZeroStore store) where T : ZeroEntity where TReference : ZeroEntity + { + return ruleBuilder.MustAsync(async (entity, id, context, cancellation) => + { + if (id.IsNullOrWhiteSpace()) + { + return true; + } + + return await store.Session().Query().AnyAsync(x => x.Id == id); + }).WithMessage("@errors.forms.reference_notfound"); + } +} diff --git a/zero/Validation/ZeroMergedValidator.cs b/zero/Validation/ZeroMergedValidator.cs new file mode 100644 index 00000000..a33e5fe0 --- /dev/null +++ b/zero/Validation/ZeroMergedValidator.cs @@ -0,0 +1,98 @@ +using FluentValidation; +using FluentValidation.Results; +using System.Collections.Concurrent; + +namespace zero.Validation; + +public class ZeroMergedValidator : IZeroMergedValidator +{ + protected IEnumerable> Validators { get; private set; } + + ConcurrentDictionary>> TypeCache = new(); + + + public ZeroMergedValidator(IEnumerable> validators) + { + Validators = validators; + } + + + /// + public async Task ValidateAsync(T model) + { + ValidationResult result = new(); + + foreach (IValidator validator in ResolveFor(model)) + { + ValidationResult innerResult = await validator.ValidateAsync(model); + result.Errors.AddRange(innerResult.Errors); + } + + return result; + } + + + /// + public IEnumerable> ResolveFor(T model) + { + Type type = model.GetType(); + + if (!TypeCache.TryGetValue(type, out IEnumerable> validators)) + { + validators = Validators.Where(validator => CanHandle(validator, type)).ToList(); + TypeCache.TryAdd(type, validators); + } + + return validators; + } + + + /// + /// Checks whether a certain validator can handle the give type + /// + bool CanHandle(IValidator validator, Type modelType) + { + Type validatorType = validator.GetType(); + Type typeToFind = typeof(ZeroValidator<,>); + + Type findValidatorBase(Type type) + { + if (type.BaseType == null) + { + return null; + } + + if (type.BaseType.IsGenericType && type.BaseType.GetGenericTypeDefinition() == typeToFind) + { + return type.BaseType; + } + + return findValidatorBase(type.BaseType); + } + + Type zeroValidatorType = findValidatorBase(validatorType); + + if (zeroValidatorType == null) + { + return false; + } + + Type implementationType = zeroValidatorType.GenericTypeArguments[1]; + + return implementationType.IsAssignableFrom(modelType); + } +} + + +public interface IZeroMergedValidator +{ + /// + /// Get all validators which can run for the given model + /// + IEnumerable> ResolveFor(T model); + + /// + /// Validates a model by using all registered ZeroValidators for this entity type + /// + Task ValidateAsync(T model); +} \ No newline at end of file diff --git a/zero/Validation/ZeroValidationModule.cs b/zero/Validation/ZeroValidationModule.cs new file mode 100644 index 00000000..a33d66cd --- /dev/null +++ b/zero/Validation/ZeroValidationModule.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace zero.Validation; + +internal class ZeroValidationModule : ZeroModule +{ + public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(typeof(IZeroMergedValidator<>), typeof(ZeroMergedValidator<>)); + } +} \ No newline at end of file diff --git a/zero/Validation/ZeroValidator.cs b/zero/Validation/ZeroValidator.cs new file mode 100644 index 00000000..acdcf9e3 --- /dev/null +++ b/zero/Validation/ZeroValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using FluentValidation.Results; + +namespace zero.Validation; + +public class ZeroValidator : ZeroValidator +{ +} + +public abstract class ZeroValidator : AbstractValidator, IValidator where TImplementation : TInterface +{ + public ValidationResult Validate(TInterface instance) + { + if (!(instance is TImplementation)) + { + throw new ArgumentException($"Parameter {nameof(instance)} has to be of type {typeof(TImplementation)}."); + } + return base.Validate((TImplementation)instance); + } + + public Task ValidateAsync(TInterface instance, CancellationToken cancellation = default) + { + if (!(instance is TImplementation)) + { + throw new ArgumentException($"Parameter {nameof(instance)} has to be of type {typeof(TImplementation)}."); + } + return base.ValidateAsync((TImplementation)instance, cancellation); + } +} \ No newline at end of file diff --git a/zero/ZeroApplicationBuilder.cs b/zero/ZeroApplicationBuilder.cs new file mode 100644 index 00000000..56fb023f --- /dev/null +++ b/zero/ZeroApplicationBuilder.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Builder; + +namespace zero; + +public class ZeroApplicationBuilder : IZeroApplicationBuilder +{ + protected IApplicationBuilder App { get; private set; } + + protected IZeroOptions Options { get; private set; } + + + internal ZeroApplicationBuilder(IApplicationBuilder app) + { + App = app; + App.UseStaticFiles(); + //App.UseExceptionHandler("/zero/api/error"); + App.UseMiddleware(); + App.UseRouting(); + App.UseAuthentication(); + App.UseAuthorization(); + + ZeroBuilder.Modules.Configure(app, null, app.ApplicationServices); + } + + public IZeroApplicationBuilder WithMiddleware(Action configure) + { + configure(App); + return this; + } +} + + +public interface IZeroApplicationBuilder +{ + IZeroApplicationBuilder WithMiddleware(Action configure); +} \ No newline at end of file diff --git a/zero/ZeroBuilder.cs b/zero/ZeroBuilder.cs new file mode 100644 index 00000000..b0495ce7 --- /dev/null +++ b/zero/ZeroBuilder.cs @@ -0,0 +1,70 @@ +using FluentValidation; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace zero; + +// TODO maybe use a middleware like Hangfire does: https://github.com/HangfireIO/Hangfire/blob/master/src/Hangfire.AspNetCore/HangfireEndpointRouteBuilderExtensions.cs + +public class ZeroBuilder +{ + public virtual IServiceCollection Services { get; } + + public virtual IMvcBuilder Mvc { get; } + + internal static ZeroModuleCollection Modules { get; private set; } = new(); + + readonly IConfiguration _configuration; + readonly IZeroStartupOptions _startupOptions; + + + public ZeroBuilder(IServiceCollection services, IConfiguration configuration, Action setupAction) + { + Services = services; + Mvc = services.AddMvc(); + _configuration = configuration; + + // create startup options + _startupOptions = new ZeroStartupOptions(Mvc); + _startupOptions.AssemblyDiscoveryRules.Add(new ZeroAssemblyDiscoveryRule()); + setupAction?.Invoke(_startupOptions); + + services.AddControllers(); + services.AddRazorPages(); + + // adds and discovers additional and built-in assemblies + new AssemblyDiscovery(Mvc).Execute(_startupOptions.AssemblyDiscoveryRules); + + Modules.Add(); + Modules.Add(); + Modules.Add(); + Modules.Add(); + Modules.Add(); + //Modules.Add(); + Modules.Add(); + //Modules.Add(); + //Modules.Add(); + //Modules.Add(); + //Modules.Add(); + Modules.Add(); + Modules.Add(); + //Modules.Add(); + Modules.Add(); + + Modules.ConfigureServices(services, configuration); + + // configure FluentValidation + ValidatorOptions.Global.PropertyNameResolver = ValidatorCamelCasePropertyResolver.ResolvePropertyName; + } + + + /// + /// Use specified options + /// + public ZeroBuilder WithOptions(Action configureOptions) + { + Services.Configure(configureOptions); + return this; + } +} diff --git a/zero/zero.csproj b/zero/zero.csproj new file mode 100644 index 00000000..05fd771f --- /dev/null +++ b/zero/zero.csproj @@ -0,0 +1,24 @@ + + + + zero + 1.0.0 + preview + net7.0 + true + zero + embedded + + + + + + + + + + + + + + \ No newline at end of file