From 49b0cb7b465561447b8d4693ba988b13e0e1b57a Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 15 May 2021 16:01:21 +0800 Subject: feat: Timeline post change notification with signalr. --- BackEnd/Timeline.Tests/Helpers/TestApplication.cs | 7 ++ .../IntegratedTests/IntegratedTestBase.cs | 16 +++ .../IntegratedTests/TimelineHubTest.cs | 82 ++++++++++++ .../IntegratedTests/TimelinePostTest.cs | 2 +- BackEnd/Timeline.Tests/Timeline.Tests.csproj | 1 + BackEnd/Timeline.Tests/packages.lock.json | 83 +++++++++++- .../Timeline/Controllers/TimelinePostController.cs | 16 ++- BackEnd/Timeline/SignalRHub/ITimelineClient.cs | 9 ++ BackEnd/Timeline/SignalRHub/TimelineHub.cs | 58 +++++++++ BackEnd/Timeline/Startup.cs | 4 + BackEnd/Timeline/Timeline.csproj | 140 ++++++++++----------- 11 files changed, 344 insertions(+), 74 deletions(-) create mode 100644 BackEnd/Timeline.Tests/IntegratedTests/TimelineHubTest.cs create mode 100644 BackEnd/Timeline/SignalRHub/ITimelineClient.cs create mode 100644 BackEnd/Timeline/SignalRHub/TimelineHub.cs diff --git a/BackEnd/Timeline.Tests/Helpers/TestApplication.cs b/BackEnd/Timeline.Tests/Helpers/TestApplication.cs index e0db966c..723ef500 100644 --- a/BackEnd/Timeline.Tests/Helpers/TestApplication.cs +++ b/BackEnd/Timeline.Tests/Helpers/TestApplication.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using System.Collections.Generic; using System.IO; @@ -46,5 +48,10 @@ namespace Timeline.Tests.Helpers Directory.Delete(WorkDirectory, true); } + + public TestServer Server + { + get => (TestServer)Host.Services.GetRequiredService(); + } } } diff --git a/BackEnd/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs b/BackEnd/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs index 588f2f93..259ebfa1 100644 --- a/BackEnd/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs +++ b/BackEnd/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs @@ -105,6 +105,14 @@ namespace Timeline.Tests.IntegratedTests return Task.FromResult(client); } + public async Task CreateTokenWithCredentialAsync(string username, string password) + { + var client = await CreateDefaultClient(); + var res = await client.TestPostAsync("token/create", + new HttpCreateTokenRequest { Username = username, Password = password }); + return res.Token; + } + public async Task CreateClientWithCredential(string username, string password, bool setApiBase = true) { var client = await CreateDefaultClient(setApiBase); @@ -115,6 +123,14 @@ namespace Timeline.Tests.IntegratedTests return client; } + public Task CreateTokenAsync(int userNumber) + { + if (userNumber == 0) + return CreateTokenWithCredentialAsync("admin", "adminpw"); + else + return CreateTokenWithCredentialAsync($"user{userNumber}", $"user{userNumber}pw"); + } + public Task CreateClientAs(int userNumber, bool setApiBase = true) { if (userNumber < 0) diff --git a/BackEnd/Timeline.Tests/IntegratedTests/TimelineHubTest.cs b/BackEnd/Timeline.Tests/IntegratedTests/TimelineHubTest.cs new file mode 100644 index 00000000..66df74d7 --- /dev/null +++ b/BackEnd/Timeline.Tests/IntegratedTests/TimelineHubTest.cs @@ -0,0 +1,82 @@ +using FluentAssertions; +using Microsoft.AspNetCore.SignalR.Client; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Timeline.SignalRHub; +using Xunit; + +namespace Timeline.Tests.IntegratedTests +{ + public class TimelineHubTest : BaseTimelineTest + { + private HubConnection CreateConnection(string? token) + { + return new HubConnectionBuilder().WithUrl($"http://localhost/api/hub/timeline{(token is null ? "" : "?token=" + token)}", + options => options.HttpMessageHandlerFactory = _ => TestApp.Server.CreateHandler()).Build(); + } + + [Theory] + [MemberData(nameof(TimelineNameGeneratorTestData))] + public async Task TimelinePostUpdate_Should_Work(TimelineNameGenerator generator) + { + var token = await CreateTokenAsync(1); + + await using var connection = CreateConnection(token); + + var changed = false; + + connection.On(nameof(ITimelineClient.OnTimelinePostChanged), (timelineName) => + { + timelineName.Should().Be(generator(1)); + changed = true; + }); + + await connection.StartAsync(); + connection.State.Should().Be(HubConnectionState.Connected); + + using var client = await CreateClientAsUser(); + + await client.TestPostAsync($"timelines/{generator(1)}/posts", TimelinePostTest.CreateTextPostRequest("aaa")); + changed.Should().BeFalse(); + + await connection.InvokeAsync(nameof(TimelineHub.SubscribeTimelinePostChange), generator(1)); + + await client.TestPostAsync($"timelines/{generator(1)}/posts", TimelinePostTest.CreateTextPostRequest("bbb")); + changed.Should().BeTrue(); + + changed = false; + + await connection.InvokeAsync(nameof(TimelineHub.UnsubscribeTimelinePostChange), generator(1)); + + await client.TestPostAsync($"timelines/{generator(1)}/posts", TimelinePostTest.CreateTextPostRequest("ccc")); + changed.Should().BeFalse(); + } + + [Fact] + public async Task TimelinePostUpdate_InvalidName() + { + await using var connection = CreateConnection(null); + await connection.StartAsync(); + await connection.Awaiting(c => c.InvokeAsync(nameof(TimelineHub.SubscribeTimelinePostChange), "!!!")).Should().ThrowAsync(); + } + + [Fact] + public async Task TimelinePostUpdate_NotExist() + { + await using var connection = CreateConnection(null); + await connection.StartAsync(); + await connection.Awaiting(c => c.InvokeAsync(nameof(TimelineHub.SubscribeTimelinePostChange), "timelinenotexist")).Should().ThrowAsync(); + } + + [Fact] + public async Task TimelinePostUpdate_Forbid() + { + await using var connection = CreateConnection(null); + await connection.StartAsync(); + await connection.Awaiting(c => c.InvokeAsync(nameof(TimelineHub.SubscribeTimelinePostChange), "t1")).Should().ThrowAsync(); + } + } +} + diff --git a/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs b/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs index 097275b0..ab8f6f66 100644 --- a/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs +++ b/BackEnd/Timeline.Tests/IntegratedTests/TimelinePostTest.cs @@ -19,7 +19,7 @@ namespace Timeline.Tests.IntegratedTests { public class TimelinePostTest : BaseTimelineTest { - private static HttpTimelinePostCreateRequest CreateTextPostRequest(string text, DateTime? time = null, string? color = null) + public static HttpTimelinePostCreateRequest CreateTextPostRequest(string text, DateTime? time = null, string? color = null) { return new HttpTimelinePostCreateRequest() { diff --git a/BackEnd/Timeline.Tests/Timeline.Tests.csproj b/BackEnd/Timeline.Tests/Timeline.Tests.csproj index 4a07024c..127a6b9a 100644 --- a/BackEnd/Timeline.Tests/Timeline.Tests.csproj +++ b/BackEnd/Timeline.Tests/Timeline.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/BackEnd/Timeline.Tests/packages.lock.json b/BackEnd/Timeline.Tests/packages.lock.json index 6a445ddc..be424c80 100644 --- a/BackEnd/Timeline.Tests/packages.lock.json +++ b/BackEnd/Timeline.Tests/packages.lock.json @@ -27,6 +27,16 @@ "Newtonsoft.Json.Bson": "1.0.1" } }, + "Microsoft.AspNetCore.SignalR.Client": { + "type": "Direct", + "requested": "[5.0.6, )", + "resolved": "5.0.6", + "contentHash": "MgY6tg5iVaFwMRcwAQiUAp+BC+h1iGWb72SFBaIvryOc6tmCz/JrXzcS7P993W0mznhs+vHh+p2Nf+jo+dNLpw==", + "dependencies": { + "Microsoft.AspNetCore.Http.Connections.Client": "5.0.6", + "Microsoft.AspNetCore.SignalR.Client.Core": "5.0.6" + } + }, "Microsoft.AspNetCore.TestHost": { "type": "Direct", "requested": "[5.0.5, )", @@ -114,6 +124,41 @@ "resolved": "0.24.0", "contentHash": "xvoxO3Ck4mNflc+62d5LCI8xCVmj+SEdpGPptUZmPtbqK88iZkSxtprJWUadHNt8tXY6rFx8oHGpEw1Qcu3lAw==" }, + "Microsoft.AspNetCore.Connections.Abstractions": { + "type": "Transitive", + "resolved": "5.0.6", + "contentHash": "dqtwdQL7cUdWHiyCkVB/0LIpBtdhwnhh9AMCOf+0eBb3wCF5/YvKMU0oVtMJViy+PFSxwjM8RyaVyC9G0Gl4Rg==", + "dependencies": { + "Microsoft.AspNetCore.Http.Features": "5.0.6" + } + }, + "Microsoft.AspNetCore.Http.Connections.Client": { + "type": "Transitive", + "resolved": "5.0.6", + "contentHash": "PchO5EMTZmOd/z13NrQXDQjifCIj5+wewe+/UENRakVPIPl+oKcnyFF0xTeJRBWc8DbIm4OdAr/ET/fqpUgKOg==", + "dependencies": { + "Microsoft.AspNetCore.Http.Connections.Common": "5.0.6", + "Microsoft.Extensions.Logging.Abstractions": "5.0.0", + "Microsoft.Extensions.Options": "5.0.0" + } + }, + "Microsoft.AspNetCore.Http.Connections.Common": { + "type": "Transitive", + "resolved": "5.0.6", + "contentHash": "9V35MkPVkKhFfhtc/tEROmyQ0k94qjZz97sia0rRDy5zxZuu2OTzQst+9oW+iujoQotpWAjZD+gRu3S04uetjQ==", + "dependencies": { + "Microsoft.AspNetCore.Connections.Abstractions": "5.0.6" + } + }, + "Microsoft.AspNetCore.Http.Features": { + "type": "Transitive", + "resolved": "5.0.6", + "contentHash": "MfpLQRQK/iBZLiaRgPd4dNq4REVbycYH9apU0i18UWQsqZt2Oa0+gZRnz8j4eDCzzCwQSoVXtuCtuCdEnIk9vg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "5.0.1", + "System.IO.Pipelines": "5.0.1" + } + }, "Microsoft.AspNetCore.JsonPatch": { "type": "Transitive", "resolved": "5.0.0", @@ -133,6 +178,35 @@ "Newtonsoft.Json.Bson": "1.0.2" } }, + "Microsoft.AspNetCore.SignalR.Client.Core": { + "type": "Transitive", + "resolved": "5.0.6", + "contentHash": "J2kWDD1xnHDYMNLVxZ5MarqAg/lygYYKwYoqPobAWDlCF1wpXbmO/dAgby0NScTrvzSd6+NdyHp7dgxz/whLnw==", + "dependencies": { + "Microsoft.AspNetCore.SignalR.Common": "5.0.6", + "Microsoft.AspNetCore.SignalR.Protocols.Json": "5.0.6", + "Microsoft.Extensions.DependencyInjection": "5.0.1", + "Microsoft.Extensions.Logging": "5.0.0", + "System.Threading.Channels": "5.0.0" + } + }, + "Microsoft.AspNetCore.SignalR.Common": { + "type": "Transitive", + "resolved": "5.0.6", + "contentHash": "x10OXN4vP8dB7SaqEU+Te2tbKy5HAFMRfhbpHSijY16S3JDfp6HqzdWjAKjinmzEoawtCegedTJ4b3/eslQbOQ==", + "dependencies": { + "Microsoft.AspNetCore.Connections.Abstractions": "5.0.6", + "Microsoft.Extensions.Options": "5.0.0" + } + }, + "Microsoft.AspNetCore.SignalR.Protocols.Json": { + "type": "Transitive", + "resolved": "5.0.6", + "contentHash": "fFwax6INapN++2DGfxriYJm4lFDTXNyeoBa1qi2ppJqosQATLI2JpErDHIjZwSWqHLL3umxGNWGpQPDzSkIkVg==", + "dependencies": { + "Microsoft.AspNetCore.SignalR.Common": "5.0.6" + } + }, "Microsoft.AspNetCore.SpaServices.Extensions": { "type": "Transitive", "resolved": "5.0.5", @@ -322,8 +396,8 @@ }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "cI/VWn9G1fghXrNDagX9nYaaB/nokkZn0HYAawGaELQrl8InSezfe9OnfPZLcJq3esXxygh3hkq2c3qoV3SDyQ==" + "resolved": "5.0.1", + "contentHash": "5WPSmL4YeP7eW+Vc8XZ4DwjYWBAiSwDV9Hm63JJWcz1Ie3Xjv4KuJXzgCstj48LkLfVCYa7mLcx7y+q6yqVvtw==" }, "Microsoft.IdentityModel.JsonWebTokens": { "type": "Transitive", @@ -1457,6 +1531,11 @@ "System.Threading.Tasks": "4.3.0" } }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "RLBIxntLaG9pRmmuVDwY1kc8Bvp/FQzSxPU+19VekkScKkWtVP9r8bLhm28ama3usc816UBrmkg3vv3jUea/hw==" + }, "System.Threading.Tasks": { "type": "Transitive", "resolved": "4.3.0", diff --git a/BackEnd/Timeline/Controllers/TimelinePostController.cs b/BackEnd/Timeline/Controllers/TimelinePostController.cs index da45cbea..f00a689c 100644 --- a/BackEnd/Timeline/Controllers/TimelinePostController.cs +++ b/BackEnd/Timeline/Controllers/TimelinePostController.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; @@ -13,6 +15,7 @@ using Timeline.Models.Validation; using Timeline.Services.Mapper; using Timeline.Services.Timeline; using Timeline.Services.User; +using Timeline.SignalRHub; namespace Timeline.Controllers { @@ -24,6 +27,8 @@ namespace Timeline.Controllers [ProducesErrorResponseType(typeof(CommonResponse))] public class TimelinePostController : MyControllerBase { + private readonly ILogger _logger; + private readonly ITimelineService _timelineService; private readonly ITimelinePostService _postService; @@ -31,12 +36,16 @@ namespace Timeline.Controllers private readonly MarkdownProcessor _markdownProcessor; - public TimelinePostController(ITimelineService timelineService, ITimelinePostService timelinePostService, IGenericMapper mapper, MarkdownProcessor markdownProcessor) + private readonly IHubContext _timelineHubContext; + + public TimelinePostController(ILogger logger, ITimelineService timelineService, ITimelinePostService timelinePostService, IGenericMapper mapper, MarkdownProcessor markdownProcessor, IHubContext timelineHubContext) { + _logger = logger; _timelineService = timelineService; _postService = timelinePostService; _mapper = mapper; _markdownProcessor = markdownProcessor; + _timelineHubContext = timelineHubContext; } private bool UserHasAllTimelineManagementPermission => UserHasPermission(UserPermission.AllTimelineManagement); @@ -207,6 +216,11 @@ namespace Timeline.Controllers try { var post = await _postService.CreatePostAsync(timelineId, userId, createRequest); + + var group = TimelineHub.GenerateTimelinePostChangeListeningGroupName(timeline); + await _timelineHubContext.Clients.Group(group).SendAsync(nameof(ITimelineClient.OnTimelinePostChanged), timeline); + _logger.LogInformation("Notify group {0} of timeline post change.", group); + var result = await Map(post); return result; } diff --git a/BackEnd/Timeline/SignalRHub/ITimelineClient.cs b/BackEnd/Timeline/SignalRHub/ITimelineClient.cs new file mode 100644 index 00000000..0d1be093 --- /dev/null +++ b/BackEnd/Timeline/SignalRHub/ITimelineClient.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace Timeline.SignalRHub +{ + public interface ITimelineClient + { + Task OnTimelinePostChanged(string timelineName); + } +} diff --git a/BackEnd/Timeline/SignalRHub/TimelineHub.cs b/BackEnd/Timeline/SignalRHub/TimelineHub.cs new file mode 100644 index 00000000..2ad7bd66 --- /dev/null +++ b/BackEnd/Timeline/SignalRHub/TimelineHub.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; +using Timeline.Auth; +using Timeline.Services; +using Timeline.Services.Timeline; +using Timeline.Services.User; + +namespace Timeline.SignalRHub +{ + public class TimelineHub : Hub + { + private readonly ILogger _logger; + private readonly ITimelineService _timelineService; + + public TimelineHub(ILogger logger, ITimelineService timelineService) + { + _logger = logger; + _timelineService = timelineService; + } + + public static string GenerateTimelinePostChangeListeningGroupName(string timelineName) + { + return $"timeline-post-change-{timelineName}"; + } + + public async Task SubscribeTimelinePostChange(string timelineName) + { + try + { + var timelineId = await _timelineService.GetTimelineIdByNameAsync(timelineName); + var user = Context.User; + if (!user.HasPermission(UserPermission.AllTimelineManagement) && !await _timelineService.HasReadPermissionAsync(timelineId, user.GetOptionalUserId())) + throw new HubException("Forbidden."); + + var group = GenerateTimelinePostChangeListeningGroupName(timelineName); + await Groups.AddToGroupAsync(Context.ConnectionId, group); + _logger.LogInformation("Add connection {0} to group {1}", Context.ConnectionId, group); + } + catch (ArgumentException) + { + throw new HubException("Timeline name is illegal."); + } + catch (EntityNotExistException) + { + throw new HubException("Timeline not exist."); + } + } + + public async Task UnsubscribeTimelinePostChange(string timelineName) + { + var group = GenerateTimelinePostChangeListeningGroupName(timelineName); + await Groups.RemoveFromGroupAsync(Context.ConnectionId, group); + _logger.LogInformation("Remove connection {0} from group {1}", Context.ConnectionId, group); + } + } +} diff --git a/BackEnd/Timeline/Startup.cs b/BackEnd/Timeline/Startup.cs index 1e0d4779..18097e2c 100644 --- a/BackEnd/Timeline/Startup.cs +++ b/BackEnd/Timeline/Startup.cs @@ -26,6 +26,7 @@ using Timeline.Services.Mapper; using Timeline.Services.Timeline; using Timeline.Services.Token; using Timeline.Services.User; +using Timeline.SignalRHub; using Timeline.Swagger; namespace Timeline @@ -85,6 +86,8 @@ namespace Timeline options.InvalidModelStateResponseFactory = InvalidModelResponseFactory.Factory; }); + services.AddSignalR(); + services.AddAuthentication(AuthenticationConstants.Scheme) .AddScheme(AuthenticationConstants.Scheme, AuthenticationConstants.DisplayName, o => { }); services.AddAuthorization(); @@ -154,6 +157,7 @@ namespace Timeline app.UseEndpoints(endpoints => { endpoints.MapControllers(); + endpoints.MapHub("api/hub/timeline"); }); UnknownEndpointMiddleware.Attach(app); diff --git a/BackEnd/Timeline/Timeline.csproj b/BackEnd/Timeline/Timeline.csproj index b9653b25..955947af 100644 --- a/BackEnd/Timeline/Timeline.csproj +++ b/BackEnd/Timeline/Timeline.csproj @@ -49,19 +49,19 @@ - True - True - Resource.resx + True + True + Resource.resx - True - True - Resource.resx + True + True + Resource.resx - True - True - Resource.resx + True + True + Resource.resx True @@ -69,122 +69,122 @@ Resource.resx - True - True - Resource.resx + True + True + Resource.resx - True - True - Resource.resx + True + True + Resource.resx - True - True - Resource.resx + True + True + Resource.resx - True - True - Resource.resx + True + True + Resource.resx - True - True - Resource.resx + True + True + Resource.resx - True - True - Resource.resx + True + True + Resource.resx - True - True - Resource.resx + True + True + Resource.resx - True - True - Resource.resx + True + True + Resource.resx - True - True - Resource.resx + True + True + Resource.resx - True - True - Resource.resx + True + True + Resource.resx - True - True - Resource.resx + True + True + Resource.resx - ResXFileCodeGenerator - Resource.Designer.cs + ResXFileCodeGenerator + Resource.Designer.cs - ResXFileCodeGenerator - Resource.Designer.cs + ResXFileCodeGenerator + Resource.Designer.cs - ResXFileCodeGenerator - Resource.Designer.cs + ResXFileCodeGenerator + Resource.Designer.cs ResXFileCodeGenerator Resource.Designer.cs - ResXFileCodeGenerator - Resource.Designer.cs + ResXFileCodeGenerator + Resource.Designer.cs - ResXFileCodeGenerator - Resource.Designer.cs + ResXFileCodeGenerator + Resource.Designer.cs - ResXFileCodeGenerator - Resource.Designer.cs + ResXFileCodeGenerator + Resource.Designer.cs - ResXFileCodeGenerator - Resource.Designer.cs + ResXFileCodeGenerator + Resource.Designer.cs - ResXFileCodeGenerator - Resource.Designer.cs + ResXFileCodeGenerator + Resource.Designer.cs - ResXFileCodeGenerator - Resource.Designer.cs + ResXFileCodeGenerator + Resource.Designer.cs - ResXFileCodeGenerator - Resource.Designer.cs + ResXFileCodeGenerator + Resource.Designer.cs - ResXFileCodeGenerator - Resource.Designer.cs + ResXFileCodeGenerator + Resource.Designer.cs - ResXFileCodeGenerator - Resource.Designer.cs + ResXFileCodeGenerator + Resource.Designer.cs - ResXFileCodeGenerator - Resource.Designer.cs + ResXFileCodeGenerator + Resource.Designer.cs - ResXFileCodeGenerator - Resource.Designer.cs + ResXFileCodeGenerator + Resource.Designer.cs \ No newline at end of file -- cgit v1.2.3