diff --git a/.dockerignore b/.dockerignore index 19ec84e3b..9b8b49c2a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,4 +19,6 @@ **/node_modules/ # Scripts (should be copied from node_modules on build) -**/wwwroot/scripts/**/*.* \ No newline at end of file +**/wwwroot/scripts/**/*.* + +**/src/Squidex/appsettings.Development.json \ No newline at end of file diff --git a/.gitignore b/.gitignore index 549a8b85d..4c398ec98 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,6 @@ _test-output/ node_modules/ # Scripts (should be copied from node_modules on build) -**/wwwroot/scripts/**/*.* \ No newline at end of file +**/wwwroot/scripts/**/*.* + +/src/Squidex/appsettings.Development.json diff --git a/Dockerfile.build b/Dockerfile.build index d9057ded8..ffe02d925 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -37,7 +37,7 @@ WORKDIR / # Build Frontend RUN cp -a /tmp/node_modules /src/Squidex/ \ && cd /src/Squidex \ - %% npm run test:coverage \ + && npm run test:coverage \ && npm run build:copy \ && npm run build diff --git a/README.md b/README.md index 450143783..657ab3391 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # What is Squidex? -Squidex is an open source headless CMS and content management hub. In contrast to antraditional CMS Squidex provides a rich API with OData filter and Swagger definitions. It is up to you to build your UI on top of it. It can be website, a native app or just another server. +Squidex is an open source headless CMS and content management hub. In contrast to a traditional CMS Squidex provides a rich API with OData filter and Swagger definitions. It is up to you to build your UI on top of it. It can be website, a native app or just another server. We built it on top of ASP.NET Core and CQRS and is tested for Windows and Linux on modern Browsers. [![Gitter](https://img.shields.io/gitter/room/nwjs/nw.js.svg?style=flat-square)](https://gitter.im/squidex-cms/Lobby) diff --git a/Squidex.sln b/Squidex.sln index 7c456fe9f..606cec592 100644 --- a/Squidex.sln +++ b/Squidex.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26228.4 +VisualStudioVersion = 15.0.26228.12 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Squidex", "src\Squidex\Squidex.csproj", "{61F6BBCE-A080-4400-B194-70E2F5D2096E}" EndProject @@ -32,9 +32,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Squidex.Read.Tests", "tests EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Squidex.Infrastructure.Redis", "src\Squidex.Infrastructure.Redis\Squidex.Infrastructure.Redis.csproj", "{D7166C56-178A-4457-B56A-C615C7450DEE}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{1667F3B4-31E6-45B2-90FB-97B1ECFE9874}" -EndProject -Project("{13B669BE-BB05-4DDF-9536-439F39A36129}") = "GenerateLanguages", "tools\GenerateLanguages\GenerateLanguages.csproj", "{927E1F1C-95F0-4991-B33F-603977204B02}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Squidex.Infrastructure.RabbitMq", "src\Squidex.Infrastructure.RabbitMq\Squidex.Infrastructure.RabbitMq.csproj", "{C1E5BBB6-6B6A-4DE5-B19D-0538304DE343}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -150,18 +148,18 @@ Global {D7166C56-178A-4457-B56A-C615C7450DEE}.Release|Any CPU.Build.0 = Release|Any CPU {D7166C56-178A-4457-B56A-C615C7450DEE}.Release|x64.ActiveCfg = Release|Any CPU {D7166C56-178A-4457-B56A-C615C7450DEE}.Release|x86.ActiveCfg = Release|Any CPU - {927E1F1C-95F0-4991-B33F-603977204B02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {927E1F1C-95F0-4991-B33F-603977204B02}.Debug|Any CPU.Build.0 = Debug|Any CPU - {927E1F1C-95F0-4991-B33F-603977204B02}.Debug|x64.ActiveCfg = Debug|Any CPU - {927E1F1C-95F0-4991-B33F-603977204B02}.Debug|x64.Build.0 = Debug|Any CPU - {927E1F1C-95F0-4991-B33F-603977204B02}.Debug|x86.ActiveCfg = Debug|Any CPU - {927E1F1C-95F0-4991-B33F-603977204B02}.Debug|x86.Build.0 = Debug|Any CPU - {927E1F1C-95F0-4991-B33F-603977204B02}.Release|Any CPU.ActiveCfg = Release|Any CPU - {927E1F1C-95F0-4991-B33F-603977204B02}.Release|Any CPU.Build.0 = Release|Any CPU - {927E1F1C-95F0-4991-B33F-603977204B02}.Release|x64.ActiveCfg = Release|Any CPU - {927E1F1C-95F0-4991-B33F-603977204B02}.Release|x64.Build.0 = Release|Any CPU - {927E1F1C-95F0-4991-B33F-603977204B02}.Release|x86.ActiveCfg = Release|Any CPU - {927E1F1C-95F0-4991-B33F-603977204B02}.Release|x86.Build.0 = Release|Any CPU + {C1E5BBB6-6B6A-4DE5-B19D-0538304DE343}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1E5BBB6-6B6A-4DE5-B19D-0538304DE343}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1E5BBB6-6B6A-4DE5-B19D-0538304DE343}.Debug|x64.ActiveCfg = Debug|Any CPU + {C1E5BBB6-6B6A-4DE5-B19D-0538304DE343}.Debug|x64.Build.0 = Debug|Any CPU + {C1E5BBB6-6B6A-4DE5-B19D-0538304DE343}.Debug|x86.ActiveCfg = Debug|Any CPU + {C1E5BBB6-6B6A-4DE5-B19D-0538304DE343}.Debug|x86.Build.0 = Debug|Any CPU + {C1E5BBB6-6B6A-4DE5-B19D-0538304DE343}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1E5BBB6-6B6A-4DE5-B19D-0538304DE343}.Release|Any CPU.Build.0 = Release|Any CPU + {C1E5BBB6-6B6A-4DE5-B19D-0538304DE343}.Release|x64.ActiveCfg = Release|Any CPU + {C1E5BBB6-6B6A-4DE5-B19D-0538304DE343}.Release|x64.Build.0 = Release|Any CPU + {C1E5BBB6-6B6A-4DE5-B19D-0538304DE343}.Release|x86.ActiveCfg = Release|Any CPU + {C1E5BBB6-6B6A-4DE5-B19D-0538304DE343}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -179,6 +177,6 @@ Global {6A811927-3C37-430A-90F4-503E37123956} = {8CF53B92-5EB1-461D-98F8-70DA9B603FBF} {8B074219-F69A-4E41-83C6-12EE1E647779} = {4C6B06C2-6D77-4E0E-AE32-D7050236433A} {D7166C56-178A-4457-B56A-C615C7450DEE} = {8CF53B92-5EB1-461D-98F8-70DA9B603FBF} - {927E1F1C-95F0-4991-B33F-603977204B02} = {1667F3B4-31E6-45B2-90FB-97B1ECFE9874} + {C1E5BBB6-6B6A-4DE5-B19D-0538304DE343} = {8CF53B92-5EB1-461D-98F8-70DA9B603FBF} EndGlobalSection EndGlobal diff --git a/src/Squidex.Core/Squidex.Core.csproj b/src/Squidex.Core/Squidex.Core.csproj index 05847ae26..d5cb482ce 100644 --- a/src/Squidex.Core/Squidex.Core.csproj +++ b/src/Squidex.Core/Squidex.Core.csproj @@ -13,8 +13,8 @@ - - + + diff --git a/src/Squidex.Events/Squidex.Events.csproj b/src/Squidex.Events/Squidex.Events.csproj index c5dd52a47..fad168319 100644 --- a/src/Squidex.Events/Squidex.Events.csproj +++ b/src/Squidex.Events/Squidex.Events.csproj @@ -12,6 +12,6 @@ - + diff --git a/src/Squidex.Infrastructure.MongoDb/EventStore/MongoEventStore.cs b/src/Squidex.Infrastructure.MongoDb/EventStore/MongoEventStore.cs index b7d3ff079..7e6dac229 100644 --- a/src/Squidex.Infrastructure.MongoDb/EventStore/MongoEventStore.cs +++ b/src/Squidex.Infrastructure.MongoDb/EventStore/MongoEventStore.cs @@ -10,6 +10,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Reactive.Linq; +using System.Threading; using System.Threading.Tasks; using MongoDB.Bson; using MongoDB.Driver; @@ -17,6 +18,7 @@ using NodaTime; using Squidex.Infrastructure.CQRS.Events; using Squidex.Infrastructure.Reflection; +// ReSharper disable ConvertIfStatementToConditionalTernaryExpression // ReSharper disable ClassNeverInstantiated.Local // ReSharper disable UnusedMember.Local // ReSharper disable InvertIf @@ -55,61 +57,82 @@ namespace Squidex.Infrastructure.MongoDb.EventStore { var indexNames = await Task.WhenAll( + collection.Indexes.CreateOneAsync(IndexKeys.Ascending(x => x.EventsOffset), new CreateIndexOptions { Unique = true }), + collection.Indexes.CreateOneAsync(IndexKeys.Ascending(x => x.EventStreamOffset).Ascending(x => x.EventStream), new CreateIndexOptions { Unique = true }), collection.Indexes.CreateOneAsync(IndexKeys.Descending(x => x.EventsOffset), new CreateIndexOptions { Unique = true }), collection.Indexes.CreateOneAsync(IndexKeys.Descending(x => x.EventStreamOffset).Ascending(x => x.EventStream), new CreateIndexOptions { Unique = true })); eventsOffsetIndex = indexNames[0]; } - public IObservable GetEventsAsync(string streamName) + public IObservable GetEventsAsync(string streamFilter, long lastReceivedEventNumber = -1) { - Guard.NotNullOrEmpty(streamName, nameof(streamName)); - - return Observable.Create(async (observer, ct) => + return Observable.Create((observer, ct) => { - await Collection.Find(x => x.EventStream == streamName).ForEachAsync(commit => + return GetEventsAsync(storedEvent => { - var eventNumber = commit.EventsOffset; - var eventStreamNumber = commit.EventStreamOffset; - - foreach (var @event in commit.Events) - { - eventNumber++; - eventStreamNumber++; + observer.OnNext(storedEvent); - var eventData = SimpleMapper.Map(@event, new EventData()); - - observer.OnNext(new StoredEvent(eventNumber, eventStreamNumber, eventData)); - } - }, ct); + return Tasks.TaskHelper.Done; + }, ct, streamFilter, lastReceivedEventNumber); }); } - public IObservable GetEventsAsync(long lastReceivedEventNumber = -1) + public async Task GetEventsAsync(Func callback, CancellationToken cancellationToken, string streamFilter = null, long lastReceivedEventNumber = -1) { - return Observable.Create(async (observer, ct) => + Guard.NotNull(callback, nameof(callback)); + + var filters = new List>(); + + if (lastReceivedEventNumber >= 0) { - var commitOffset = await GetPreviousOffset(lastReceivedEventNumber); + var commitOffset = await GetPreviousOffsetAsync(lastReceivedEventNumber); + + filters.Add(Filter.Gte(x => x.EventsOffset, commitOffset)); + } - await Collection.Find(x => x.EventsOffset >= commitOffset).SortBy(x => x.EventsOffset).ForEachAsync(commit => + if (!string.IsNullOrWhiteSpace(streamFilter) && !string.Equals(streamFilter, "*", StringComparison.OrdinalIgnoreCase)) + { + if (streamFilter.StartsWith("^")) + { + filters.Add(Filter.Regex(x => x.EventStream, streamFilter)); + } + else { - var eventNumber = commit.EventsOffset; - var eventStreamNumber = commit.EventStreamOffset; + filters.Add(Filter.Eq(x => x.EventStream, streamFilter)); + } + } - foreach (var @event in commit.Events) - { - eventNumber++; - eventStreamNumber++; + FilterDefinition filter = new BsonDocument(); - if (eventNumber > lastReceivedEventNumber) - { - var eventData = SimpleMapper.Map(@event, new EventData()); + if (filters.Count > 1) + { + filter = Filter.And(filters); + } + else if (filters.Count == 1) + { + filter = filters[0]; + } - observer.OnNext(new StoredEvent(eventNumber, eventStreamNumber, eventData)); - } + await Collection.Find(filter).SortBy(x => x.EventsOffset).ForEachAsync(async commit => + { + var eventNumber = commit.EventsOffset; + var eventStreamNumber = commit.EventStreamOffset; + + foreach (var mongoEvent in commit.Events) + { + eventNumber++; + eventStreamNumber++; + + if (eventNumber > lastReceivedEventNumber) + { + var eventData = SimpleMapper.Map(mongoEvent, new EventData()); + + await callback(new StoredEvent(eventNumber, eventStreamNumber, eventData)); } - }, ct); - }); + + } + }, cancellationToken); } public async Task AppendEventsAsync(Guid commitId, string streamName, int expectedVersion, IEnumerable events) @@ -130,7 +153,7 @@ namespace Squidex.Infrastructure.MongoDb.EventStore if (commitEvents.Any()) { - var offset = await GetEventOffset(); + var offset = await GetEventOffsetAsync(); var commit = new MongoEventCommit { @@ -157,7 +180,7 @@ namespace Squidex.Infrastructure.MongoDb.EventStore { if (ex.Message.IndexOf(eventsOffsetIndex, StringComparison.OrdinalIgnoreCase) >= 0) { - commit.EventsOffset = await GetEventOffset(); + commit.EventsOffset = await GetEventOffsetAsync(); } else if (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { @@ -174,25 +197,24 @@ namespace Squidex.Infrastructure.MongoDb.EventStore } } - private async Task GetPreviousOffset(long startEventNumber) + private async Task GetPreviousOffsetAsync(long startEventNumber) { var document = await Collection.Find(x => x.EventsOffset <= startEventNumber) .Project(Projection - .Include(x => x.EventStreamOffset) - .Include(x => x.EventsCount)) + .Include(x => x.EventsOffset)) .SortByDescending(x => x.EventsOffset).Limit(1) .FirstOrDefaultAsync(); if (document != null) { - return document["EventStreamOffset"].ToInt64(); + return document["EventsOffset"].ToInt64(); } return -1; } - private async Task GetEventOffset() + private async Task GetEventOffsetAsync() { var document = await Collection.Find(new BsonDocument()) diff --git a/src/Squidex.Infrastructure.RabbitMq/RabbitMqEventConsumer.cs b/src/Squidex.Infrastructure.RabbitMq/RabbitMqEventConsumer.cs new file mode 100644 index 000000000..b1b0c047b --- /dev/null +++ b/src/Squidex.Infrastructure.RabbitMq/RabbitMqEventConsumer.cs @@ -0,0 +1,100 @@ +// ========================================================================== +// RabbitMqEventConsumer.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using RabbitMQ.Client; +using Squidex.Infrastructure.CQRS.Events; +using Squidex.Infrastructure.Tasks; + +// ReSharper disable InvertIf + +namespace Squidex.Infrastructure.RabbitMq +{ + public sealed class RabbitMqEventConsumer : DisposableObjectBase, IExternalSystem, IEventConsumer + { + private readonly JsonSerializerSettings serializerSettings; + private readonly string eventPublisherName; + private readonly string exchange; + private readonly string eventsFilter; + private readonly ConnectionFactory connectionFactory; + private readonly Lazy connection; + private readonly Lazy channel; + + public string Name + { + get { return eventPublisherName; } + } + + public string EventsFilter + { + get { return eventsFilter; } + } + + public RabbitMqEventConsumer(JsonSerializerSettings serializerSettings, string eventPublisherName, string uri, string exchange, string eventsFilter) + { + Guard.NotNullOrEmpty(uri, nameof(uri)); + Guard.NotNullOrEmpty(eventPublisherName, nameof(eventPublisherName)); + Guard.NotNullOrEmpty(exchange, nameof(exchange)); + Guard.NotNull(serializerSettings, nameof(serializerSettings)); + + connectionFactory = new ConnectionFactory { Uri = uri }; + + connection = new Lazy(connectionFactory.CreateConnection); + channel = new Lazy(() => connection.Value.CreateModel()); + + this.exchange = exchange; + this.eventsFilter = eventsFilter; + this.eventPublisherName = eventPublisherName; + this.serializerSettings = serializerSettings; + } + + protected override void DisposeObject(bool disposing) + { + if (connection.IsValueCreated) + { + connection.Value.Close(); + connection.Value.Dispose(); + } + } + + public void Connect() + { + try + { + var currentConnection = connection.Value; + + if (!currentConnection.IsOpen) + { + throw new ConfigurationException($"RabbitMq event bus failed to connect to {connectionFactory.Endpoint}"); + } + } + catch (Exception e) + { + throw new ConfigurationException($"RabbitMq event bus failed to connect to {connectionFactory.Endpoint}", e); + } + } + + public Task ClearAsync() + { + return TaskHelper.Done; + } + + public Task On(Envelope @event) + { + var jsonString = JsonConvert.SerializeObject(@event, serializerSettings); + var jsonBytes = Encoding.UTF8.GetBytes(jsonString); + + channel.Value.BasicPublish(exchange, string.Empty, null, jsonBytes); + + return TaskHelper.Done; + } + } +} diff --git a/src/Squidex.Infrastructure.RabbitMq/Squidex.Infrastructure.RabbitMq.csproj b/src/Squidex.Infrastructure.RabbitMq/Squidex.Infrastructure.RabbitMq.csproj new file mode 100644 index 000000000..ab2f8af4c --- /dev/null +++ b/src/Squidex.Infrastructure.RabbitMq/Squidex.Infrastructure.RabbitMq.csproj @@ -0,0 +1,15 @@ + + + netstandard1.6 + + + full + True + + + + + + + + \ No newline at end of file diff --git a/src/Squidex.Infrastructure.Redis/RedisInfrastructureErrors.cs b/src/Squidex.Infrastructure.Redis/RedisInfrastructureErrors.cs deleted file mode 100644 index 1fca8ec32..000000000 --- a/src/Squidex.Infrastructure.Redis/RedisInfrastructureErrors.cs +++ /dev/null @@ -1,19 +0,0 @@ -// ========================================================================== -// RedisInfrastructureErrors.cs -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex Group -// All rights reserved. -// ========================================================================== - -using Microsoft.Extensions.Logging; - -namespace Squidex.Infrastructure.Redis -{ - public static class RedisInfrastructureErrors - { - public static readonly EventId InvalidatingReceivedFailed = new EventId(50001, "InvalidingReceivedFailed"); - - public static readonly EventId InvalidatingPublishedFailed = new EventId(50002, "InvalidatingPublishedFailed"); - } -} diff --git a/src/Squidex.Infrastructure.Redis/RedisPubSub.cs b/src/Squidex.Infrastructure.Redis/RedisPubSub.cs index dcf893bb8..de71cb843 100644 --- a/src/Squidex.Infrastructure.Redis/RedisPubSub.cs +++ b/src/Squidex.Infrastructure.Redis/RedisPubSub.cs @@ -8,7 +8,7 @@ using System; using System.Collections.Concurrent; -using Microsoft.Extensions.Logging; +using Squidex.Infrastructure.Log; using StackExchange.Redis; namespace Squidex.Infrastructure.Redis @@ -16,31 +16,30 @@ namespace Squidex.Infrastructure.Redis public class RedisPubSub : IPubSub, IExternalSystem { private readonly ConcurrentDictionary subscriptions = new ConcurrentDictionary(); - private readonly IConnectionMultiplexer redis; - private readonly ILogger logger; - private readonly ISubscriber subscriber; + private readonly IConnectionMultiplexer redisClient; + private readonly ISemanticLog log; + private readonly ISubscriber redisSubscriber; - public RedisPubSub(IConnectionMultiplexer redis, ILogger logger) + public RedisPubSub(IConnectionMultiplexer redis, ISemanticLog log) { Guard.NotNull(redis, nameof(redis)); - Guard.NotNull(logger, nameof(logger)); + Guard.NotNull(log, nameof(log)); - this.redis = redis; + this.log = log; - this.logger = logger; - - subscriber = redis.GetSubscriber(); + redisClient = redis; + redisSubscriber = redis.GetSubscriber(); } public void Connect() { try { - redis.GetStatus(); + redisClient.GetStatus(); } catch (Exception ex) { - throw new ConfigurationException($"Redis connection failed to connect to database {redis.Configuration}", ex); + throw new ConfigurationException($"Redis connection failed to connect to database {redisClient.Configuration}", ex); } } @@ -48,14 +47,14 @@ namespace Squidex.Infrastructure.Redis { Guard.NotNullOrEmpty(channelName, nameof(channelName)); - subscriptions.GetOrAdd(channelName, c => new RedisSubscription(subscriber, c, logger)).Publish(token, notifySelf); + subscriptions.GetOrAdd(channelName, c => new RedisSubscription(redisSubscriber, c, log)).Publish(token, notifySelf); } public IDisposable Subscribe(string channelName, Action handler) { Guard.NotNullOrEmpty(channelName, nameof(channelName)); - return subscriptions.GetOrAdd(channelName, c => new RedisSubscription(subscriber, c, logger)).Subscribe(handler); + return subscriptions.GetOrAdd(channelName, c => new RedisSubscription(redisSubscriber, c, log)).Subscribe(handler); } } } diff --git a/src/Squidex.Infrastructure.Redis/RedisSubscription.cs b/src/Squidex.Infrastructure.Redis/RedisSubscription.cs index 045858ce4..a7b628dd8 100644 --- a/src/Squidex.Infrastructure.Redis/RedisSubscription.cs +++ b/src/Squidex.Infrastructure.Redis/RedisSubscription.cs @@ -9,7 +9,7 @@ using System; using System.Linq; using System.Reactive.Subjects; -using Microsoft.Extensions.Logging; +using Squidex.Infrastructure.Log; using StackExchange.Redis; // ReSharper disable InvertIf @@ -22,11 +22,11 @@ namespace Squidex.Infrastructure.Redis private readonly Subject subject = new Subject(); private readonly ISubscriber subscriber; private readonly string channelName; - private readonly ILogger logger; + private readonly ISemanticLog log; - public RedisSubscription(ISubscriber subscriber, string channelName, ILogger logger) + public RedisSubscription(ISubscriber subscriber, string channelName, ISemanticLog log) { - this.logger = logger; + this.log = log; this.subscriber = subscriber; this.subscriber.Subscribe(channelName, (channel, value) => HandleInvalidation(value)); @@ -38,13 +38,16 @@ namespace Squidex.Infrastructure.Redis { try { - var message = string.Join("#", (notifySelf ? Guid.Empty : InstanceId).ToString()); + var message = string.Join("#", (notifySelf ? Guid.Empty : InstanceId).ToString(), token); subscriber.Publish(channelName, message); } catch (Exception ex) { - logger.LogError(RedisInfrastructureErrors.InvalidatingReceivedFailed, ex, "Failed to send invalidation message {0}", token); + log.LogError(ex, w => w + .WriteProperty("action", "PublishRedisMessage") + .WriteProperty("state", "Failed") + .WriteProperty("token", token)); } } @@ -78,7 +81,9 @@ namespace Squidex.Infrastructure.Redis } catch (Exception ex) { - logger.LogError(RedisInfrastructureErrors.InvalidatingReceivedFailed, ex, "Failed to receive invalidation message."); + log.LogError(ex, w => w + .WriteProperty("action", "ReceiveRedisMessage") + .WriteProperty("state", "Failed")); } } diff --git a/src/Squidex.Infrastructure.Redis/Squidex.Infrastructure.Redis.csproj b/src/Squidex.Infrastructure.Redis/Squidex.Infrastructure.Redis.csproj index 98a461896..5e5b44588 100644 --- a/src/Squidex.Infrastructure.Redis/Squidex.Infrastructure.Redis.csproj +++ b/src/Squidex.Infrastructure.Redis/Squidex.Infrastructure.Redis.csproj @@ -10,7 +10,6 @@ - diff --git a/src/Squidex.Infrastructure/CQRS/Commands/CommandContext.cs b/src/Squidex.Infrastructure/CQRS/Commands/CommandContext.cs index 31f76e399..113f85317 100644 --- a/src/Squidex.Infrastructure/CQRS/Commands/CommandContext.cs +++ b/src/Squidex.Infrastructure/CQRS/Commands/CommandContext.cs @@ -13,6 +13,7 @@ namespace Squidex.Infrastructure.CQRS.Commands public sealed class CommandContext { private readonly ICommand command; + private readonly Guid contextId = Guid.NewGuid(); private Exception exception; private Tuple result; @@ -41,6 +42,11 @@ namespace Squidex.Infrastructure.CQRS.Commands get { return exception; } } + public Guid ContextId + { + get { return contextId; } + } + public CommandContext(ICommand command) { Guard.NotNull(command, nameof(command)); diff --git a/src/Squidex.Infrastructure/CQRS/Commands/LogExceptionHandler.cs b/src/Squidex.Infrastructure/CQRS/Commands/LogExceptionHandler.cs index 0e31d7548..80efac561 100644 --- a/src/Squidex.Infrastructure/CQRS/Commands/LogExceptionHandler.cs +++ b/src/Squidex.Infrastructure/CQRS/Commands/LogExceptionHandler.cs @@ -7,7 +7,7 @@ // ========================================================================== using System.Threading.Tasks; -using Microsoft.Extensions.Logging; +using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Tasks; // ReSharper disable InvertIf @@ -16,11 +16,13 @@ namespace Squidex.Infrastructure.CQRS.Commands { public sealed class LogExceptionHandler : ICommandHandler { - private readonly ILogger logger; + private readonly ISemanticLog log; - public LogExceptionHandler(ILogger logger) + public LogExceptionHandler(ISemanticLog log) { - this.logger = logger; + Guard.NotNull(log, nameof(log)); + + this.log = log; } public Task HandleAsync(CommandContext context) @@ -29,12 +31,20 @@ namespace Squidex.Infrastructure.CQRS.Commands if (exception != null) { - logger.LogError(InfrastructureErrors.CommandFailed, exception, "Handling {0} command failed", context.Command); + log.LogError(exception, w => w + .WriteProperty("action", "HandleCommand.") + .WriteProperty("actionId", context.ContextId.ToString()) + .WriteProperty("state", "Failed") + .WriteProperty("commandType", context.Command.GetType().Name)); } if (!context.IsHandled) { - logger.LogCritical(InfrastructureErrors.CommandUnknown, exception, "Unknown command {0}", context.Command); + log.LogFatal(exception, w => w + .WriteProperty("action", "HandleCommand.") + .WriteProperty("actionId", context.ContextId.ToString()) + .WriteProperty("state", "Unhandled") + .WriteProperty("commandType", context.Command.GetType().Name)); } return TaskHelper.False; diff --git a/src/Squidex.Infrastructure/CQRS/Commands/LogExecutingHandler.cs b/src/Squidex.Infrastructure/CQRS/Commands/LogExecutingHandler.cs index d95a02cd6..646c97262 100644 --- a/src/Squidex.Infrastructure/CQRS/Commands/LogExecutingHandler.cs +++ b/src/Squidex.Infrastructure/CQRS/Commands/LogExecutingHandler.cs @@ -7,23 +7,29 @@ // ========================================================================== using System.Threading.Tasks; -using Microsoft.Extensions.Logging; +using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Tasks; namespace Squidex.Infrastructure.CQRS.Commands { public sealed class LogExecutingHandler : ICommandHandler { - private readonly ILogger logger; + private readonly ISemanticLog log; - public LogExecutingHandler(ILogger logger) + public LogExecutingHandler(ISemanticLog log) { - this.logger = logger; + Guard.NotNull(log, nameof(log)); + + this.log = log; } public Task HandleAsync(CommandContext context) { - logger.LogInformation("Handling {0} command", context.Command); + log.LogInformation(w => w + .WriteProperty("action", "HandleCommand.") + .WriteProperty("actionId", context.ContextId.ToString()) + .WriteProperty("state", "Started") + .WriteProperty("commandType", context.Command.GetType().Name)); return TaskHelper.False; } diff --git a/src/Squidex.Infrastructure/CQRS/Events/CompoundEventConsumer.cs b/src/Squidex.Infrastructure/CQRS/Events/CompoundEventConsumer.cs index 83c1843ba..7a792650e 100644 --- a/src/Squidex.Infrastructure/CQRS/Events/CompoundEventConsumer.cs +++ b/src/Squidex.Infrastructure/CQRS/Events/CompoundEventConsumer.cs @@ -17,6 +17,11 @@ namespace Squidex.Infrastructure.CQRS.Events public string Name { get; } + public string EventsFilter + { + get { return inners.FirstOrDefault()?.EventsFilter; } + } + public CompoundEventConsumer(IEventConsumer first, params IEventConsumer[] inners) { Guard.NotNull(first, nameof(first)); @@ -24,7 +29,7 @@ namespace Squidex.Infrastructure.CQRS.Events this.inners = new[] { first }.Union(inners).ToArray(); - Name = first.GetType().Name; + Name = first.Name; } public CompoundEventConsumer(string name, params IEventConsumer[] inners) diff --git a/src/Squidex.Infrastructure/CQRS/Events/DefaultMemoryEventNotifier.cs b/src/Squidex.Infrastructure/CQRS/Events/DefaultEventNotifier.cs similarity index 82% rename from src/Squidex.Infrastructure/CQRS/Events/DefaultMemoryEventNotifier.cs rename to src/Squidex.Infrastructure/CQRS/Events/DefaultEventNotifier.cs index afd527615..4d2207875 100644 --- a/src/Squidex.Infrastructure/CQRS/Events/DefaultMemoryEventNotifier.cs +++ b/src/Squidex.Infrastructure/CQRS/Events/DefaultEventNotifier.cs @@ -1,5 +1,5 @@ // ========================================================================== -// InMemoryEventNotifier.cs +// DefaultEventNotifier.cs // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex Group @@ -10,13 +10,13 @@ using System; namespace Squidex.Infrastructure.CQRS.Events { - public sealed class DefaultMemoryEventNotifier : IEventNotifier + public sealed class DefaultEventNotifier : IEventNotifier { - private static readonly string ChannelName = typeof(DefaultMemoryEventNotifier).Name; + private static readonly string ChannelName = typeof(DefaultEventNotifier).Name; private readonly IPubSub invalidator; - public DefaultMemoryEventNotifier(IPubSub invalidator) + public DefaultEventNotifier(IPubSub invalidator) { Guard.NotNull(invalidator, nameof(invalidator)); diff --git a/src/Squidex.Infrastructure/CQRS/Events/Envelope_1.cs b/src/Squidex.Infrastructure/CQRS/Events/Envelope_1.cs index e2bf2aa45..502917536 100644 --- a/src/Squidex.Infrastructure/CQRS/Events/Envelope_1.cs +++ b/src/Squidex.Infrastructure/CQRS/Events/Envelope_1.cs @@ -6,9 +6,6 @@ // All rights reserved. // ========================================================================== -using System; -using NodaTime; - namespace Squidex.Infrastructure.CQRS.Events { public class Envelope where TPayload : class @@ -45,18 +42,6 @@ namespace Squidex.Infrastructure.CQRS.Events this.headers = headers; } - public static Envelope Create(TPayload payload) - { - var eventId = Guid.NewGuid(); - - var envelope = - new Envelope(payload) - .SetEventId(eventId) - .SetTimestamp(SystemClock.Instance.GetCurrentInstant()); - - return envelope; - } - public Envelope To() where TOther : class { return new Envelope(payload as TOther, headers.Clone()); diff --git a/src/Squidex.Infrastructure/CQRS/Events/EventReceiver.cs b/src/Squidex.Infrastructure/CQRS/Events/EventReceiver.cs index c0660a18e..d7774e075 100644 --- a/src/Squidex.Infrastructure/CQRS/Events/EventReceiver.cs +++ b/src/Squidex.Infrastructure/CQRS/Events/EventReceiver.cs @@ -7,9 +7,8 @@ // ========================================================================== using System; -using System.Reactive.Linq; using System.Threading.Tasks; -using Microsoft.Extensions.Logging; +using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Timers; // ReSharper disable MethodSupportsCancellation @@ -24,23 +23,23 @@ namespace Squidex.Infrastructure.CQRS.Events private readonly IEventStore eventStore; private readonly IEventNotifier eventNotifier; private readonly IEventConsumerInfoRepository eventConsumerInfoRepository; - private readonly ILogger logger; + private readonly ISemanticLog log; private CompletionTimer timer; public EventReceiver( EventDataFormatter formatter, - IEventStore eventStore, + IEventStore eventStore, IEventNotifier eventNotifier, IEventConsumerInfoRepository eventConsumerInfoRepository, - ILogger logger) + ISemanticLog log) { - Guard.NotNull(logger, nameof(logger)); + Guard.NotNull(log, nameof(log)); Guard.NotNull(formatter, nameof(formatter)); Guard.NotNull(eventStore, nameof(eventStore)); Guard.NotNull(eventNotifier, nameof(eventNotifier)); Guard.NotNull(eventConsumerInfoRepository, nameof(eventConsumerInfoRepository)); - this.logger = logger; + this.log = log; this.formatter = formatter; this.eventStore = eventStore; this.eventNotifier = eventNotifier; @@ -57,13 +56,17 @@ namespace Squidex.Infrastructure.CQRS.Events } catch (Exception ex) { - logger.LogCritical(InfrastructureErrors.EventHandlingFailed, ex, "Event stream {0} has been aborted"); + log.LogWarning(ex, w => w + .WriteProperty("action", "DisposeEventReceiver") + .WriteProperty("state", "Failed")); } } } - public void Trigger() + public void Next() { + ThrowIfDisposed(); + timer?.Trigger(); } @@ -71,6 +74,8 @@ namespace Squidex.Infrastructure.CQRS.Events { Guard.NotNull(eventConsumer, nameof(eventConsumer)); + ThrowIfDisposed(); + if (timer != null) { return; @@ -78,7 +83,7 @@ namespace Squidex.Infrastructure.CQRS.Events var consumerName = eventConsumer.Name; var consumerStarted = false; - + timer = new CompletionTimer(delay, async ct => { if (!consumerStarted) @@ -104,18 +109,13 @@ namespace Squidex.Infrastructure.CQRS.Events { return; } - - await eventStore.GetEventsAsync(lastHandledEventNumber) - .Select(storedEvent => - { - HandleEventAsync(eventConsumer, storedEvent, consumerName).Wait(); - - return storedEvent; - }).DefaultIfEmpty(); + + await eventStore.GetEventsAsync(se => HandleEventAsync(eventConsumer, se, consumerName), ct, + eventConsumer.EventsFilter, lastHandledEventNumber); } catch (Exception ex) { - logger.LogError(InfrastructureErrors.EventHandlingFailed, ex, "Failed to handle events"); + log.LogFatal(ex, w => w.WriteProperty("action", "EventHandlingFailed")); await eventConsumerInfoRepository.StopAsync(consumerName, ex.ToString()); } @@ -134,18 +134,31 @@ namespace Squidex.Infrastructure.CQRS.Events private async Task ResetAsync(IEventConsumer eventConsumer, string consumerName) { + var actionId = Guid.NewGuid().ToString(); try { - logger.LogDebug("[{0}]: Reset started", eventConsumer); + log.LogInformation(w => w + .WriteProperty("action", "EventConsumerReset") + .WriteProperty("actionId", actionId) + .WriteProperty("state", "Started") + .WriteProperty("eventConsumer", eventConsumer.GetType().Name)); await eventConsumer.ClearAsync(); await eventConsumerInfoRepository.SetLastHandledEventNumberAsync(consumerName, -1); - logger.LogDebug("[{0}]: Reset completed", eventConsumer); + log.LogInformation(w => w + .WriteProperty("action", "EventConsumerReset") + .WriteProperty("actionId", actionId) + .WriteProperty("state", "Completed") + .WriteProperty("eventConsumer", eventConsumer.GetType().Name)); } catch (Exception ex) { - logger.LogError(InfrastructureErrors.EventResetFailed, ex, "[{0}]: Reset failed", eventConsumer); + log.LogFatal(ex, w => w + .WriteProperty("action", "EventConsumerReset") + .WriteProperty("actionId", actionId) + .WriteProperty("state", "Completed") + .WriteProperty("eventConsumer", eventConsumer.GetType().Name)); throw; } @@ -153,17 +166,37 @@ namespace Squidex.Infrastructure.CQRS.Events private async Task DispatchConsumer(Envelope @event, IEventConsumer eventConsumer) { + var eventId = @event.Headers.EventId().ToString(); + var eventType = @event.Payload.GetType().Name; try { - logger.LogDebug("[{0}]: Handling event {1} ({2})", eventConsumer, @event.Payload, @event.Headers.EventId()); + log.LogInformation(w => w + .WriteProperty("action", "HandleEvent") + .WriteProperty("actionId", eventId) + .WriteProperty("state", "Started") + .WriteProperty("eventId", eventId) + .WriteProperty("eventType", eventType) + .WriteProperty("eventConsumer", eventConsumer.GetType().Name)); await eventConsumer.On(@event); - logger.LogDebug("[{0}]: Handled event {1} ({2})", eventConsumer, @event.Payload, @event.Headers.EventId()); + log.LogInformation(w => w + .WriteProperty("action", "HandleEvent") + .WriteProperty("actionId", eventId) + .WriteProperty("state", "Completed") + .WriteProperty("eventId", eventId) + .WriteProperty("eventType", eventType) + .WriteProperty("eventConsumer", eventConsumer.GetType().Name)); } catch (Exception ex) { - logger.LogError(InfrastructureErrors.EventHandlingFailed, ex, "[{0}]: Failed to handle event {1} ({2})", eventConsumer, @event.Payload, @event.Headers.EventId()); + log.LogError(ex, w => w + .WriteProperty("action", "HandleEvent") + .WriteProperty("actionId", eventId) + .WriteProperty("state", "Started") + .WriteProperty("eventId", eventId) + .WriteProperty("eventType", eventType) + .WriteProperty("eventConsumer", eventConsumer.GetType().Name)); throw; } @@ -182,7 +215,11 @@ namespace Squidex.Infrastructure.CQRS.Events } catch (Exception ex) { - logger.LogError(InfrastructureErrors.EventDeserializationFailed, ex, "Failed to parse event {0}", storedEvent.Data.EventId); + log.LogFatal(ex, w => w + .WriteProperty("action", "ParseEvent") + .WriteProperty("state", "Failed") + .WriteProperty("eventId", storedEvent.Data.EventId.ToString()) + .WriteProperty("eventNumber", storedEvent.EventNumber)); throw; } diff --git a/src/Squidex.Infrastructure/CQRS/Events/IEventConsumer.cs b/src/Squidex.Infrastructure/CQRS/Events/IEventConsumer.cs index da0eedbb8..1cb4fba4d 100644 --- a/src/Squidex.Infrastructure/CQRS/Events/IEventConsumer.cs +++ b/src/Squidex.Infrastructure/CQRS/Events/IEventConsumer.cs @@ -14,6 +14,8 @@ namespace Squidex.Infrastructure.CQRS.Events { string Name { get; } + string EventsFilter { get; } + Task ClearAsync(); Task On(Envelope @event); diff --git a/src/Squidex.Infrastructure/CQRS/Events/IEventStore.cs b/src/Squidex.Infrastructure/CQRS/Events/IEventStore.cs index 9feff2f6a..ef69f6b03 100644 --- a/src/Squidex.Infrastructure/CQRS/Events/IEventStore.cs +++ b/src/Squidex.Infrastructure/CQRS/Events/IEventStore.cs @@ -8,15 +8,16 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace Squidex.Infrastructure.CQRS.Events { public interface IEventStore { - IObservable GetEventsAsync(long lastReceivedEventNumber = -1); + IObservable GetEventsAsync(string streamFilter = null, long lastReceivedEventNumber = -1); - IObservable GetEventsAsync(string streamName); + Task GetEventsAsync(Func callback, CancellationToken cancellationToken, string streamFilter = null, long lastReceivedEventNumber = -1); Task AppendEventsAsync(Guid commitId, string streamName, int expectedVersion, IEnumerable events); } diff --git a/src/Squidex.Infrastructure/DisposableObjectBase.cs b/src/Squidex.Infrastructure/DisposableObjectBase.cs index a8ead1cbd..f79a12c8f 100644 --- a/src/Squidex.Infrastructure/DisposableObjectBase.cs +++ b/src/Squidex.Infrastructure/DisposableObjectBase.cs @@ -36,20 +36,13 @@ namespace Squidex.Infrastructure return; } - if (disposing) + lock (disposeLock) { - lock (disposeLock) + if (!isDisposed) { - if (!isDisposed) - { - DisposeObject(true); - } + DisposeObject(disposing); } } - else - { - DisposeObject(false); - } isDisposed = true; } diff --git a/src/Squidex.Infrastructure/InfrastructureErrors.cs b/src/Squidex.Infrastructure/InfrastructureErrors.cs deleted file mode 100644 index 0d006dd0b..000000000 --- a/src/Squidex.Infrastructure/InfrastructureErrors.cs +++ /dev/null @@ -1,25 +0,0 @@ -// ========================================================================== -// InfrastructureErrors.cs -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex Group -// All rights reserved. -// ========================================================================== - -using Microsoft.Extensions.Logging; - -namespace Squidex.Infrastructure -{ - public class InfrastructureErrors - { - public static readonly EventId CommandUnknown = new EventId(20000, "CommandUnknown"); - - public static readonly EventId CommandFailed = new EventId(20001, "CommandFailed"); - - public static readonly EventId EventResetFailed = new EventId(10000, "EventResetFailed"); - - public static readonly EventId EventHandlingFailed = new EventId(10001, "EventHandlingFailed"); - - public static readonly EventId EventDeserializationFailed = new EventId(10002, "EventDeserializationFailed"); - } -} diff --git a/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLogger.cs b/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLogger.cs new file mode 100644 index 000000000..812b513d9 --- /dev/null +++ b/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLogger.cs @@ -0,0 +1,102 @@ +// ========================================================================== +// SemanticLogLogger.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Microsoft.Extensions.Logging; + +// ReSharper disable SwitchStatementMissingSomeCases + +namespace Squidex.Infrastructure.Log.Adapter +{ + internal sealed class SemanticLogLogger : ILogger + { + private readonly ISemanticLog semanticLog; + + public SemanticLogLogger(ISemanticLog semanticLog) + { + this.semanticLog = semanticLog; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + SemanticLogLevel semanticLogLevel; + + switch (logLevel) + { + case LogLevel.Trace: + semanticLogLevel = SemanticLogLevel.Trace; + break; + case LogLevel.Debug: + semanticLogLevel = SemanticLogLevel.Debug; + break; + case LogLevel.Information: + semanticLogLevel = SemanticLogLevel.Information; + break; + case LogLevel.Warning: + semanticLogLevel = SemanticLogLevel.Warning; + break; + case LogLevel.Error: + semanticLogLevel = SemanticLogLevel.Error; + break; + case LogLevel.Critical: + semanticLogLevel = SemanticLogLevel.Fatal; + break; + default: + semanticLogLevel = SemanticLogLevel.Debug; + break; + } + + semanticLog.Log(semanticLogLevel, writer => + { + var message = formatter(state, exception); + + if (!string.IsNullOrWhiteSpace(message)) + { + writer.WriteProperty(nameof(message), message); + } + + if (eventId.Id > 0) + { + writer.WriteObject(nameof(eventId), eventIdWriter => + { + eventIdWriter.WriteProperty("id", eventId.Id); + + if (!string.IsNullOrWhiteSpace(eventId.Name)) + { + eventIdWriter.WriteProperty("name", eventId.Name); + } + }); + } + + if (exception != null) + { + writer.WriteException(exception); + } + }); + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public IDisposable BeginScope(TState state) + { + return NoopDisposable.Instance; + } + + private class NoopDisposable : IDisposable + { + public static readonly NoopDisposable Instance = new NoopDisposable(); + + public void Dispose() + { + } + } + } +} diff --git a/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLoggerFactoryExtensions.cs b/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLoggerFactoryExtensions.cs new file mode 100644 index 000000000..662da7078 --- /dev/null +++ b/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLoggerFactoryExtensions.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// SemanticLogLoggerFactoryExtensions.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Microsoft.Extensions.Logging; + +namespace Squidex.Infrastructure.Log.Adapter +{ + public static class SemanticLogLoggerFactoryExtensions + { + public static ILoggerFactory AddSemanticLog(this ILoggerFactory factory, ISemanticLog semanticLog) + { + factory.AddProvider(new SemanticLogLoggerProvider(semanticLog)); + + return factory; + } + } +} diff --git a/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLoggerProvider.cs b/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLoggerProvider.cs new file mode 100644 index 000000000..3bd84a2b1 --- /dev/null +++ b/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLoggerProvider.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// SemanticLogLoggerProvider.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Microsoft.Extensions.Logging; + +namespace Squidex.Infrastructure.Log.Adapter +{ + public class SemanticLogLoggerProvider : ILoggerProvider + { + private readonly ISemanticLog semanticLog; + + public SemanticLogLoggerProvider(ISemanticLog semanticLog) + { + this.semanticLog = semanticLog; + } + + public ILogger CreateLogger(string categoryName) + { + return new SemanticLogLogger(semanticLog.CreateScope(writer => + { + writer.WriteProperty("category", categoryName); + })); + } + + public void Dispose() + { + } + } +} diff --git a/src/Squidex.Infrastructure/Log/ApplicationInfoLogAppender.cs b/src/Squidex.Infrastructure/Log/ApplicationInfoLogAppender.cs new file mode 100644 index 000000000..f97943a44 --- /dev/null +++ b/src/Squidex.Infrastructure/Log/ApplicationInfoLogAppender.cs @@ -0,0 +1,42 @@ +// ========================================================================== +// ApplicationInfoLogAppender.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Reflection; + +namespace Squidex.Infrastructure.Log +{ + public sealed class ApplicationInfoLogAppender : ILogAppender + { + private readonly string applicationName; + private readonly string applicationVersion; + private readonly string applicationSessionId; + + public ApplicationInfoLogAppender(Type type, Guid applicationSession) + : this(type?.GetTypeInfo().Assembly, applicationSession) + { + } + + public ApplicationInfoLogAppender(Assembly assembly, Guid applicationSession) + { + Guard.NotNull(assembly, nameof(assembly)); + + applicationName = assembly.GetName().Name; + applicationVersion = assembly.GetName().Version.ToString(); + applicationSessionId = applicationSession.ToString(); + } + + public void Append(IObjectWriter writer) + { + writer.WriteObject("app", w => w + .WriteProperty("name", applicationName) + .WriteProperty("version", applicationVersion) + .WriteProperty("sessionId", applicationSessionId)); + } + } +} diff --git a/src/Squidex.Infrastructure/Log/ConsoleLogChannel.cs b/src/Squidex.Infrastructure/Log/ConsoleLogChannel.cs new file mode 100644 index 000000000..c9c3b3d81 --- /dev/null +++ b/src/Squidex.Infrastructure/Log/ConsoleLogChannel.cs @@ -0,0 +1,28 @@ +// ========================================================================== +// ConsoleLogChannel.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Squidex.Infrastructure.Log.Internal; + +namespace Squidex.Infrastructure.Log +{ + public sealed class ConsoleLogChannel : ILogChannel, IDisposable + { + private readonly ConsoleLogProcessor processor = new ConsoleLogProcessor(); + + public void Dispose() + { + processor.Dispose(); + } + + public void Log(SemanticLogLevel logLevel, string message) + { + processor.EnqueueMessage(new LogMessageEntry { Message = message, Level = logLevel }); + } + } +} diff --git a/src/Squidex.Infrastructure/Log/ConstantsLogWriter.cs b/src/Squidex.Infrastructure/Log/ConstantsLogWriter.cs new file mode 100644 index 000000000..4af82cbed --- /dev/null +++ b/src/Squidex.Infrastructure/Log/ConstantsLogWriter.cs @@ -0,0 +1,29 @@ +// ========================================================================== +// ConstantsLogWriter.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; + +namespace Squidex.Infrastructure.Log +{ + public sealed class ConstantsLogWriter : ILogAppender + { + private readonly Action objectWriter; + + public ConstantsLogWriter(Action objectWriter) + { + Guard.NotNull(objectWriter, nameof(objectWriter)); + + this.objectWriter = objectWriter; + } + + public void Append(IObjectWriter writer) + { + objectWriter(writer); + } + } +} diff --git a/src/Squidex.Infrastructure/Log/DebugLogChannel.cs b/src/Squidex.Infrastructure/Log/DebugLogChannel.cs new file mode 100644 index 000000000..7816b83eb --- /dev/null +++ b/src/Squidex.Infrastructure/Log/DebugLogChannel.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// DebugLogChannel.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Diagnostics; + +namespace Squidex.Infrastructure.Log +{ + public sealed class DebugLogChannel : ILogChannel + { + public void Log(SemanticLogLevel logLevel, string message) + { + Debug.WriteLine(message); + } + } +} diff --git a/src/Squidex.Infrastructure/Log/FileChannel.cs b/src/Squidex.Infrastructure/Log/FileChannel.cs new file mode 100644 index 000000000..aea8b916e --- /dev/null +++ b/src/Squidex.Infrastructure/Log/FileChannel.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// FileChannel.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Infrastructure.Log.Internal; + +namespace Squidex.Infrastructure.Log +{ + public sealed class FileChannel : ILogChannel, IExternalSystem + { + private readonly FileLogProcessor processor; + + public FileChannel(string path) + { + Guard.NotNullOrEmpty(path, nameof(path)); + + processor = new FileLogProcessor(path); + } + + public void Log(SemanticLogLevel logLevel, string message) + { + processor.EnqueueMessage(new LogMessageEntry { Message = message, Level = logLevel }); + } + + public void Connect() + { + processor.Connect(); + } + } +} diff --git a/src/Squidex.Infrastructure/Log/IArrayWriter.cs b/src/Squidex.Infrastructure/Log/IArrayWriter.cs new file mode 100644 index 000000000..a8159ac7d --- /dev/null +++ b/src/Squidex.Infrastructure/Log/IArrayWriter.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// IArrayWriter.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; + +namespace Squidex.Infrastructure.Log +{ + public interface IArrayWriter + { + IArrayWriter WriteValue(string value); + IArrayWriter WriteValue(double value); + IArrayWriter WriteValue(long value); + IArrayWriter WriteValue(bool value); + + IArrayWriter WriteValue(TimeSpan value); + IArrayWriter WriteValue(DateTime value); + IArrayWriter WriteValue(DateTimeOffset value); + + IArrayWriter WriteObject(Action objectWriter); + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Log/ILogAppender.cs b/src/Squidex.Infrastructure/Log/ILogAppender.cs new file mode 100644 index 000000000..596c6b8d6 --- /dev/null +++ b/src/Squidex.Infrastructure/Log/ILogAppender.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// ILogAppender.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== +namespace Squidex.Infrastructure.Log +{ + public interface ILogAppender + { + void Append(IObjectWriter writer); + } +} diff --git a/src/Squidex.Infrastructure/Log/ILogChannel.cs b/src/Squidex.Infrastructure/Log/ILogChannel.cs new file mode 100644 index 000000000..20ebee481 --- /dev/null +++ b/src/Squidex.Infrastructure/Log/ILogChannel.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// ILogChannel.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== +namespace Squidex.Infrastructure.Log +{ + public interface ILogChannel + { + void Log(SemanticLogLevel logLevel, string message); + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Log/IObjectWriter.cs b/src/Squidex.Infrastructure/Log/IObjectWriter.cs new file mode 100644 index 000000000..db331826b --- /dev/null +++ b/src/Squidex.Infrastructure/Log/IObjectWriter.cs @@ -0,0 +1,27 @@ +// ========================================================================== +// IObjectWriter.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; + +namespace Squidex.Infrastructure.Log +{ + public interface IObjectWriter + { + IObjectWriter WriteProperty(string property, string value); + IObjectWriter WriteProperty(string property, double value); + IObjectWriter WriteProperty(string property, long value); + IObjectWriter WriteProperty(string property, bool value); + + IObjectWriter WriteProperty(string property, TimeSpan value); + IObjectWriter WriteProperty(string property, DateTime value); + IObjectWriter WriteProperty(string property, DateTimeOffset value); + + IObjectWriter WriteObject(string property, Action objectWriter); + IObjectWriter WriteArray(string property, Action arrayWriter); + } +} diff --git a/src/Squidex.Infrastructure/Log/ISemanticLog.cs b/src/Squidex.Infrastructure/Log/ISemanticLog.cs new file mode 100644 index 000000000..1fb09f70d --- /dev/null +++ b/src/Squidex.Infrastructure/Log/ISemanticLog.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// ISemanticLog.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; + +namespace Squidex.Infrastructure.Log +{ + public interface ISemanticLog + { + void Log(SemanticLogLevel logLevel, Action action); + + ISemanticLog CreateScope(Action objectWriter); + } +} diff --git a/src/Squidex.Infrastructure/Log/Internal/AnsiLogConsole.cs b/src/Squidex.Infrastructure/Log/Internal/AnsiLogConsole.cs new file mode 100644 index 000000000..41666b8cf --- /dev/null +++ b/src/Squidex.Infrastructure/Log/Internal/AnsiLogConsole.cs @@ -0,0 +1,39 @@ +// ========================================================================== +// AnsiLogConsole.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Text; + +// ReSharper disable SwitchStatementMissingSomeCases + +namespace Squidex.Infrastructure.Log.Internal +{ + public class AnsiLogConsole : IConsole + { + private readonly StringBuilder outputBuilder = new StringBuilder(); + + public void WriteLine(SemanticLogLevel level, string message) + { + if (level >= SemanticLogLevel.Error) + { + outputBuilder.Append("\x1B[1m\x1B[31m"); + outputBuilder.Append(message); + outputBuilder.Append("\x1B[39m\x1B[22m"); + outputBuilder.AppendLine(); + + Console.Error.Write(outputBuilder.ToString()); + } + else + { + Console.Out.Write(outputBuilder.ToString()); + } + + outputBuilder.Clear(); + } + } +} diff --git a/src/Squidex.Infrastructure/Log/Internal/ConsoleLogProcessor.cs b/src/Squidex.Infrastructure/Log/Internal/ConsoleLogProcessor.cs new file mode 100644 index 000000000..f48a74f79 --- /dev/null +++ b/src/Squidex.Infrastructure/Log/Internal/ConsoleLogProcessor.cs @@ -0,0 +1,73 @@ +// ========================================================================== +// ConsoleLogProcessor.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +namespace Squidex.Infrastructure.Log.Internal +{ + public class ConsoleLogProcessor : IDisposable + { + private readonly IConsole console; + private const int MaxQueuedMessages = 1024; + private readonly BlockingCollection messageQueue = new BlockingCollection(MaxQueuedMessages); + private readonly Task outputTask; + + public ConsoleLogProcessor() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + console = new WindowsLogConsole(); + } + else + { + console = new AnsiLogConsole(); + } + + outputTask = Task.Factory.StartNew(ProcessLogQueue, this, TaskCreationOptions.LongRunning); + } + + public void EnqueueMessage(LogMessageEntry message) + { + messageQueue.Add(message); + } + + private void ProcessLogQueue() + { + foreach (var entry in messageQueue.GetConsumingEnumerable()) + { + console.WriteLine(entry.Level, entry.Message); + } + } + + private static void ProcessLogQueue(object state) + { + var processor = (ConsoleLogProcessor)state; + + processor.ProcessLogQueue(); + } + + public void Dispose() + { + messageQueue.CompleteAdding(); + + try + { + outputTask.Wait(1500); + } + catch (TaskCanceledException) + { + } + catch (AggregateException ex) when (ex.InnerExceptions.Count == 1 && ex.InnerExceptions[0] is TaskCanceledException) + { + } + } + } +} diff --git a/src/Squidex.Infrastructure/Log/Internal/FileLogChannel.cs b/src/Squidex.Infrastructure/Log/Internal/FileLogChannel.cs new file mode 100644 index 000000000..f9477dbd1 --- /dev/null +++ b/src/Squidex.Infrastructure/Log/Internal/FileLogChannel.cs @@ -0,0 +1,95 @@ +// ========================================================================== +// FileLogChannel.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace Squidex.Infrastructure.Log.Internal +{ + public class FileLogProcessor : IDisposable + { + private const int MaxQueuedMessages = 1024; + private const int Retries = 10; + private readonly BlockingCollection messageQueue = new BlockingCollection(MaxQueuedMessages); + private readonly Task outputTask; + private readonly string path; + + public FileLogProcessor(string path) + { + this.path = path; + + outputTask = Task.Factory.StartNew(ProcessLogQueue, this, TaskCreationOptions.LongRunning); + } + + public void Connect() + { + var fileInfo = new FileInfo(path); + + if (!fileInfo.Directory.Exists) + { + throw new ConfigurationException($"Log directory '{fileInfo.Directory.FullName}' does not exist."); + } + } + + public void EnqueueMessage(LogMessageEntry message) + { + messageQueue.Add(message); + } + + private async Task ProcessLogQueue() + { + foreach (var entry in messageQueue.GetConsumingEnumerable()) + { + for (var i = 1; i <= Retries; i++) + { + try + { + File.AppendAllText(path, entry.Message + Environment.NewLine, Encoding.UTF8); + + break; + } + catch (Exception ex) + { + await Task.Delay(i * 10); + + if (i == Retries) + { + Console.WriteLine("Failed to write to log file '{0}': {1}", path, ex); + } + } + } + } + } + + private static Task ProcessLogQueue(object state) + { + var processor = (FileLogProcessor)state; + + return processor.ProcessLogQueue(); + } + + public void Dispose() + { + messageQueue.CompleteAdding(); + + try + { + outputTask.Wait(1500); + } + catch (TaskCanceledException) + { + } + catch (AggregateException ex) when (ex.InnerExceptions.Count == 1 && ex.InnerExceptions[0] is TaskCanceledException) + { + } + } + } +} diff --git a/src/Squidex.Infrastructure/Log/Internal/IConsole.cs b/src/Squidex.Infrastructure/Log/Internal/IConsole.cs new file mode 100644 index 000000000..801a61e9b --- /dev/null +++ b/src/Squidex.Infrastructure/Log/Internal/IConsole.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// IConsole.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== +namespace Squidex.Infrastructure.Log.Internal +{ + public interface IConsole + { + void WriteLine(SemanticLogLevel level, string message); + } +} diff --git a/src/Squidex.Infrastructure/Log/Internal/LogMessageEntry.cs b/src/Squidex.Infrastructure/Log/Internal/LogMessageEntry.cs new file mode 100644 index 000000000..5843bcd7c --- /dev/null +++ b/src/Squidex.Infrastructure/Log/Internal/LogMessageEntry.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// LogMessageEntry.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== +namespace Squidex.Infrastructure.Log.Internal +{ + public struct LogMessageEntry + { + public SemanticLogLevel Level; + + public string Message; + } +} diff --git a/src/Squidex.Infrastructure/Log/Internal/WindowsLogConsole.cs b/src/Squidex.Infrastructure/Log/Internal/WindowsLogConsole.cs new file mode 100644 index 000000000..b471cc5a5 --- /dev/null +++ b/src/Squidex.Infrastructure/Log/Internal/WindowsLogConsole.cs @@ -0,0 +1,29 @@ +// ========================================================================== +// WindowsLogConsole.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; + +namespace Squidex.Infrastructure.Log.Internal +{ + public class WindowsLogConsole : IConsole + { + public void WriteLine(SemanticLogLevel level, string message) + { + if (level >= SemanticLogLevel.Error) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.Error.WriteLine(message); + Console.ResetColor(); + } + else + { + Console.Out.WriteLine(message); + } + } + } +} diff --git a/src/Squidex.Infrastructure/Log/JsonLogWriter.cs b/src/Squidex.Infrastructure/Log/JsonLogWriter.cs new file mode 100644 index 000000000..8e61631cb --- /dev/null +++ b/src/Squidex.Infrastructure/Log/JsonLogWriter.cs @@ -0,0 +1,184 @@ +// ========================================================================== +// JsonLogWriter.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Globalization; +using System.IO; +using Newtonsoft.Json; + +namespace Squidex.Infrastructure.Log +{ + public sealed class JsonLogWriter : IObjectWriter, IArrayWriter + { + private readonly bool extraLine; + private readonly StringWriter textWriter = new StringWriter(); + private readonly JsonWriter jsonWriter; + + public JsonLogWriter(Formatting formatting = Formatting.None, bool extraLine = false) + { + this.extraLine = extraLine; + + jsonWriter = new JsonTextWriter(textWriter) { Formatting = formatting }; + jsonWriter.WriteStartObject(); + } + + IArrayWriter IArrayWriter.WriteValue(string value) + { + jsonWriter.WriteValue(value); + + return this; + } + + IArrayWriter IArrayWriter.WriteValue(double value) + { + jsonWriter.WriteValue(value); + + return this; + } + + IArrayWriter IArrayWriter.WriteValue(long value) + { + jsonWriter.WriteValue(value); + + return this; + } + + IArrayWriter IArrayWriter.WriteValue(bool value) + { + jsonWriter.WriteValue(value); + + return this; + } + + IArrayWriter IArrayWriter.WriteValue(DateTime value) + { + jsonWriter.WriteValue(value.ToString("o", CultureInfo.InvariantCulture)); + + return this; + } + + IArrayWriter IArrayWriter.WriteValue(DateTimeOffset value) + { + jsonWriter.WriteValue(value.ToString("o", CultureInfo.InvariantCulture)); + + return this; + } + + IArrayWriter IArrayWriter.WriteValue(TimeSpan value) + { + jsonWriter.WriteValue(value); + + return this; + } + + IObjectWriter IObjectWriter.WriteProperty(string property, string value) + { + jsonWriter.WritePropertyName(property); + jsonWriter.WriteValue(value); + + return this; + } + + IObjectWriter IObjectWriter.WriteProperty(string property, double value) + { + jsonWriter.WritePropertyName(property); + jsonWriter.WriteValue(value); + + return this; + } + + IObjectWriter IObjectWriter.WriteProperty(string property, long value) + { + jsonWriter.WritePropertyName(property); + jsonWriter.WriteValue(value); + + return this; + } + + IObjectWriter IObjectWriter.WriteProperty(string property, bool value) + { + jsonWriter.WritePropertyName(property); + jsonWriter.WriteValue(value); + + return this; + } + + IObjectWriter IObjectWriter.WriteProperty(string property, DateTime value) + { + jsonWriter.WritePropertyName(property); + jsonWriter.WriteValue(value.ToString("o", CultureInfo.InvariantCulture)); + + return this; + } + + IObjectWriter IObjectWriter.WriteProperty(string property, DateTimeOffset value) + { + jsonWriter.WritePropertyName(property); + jsonWriter.WriteValue(value.ToString("o", CultureInfo.InvariantCulture)); + + return this; + } + + IObjectWriter IObjectWriter.WriteProperty(string property, TimeSpan value) + { + jsonWriter.WritePropertyName(property); + jsonWriter.WriteValue(value); + + return this; + } + + IObjectWriter IObjectWriter.WriteObject(string property, Action objectWriter) + { + jsonWriter.WritePropertyName(property); + jsonWriter.WriteStartObject(); + + objectWriter?.Invoke(this); + + jsonWriter.WriteEndObject(); + + return this; + } + + IObjectWriter IObjectWriter.WriteArray(string property, Action arrayWriter) + { + jsonWriter.WritePropertyName(property); + jsonWriter.WriteStartArray(); + + arrayWriter?.Invoke(this); + + jsonWriter.WriteEndArray(); + + return this; + } + + IArrayWriter IArrayWriter.WriteObject(Action objectWriter) + { + jsonWriter.WriteStartObject(); + + objectWriter?.Invoke(this); + + jsonWriter.WriteEndObject(); + + return this; + } + + public override string ToString() + { + jsonWriter.WriteEndObject(); + + var result = textWriter.ToString(); + + if (extraLine) + { + result += Environment.NewLine; + } + + return result; + } + } +} diff --git a/src/Squidex.Infrastructure/Log/SemanticLog.cs b/src/Squidex.Infrastructure/Log/SemanticLog.cs new file mode 100644 index 000000000..f335d3edd --- /dev/null +++ b/src/Squidex.Infrastructure/Log/SemanticLog.cs @@ -0,0 +1,86 @@ +// ========================================================================== +// SemanticLog.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Squidex.Infrastructure.Log +{ + public sealed class SemanticLog : ISemanticLog + { + private readonly IEnumerable channels; + private readonly IEnumerable appenders; + private readonly Func writerFactory; + + public SemanticLog( + IEnumerable channels, + IEnumerable appenders, + Func writerFactory) + { + Guard.NotNull(channels, nameof(channels)); + Guard.NotNull(appenders, nameof(appenders)); + + this.channels = channels; + this.appenders = appenders; + this.writerFactory = writerFactory; + } + + public void Log(SemanticLogLevel logLevel, Action action) + { + Guard.NotNull(action, nameof(action)); + + var formattedText = FormatText(logLevel, action); + + List exceptions = null; + + foreach (var channel in channels) + { + try + { + channel.Log(logLevel, formattedText); + } + catch (Exception ex) + { + if (exceptions == null) + { + exceptions = new List(); + } + + exceptions.Add(ex); + } + } + + if (exceptions != null && exceptions.Count > 0) + { + throw new AggregateException("An error occurred while writing to logger(s).", exceptions); + } + } + + private string FormatText(SemanticLogLevel logLevel, Action objectWriter) + { + var writer = writerFactory(); + + writer.WriteProperty(nameof(logLevel), logLevel.ToString()); + + objectWriter(writer); + + foreach (var appender in appenders) + { + appender.Append(writer); + } + + return writer.ToString(); + } + + public ISemanticLog CreateScope(Action objectWriter) + { + return new SemanticLog(channels, appenders.Union(new ILogAppender[] { new ConstantsLogWriter(objectWriter) }).ToArray(), writerFactory); + } + } +} diff --git a/src/Squidex.Infrastructure/Log/SemanticLogExtensions.cs b/src/Squidex.Infrastructure/Log/SemanticLogExtensions.cs new file mode 100644 index 000000000..51ffdf6e6 --- /dev/null +++ b/src/Squidex.Infrastructure/Log/SemanticLogExtensions.cs @@ -0,0 +1,125 @@ +// ========================================================================== +// SemanticLogExtensions.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Diagnostics; + +// ReSharper disable InvertIf + +namespace Squidex.Infrastructure.Log +{ + public static class SemanticLogExtensions + { + public static void LogTrace(this ISemanticLog log, Action objectWriter) + { + log.Log(SemanticLogLevel.Trace, objectWriter); + } + + public static IDisposable MeasureTrace(this ISemanticLog log, Action objectWriter) + { + return new TimeMeasurer(log, SemanticLogLevel.Trace, objectWriter); + } + + public static void LogDebug(this ISemanticLog log, Action objectWriter) + { + log.Log(SemanticLogLevel.Debug, objectWriter); + } + + public static IDisposable MeasureDebug(this ISemanticLog log, Action objectWriter) + { + return new TimeMeasurer(log, SemanticLogLevel.Debug, objectWriter); + } + + public static void LogInformation(this ISemanticLog log, Action objectWriter) + { + log.Log(SemanticLogLevel.Information, objectWriter); + } + + public static IDisposable MeasureInformation(this ISemanticLog log, Action objectWriter) + { + return new TimeMeasurer(log, SemanticLogLevel.Information, objectWriter); + } + + public static void LogWarning(this ISemanticLog log, Action objectWriter) + { + log.Log(SemanticLogLevel.Warning, objectWriter); + } + + public static void LogWarning(this ISemanticLog log, Exception exception, Action objectWriter = null) + { + log.Log(SemanticLogLevel.Warning, writer => writer.WriteException(exception, objectWriter)); + } + + public static void LogError(this ISemanticLog log, Action objectWriter) + { + log.Log(SemanticLogLevel.Error, objectWriter); + } + + public static void LogError(this ISemanticLog log, Exception exception, Action objectWriter = null) + { + log.Log(SemanticLogLevel.Error, writer => writer.WriteException(exception, objectWriter)); + } + + public static void LogFatal(this ISemanticLog log, Action objectWriter) + { + log.Log(SemanticLogLevel.Fatal, objectWriter); + } + + public static void LogFatal(this ISemanticLog log, Exception exception, Action objectWriter = null) + { + log.Log(SemanticLogLevel.Fatal, writer => writer.WriteException(exception, objectWriter)); + } + + private static void WriteException(this IObjectWriter writer, Exception exception, Action objectWriter) + { + objectWriter?.Invoke(writer); + + if (exception != null) + { + writer.WriteException(exception); + } + } + + public static IObjectWriter WriteException(this IObjectWriter writer, Exception exception) + { + return writer.WriteObject(nameof(exception), inner => + { + inner.WriteProperty("message", exception.Message); + inner.WriteProperty("stackTrace", exception.StackTrace); + }); + } + + private sealed class TimeMeasurer : IDisposable + { + private readonly Stopwatch watch = Stopwatch.StartNew(); + private readonly SemanticLogLevel logLevel; + private readonly Action objectWriter; + private readonly ISemanticLog log; + + public TimeMeasurer(ISemanticLog log, SemanticLogLevel logLevel, Action objectWriter) + { + this.logLevel = logLevel; + this.log = log; + + this.objectWriter = objectWriter; + } + + public void Dispose() + { + watch.Stop(); + + log.Log(logLevel, writer => + { + objectWriter?.Invoke(writer); + + writer.WriteProperty("elapsedMs", watch.ElapsedMilliseconds); + }); + } + } + } +} diff --git a/src/Squidex.Infrastructure/Log/SemanticLogLevel.cs b/src/Squidex.Infrastructure/Log/SemanticLogLevel.cs new file mode 100644 index 000000000..aa724f5db --- /dev/null +++ b/src/Squidex.Infrastructure/Log/SemanticLogLevel.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// SemanticLogLevel.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== +namespace Squidex.Infrastructure.Log +{ + public enum SemanticLogLevel + { + Trace, + Debug, + Information, + Warning, + Error, + Fatal + } +} diff --git a/src/Squidex.Infrastructure/Log/TimestampLogAppender.cs b/src/Squidex.Infrastructure/Log/TimestampLogAppender.cs new file mode 100644 index 000000000..cb399e591 --- /dev/null +++ b/src/Squidex.Infrastructure/Log/TimestampLogAppender.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// TimestampLogAppender.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; + +namespace Squidex.Infrastructure.Log +{ + public sealed class TimestampLogAppender : ILogAppender + { + private readonly Func timestamp; + + public TimestampLogAppender() + : this(() => DateTime.UtcNow) + { + } + + public TimestampLogAppender(Func timestamp) + { + Guard.NotNull(timestamp, nameof(timestamp)); + + this.timestamp = timestamp; + } + + public void Append(IObjectWriter writer) + { + writer.WriteProperty("timestamp", timestamp()); + } + } +} diff --git a/src/Squidex.Infrastructure/Singletons.cs b/src/Squidex.Infrastructure/Singletons.cs new file mode 100644 index 000000000..b9b6fe9f0 --- /dev/null +++ b/src/Squidex.Infrastructure/Singletons.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// Singletons.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Concurrent; + +namespace Squidex.Infrastructure +{ + public static class Singletons + { + private static readonly ConcurrentDictionary instances = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + public static T GetOrAdd(string key, Func factory) + { + return instances.GetOrAdd(key, factory); + } + } +} diff --git a/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj b/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj index c845e08be..ebc95d857 100644 --- a/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj +++ b/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj @@ -1,6 +1,7 @@  netstandard1.6 + $(NoWarn);IDE0017 full @@ -10,8 +11,8 @@ - - + + diff --git a/src/Squidex.Read.MongoDb/Apps/MongoAppRepository_EventHandling.cs b/src/Squidex.Read.MongoDb/Apps/MongoAppRepository_EventHandling.cs index ff92bae3b..96b6a6c41 100644 --- a/src/Squidex.Read.MongoDb/Apps/MongoAppRepository_EventHandling.cs +++ b/src/Squidex.Read.MongoDb/Apps/MongoAppRepository_EventHandling.cs @@ -23,6 +23,11 @@ namespace Squidex.Read.MongoDb.Apps get { return GetType().Name; } } + public string EventsFilter + { + get { return "^app-"; } + } + public Task On(Envelope @event) { return this.DispatchActionAsync(@event.Payload, @event.Headers); diff --git a/src/Squidex.Read.MongoDb/Contents/MongoContentRepository_EventHandling.cs b/src/Squidex.Read.MongoDb/Contents/MongoContentRepository_EventHandling.cs index 4d13b2e00..784887ce7 100644 --- a/src/Squidex.Read.MongoDb/Contents/MongoContentRepository_EventHandling.cs +++ b/src/Squidex.Read.MongoDb/Contents/MongoContentRepository_EventHandling.cs @@ -37,6 +37,11 @@ namespace Squidex.Read.MongoDb.Contents get { return GetType().Name; } } + public string EventsFilter + { + get { return "^content-"; } + } + public async Task ClearAsync() { using (var collections = await database.ListCollectionsAsync()) diff --git a/src/Squidex.Read.MongoDb/Contents/Visitors/EdmModelExtensions.cs b/src/Squidex.Read.MongoDb/Contents/Visitors/EdmModelExtensions.cs index d3b992635..eab1e174c 100644 --- a/src/Squidex.Read.MongoDb/Contents/Visitors/EdmModelExtensions.cs +++ b/src/Squidex.Read.MongoDb/Contents/Visitors/EdmModelExtensions.cs @@ -19,6 +19,11 @@ namespace Squidex.Read.MongoDb.Contents.Visitors { var path = model.EntityContainer.EntitySets().First().Path.Path.Last().Split('.').Last(); + if (query.StartsWith("?")) + { + query = query.Substring(1); + } + var parser = new ODataUriParser(model, new Uri($"{path}?{query}", UriKind.Relative)); return parser; diff --git a/src/Squidex.Read.MongoDb/Contents/Visitors/FindExtensions.cs b/src/Squidex.Read.MongoDb/Contents/Visitors/FindExtensions.cs index da90bb509..3368d5570 100644 --- a/src/Squidex.Read.MongoDb/Contents/Visitors/FindExtensions.cs +++ b/src/Squidex.Read.MongoDb/Contents/Visitors/FindExtensions.cs @@ -51,6 +51,10 @@ namespace Squidex.Read.MongoDb.Contents.Visitors { cursor = cursor.Skip((int)skip.Value); } + else + { + cursor = cursor.Skip(null); + } return cursor; } diff --git a/src/Squidex.Read.MongoDb/History/MongoHistoryEventRepository.cs b/src/Squidex.Read.MongoDb/History/MongoHistoryEventRepository.cs index da4376ba3..e05463748 100644 --- a/src/Squidex.Read.MongoDb/History/MongoHistoryEventRepository.cs +++ b/src/Squidex.Read.MongoDb/History/MongoHistoryEventRepository.cs @@ -27,6 +27,16 @@ namespace Squidex.Read.MongoDb.History private readonly Dictionary texts = new Dictionary(); private int sessionEventCount; + public string Name + { + get { return GetType().Name; } + } + + public string EventsFilter + { + get { return "*"; } + } + public MongoHistoryEventRepository(IMongoDatabase database, IEnumerable creators) : base(database) { @@ -67,11 +77,6 @@ namespace Squidex.Read.MongoDb.History return entities.Select(x => (IHistoryEventEntity)new ParsedHistoryEvent(x, texts)).ToList(); } - public string Name - { - get { return GetType().Name; } - } - public async Task On(Envelope @event) { foreach (var creator in creators) diff --git a/src/Squidex.Read.MongoDb/Schemas/MongoSchemaRepository_EventHandling.cs b/src/Squidex.Read.MongoDb/Schemas/MongoSchemaRepository_EventHandling.cs index 93e2229b4..c6652b884 100644 --- a/src/Squidex.Read.MongoDb/Schemas/MongoSchemaRepository_EventHandling.cs +++ b/src/Squidex.Read.MongoDb/Schemas/MongoSchemaRepository_EventHandling.cs @@ -26,6 +26,11 @@ namespace Squidex.Read.MongoDb.Schemas get { return GetType().Name; } } + public string EventsFilter + { + get { return "^schema-"; } + } + public Task On(Envelope @event) { return this.DispatchActionAsync(@event.Payload, @event.Headers); diff --git a/src/Squidex.Read.MongoDb/Squidex.Read.MongoDb.csproj b/src/Squidex.Read.MongoDb/Squidex.Read.MongoDb.csproj index b6da4c68e..6248495aa 100644 --- a/src/Squidex.Read.MongoDb/Squidex.Read.MongoDb.csproj +++ b/src/Squidex.Read.MongoDb/Squidex.Read.MongoDb.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/Squidex.Read/Apps/Services/Implementations/CachingAppProvider.cs b/src/Squidex.Read/Apps/Services/Implementations/CachingAppProvider.cs index 18223174e..b983bd71a 100644 --- a/src/Squidex.Read/Apps/Services/Implementations/CachingAppProvider.cs +++ b/src/Squidex.Read/Apps/Services/Implementations/CachingAppProvider.cs @@ -32,6 +32,11 @@ namespace Squidex.Read.Apps.Services.Implementations get { return GetType().Name; } } + public string EventsFilter + { + get { return "*"; } + } + public CachingAppProvider(IMemoryCache cache, IAppRepository repository) : base(cache) { diff --git a/src/Squidex.Read/Schemas/Services/Implementations/CachingSchemaProvider.cs b/src/Squidex.Read/Schemas/Services/Implementations/CachingSchemaProvider.cs index 2b9c1e268..baba6aff1 100644 --- a/src/Squidex.Read/Schemas/Services/Implementations/CachingSchemaProvider.cs +++ b/src/Squidex.Read/Schemas/Services/Implementations/CachingSchemaProvider.cs @@ -32,6 +32,11 @@ namespace Squidex.Read.Schemas.Services.Implementations get { return GetType().Name; } } + public string EventsFilter + { + get { return "*"; } + } + public CachingSchemaProvider(IMemoryCache cache, ISchemaRepository repository) : base(cache) { @@ -108,6 +113,10 @@ namespace Squidex.Read.Schemas.Services.Implementations { Remove(fieldEvent.AppId, fieldEvent.SchemaId); } + else if (@event.Payload is SchemaCreated schemaCreatedEvent) + { + Remove(schemaCreatedEvent.AppId, schemaCreatedEvent.SchemaId); + } else if (@event.Payload is SchemaDeleted schemaDeletedEvent) { Remove(schemaDeletedEvent.AppId, schemaDeletedEvent.SchemaId); diff --git a/src/Squidex.Read/Squidex.Read.csproj b/src/Squidex.Read/Squidex.Read.csproj index fca27e890..b8f63e9f0 100644 --- a/src/Squidex.Read/Squidex.Read.csproj +++ b/src/Squidex.Read/Squidex.Read.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Squidex.Write/Contents/ContentCommandHandler.cs b/src/Squidex.Write/Contents/ContentCommandHandler.cs index 8b6e74739..51d3a5337 100644 --- a/src/Squidex.Write/Contents/ContentCommandHandler.cs +++ b/src/Squidex.Write/Contents/ContentCommandHandler.cs @@ -92,7 +92,6 @@ namespace Squidex.Write.Contents Guard.Valid(command, nameof(command), message); var taskForApp = appProvider.FindAppByIdAsync(command.AppId.Id); - var taskForSchema = schemas.FindSchemaByIdAsync(command.SchemaId.Id); await Task.WhenAll(taskForApp, taskForSchema); diff --git a/src/Squidex.Write/Squidex.Write.csproj b/src/Squidex.Write/Squidex.Write.csproj index 719c4a55d..ffe61a370 100644 --- a/src/Squidex.Write/Squidex.Write.csproj +++ b/src/Squidex.Write/Squidex.Write.csproj @@ -14,6 +14,6 @@ - + diff --git a/src/Squidex/Config/Domain/ClusterModule.cs b/src/Squidex/Config/Domain/ClusterModule.cs deleted file mode 100644 index 1e220f1d5..000000000 --- a/src/Squidex/Config/Domain/ClusterModule.cs +++ /dev/null @@ -1,85 +0,0 @@ -// ========================================================================== -// ClusterModule.cs -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex Group -// All rights reserved. -// ========================================================================== - -using System; -using Autofac; -using Microsoft.Extensions.Configuration; -using Squidex.Infrastructure; -using Squidex.Infrastructure.CQRS.Events; -using Squidex.Infrastructure.Redis; -using StackExchange.Redis; - -namespace Squidex.Config.Domain -{ - public class ClusterModule : Module - { - private IConfiguration Configuration { get; } - - public ClusterModule(IConfiguration configuration) - { - Configuration = configuration; - } - - protected override void Load(ContainerBuilder builder) - { - var handleEvents = Configuration.GetValue("squidex:handleEvents"); - - if (handleEvents) - { - builder.RegisterType() - .AsSelf() - .InstancePerDependency(); - } - - var clustererType = Configuration.GetValue("squidex:clusterer:type"); - - if (string.IsNullOrWhiteSpace(clustererType)) - { - throw new ConfigurationException("You must specify the clusterer type in the 'squidex:clusterer:type' configuration section."); - } - - if (string.Equals(clustererType, "Redis", StringComparison.OrdinalIgnoreCase)) - { - var connectionString = Configuration.GetValue("squidex:clusterer:redis:connectionString"); - - if (string.IsNullOrWhiteSpace(connectionString)) - { - throw new ConfigurationException("You must specify the Redis connection string in the 'squidex:clusterer:redis:connectionString' configuration section."); - } - - try - { - var connectionMultiplexer = ConnectionMultiplexer.Connect(connectionString); - - builder.RegisterInstance(connectionMultiplexer) - .As() - .SingleInstance(); - } - catch (Exception ex) - { - throw new ConfigurationException($"Redis connection failed to connect to database {connectionString}", ex); - } - - builder.RegisterType() - .As() - .As() - .SingleInstance(); - } - else if (string.Equals(clustererType, "None", StringComparison.OrdinalIgnoreCase)) - { - builder.RegisterType() - .As() - .SingleInstance(); - } - else - { - throw new ConfigurationException($"Unsupported clusterer type '{clustererType}' for key 'squidex:clusterer:type', supported: Redis, None."); - } - } - } -} diff --git a/src/Squidex/Config/Domain/EventPublishersModule.cs b/src/Squidex/Config/Domain/EventPublishersModule.cs new file mode 100644 index 000000000..c30f762ca --- /dev/null +++ b/src/Squidex/Config/Domain/EventPublishersModule.cs @@ -0,0 +1,80 @@ +// ========================================================================== +// RabbitMqModule.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Autofac; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; +using Squidex.Infrastructure; +using Squidex.Infrastructure.CQRS.Events; +using Squidex.Infrastructure.RabbitMq; + +// ReSharper disable InvertIf + +namespace Squidex.Config.Domain +{ + public sealed class EventPublishersModule : Module + { + private IConfiguration Configuration { get; } + + public EventPublishersModule(IConfiguration configuration) + { + Configuration = configuration; + } + + protected override void Load(ContainerBuilder builder) + { + var eventPublishers = Configuration.GetSection("eventPublishers"); + + foreach (var child in eventPublishers.GetChildren()) + { + var eventPublisherType = child.GetValue("type"); + + if (string.IsNullOrWhiteSpace(eventPublisherType)) + { + throw new ConfigurationException($"Configure EventPublisher type with 'eventPublishers:{child.Key}:type'."); + } + + var eventsFilter = Configuration.GetValue("eventsFilter"); + + var enabled = child.GetValue("enabled"); + + if (string.Equals(eventPublisherType, "RabbitMq", StringComparison.OrdinalIgnoreCase)) + { + var configuration = child.GetValue("configuration"); + + if (string.IsNullOrWhiteSpace(configuration)) + { + throw new ConfigurationException($"Configure EventPublisher RabbitMq configuration with 'eventPublishers:{child.Key}:configuration'."); + } + + var exchange = child.GetValue("exchange"); + + if (string.IsNullOrWhiteSpace(exchange)) + { + throw new ConfigurationException($"Configure EventPublisher RabbitMq exchange with 'eventPublishers:{child.Key}:configuration'."); + } + + var name = $"EventPublishers_{child.Key}"; + + if (enabled) + { + builder.Register(c => new RabbitMqEventConsumer(c.Resolve(), name, configuration, exchange, eventsFilter)) + .As() + .As() + .SingleInstance(); + } + } + else + { + throw new ConfigurationException($"Unsupported value '{child.Key}' for 'eventPublishers:{child.Key}:type', supported: RabbitMq."); + } + } + } + } +} diff --git a/src/Squidex/Config/Domain/EventStoreModule.cs b/src/Squidex/Config/Domain/EventStoreModule.cs index ccc10e035..38f00ae1a 100644 --- a/src/Squidex/Config/Domain/EventStoreModule.cs +++ b/src/Squidex/Config/Domain/EventStoreModule.cs @@ -8,17 +8,20 @@ using System; using Autofac; +using Autofac.Core; using Microsoft.Extensions.Configuration; using MongoDB.Driver; -using NodaTime; using Squidex.Infrastructure; using Squidex.Infrastructure.CQRS.Events; using Squidex.Infrastructure.MongoDb.EventStore; namespace Squidex.Config.Domain { - public class EventStoreModule : Module + public sealed class EventStoreModule : Module { + private const string MongoClientRegistration = "EventStoreMongoClient"; + private const string MongoDatabaseRegistration = "EventStoreMongoDatabase"; + private IConfiguration Configuration { get; } public EventStoreModule(IConfiguration configuration) @@ -28,45 +31,55 @@ namespace Squidex.Config.Domain protected override void Load(ContainerBuilder builder) { - var storeType = Configuration.GetValue("squidex:eventStore:type"); + var consumeEvents = Configuration.GetValue("eventStore:consume"); - if (string.IsNullOrWhiteSpace(storeType)) + if (consumeEvents) { - throw new ConfigurationException("You must specify the store type in the 'squidex:eventStore:type' configuration section."); + builder.RegisterType() + .AsSelf() + .InstancePerDependency(); } - if (string.Equals(storeType, "MongoDb", StringComparison.OrdinalIgnoreCase)) + var eventStoreType = Configuration.GetValue("eventStore:type"); + + if (string.IsNullOrWhiteSpace(eventStoreType)) { - var databaseName = Configuration.GetValue("squidex:eventStore:mongoDb:databaseName"); + throw new ConfigurationException("Configure EventStore type with 'eventStore:type'."); + } - if (string.IsNullOrWhiteSpace(databaseName)) + if (string.Equals(eventStoreType, "MongoDb", StringComparison.OrdinalIgnoreCase)) + { + var configuration = Configuration.GetValue("eventStore:mongoDb:configuration"); + + if (string.IsNullOrWhiteSpace(configuration)) { - throw new ConfigurationException("You must specify the MongoDB database name in the 'squidex:eventStore:mongoDb:databaseName' configuration section."); + throw new ConfigurationException("Configure EventStore MongoDb configuration with 'eventStore:mongoDb:configuration'."); } - var connectionString = Configuration.GetValue("squidex:eventStore:mongoDb:connectionString"); + var database = Configuration.GetValue("eventStore:mongoDb:database"); - if (string.IsNullOrWhiteSpace(connectionString)) + if (string.IsNullOrWhiteSpace(database)) { - throw new ConfigurationException("You must specify the MongoDB connection string in the 'squidex:eventStore:mongoDb:connectionString' configuration section."); + throw new ConfigurationException("Configure EventStore MongoDb Database name with 'eventStore:mongoDb:database'."); } - builder.Register(c => - { - var mongoDbClient = new MongoClient(connectionString); - var mongoDatabase = mongoDbClient.GetDatabase(databaseName); + builder.Register(c => Singletons.GetOrAdd(configuration, s => new MongoClient(s))) + .Named(MongoClientRegistration) + .SingleInstance(); - var eventStore = new MongoEventStore(mongoDatabase, c.Resolve(), c.Resolve()); + builder.Register(c => c.ResolveNamed(MongoClientRegistration).GetDatabase(database)) + .Named(MongoDatabaseRegistration) + .SingleInstance(); - return eventStore; - }) + builder.RegisterType() + .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseRegistration)) .As() .As() .SingleInstance(); } else { - throw new ConfigurationException($"Unsupported store type '{storeType}' for key 'squidex:eventStore:type', supported: MongoDb."); + throw new ConfigurationException($"Unsupported value '{eventStoreType}' for 'eventStore:type', supported: MongoDb."); } } } diff --git a/src/Squidex/Config/Domain/InfrastructureModule.cs b/src/Squidex/Config/Domain/InfrastructureModule.cs index 68e3851af..0c024ae3f 100644 --- a/src/Squidex/Config/Domain/InfrastructureModule.cs +++ b/src/Squidex/Config/Domain/InfrastructureModule.cs @@ -6,12 +6,14 @@ // All rights reserved. // ========================================================================== +using System; using Autofac; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; +using Newtonsoft.Json; using NodaTime; using Squidex.Core.Schemas; using Squidex.Core.Schemas.Json; @@ -19,12 +21,14 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.CQRS.Commands; using Squidex.Infrastructure.CQRS.Events; +using Squidex.Infrastructure.Log; +using Squidex.Pipeline; // ReSharper disable UnusedAutoPropertyAccessor.Local namespace Squidex.Config.Domain { - public class InfrastructureModule : Module + public sealed class InfrastructureModule : Module { private IConfiguration Configuration { get; } @@ -35,6 +39,53 @@ namespace Squidex.Config.Domain protected override void Load(ContainerBuilder builder) { + if (Configuration.GetValue("logging:human")) + { + builder.Register(c => new Func(() => new JsonLogWriter(Formatting.Indented, true))) + .AsSelf() + .SingleInstance(); + } + else + { + builder.Register(c => new Func(() => new JsonLogWriter())) + .AsSelf() + .SingleInstance(); + } + + var loggingFile = Configuration.GetValue("logging:file"); + + if (!string.IsNullOrWhiteSpace(loggingFile)) + { + builder.RegisterInstance(new FileChannel(loggingFile)) + .As() + .As() + .SingleInstance(); + } + + builder.Register(c => new ApplicationInfoLogAppender(GetType(), Guid.NewGuid())) + .As() + .SingleInstance(); + + builder.RegisterType() + .As() + .SingleInstance(); + + builder.RegisterType() + .As() + .SingleInstance(); + + builder.RegisterType() + .As() + .SingleInstance(); + + builder.RegisterType() + .As() + .SingleInstance(); + + builder.RegisterType() + .As() + .SingleInstance(); + builder.Register(c => SystemClock.Instance) .As() .SingleInstance(); @@ -63,7 +114,7 @@ namespace Squidex.Config.Domain .As() .SingleInstance(); - builder.RegisterType() + builder.RegisterType() .As() .SingleInstance(); diff --git a/src/Squidex/Config/Domain/PubSubModule.cs b/src/Squidex/Config/Domain/PubSubModule.cs new file mode 100644 index 000000000..7c406b05a --- /dev/null +++ b/src/Squidex/Config/Domain/PubSubModule.cs @@ -0,0 +1,70 @@ +// ========================================================================== +// ClusterModule.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Autofac; +using Autofac.Core; +using Microsoft.Extensions.Configuration; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Redis; +using StackExchange.Redis; + +namespace Squidex.Config.Domain +{ + public sealed class PubSubModule : Module + { + private const string RedisRegistration = "PubSubRedis"; + + private IConfiguration Configuration { get; } + + public PubSubModule(IConfiguration configuration) + { + Configuration = configuration; + } + + protected override void Load(ContainerBuilder builder) + { + var pubSubType = Configuration.GetValue("pubSub:type"); + + if (string.IsNullOrWhiteSpace(pubSubType)) + { + throw new ConfigurationException("Configure the PubSub type with 'pubSub:type'."); + } + + if (string.Equals(pubSubType, "Redis", StringComparison.OrdinalIgnoreCase)) + { + var configuration = Configuration.GetValue("pubsub:redis:configuration"); + + if (string.IsNullOrWhiteSpace(configuration)) + { + throw new ConfigurationException("Configure PubSub Redis configuration with pubSub:redis:configuration'."); + } + + builder.Register(c => Singletons.GetOrAdd(configuration, s => ConnectionMultiplexer.Connect(s))) + .Named(RedisRegistration) + .SingleInstance(); + + builder.RegisterType() + .WithParameter(ResolvedParameter.ForNamed(RedisRegistration)) + .As() + .As() + .SingleInstance(); + } + else if (string.Equals(pubSubType, "InMemory", StringComparison.OrdinalIgnoreCase)) + { + builder.RegisterType() + .As() + .SingleInstance(); + } + else + { + throw new ConfigurationException($"Unsupported value '{pubSubType}' for 'pubSub:type', supported: Redis, InMemory."); + } + } + } +} diff --git a/src/Squidex/Config/Domain/StoreModule.cs b/src/Squidex/Config/Domain/StoreModule.cs index 194aff43a..85a17d57d 100644 --- a/src/Squidex/Config/Domain/StoreModule.cs +++ b/src/Squidex/Config/Domain/StoreModule.cs @@ -24,11 +24,11 @@ namespace Squidex.Config.Domain protected override void Load(ContainerBuilder builder) { - var storeType = Configuration.GetValue("squidex:stores:type"); + var storeType = Configuration.GetValue("store:type"); if (string.IsNullOrWhiteSpace(storeType)) { - throw new ConfigurationException("You must specify the store type in the 'squidex:stores:type' configuration section."); + throw new ConfigurationException("Configure the Store type with 'store:type'."); } if (string.Equals(storeType, "MongoDB", StringComparison.OrdinalIgnoreCase)) @@ -37,7 +37,7 @@ namespace Squidex.Config.Domain } else { - throw new ConfigurationException($"Unsupported store type '{storeType}' for key 'squidex:stores:type', supported: MongoDb."); + throw new ConfigurationException($"Unsupported value '{storeType}' for 'stores:type', supported: MongoDb."); } } } diff --git a/src/Squidex/Config/Domain/StoreMongoDbModule.cs b/src/Squidex/Config/Domain/StoreMongoDbModule.cs index 151ac5d9e..ed62559b1 100644 --- a/src/Squidex/Config/Domain/StoreMongoDbModule.cs +++ b/src/Squidex/Config/Domain/StoreMongoDbModule.cs @@ -34,8 +34,9 @@ namespace Squidex.Config.Domain { public class StoreMongoDbModule : Module { - private const string MongoDatabaseName = "MongoDatabaseName"; - private const string MongoDatabaseNameContent = "MongoDatabaseNameContent"; + private const string MongoClientRegistration = "StoreMongoClient"; + private const string MongoDatabaseRegistration = "StoreMongoDatabaseName"; + private const string MongoContentDatabaseRegistration = "StoreMongoDatabaseNameContent"; private IConfiguration Configuration { get; } @@ -46,42 +47,42 @@ namespace Squidex.Config.Domain protected override void Load(ContainerBuilder builder) { - var databaseName = Configuration.GetValue("squidex:stores:mongoDb:databaseName"); + var configuration = Configuration.GetValue("store:mongoDb:configuration"); - if (string.IsNullOrWhiteSpace(databaseName)) + if (string.IsNullOrWhiteSpace(configuration)) { - throw new ConfigurationException("You must specify the MongoDB database name in the 'squidex:stores:mongoDb:databaseName' configuration section."); + throw new ConfigurationException("Configure the Store MongoDb configuration with 'store:mongoDb:configuration'."); } - var connectionString = Configuration.GetValue("squidex:stores:mongoDb:connectionString"); + var database = Configuration.GetValue("store:mongoDb:database"); - if (string.IsNullOrWhiteSpace(connectionString)) + if (string.IsNullOrWhiteSpace(database)) { - throw new ConfigurationException("You must specify the MongoDB connection string in the 'squidex:stores:mongoDb:connectionString' configuration section."); + throw new ConfigurationException("Configure the Store MongoDb database with 'store:mongoDb:database'."); } - var databaseNameContent = Configuration.GetValue("squidex:stores:mongoDb:databaseNameContent"); + var contentDatabase = Configuration.GetValue("store:mongoDb:databaseNameContent"); - if (string.IsNullOrWhiteSpace(databaseNameContent)) + if (string.IsNullOrWhiteSpace(contentDatabase)) { - databaseNameContent = databaseName; + contentDatabase = database; } - builder.Register(c => new MongoClient(connectionString)) - .As() + builder.Register(c => Singletons.GetOrAdd(configuration, s => new MongoClient(s))) + .Named(MongoClientRegistration) .SingleInstance(); - builder.Register(c => c.Resolve().GetDatabase(databaseName)) - .Named(MongoDatabaseName) + builder.Register(c => c.ResolveNamed(MongoClientRegistration).GetDatabase(database)) + .Named(MongoDatabaseRegistration) .SingleInstance(); - builder.Register(c => c.Resolve().GetDatabase(databaseNameContent)) - .Named(MongoDatabaseNameContent) + builder.Register(c => c.ResolveNamed(MongoClientRegistration).GetDatabase(contentDatabase)) + .Named(MongoContentDatabaseRegistration) .SingleInstance(); builder.Register>(c => { - var usersCollection = c.ResolveNamed(MongoDatabaseName).GetCollection("Identity_Users"); + var usersCollection = c.ResolveNamed(MongoDatabaseRegistration).GetCollection("Identity_Users"); IndexChecks.EnsureUniqueIndexOnNormalizedEmail(usersCollection); IndexChecks.EnsureUniqueIndexOnNormalizedUserName(usersCollection); @@ -92,7 +93,7 @@ namespace Squidex.Config.Domain builder.Register>(c => { - var rolesCollection = c.ResolveNamed(MongoDatabaseName).GetCollection("Identity_Roles"); + var rolesCollection = c.ResolveNamed(MongoDatabaseRegistration).GetCollection("Identity_Roles"); IndexChecks.EnsureUniqueIndexOnNormalizedRoleName(rolesCollection); @@ -105,25 +106,25 @@ namespace Squidex.Config.Domain .InstancePerLifetimeScope(); builder.RegisterType() - .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseName)) + .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseRegistration)) .As() .SingleInstance(); builder.RegisterType() - .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseName)) + .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseRegistration)) .As() .AsSelf() .SingleInstance(); builder.RegisterType() - .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseNameContent)) + .WithParameter(ResolvedParameter.ForNamed(MongoContentDatabaseRegistration)) .As() .As() .AsSelf() .SingleInstance(); builder.RegisterType() - .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseName)) + .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseRegistration)) .As() .As() .As() @@ -131,14 +132,14 @@ namespace Squidex.Config.Domain .SingleInstance(); builder.RegisterType() - .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseName)) + .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseRegistration)) .As() .As() .AsSelf() .SingleInstance(); builder.RegisterType() - .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseName)) + .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseRegistration)) .As() .As() .As() diff --git a/src/Squidex/Config/Identity/IdentityServices.cs b/src/Squidex/Config/Identity/IdentityServices.cs index 1333075b4..6fca53cb3 100644 --- a/src/Squidex/Config/Identity/IdentityServices.cs +++ b/src/Squidex/Config/Identity/IdentityServices.cs @@ -1,4 +1,5 @@ -// ========================================================================== + +// ========================================================================== // IdentityServices.cs // Squidex Headless CMS // ========================================================================== @@ -30,29 +31,40 @@ namespace Squidex.Config.Identity { var dataProtection = services.AddDataProtection().SetApplicationName("Squidex"); - var clustererType = configuration.GetValue("squidex:clusterer:type"); + var keyStoreType = configuration.GetValue("identity:keysStore:type"); + + if (string.IsNullOrWhiteSpace(keyStoreType)) + { + throw new ConfigurationException("Configure KeyStore type with 'identity:keysStore:type'."); + } - if (clustererType.Equals("redis", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(keyStoreType, "Redis", StringComparison.OrdinalIgnoreCase)) { - var connectionString = configuration.GetValue("squidex:clusterer:redis:connectionString"); + var redisConfiguration = configuration.GetValue("identity:keysStore:redis:configuration"); - if (string.IsNullOrWhiteSpace(connectionString)) + if (string.IsNullOrWhiteSpace(redisConfiguration)) { - throw new ConfigurationException("You must specify the Redis connection string in the 'squidex:clusterer:redis:connectionString' configuration section."); + throw new ConfigurationException("Configure KeyStore Redis configuration with 'identity:keysStore:redis:configuration'."); } - var connectionMultiplexer = ConnectionMultiplexer.Connect(connectionString); + var connectionMultiplexer = Singletons.GetOrAdd(redisConfiguration, s => ConnectionMultiplexer.Connect(s)); dataProtection.PersistKeysToRedis(connectionMultiplexer); } - else + else if (string.Equals(keyStoreType, "Folder", StringComparison.OrdinalIgnoreCase)) { - var keysFolder = configuration.GetValue("squidex:identity:keysFolder"); + var folderPath = configuration.GetValue("identity:keysStore:folder:path"); - if (!string.IsNullOrWhiteSpace(keysFolder)) + if (string.IsNullOrWhiteSpace(folderPath)) { - dataProtection.PersistKeysToFileSystem(new DirectoryInfo(keysFolder)); + throw new ConfigurationException("Configure KeyStore Folder path with 'identity:keysStore:folder:path'."); } + + dataProtection.PersistKeysToFileSystem(new DirectoryInfo(folderPath)); + } + else if (!string.Equals(keyStoreType, "InMemory", StringComparison.OrdinalIgnoreCase)) + { + throw new ConfigurationException($"Unsupported value '{keyStoreType}' for 'identity:keysStore:type', supported: Redis, Folder, InMemory."); } return services; diff --git a/src/Squidex/Config/MyUrlsOptions.cs b/src/Squidex/Config/MyUrlsOptions.cs index c2add641d..19b462f3c 100644 --- a/src/Squidex/Config/MyUrlsOptions.cs +++ b/src/Squidex/Config/MyUrlsOptions.cs @@ -7,6 +7,7 @@ // ========================================================================== using System; +using Squidex.Infrastructure; namespace Squidex.Config { @@ -18,6 +19,11 @@ namespace Squidex.Config public string BuildUrl(string path, bool trailingSlash = true) { + if (string.IsNullOrWhiteSpace(BaseUrl)) + { + throw new ConfigurationException("Configure BaseUrl with 'urls:baseUrl'."); + } + var url = $"{BaseUrl.TrimEnd('/')}/{path.Trim('/')}"; if (trailingSlash && diff --git a/src/Squidex/Config/Web/WebDependencies.cs b/src/Squidex/Config/Web/WebDependencies.cs index 019def3c5..ac0a90cf2 100644 --- a/src/Squidex/Config/Web/WebDependencies.cs +++ b/src/Squidex/Config/Web/WebDependencies.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using Squidex.Config.Domain; +using Squidex.Pipeline; namespace Squidex.Config.Web { @@ -15,7 +16,10 @@ namespace Squidex.Config.Web { public static void AddMyMvc(this IServiceCollection services) { - services.AddMvc().AddMySerializers(); + services.AddMvc(options => + { + options.Filters.Add(typeof(LogPerformanceAttribute)); + }).AddMySerializers(); } } } diff --git a/src/Squidex/Config/Web/WebUsages.cs b/src/Squidex/Config/Web/WebUsages.cs index d978865c2..b6874df63 100644 --- a/src/Squidex/Config/Web/WebUsages.cs +++ b/src/Squidex/Config/Web/WebUsages.cs @@ -21,7 +21,12 @@ namespace Squidex.Config.Web { public static void UseMyForwardingRules(this IApplicationBuilder app) { - app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedProto, RequireHeaderSymmetry = false }); + app.UseForwardedHeaders(new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedProto, + ForwardLimit = null, + RequireHeaderSymmetry = false + }); app.UseMiddleware(); } diff --git a/src/Squidex/Controllers/UI/Account/AccountController.cs b/src/Squidex/Controllers/UI/Account/AccountController.cs index b701907d1..d53a4eca2 100644 --- a/src/Squidex/Controllers/UI/Account/AccountController.cs +++ b/src/Squidex/Controllers/UI/Account/AccountController.cs @@ -16,12 +16,12 @@ using IdentityServer4.Services; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.MongoDB; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; using NSwag.Annotations; using Microsoft.Extensions.Options; using Squidex.Config; using Squidex.Config.Identity; using Squidex.Core.Identity; +using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Tasks; // ReSharper disable InvertIf @@ -33,12 +33,11 @@ namespace Squidex.Controllers.UI.Account [SwaggerIgnore] public sealed class AccountController : Controller { - private static readonly EventId IdentityEventId = new EventId(8000, "IdentityEventId"); private readonly SignInManager signInManager; private readonly UserManager userManager; private readonly IOptions identityOptions; private readonly IOptions urlOptions; - private readonly ILogger logger; + private readonly ISemanticLog log; private readonly IIdentityServerInteractionService interactions; public AccountController( @@ -46,10 +45,10 @@ namespace Squidex.Controllers.UI.Account UserManager userManager, IOptions identityOptions, IOptions urlOptions, - ILogger logger, + ISemanticLog log, IIdentityServerInteractionService interactions) { - this.logger = logger; + this.log = log; this.urlOptions = urlOptions; this.userManager = userManager; this.interactions = interactions; @@ -264,14 +263,19 @@ namespace Squidex.Controllers.UI.Account errorMessageBuilder.AppendLine(error.Description); } - logger.LogError(IdentityEventId, "Operation '{0}' failed with errors: {1}", operationName, errorMessageBuilder.ToString()); + log.LogError(w => w + .WriteProperty("action", operationName) + .WriteProperty("status", "Failed") + .WriteProperty("message", errorMessageBuilder.ToString())); } return result.Succeeded; } - catch (Exception e) + catch (Exception ex) { - logger.LogError(IdentityEventId, e, "Operation '{0}' failed with exception", operationName); + log.LogError(ex, w => w + .WriteProperty("action", operationName) + .WriteProperty("status", "Failed")); return false; } diff --git a/src/Squidex/Pipeline/ActionContextLogAppender.cs b/src/Squidex/Pipeline/ActionContextLogAppender.cs new file mode 100644 index 000000000..41f5ebf02 --- /dev/null +++ b/src/Squidex/Pipeline/ActionContextLogAppender.cs @@ -0,0 +1,59 @@ +// ========================================================================== +// HttpLogAppender.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Squidex.Infrastructure.Log; + +namespace Squidex.Pipeline +{ + public class ActionContextLogAppender : ILogAppender + { + private readonly IActionContextAccessor actionContextAccessor; + + public ActionContextLogAppender(IActionContextAccessor actionContextAccessor) + { + this.actionContextAccessor = actionContextAccessor; + } + + public void Append(IObjectWriter writer) + { + var actionContext = actionContextAccessor.ActionContext; + + if (actionContext == null) + { + return; + } + + var httpContext = actionContext.HttpContext; + + Guid requestId; + + if (httpContext.Items.TryGetValue(nameof(requestId), out var value) && value is Guid requestIdValue) + { + requestId = requestIdValue; + } + else + { + httpContext.Items[nameof(requestId)] = requestId = Guid.NewGuid(); + } + + writer.WriteObject("web", w => w + .WriteProperty("requestId", requestId.ToString()) + .WriteProperty("requestPath", httpContext.Request.Path) + .WriteProperty("requestMethod", httpContext.Request.Method) + .WriteObject("routeValues", r => + { + foreach (var kvp in actionContext.ActionDescriptor.RouteValues) + { + r.WriteProperty(kvp.Key, kvp.Value); + } + })); + } + } +} diff --git a/src/Squidex/Pipeline/LogPerformanceAttribute.cs b/src/Squidex/Pipeline/LogPerformanceAttribute.cs new file mode 100644 index 000000000..ce7c919af --- /dev/null +++ b/src/Squidex/Pipeline/LogPerformanceAttribute.cs @@ -0,0 +1,38 @@ +// ========================================================================== +// LogPerformanceAttribute.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Diagnostics; +using Microsoft.AspNetCore.Mvc.Filters; +using Squidex.Infrastructure.Log; + +namespace Squidex.Pipeline +{ + public sealed class LogPerformanceAttribute : ActionFilterAttribute + { + private readonly ISemanticLog log; + + public LogPerformanceAttribute(ISemanticLog log) + { + this.log = log; + } + + public override void OnActionExecuting(ActionExecutingContext context) + { + context.HttpContext.Items["Watch"] = Stopwatch.StartNew(); + } + + public override void OnActionExecuted(ActionExecutedContext context) + { + var stopWatch = (Stopwatch)context.HttpContext.Items["Watch"]; + + stopWatch.Stop(); + + log.LogInformation(w => w.WriteProperty("elapsedRequestMs", stopWatch.ElapsedMilliseconds)); + } + } +} diff --git a/src/Squidex/Pipeline/WebpackMiddleware.cs b/src/Squidex/Pipeline/WebpackMiddleware.cs index a65f77cd9..043b1e466 100644 --- a/src/Squidex/Pipeline/WebpackMiddleware.cs +++ b/src/Squidex/Pipeline/WebpackMiddleware.cs @@ -10,7 +10,6 @@ using System; using System.IO; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; // ReSharper disable LoopCanBeConvertedToQuery @@ -23,12 +22,9 @@ namespace Squidex.Pipeline private static readonly string[] Scripts = { "shims.js", "app.js" }; private static readonly string[] Styles = new string[0]; private readonly RequestDelegate next; - private readonly ILogger logger; - public WebpackMiddleware(RequestDelegate next, ILoggerFactory loggerFactory) + public WebpackMiddleware(RequestDelegate next) { - logger = loggerFactory.CreateLogger(); - this.next = next; } @@ -76,15 +72,13 @@ namespace Squidex.Pipeline context.Response.Body = body; } - private string InjectStyles(string response) + private static string InjectStyles(string response) { if (!response.Contains("")) { return response; } - - logger.LogInformation("A full html page is returned so the necessary styles for webpack will be injected"); - + var stylesTag = string.Empty; foreach (var file in Styles) @@ -94,20 +88,16 @@ namespace Squidex.Pipeline response = response.Replace("", $"{stylesTag}"); - logger.LogInformation($"Inject style {stylesTag} as a last element in the head "); - return response; } - private string InjectScripts(string response) + private static string InjectScripts(string response) { if (!response.Contains("")) { return response; } - - logger.LogInformation("A full html page is returned so the necessary script for webpack will be injected"); - + var scriptsTag = string.Empty; foreach (var file in Scripts) @@ -117,8 +107,6 @@ namespace Squidex.Pipeline response = response.Replace("", $"{scriptsTag}"); - logger.LogInformation($"Inject script {scriptsTag} as a last element in the body "); - return response; } diff --git a/src/Squidex/Squidex.csproj b/src/Squidex/Squidex.csproj index 8f685d3ca..06046590b 100644 --- a/src/Squidex/Squidex.csproj +++ b/src/Squidex/Squidex.csproj @@ -15,7 +15,7 @@ - + PreserveNewest @@ -23,6 +23,7 @@ + @@ -34,9 +35,9 @@ - + - + @@ -56,9 +57,9 @@ - - - + + + diff --git a/src/Squidex/Startup.cs b/src/Squidex/Startup.cs index 2699676b9..61caf9db5 100644 --- a/src/Squidex/Startup.cs +++ b/src/Squidex/Startup.cs @@ -22,6 +22,8 @@ using Squidex.Config.Domain; using Squidex.Config.Identity; using Squidex.Config.Swagger; using Squidex.Config.Web; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Log.Adapter; // ReSharper disable ConvertClosureToMethodGroup // ReSharper disable AccessToModifiedClosure @@ -49,7 +51,7 @@ namespace Squidex .SetBasePath(env.ContentRootPath) .AddJsonFile("appsettings.json", true, true) .AddJsonFile($"appsettings.{env.EnvironmentName}.json", true) - .AddEnvironmentVariables(); + .AddEnvironmentVariables("SQUIDEX__"); Configuration = builder.Build(); } @@ -69,15 +71,16 @@ namespace Squidex services.AddRouting(); services.Configure( - Configuration.GetSection("squidex:urls")); + Configuration.GetSection("urls")); services.Configure( - Configuration.GetSection("squidex:identity")); + Configuration.GetSection("identity")); var builder = new ContainerBuilder(); builder.Populate(services); - builder.RegisterModule(new ClusterModule(Configuration)); + builder.RegisterModule(new EventPublishersModule(Configuration)); builder.RegisterModule(new EventStoreModule(Configuration)); builder.RegisterModule(new InfrastructureModule(Configuration)); + builder.RegisterModule(new PubSubModule(Configuration)); builder.RegisterModule(new ReadModule(Configuration)); builder.RegisterModule(new StoreModule(Configuration)); builder.RegisterModule(new WebModule(Configuration)); @@ -95,8 +98,7 @@ namespace Squidex public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory) { - loggerFactory.AddConsole(LogLevel.Debug); - loggerFactory.AddDebug(); + loggerFactory.AddSemanticLog(app.ApplicationServices.GetRequiredService()); app.TestExternalSystems(); @@ -158,7 +160,8 @@ namespace Squidex app.UseDeveloperExceptionPage(); app.UseWebpackProxy(); - app.Use((context, next) => { + app.Use((context, next) => + { if (!Path.HasExtension(context.Request.Path.Value)) { context.Request.Path = new PathString("/index.html"); @@ -168,7 +171,8 @@ namespace Squidex } else { - app.Use((context, next) => { + app.Use((context, next) => + { if (!Path.HasExtension(context.Request.Path.Value)) { context.Request.Path = new PathString("/build/index.html"); diff --git a/src/Squidex/app/app.module.ts b/src/Squidex/app/app.module.ts index c9952071c..8f00fce4f 100644 --- a/src/Squidex/app/app.module.ts +++ b/src/Squidex/app/app.module.ts @@ -5,8 +5,9 @@ * Copyright (c) Sebastian Stehle. All rights reserved */ -import { NgModule } from '@angular/core'; +import { ApplicationRef, NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { AppComponent } from './app.component'; @@ -47,6 +48,7 @@ export function configUserReport() { @NgModule({ imports: [ BrowserModule, + BrowserAnimationsModule, SqxFrameworkModule.forRoot(), SqxSharedModule.forRoot(), SqxShellModule, @@ -62,14 +64,14 @@ export function configUserReport() { { provide: TitlesConfig, useFactory: configTitles }, { provide: UserReportConfig, useFactory: configUserReport } ], - bootstrap: [AppComponent] + entryComponents: [AppComponent] }) export class AppModule { - /*public ngDoBootstrap(appRef: ApplicationRef) { + public ngDoBootstrap(appRef: ApplicationRef) { try { appRef.bootstrap(AppComponent); } catch (e) { console.log('Application element not found'); } - }*/ + } } \ No newline at end of file diff --git a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html index a5ddd01f7..f8b161d69 100644 --- a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html +++ b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html @@ -35,7 +35,7 @@ - + diff --git a/src/Squidex/app/features/administration/pages/users/users-page.component.html b/src/Squidex/app/features/administration/pages/users/users-page.component.html index 6f450329b..15d49ae4a 100644 --- a/src/Squidex/app/features/administration/pages/users/users-page.component.html +++ b/src/Squidex/app/features/administration/pages/users/users-page.component.html @@ -45,7 +45,7 @@ - + diff --git a/src/Squidex/app/features/content/pages/contents/contents-page.component.html b/src/Squidex/app/features/content/pages/contents/contents-page.component.html index e09438553..d41955281 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-page.component.html +++ b/src/Squidex/app/features/content/pages/contents/contents-page.component.html @@ -53,7 +53,7 @@ - + diff --git a/src/Squidex/app/features/settings/pages/clients/clients-page.component.ts b/src/Squidex/app/features/settings/pages/clients/clients-page.component.ts index c4aa99db6..48d0362f7 100644 --- a/src/Squidex/app/features/settings/pages/clients/clients-page.component.ts +++ b/src/Squidex/app/features/settings/pages/clients/clients-page.component.ts @@ -34,6 +34,7 @@ export class ClientsPageComponent extends AppComponentBase implements OnInit { public appClients: ImmutableArray; + public addClientFormSubmitted = false; public addClientForm: FormGroup = this.formBuilder.group({ name: ['', @@ -92,10 +93,12 @@ export class ClientsPageComponent extends AppComponentBase implements OnInit { } public resetClientForm() { + this.addClientFormSubmitted = false; this.addClientForm.reset(); } public attachClient() { + this.addClientFormSubmitted = true; this.addClientForm.markAsDirty(); if (this.addClientForm.valid) { @@ -104,6 +107,7 @@ export class ClientsPageComponent extends AppComponentBase implements OnInit { const requestDto = new CreateAppClientDto(this.addClientForm.get('name').value); const reset = () => { + this.addClientFormSubmitted = false; this.addClientForm.reset(); this.addClientForm.enable(); }; diff --git a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html index 08d8e6664..de510b727 100644 --- a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html +++ b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html @@ -40,7 +40,7 @@ - +