From 232a19d7dfe0e3847b3a9a9a9be83485ffb9031c Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 30 May 2020 16:23:25 +0800 Subject: Merge front end to this repo. But I need to wait for aspnet core support for custom port and package manager for dev server. --- .../IntegratedTests/IntegratedTestBase.cs | 125 +++++++++++---------- 1 file changed, 64 insertions(+), 61 deletions(-) (limited to 'Timeline.Tests/IntegratedTests/IntegratedTestBase.cs') diff --git a/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs b/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs index a4a7638c..6c349ffb 100644 --- a/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs +++ b/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs @@ -1,43 +1,43 @@ -using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; -using System; +using System; using System.Collections.Generic; -using System.Net.Http; +using System.Net.Http; using System.Text.Json; using System.Text.Json.Serialization; -using System.Threading.Tasks; +using System.Threading.Tasks; using Timeline.Models; using Timeline.Models.Converters; -using Timeline.Models.Http; +using Timeline.Models.Http; using Timeline.Services; -using Timeline.Tests.Helpers; -using Xunit; - -namespace Timeline.Tests.IntegratedTests -{ - public abstract class IntegratedTestBase : IClassFixture>, IAsyncLifetime - { - protected TestApplication TestApp { get; } - - protected WebApplicationFactory Factory => TestApp.Factory; - - public IReadOnlyList UserInfos { get; private set; } - - private readonly int _userCount; - - public IntegratedTestBase(WebApplicationFactory factory) : this(factory, 1) - { - +using Timeline.Tests.Helpers; +using Xunit; + +namespace Timeline.Tests.IntegratedTests +{ + public abstract class IntegratedTestBase : IClassFixture>, IAsyncLifetime + { + protected TestApplication TestApp { get; } + + protected WebApplicationFactory Factory => TestApp.Factory; + + public IReadOnlyList UserInfos { get; private set; } + + private readonly int _userCount; + + public IntegratedTestBase(WebApplicationFactory factory) : this(factory, 1) + { + } - public IntegratedTestBase(WebApplicationFactory factory, int userCount) - { - if (userCount < 0) - throw new ArgumentOutOfRangeException(nameof(userCount), userCount, "User count can't be negative."); - - _userCount = userCount; - - TestApp = new TestApplication(factory); + public IntegratedTestBase(WebApplicationFactory factory, int userCount) + { + if (userCount < 0) + throw new ArgumentOutOfRangeException(nameof(userCount), userCount, "User count can't be negative."); + + _userCount = userCount; + + TestApp = new TestApplication(factory); } protected virtual Task OnInitializeAsync() @@ -115,34 +115,37 @@ namespace Timeline.Tests.IntegratedTests await OnDisposeAsync(); OnDispose(); await TestApp.DisposeAsync(); - } - - public Task CreateDefaultClient() - { - return Task.FromResult(Factory.CreateDefaultClient()); - } - - public async Task CreateClientWithCredential(string username, string password) - { - var client = Factory.CreateDefaultClient(); - var response = await client.PostAsJsonAsync("/token/create", - new CreateTokenRequest { Username = username, Password = password }); - var token = response.Should().HaveStatusCode(200) - .And.HaveJsonBody().Which.Token; - client.DefaultRequestHeaders.Add("Authorization", "Bearer " + token); - return client; - } - - public Task CreateClientAs(int userNumber) - { - if (userNumber < 0) - return CreateDefaultClient(); - if (userNumber == 0) - return CreateClientWithCredential("admin", "adminpw"); - else - return CreateClientWithCredential($"user{userNumber}", $"user{userNumber}pw"); - } - + } + + public Task CreateDefaultClient() + { + var client = Factory.CreateDefaultClient(); + client.BaseAddress = new Uri(client.BaseAddress, "api"); + return Task.FromResult(client); + } + + public async Task CreateClientWithCredential(string username, string password) + { + var client = Factory.CreateDefaultClient(); + client.BaseAddress = new Uri(client.BaseAddress, "api"); + var response = await client.PostAsJsonAsync("/token/create", + new CreateTokenRequest { Username = username, Password = password }); + var token = response.Should().HaveStatusCode(200) + .And.HaveJsonBody().Which.Token; + client.DefaultRequestHeaders.Add("Authorization", "Bearer " + token); + return client; + } + + public Task CreateClientAs(int userNumber) + { + if (userNumber < 0) + return CreateDefaultClient(); + if (userNumber == 0) + return CreateClientWithCredential("admin", "adminpw"); + else + return CreateClientWithCredential($"user{userNumber}", $"user{userNumber}pw"); + } + public Task CreateClientAsAdministrator() { return CreateClientAs(0); @@ -152,5 +155,5 @@ namespace Timeline.Tests.IntegratedTests { return CreateClientAs(1); } - } -} + } +} -- cgit v1.2.3 From 1fb1ad817d476bcdf3d3666d402fd1cda9c87083 Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 30 May 2020 23:16:49 +0800 Subject: Just get everything works! --- .../IntegratedTests/IntegratedTestBase.cs | 8 +- Timeline.Tests/IntegratedTests/TimelineTest.cs | 74 ++-- Timeline.Tests/IntegratedTests/UserTest.cs | 62 +-- Timeline/ClientApp/package.json | 1 - Timeline/ClientApp/src/config.ts | 13 +- Timeline/ClientApp/webpack.config.prod.dev.ts | 15 - Timeline/Routes/ApiRoutePrefixConvention.cs | 3 +- .../SpaServices/SpaDevelopmentServerMiddleware.cs | 489 +++++++++++++++++++++ Timeline/Startup.cs | 4 +- 9 files changed, 565 insertions(+), 104 deletions(-) delete mode 100644 Timeline/ClientApp/webpack.config.prod.dev.ts create mode 100644 Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs (limited to 'Timeline.Tests/IntegratedTests/IntegratedTestBase.cs') diff --git a/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs b/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs index 6c349ffb..e42483bd 100644 --- a/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs +++ b/Timeline.Tests/IntegratedTests/IntegratedTestBase.cs @@ -100,7 +100,7 @@ namespace Timeline.Tests.IntegratedTests options.Converters.Add(new JsonDateTimeConverter()); foreach (var user in users) { - var s = await client.GetStringAsync($"/users/{user.Username}"); + var s = await client.GetStringAsync($"users/{user.Username}"); userInfoList.Add(JsonSerializer.Deserialize(s, options)); } @@ -120,15 +120,15 @@ namespace Timeline.Tests.IntegratedTests public Task CreateDefaultClient() { var client = Factory.CreateDefaultClient(); - client.BaseAddress = new Uri(client.BaseAddress, "api"); + client.BaseAddress = new Uri(client.BaseAddress, "api/"); return Task.FromResult(client); } public async Task CreateClientWithCredential(string username, string password) { var client = Factory.CreateDefaultClient(); - client.BaseAddress = new Uri(client.BaseAddress, "api"); - var response = await client.PostAsJsonAsync("/token/create", + client.BaseAddress = new Uri(client.BaseAddress, "api/"); + var response = await client.PostAsJsonAsync("token/create", new CreateTokenRequest { Username = username, Password = password }); var token = response.Should().HaveStatusCode(200) .And.HaveJsonBody().Which.Token; diff --git a/Timeline.Tests/IntegratedTests/TimelineTest.cs b/Timeline.Tests/IntegratedTests/TimelineTest.cs index 845208e8..d8017b8a 100644 --- a/Timeline.Tests/IntegratedTests/TimelineTest.cs +++ b/Timeline.Tests/IntegratedTests/TimelineTest.cs @@ -72,12 +72,12 @@ namespace Timeline.Tests.IntegratedTests private static string GeneratePersonalTimelineUrl(int id, string subpath = null) { - return $"timelines/@{(id == 0 ? "admin" : ("user" + id))}{(subpath == null ? "" : ("/" + subpath))}"; + return $"timelines/@{(id == 0 ? "admin" : ("user" + id))}/{(subpath ?? "")}"; } private static string GenerateOrdinaryTimelineUrl(int id, string subpath = null) { - return $"timelines/t{id}{(subpath == null ? "" : ("/" + subpath))}"; + return $"timelines/t{id}/{(subpath ?? "")}"; } public static IEnumerable TimelineUrlGeneratorData() @@ -88,12 +88,12 @@ namespace Timeline.Tests.IntegratedTests private static string GeneratePersonalTimelineUrlByName(string name, string subpath = null) { - return $"timelines/@{name}{(subpath == null ? "" : ("/" + subpath))}"; + return $"timelines/@{name}/{(subpath ?? "")}"; } private static string GenerateOrdinaryTimelineUrlByName(string name, string subpath = null) { - return $"timelines/{name}{(subpath == null ? "" : ("/" + subpath))}"; + return $"timelines/{name}/{(subpath ?? "")}"; } public static IEnumerable TimelineUrlByNameGeneratorData() @@ -116,8 +116,8 @@ namespace Timeline.Tests.IntegratedTests body.Members.Should().NotBeNull().And.BeEmpty(); var links = body._links; links.Should().NotBeNull(); - links.Self.Should().EndWith("/timelines/@user1"); - links.Posts.Should().EndWith("/timelines/@user1/posts"); + links.Self.Should().EndWith("timelines/@user1"); + links.Posts.Should().EndWith("timelines/@user1/posts"); } { @@ -130,8 +130,8 @@ namespace Timeline.Tests.IntegratedTests body.Members.Should().NotBeNull().And.BeEmpty(); var links = body._links; links.Should().NotBeNull(); - links.Self.Should().EndWith("/timelines/t1"); - links.Posts.Should().EndWith("/timelines/t1/posts"); + links.Self.Should().EndWith("timelines/t1"); + links.Posts.Should().EndWith("timelines/t1/posts"); } } @@ -143,7 +143,7 @@ namespace Timeline.Tests.IntegratedTests var client = await CreateDefaultClient(); { - var res = await client.GetAsync("/timelines/@user1"); + var res = await client.GetAsync("timelines/@user1"); user1Timeline = res.Should().HaveStatusCode(200) .And.HaveJsonBody().Which; } @@ -153,7 +153,7 @@ namespace Timeline.Tests.IntegratedTests testResult.Add(user1Timeline); testResult.AddRange(_testTimelines); - var res = await client.GetAsync("/timelines"); + var res = await client.GetAsync("timelines"); res.Should().HaveStatusCode(200) .And.HaveJsonBody>() .Which.Should().BeEquivalentTo(testResult); @@ -176,27 +176,27 @@ namespace Timeline.Tests.IntegratedTests var client = await CreateClientAsUser(); { - var res = await client.PutAsync("/timelines/@user1/members/user3", null); + var res = await client.PutAsync("timelines/@user1/members/user3", null); res.Should().HaveStatusCode(200); } { - var res = await client.PutAsync("/timelines/t1/members/user3", null); + var res = await client.PutAsync("timelines/t1/members/user3", null); res.Should().HaveStatusCode(200); } { - var res = await client.PatchAsJsonAsync("/timelines/@user1", new TimelinePatchRequest { Visibility = TimelineVisibility.Public }); + var res = await client.PatchAsJsonAsync("timelines/@user1", new TimelinePatchRequest { Visibility = TimelineVisibility.Public }); res.Should().HaveStatusCode(200); } { - var res = await client.PatchAsJsonAsync("/timelines/t1", new TimelinePatchRequest { Visibility = TimelineVisibility.Register }); + var res = await client.PatchAsJsonAsync("timelines/t1", new TimelinePatchRequest { Visibility = TimelineVisibility.Register }); res.Should().HaveStatusCode(200); } { - var res = await client.GetAsync("/timelines/@user1"); + var res = await client.GetAsync("timelines/@user1"); var timeline = res.Should().HaveStatusCode(200) .And.HaveJsonBody().Which; testResultRelate.Add(timeline); @@ -206,7 +206,7 @@ namespace Timeline.Tests.IntegratedTests } { - var res = await client.GetAsync("/timelines/t1"); + var res = await client.GetAsync("timelines/t1"); var timeline = res.Should().HaveStatusCode(200) .And.HaveJsonBody().Which; testResultRelate.Add(timeline); @@ -219,27 +219,27 @@ namespace Timeline.Tests.IntegratedTests var client = await CreateClientAs(2); { - var res = await client.PutAsync("/timelines/@user2/members/user3", null); + var res = await client.PutAsync("timelines/@user2/members/user3", null); res.Should().HaveStatusCode(200); } { - var res = await client.PutAsync("/timelines/t2/members/user3", null); + var res = await client.PutAsync("timelines/t2/members/user3", null); res.Should().HaveStatusCode(200); } { - var res = await client.PatchAsJsonAsync("/timelines/@user2", new TimelinePatchRequest { Visibility = TimelineVisibility.Register }); + var res = await client.PatchAsJsonAsync("timelines/@user2", new TimelinePatchRequest { Visibility = TimelineVisibility.Register }); res.Should().HaveStatusCode(200); } { - var res = await client.PatchAsJsonAsync("/timelines/t2", new TimelinePatchRequest { Visibility = TimelineVisibility.Private }); + var res = await client.PatchAsJsonAsync("timelines/t2", new TimelinePatchRequest { Visibility = TimelineVisibility.Private }); res.Should().HaveStatusCode(200); } { - var res = await client.GetAsync("/timelines/@user2"); + var res = await client.GetAsync("timelines/@user2"); var timeline = res.Should().HaveStatusCode(200) .And.HaveJsonBody().Which; testResultRelate.Add(timeline); @@ -248,7 +248,7 @@ namespace Timeline.Tests.IntegratedTests } { - var res = await client.GetAsync("/timelines/t2"); + var res = await client.GetAsync("timelines/t2"); var timeline = res.Should().HaveStatusCode(200) .And.HaveJsonBody().Which; testResultRelate.Add(timeline); @@ -261,17 +261,17 @@ namespace Timeline.Tests.IntegratedTests var client = await CreateClientAs(3); { - var res = await client.PatchAsJsonAsync("/timelines/@user3", new TimelinePatchRequest { Visibility = TimelineVisibility.Private }); + var res = await client.PatchAsJsonAsync("timelines/@user3", new TimelinePatchRequest { Visibility = TimelineVisibility.Private }); res.Should().HaveStatusCode(200); } { - var res = await client.PatchAsJsonAsync("/timelines/t3", new TimelinePatchRequest { Visibility = TimelineVisibility.Register }); + var res = await client.PatchAsJsonAsync("timelines/t3", new TimelinePatchRequest { Visibility = TimelineVisibility.Register }); res.Should().HaveStatusCode(200); } { - var res = await client.GetAsync("/timelines/@user3"); + var res = await client.GetAsync("timelines/@user3"); var timeline = res.Should().HaveStatusCode(200) .And.HaveJsonBody().Which; testResultRelate.Add(timeline); @@ -280,7 +280,7 @@ namespace Timeline.Tests.IntegratedTests } { - var res = await client.GetAsync("/timelines/t3"); + var res = await client.GetAsync("timelines/t3"); var timeline = res.Should().HaveStatusCode(200) .And.HaveJsonBody().Which; testResultRelate.Add(timeline); @@ -292,7 +292,7 @@ namespace Timeline.Tests.IntegratedTests { var client = await CreateClientAs(3); { - var res = await client.GetAsync("/timelines?relate=user3"); + var res = await client.GetAsync("timelines?relate=user3"); var body = res.Should().HaveStatusCode(200) .And.HaveJsonBody>() .Which; @@ -300,7 +300,7 @@ namespace Timeline.Tests.IntegratedTests } { - var res = await client.GetAsync("/timelines?relate=user3&relateType=own"); + var res = await client.GetAsync("timelines?relate=user3&relateType=own"); var body = res.Should().HaveStatusCode(200) .And.HaveJsonBody>() .Which; @@ -308,7 +308,7 @@ namespace Timeline.Tests.IntegratedTests } { - var res = await client.GetAsync("/timelines?relate=user3&visibility=public"); + var res = await client.GetAsync("timelines?relate=user3&visibility=public"); var body = res.Should().HaveStatusCode(200) .And.HaveJsonBody>() .Which; @@ -316,7 +316,7 @@ namespace Timeline.Tests.IntegratedTests } { - var res = await client.GetAsync("/timelines?relate=user3&visibility=register"); + var res = await client.GetAsync("timelines?relate=user3&visibility=register"); var body = res.Should().HaveStatusCode(200) .And.HaveJsonBody>() .Which; @@ -324,7 +324,7 @@ namespace Timeline.Tests.IntegratedTests } { - var res = await client.GetAsync("/timelines?relate=user3&relateType=join&visibility=private"); + var res = await client.GetAsync("timelines?relate=user3&relateType=join&visibility=private"); var body = res.Should().HaveStatusCode(200) .And.HaveJsonBody>() .Which; @@ -332,7 +332,7 @@ namespace Timeline.Tests.IntegratedTests } { - var res = await client.GetAsync("/timelines?relate=user3&relateType=own&visibility=private"); + var res = await client.GetAsync("timelines?relate=user3&relateType=own&visibility=private"); var body = res.Should().HaveStatusCode(200) .And.HaveJsonBody>() .Which; @@ -343,7 +343,7 @@ namespace Timeline.Tests.IntegratedTests { var client = await CreateDefaultClient(); { - var res = await client.GetAsync("/timelines?visibility=public"); + var res = await client.GetAsync("timelines?visibility=public"); var body = res.Should().HaveStatusCode(200) .And.HaveJsonBody>() .Which; @@ -358,17 +358,17 @@ namespace Timeline.Tests.IntegratedTests var client = await CreateClientAsUser(); { - var res = await client.GetAsync("/timelines?relate=us!!"); + var res = await client.GetAsync("timelines?relate=us!!"); res.Should().BeInvalidModel(); } { - var res = await client.GetAsync("/timelines?relateType=aaa"); + var res = await client.GetAsync("timelines?relateType=aaa"); res.Should().BeInvalidModel(); } { - var res = await client.GetAsync("/timelines?visibility=aaa"); + var res = await client.GetAsync("timelines?visibility=aaa"); res.Should().BeInvalidModel(); } } @@ -483,7 +483,7 @@ namespace Timeline.Tests.IntegratedTests res.Should().BeInvalidModel(); } { - var res = await client.DeleteAsync(generator("aaa!!!/members", "user1")); + var res = await client.DeleteAsync(generator("aaa!!!", "members/user1")); res.Should().BeInvalidModel(); } { diff --git a/Timeline.Tests/IntegratedTests/UserTest.cs b/Timeline.Tests/IntegratedTests/UserTest.cs index 8ce76299..e226d084 100644 --- a/Timeline.Tests/IntegratedTests/UserTest.cs +++ b/Timeline.Tests/IntegratedTests/UserTest.cs @@ -22,7 +22,7 @@ namespace Timeline.Tests.IntegratedTests public async Task GetList_NoAuth() { using var client = await CreateDefaultClient(); - var res = await client.GetAsync("/users"); + var res = await client.GetAsync("users"); res.Should().HaveStatusCode(200) .And.HaveJsonBody() .Which.Should().BeEquivalentTo(UserInfos); @@ -32,7 +32,7 @@ namespace Timeline.Tests.IntegratedTests public async Task GetList_User() { using var client = await CreateClientAsUser(); - var res = await client.GetAsync("/users"); + var res = await client.GetAsync("users"); res.Should().HaveStatusCode(200) .And.HaveJsonBody() .Which.Should().BeEquivalentTo(UserInfos); @@ -42,7 +42,7 @@ namespace Timeline.Tests.IntegratedTests public async Task GetList_Admin() { using var client = await CreateClientAsAdministrator(); - var res = await client.GetAsync("/users"); + var res = await client.GetAsync("users"); res.Should().HaveStatusCode(200) .And.HaveJsonBody() .Which.Should().BeEquivalentTo(UserInfos); @@ -52,7 +52,7 @@ namespace Timeline.Tests.IntegratedTests public async Task Get_NoAuth() { using var client = await CreateDefaultClient(); - var res = await client.GetAsync($"/users/admin"); + var res = await client.GetAsync($"users/admin"); res.Should().HaveStatusCode(200) .And.HaveJsonBody() .Which.Should().BeEquivalentTo(UserInfos[0]); @@ -62,7 +62,7 @@ namespace Timeline.Tests.IntegratedTests public async Task Get_User() { using var client = await CreateClientAsUser(); - var res = await client.GetAsync($"/users/admin"); + var res = await client.GetAsync($"users/admin"); res.Should().HaveStatusCode(200) .And.HaveJsonBody() .Which.Should().BeEquivalentTo(UserInfos[0]); @@ -72,7 +72,7 @@ namespace Timeline.Tests.IntegratedTests public async Task Get_Admin() { using var client = await CreateClientAsAdministrator(); - var res = await client.GetAsync($"/users/user1"); + var res = await client.GetAsync($"users/user1"); res.Should().HaveStatusCode(200) .And.HaveJsonBody() .Which.Should().BeEquivalentTo(UserInfos[1]); @@ -82,7 +82,7 @@ namespace Timeline.Tests.IntegratedTests public async Task Get_InvalidModel() { using var client = await CreateClientAsUser(); - var res = await client.GetAsync("/users/aaa!a"); + var res = await client.GetAsync("users/aaa!a"); res.Should().BeInvalidModel(); } @@ -90,7 +90,7 @@ namespace Timeline.Tests.IntegratedTests public async Task Get_404() { using var client = await CreateClientAsUser(); - var res = await client.GetAsync("/users/usernotexist"); + var res = await client.GetAsync("users/usernotexist"); res.Should().HaveStatusCode(404) .And.HaveCommonBody(ErrorCodes.UserCommon.NotExist); } @@ -100,7 +100,7 @@ namespace Timeline.Tests.IntegratedTests { using var client = await CreateClientAsUser(); { - var res = await client.PatchAsJsonAsync("/users/user1", + var res = await client.PatchAsJsonAsync("users/user1", new UserPatchRequest { Nickname = "aaa" }); res.Should().HaveStatusCode(200) .And.HaveJsonBody() @@ -108,7 +108,7 @@ namespace Timeline.Tests.IntegratedTests } { - var res = await client.GetAsync("/users/user1"); + var res = await client.GetAsync("users/user1"); res.Should().HaveStatusCode(200) .And.HaveJsonBody() .Which.Nickname.Should().Be("aaa"); @@ -122,7 +122,7 @@ namespace Timeline.Tests.IntegratedTests using var userClient = await CreateClientAsUser(); { - var res = await client.PatchAsJsonAsync("/users/user1", + var res = await client.PatchAsJsonAsync("users/user1", new UserPatchRequest { Username = "newuser", @@ -138,7 +138,7 @@ namespace Timeline.Tests.IntegratedTests } { - var res = await client.GetAsync("/users/newuser"); + var res = await client.GetAsync("users/newuser"); var body = res.Should().HaveStatusCode(200) .And.HaveJsonBody() .Which; @@ -148,7 +148,7 @@ namespace Timeline.Tests.IntegratedTests { // Token should expire. - var res = await userClient.GetAsync("/testing/auth/Authorize"); + var res = await userClient.GetAsync("testing/auth/Authorize"); res.Should().HaveStatusCode(HttpStatusCode.Unauthorized); } @@ -162,7 +162,7 @@ namespace Timeline.Tests.IntegratedTests public async Task Patch_NotExist() { using var client = await CreateClientAsAdministrator(); - var res = await client.PatchAsJsonAsync("/users/usernotexist", new UserPatchRequest { }); + var res = await client.PatchAsJsonAsync("users/usernotexist", new UserPatchRequest { }); res.Should().HaveStatusCode(404) .And.HaveCommonBody() .Which.Code.Should().Be(ErrorCodes.UserCommon.NotExist); @@ -172,7 +172,7 @@ namespace Timeline.Tests.IntegratedTests public async Task Patch_InvalidModel() { using var client = await CreateClientAsAdministrator(); - var res = await client.PatchAsJsonAsync("/users/aaa!a", new UserPatchRequest { }); + var res = await client.PatchAsJsonAsync("users/aaa!a", new UserPatchRequest { }); res.Should().BeInvalidModel(); } @@ -188,7 +188,7 @@ namespace Timeline.Tests.IntegratedTests public async Task Patch_InvalidModel_Body(UserPatchRequest body) { using var client = await CreateClientAsAdministrator(); - var res = await client.PatchAsJsonAsync("/users/user1", body); + var res = await client.PatchAsJsonAsync("users/user1", body); res.Should().BeInvalidModel(); } @@ -196,7 +196,7 @@ namespace Timeline.Tests.IntegratedTests public async Task Patch_UsernameConflict() { using var client = await CreateClientAsAdministrator(); - var res = await client.PatchAsJsonAsync("/users/user1", new UserPatchRequest { Username = "admin" }); + var res = await client.PatchAsJsonAsync("users/user1", new UserPatchRequest { Username = "admin" }); res.Should().HaveStatusCode(400) .And.HaveCommonBody(ErrorCodes.UserController.UsernameConflict); } @@ -205,7 +205,7 @@ namespace Timeline.Tests.IntegratedTests public async Task Patch_NoAuth_Unauthorized() { using var client = await CreateDefaultClient(); - var res = await client.PatchAsJsonAsync("/users/user1", new UserPatchRequest { Nickname = "aaa" }); + var res = await client.PatchAsJsonAsync("users/user1", new UserPatchRequest { Nickname = "aaa" }); res.Should().HaveStatusCode(HttpStatusCode.Unauthorized); } @@ -213,7 +213,7 @@ namespace Timeline.Tests.IntegratedTests public async Task Patch_User_Forbid() { using var client = await CreateClientAsUser(); - var res = await client.PatchAsJsonAsync("/users/admin", new UserPatchRequest { Nickname = "aaa" }); + var res = await client.PatchAsJsonAsync("users/admin", new UserPatchRequest { Nickname = "aaa" }); res.Should().HaveStatusCode(HttpStatusCode.Forbidden); } @@ -221,7 +221,7 @@ namespace Timeline.Tests.IntegratedTests public async Task Patch_Username_Forbid() { using var client = await CreateClientAsUser(); - var res = await client.PatchAsJsonAsync("/users/user1", new UserPatchRequest { Username = "aaa" }); + var res = await client.PatchAsJsonAsync("users/user1", new UserPatchRequest { Username = "aaa" }); res.Should().HaveStatusCode(HttpStatusCode.Forbidden); } @@ -229,7 +229,7 @@ namespace Timeline.Tests.IntegratedTests public async Task Patch_Password_Forbid() { using var client = await CreateClientAsUser(); - var res = await client.PatchAsJsonAsync("/users/user1", new UserPatchRequest { Password = "aaa" }); + var res = await client.PatchAsJsonAsync("users/user1", new UserPatchRequest { Password = "aaa" }); res.Should().HaveStatusCode(HttpStatusCode.Forbidden); } @@ -237,7 +237,7 @@ namespace Timeline.Tests.IntegratedTests public async Task Patch_Administrator_Forbid() { using var client = await CreateClientAsUser(); - var res = await client.PatchAsJsonAsync("/users/user1", new UserPatchRequest { Administrator = true }); + var res = await client.PatchAsJsonAsync("users/user1", new UserPatchRequest { Administrator = true }); res.Should().HaveStatusCode(HttpStatusCode.Forbidden); } @@ -246,12 +246,12 @@ namespace Timeline.Tests.IntegratedTests { using var client = await CreateClientAsAdministrator(); { - var res = await client.DeleteAsync("/users/user1"); + var res = await client.DeleteAsync("users/user1"); res.Should().BeDelete(true); } { - var res = await client.GetAsync("/users/user1"); + var res = await client.GetAsync("users/user1"); res.Should().HaveStatusCode(404); } } @@ -260,7 +260,7 @@ namespace Timeline.Tests.IntegratedTests public async Task Delete_NotExist() { using var client = await CreateClientAsAdministrator(); - var res = await client.DeleteAsync("/users/usernotexist"); + var res = await client.DeleteAsync("users/usernotexist"); res.Should().BeDelete(false); } @@ -268,7 +268,7 @@ namespace Timeline.Tests.IntegratedTests public async Task Delete_InvalidModel() { using var client = await CreateClientAsAdministrator(); - var res = await client.DeleteAsync("/users/aaa!a"); + var res = await client.DeleteAsync("users/aaa!a"); res.Should().BeInvalidModel(); } @@ -276,7 +276,7 @@ namespace Timeline.Tests.IntegratedTests public async Task Delete_NoAuth_Unauthorized() { using var client = await CreateDefaultClient(); - var res = await client.DeleteAsync("/users/aaa!a"); + var res = await client.DeleteAsync("users/aaa!a"); res.Should().HaveStatusCode(HttpStatusCode.Unauthorized); } @@ -284,11 +284,11 @@ namespace Timeline.Tests.IntegratedTests public async Task Delete_User_Forbid() { using var client = await CreateClientAsUser(); - var res = await client.DeleteAsync("/users/aaa!a"); + var res = await client.DeleteAsync("users/aaa!a"); res.Should().HaveStatusCode(HttpStatusCode.Forbidden); } - private const string createUserUrl = "/userop/createuser"; + private const string createUserUrl = "userop/createuser"; [Fact] public async Task Op_CreateUser() @@ -389,7 +389,7 @@ namespace Timeline.Tests.IntegratedTests } } - private const string changePasswordUrl = "/userop/changepassword"; + private const string changePasswordUrl = "userop/changepassword"; [Fact] public async Task Op_ChangePassword() @@ -401,7 +401,7 @@ namespace Timeline.Tests.IntegratedTests res.Should().HaveStatusCode(200); } { - var res = await client.PatchAsJsonAsync("/users/user1", new UserPatchRequest { }); + var res = await client.PatchAsJsonAsync("users/user1", new UserPatchRequest { }); res.Should().HaveStatusCode(HttpStatusCode.Unauthorized); } { diff --git a/Timeline/ClientApp/package.json b/Timeline/ClientApp/package.json index 2c9f682d..11130ccb 100644 --- a/Timeline/ClientApp/package.json +++ b/Timeline/ClientApp/package.json @@ -27,7 +27,6 @@ }, "scripts": { "start": "cross-env TS_NODE_PROJECT=\"tsconfig-for-webpack-config.json\" webpack-dev-server --config ./webpack.config.dev.ts", - "start-prod": "cross-env TS_NODE_PROJECT=\"tsconfig-for-webpack-config.json\" webpack --config ./webpack.config.prod.dev.ts && http-server dist -p 3000", "build": "cross-env TS_NODE_PROJECT=\"tsconfig-for-webpack-config.json\" webpack --config ./webpack.config.prod.ts", "lint": "eslint src/ --ext .js --ext .jsx --ext .ts --ext .tsx" }, diff --git a/Timeline/ClientApp/src/config.ts b/Timeline/ClientApp/src/config.ts index 2f088a10..d06c3332 100644 --- a/Timeline/ClientApp/src/config.ts +++ b/Timeline/ClientApp/src/config.ts @@ -1,12 +1 @@ -export const apiBaseUrl = (function () { - if (process.env.TIMELINE_USEDEVAPI) { - console.log('process.env.TIMELINE_USEDEVAPI is set, use dev api server!'); - return 'http://localhost:5000'; - } else if (process.env.NODE_ENV === 'production') { - console.log('Production mode!'); - return 'https://api.crupest.xyz'; - } else { - console.log('Development mode!'); - return 'http://localhost:5000'; - } -})(); +export const apiBaseUrl = '/api'; diff --git a/Timeline/ClientApp/webpack.config.prod.dev.ts b/Timeline/ClientApp/webpack.config.prod.dev.ts deleted file mode 100644 index 8acc2355..00000000 --- a/Timeline/ClientApp/webpack.config.prod.dev.ts +++ /dev/null @@ -1,15 +0,0 @@ -import webpack from 'webpack'; - -import baseConfig from './webpack.config.prod'; - -const config: webpack.Configuration = { - ...baseConfig, - plugins: [ - ...baseConfig.plugins, - new webpack.DefinePlugin({ - 'process.env.TIMELINE_USEDEVAPI': true, - }), - ], -}; - -export default config; diff --git a/Timeline/Routes/ApiRoutePrefixConvention.cs b/Timeline/Routes/ApiRoutePrefixConvention.cs index 2653c2ca..ca38a0d9 100644 --- a/Timeline/Routes/ApiRoutePrefixConvention.cs +++ b/Timeline/Routes/ApiRoutePrefixConvention.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Routing; using System.Linq; @@ -29,7 +30,7 @@ namespace Timeline.Routes public void Apply(ApplicationModel application) { - foreach (var selector in application.Controllers.Where(c => c.RouteValues.TryGetValue("area", out var value) && value == "api").SelectMany(c => c.Selectors)) + foreach (var selector in application.Controllers.Where(c => c.Filters.Any(f => f is IApiBehaviorMetadata)).SelectMany(c => c.Selectors)) { if (selector.AttributeRouteModel != null) { diff --git a/Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs b/Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs new file mode 100644 index 00000000..059de989 --- /dev/null +++ b/Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs @@ -0,0 +1,489 @@ +// Copied and modified from https://github.com/dotnet/aspnetcore/blob/master/src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddleware.cs +// TODO! Delete this after aspnetcore 5 is released. +// I currently manually copy this because this is not merged into aspnetcore 3.1 . + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.SpaServices; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +#nullable disable +#pragma warning disable + +namespace Timeline.SpaServices +{ + internal static class TaskTimeoutExtensions + { + public static async Task WithTimeout(this Task task, TimeSpan timeoutDelay, string message) + { + if (task == await Task.WhenAny(task, Task.Delay(timeoutDelay))) + { + task.Wait(); // Allow any errors to propagate + } + else + { + throw new TimeoutException(message); + } + } + + public static async Task WithTimeout(this Task task, TimeSpan timeoutDelay, string message) + { + if (task == await Task.WhenAny(task, Task.Delay(timeoutDelay))) + { + return task.Result; + } + else + { + throw new TimeoutException(message); + } + } + } + + /// + /// Wraps a to expose an evented API, issuing notifications + /// when the stream emits partial lines, completed lines, or finally closes. + /// + internal class EventedStreamReader + { + public delegate void OnReceivedChunkHandler(ArraySegment chunk); + public delegate void OnReceivedLineHandler(string line); + public delegate void OnStreamClosedHandler(); + + public event OnReceivedChunkHandler OnReceivedChunk; + public event OnReceivedLineHandler OnReceivedLine; + public event OnStreamClosedHandler OnStreamClosed; + + private readonly StreamReader _streamReader; + private readonly StringBuilder _linesBuffer; + + public EventedStreamReader(StreamReader streamReader) + { + _streamReader = streamReader ?? throw new ArgumentNullException(nameof(streamReader)); + _linesBuffer = new StringBuilder(); + Task.Factory.StartNew(Run); + } + + public Task WaitForMatch(Regex regex) + { + var tcs = new TaskCompletionSource(); + var completionLock = new object(); + + OnReceivedLineHandler onReceivedLineHandler = null; + OnStreamClosedHandler onStreamClosedHandler = null; + + void ResolveIfStillPending(Action applyResolution) + { + lock (completionLock) + { + if (!tcs.Task.IsCompleted) + { + OnReceivedLine -= onReceivedLineHandler; + OnStreamClosed -= onStreamClosedHandler; + applyResolution(); + } + } + } + + onReceivedLineHandler = line => + { + var match = regex.Match(line); + if (match.Success) + { + ResolveIfStillPending(() => tcs.SetResult(match)); + } + }; + + onStreamClosedHandler = () => + { + ResolveIfStillPending(() => tcs.SetException(new EndOfStreamException())); + }; + + OnReceivedLine += onReceivedLineHandler; + OnStreamClosed += onStreamClosedHandler; + + return tcs.Task; + } + + private async Task Run() + { + var buf = new char[8 * 1024]; + while (true) + { + var chunkLength = await _streamReader.ReadAsync(buf, 0, buf.Length); + if (chunkLength == 0) + { + if (_linesBuffer.Length > 0) + { + OnCompleteLine(_linesBuffer.ToString()); + _linesBuffer.Clear(); + } + + OnClosed(); + break; + } + + OnChunk(new ArraySegment(buf, 0, chunkLength)); + + int lineBreakPos = -1; + int startPos = 0; + + // get all the newlines + while ((lineBreakPos = Array.IndexOf(buf, '\n', startPos, chunkLength - startPos)) >= 0 && startPos < chunkLength) + { + var length = (lineBreakPos + 1) - startPos; + _linesBuffer.Append(buf, startPos, length); + OnCompleteLine(_linesBuffer.ToString()); + _linesBuffer.Clear(); + startPos = lineBreakPos + 1; + } + + // get the rest + if (lineBreakPos < 0 && startPos < chunkLength) + { + _linesBuffer.Append(buf, startPos, chunkLength - startPos); + } + } + } + + private void OnChunk(ArraySegment chunk) + { + var dlg = OnReceivedChunk; + dlg?.Invoke(chunk); + } + + private void OnCompleteLine(string line) + { + var dlg = OnReceivedLine; + dlg?.Invoke(line); + } + + private void OnClosed() + { + var dlg = OnStreamClosed; + dlg?.Invoke(); + } + } + + /// + /// Captures the completed-line notifications from a , + /// combining the data into a single . + /// + internal class EventedStreamStringReader : IDisposable + { + private EventedStreamReader _eventedStreamReader; + private bool _isDisposed; + private StringBuilder _stringBuilder = new StringBuilder(); + + public EventedStreamStringReader(EventedStreamReader eventedStreamReader) + { + _eventedStreamReader = eventedStreamReader + ?? throw new ArgumentNullException(nameof(eventedStreamReader)); + _eventedStreamReader.OnReceivedLine += OnReceivedLine; + } + + public string ReadAsString() => _stringBuilder.ToString(); + + private void OnReceivedLine(string line) => _stringBuilder.AppendLine(line); + + public void Dispose() + { + if (!_isDisposed) + { + _eventedStreamReader.OnReceivedLine -= OnReceivedLine; + _isDisposed = true; + } + } + } + + /// + /// Executes the script entries defined in a package.json file, + /// capturing any output written to stdio. + /// + internal class NodeScriptRunner : IDisposable + { + private Process _npmProcess; + public EventedStreamReader StdOut { get; } + public EventedStreamReader StdErr { get; } + + private static Regex AnsiColorRegex = new Regex("\x001b\\[[0-9;]*m", RegexOptions.None, TimeSpan.FromSeconds(1)); + + public NodeScriptRunner(string workingDirectory, string scriptName, string arguments, IDictionary envVars, string pkgManagerCommand, DiagnosticSource diagnosticSource, CancellationToken applicationStoppingToken) + { + if (string.IsNullOrEmpty(workingDirectory)) + { + throw new ArgumentException("Cannot be null or empty.", nameof(workingDirectory)); + } + + if (string.IsNullOrEmpty(scriptName)) + { + throw new ArgumentException("Cannot be null or empty.", nameof(scriptName)); + } + + if (string.IsNullOrEmpty(pkgManagerCommand)) + { + throw new ArgumentException("Cannot be null or empty.", nameof(pkgManagerCommand)); + } + + var exeToRun = pkgManagerCommand; + var completeArguments = $"run {scriptName} -- {arguments ?? string.Empty}"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // On Windows, the node executable is a .cmd file, so it can't be executed + // directly (except with UseShellExecute=true, but that's no good, because + // it prevents capturing stdio). So we need to invoke it via "cmd /c". + exeToRun = "cmd"; + completeArguments = $"/c {pkgManagerCommand} {completeArguments}"; + } + + var processStartInfo = new ProcessStartInfo(exeToRun) + { + Arguments = completeArguments, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = workingDirectory + }; + + if (envVars != null) + { + foreach (var keyValuePair in envVars) + { + processStartInfo.Environment[keyValuePair.Key] = keyValuePair.Value; + } + } + + _npmProcess = LaunchNodeProcess(processStartInfo, pkgManagerCommand); + StdOut = new EventedStreamReader(_npmProcess.StandardOutput); + StdErr = new EventedStreamReader(_npmProcess.StandardError); + + applicationStoppingToken.Register(((IDisposable)this).Dispose); + + if (diagnosticSource.IsEnabled("Timeline.NodeServices.Npm.NpmStarted")) + { + diagnosticSource.Write( + "Timeline.NodeServices.Npm.NpmStarted", + new + { + processStartInfo = processStartInfo, + process = _npmProcess + }); + } + } + + public void AttachToLogger(ILogger logger) + { + // When the node task emits complete lines, pass them through to the real logger + StdOut.OnReceivedLine += line => + { + if (!string.IsNullOrWhiteSpace(line)) + { + // Node tasks commonly emit ANSI colors, but it wouldn't make sense to forward + // those to loggers (because a logger isn't necessarily any kind of terminal) + logger.LogInformation(StripAnsiColors(line)); + } + }; + + StdErr.OnReceivedLine += line => + { + if (!string.IsNullOrWhiteSpace(line)) + { + logger.LogError(StripAnsiColors(line)); + } + }; + + // But when it emits incomplete lines, assume this is progress information and + // hence just pass it through to StdOut regardless of logger config. + StdErr.OnReceivedChunk += chunk => + { + var containsNewline = Array.IndexOf( + chunk.Array, '\n', chunk.Offset, chunk.Count) >= 0; + if (!containsNewline) + { + Console.Write(chunk.Array, chunk.Offset, chunk.Count); + } + }; + } + + private static string StripAnsiColors(string line) + => AnsiColorRegex.Replace(line, string.Empty); + + private static Process LaunchNodeProcess(ProcessStartInfo startInfo, string commandName) + { + try + { + var process = Process.Start(startInfo); + + // See equivalent comment in OutOfProcessNodeInstance.cs for why + process.EnableRaisingEvents = true; + + return process; + } + catch (Exception ex) + { + var message = $"Failed to start '{commandName}'. To resolve this:.\n\n" + + $"[1] Ensure that '{commandName}' is installed and can be found in one of the PATH directories.\n" + + $" Current PATH enviroment variable is: { Environment.GetEnvironmentVariable("PATH") }\n" + + " Make sure the executable is in one of those directories, or update your PATH.\n\n" + + "[2] See the InnerException for further details of the cause."; + throw new InvalidOperationException(message, ex); + } + } + + void IDisposable.Dispose() + { + if (_npmProcess != null && !_npmProcess.HasExited) + { + _npmProcess.Kill(entireProcessTree: true); + _npmProcess = null; + } + } + } + + internal static class LoggerFinder + { + public static ILogger GetOrCreateLogger( + IApplicationBuilder appBuilder, + string logCategoryName) + { + // If the DI system gives us a logger, use it. Otherwise, set up a default one + var loggerFactory = appBuilder.ApplicationServices.GetService(); + var logger = loggerFactory != null + ? loggerFactory.CreateLogger(logCategoryName) + : NullLogger.Instance; + return logger; + } + } + + /// + /// Extension methods for enabling React development server middleware support. + /// + internal class SpaDevelopmentServerMiddleware + { + private const string LogCategoryName = "Timeline.SpaServices.SpaServices"; + private static TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5); // This is a development-time only feature, so a very long timeout is fine + + public static void Attach(ISpaBuilder spaBuilder, string pkgManagerCommand, string scriptName, int port) + { + var sourcePath = spaBuilder.Options.SourcePath; + if (string.IsNullOrEmpty(sourcePath)) + { + throw new ArgumentException("Cannot be null or empty", nameof(sourcePath)); + } + + if (string.IsNullOrEmpty(scriptName)) + { + throw new ArgumentException("Cannot be null or empty", nameof(scriptName)); + } + + // Start create-react-app and attach to middleware pipeline + var appBuilder = spaBuilder.ApplicationBuilder; + var applicationStoppingToken = appBuilder.ApplicationServices.GetRequiredService().ApplicationStopping; + var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName); + var diagnosticSource = appBuilder.ApplicationServices.GetRequiredService(); + var portTask = StartCreateReactAppServerAsync(sourcePath, scriptName, pkgManagerCommand, logger, diagnosticSource, applicationStoppingToken); + + // Everything we proxy is hardcoded to target http://localhost because: + // - the requests are always from the local machine (we're not accepting remote + // requests that go directly to the create-react-app server) + // - given that, there's no reason to use https, and we couldn't even if we + // wanted to, because in general the create-react-app server has no certificate + var targetUriTask = portTask.ContinueWith( + task => new UriBuilder("http", "localhost", port).Uri); + + SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, () => + { + // On each request, we create a separate startup task with its own timeout. That way, even if + // the first request times out, subsequent requests could still work. + var timeout = spaBuilder.Options.StartupTimeout; + return targetUriTask.WithTimeout(timeout, + $"The dev server did not start listening for requests " + + $"within the timeout period of {timeout.Seconds} seconds. " + + $"Check the log output for error information."); + }); + } + + private static async Task StartCreateReactAppServerAsync( + string sourcePath, string scriptName, string pkgManagerCommand, ILogger logger, DiagnosticSource diagnosticSource, CancellationToken applicationStoppingToken) + { + logger.LogInformation($"Starting spa server."); + + var scriptRunner = new NodeScriptRunner( + sourcePath, scriptName, null, new Dictionary(), pkgManagerCommand, diagnosticSource, applicationStoppingToken); + scriptRunner.AttachToLogger(logger); + + using (var stdErrReader = new EventedStreamStringReader(scriptRunner.StdErr)) + { + try + { + // Although the dev server may eventually tell us the URL it's listening on, + // it doesn't do so until it's finished compiling, and even then only if there were + // no compiler warnings. So instead of waiting for that, consider it ready as soon + // as it starts listening for requests. + await scriptRunner.StdOut.WaitForMatch( + new Regex("Project is running at", RegexOptions.None, RegexMatchTimeout)); + } + catch (EndOfStreamException ex) + { + throw new InvalidOperationException( + $"The {pkgManagerCommand} script '{scriptName}' exited without indicating that the " + + $"dev server was listening for requests. The error output was: " + + $"{stdErrReader.ReadAsString()}", ex); + } + } + } + } + + /// + /// Extension methods for enabling development server middleware support. + /// + public static class SpaDevelopmentServerMiddlewareExtensions + { + /// + /// Handles requests by passing them through to an instance of the create-react-app server. + /// This means you can always serve up-to-date CLI-built resources without having + /// to run the create-react-app server manually. + /// + /// This feature should only be used in development. For production deployments, be + /// sure not to enable the create-react-app server. + /// + /// The . + /// The name of the script in your package.json file that launches the create-react-app server. + public static void UseSpaDevelopmentServer( + this ISpaBuilder spaBuilder, + string packageManager, + string npmScript, + int port) + { + if (spaBuilder == null) + { + throw new ArgumentNullException(nameof(spaBuilder)); + } + + if (packageManager == null) + { + throw new ArgumentNullException(nameof(packageManager)); + } + + var spaOptions = spaBuilder.Options; + + if (string.IsNullOrEmpty(spaOptions.SourcePath)) + { + throw new InvalidOperationException($"To use {nameof(UseSpaDevelopmentServer)}, you must supply a non-empty value for the {nameof(SpaOptions.SourcePath)} property of {nameof(SpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}."); + } + + SpaDevelopmentServerMiddleware.Attach(spaBuilder, packageManager, npmScript, port); + } + } +} diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs index 8da09686..9651d58e 100644 --- a/Timeline/Startup.cs +++ b/Timeline/Startup.cs @@ -127,9 +127,7 @@ namespace Timeline if (Environment.IsDevelopment()) { - // TODO! I'll waiting for aspnetcore to support custom package manager and port. - // It is already in master branch code but not published. - spa.UseReactDevelopmentServer(npmScript: "start"); + SpaServices.SpaDevelopmentServerMiddlewareExtensions.UseSpaDevelopmentServer(spa, packageManager: "yarn", npmScript: "start", port: 3000); } }); } -- cgit v1.2.3