aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Timeline.Tests/IntegratedTests/IntegratedTestBase.cs8
-rw-r--r--Timeline.Tests/IntegratedTests/TimelineTest.cs74
-rw-r--r--Timeline.Tests/IntegratedTests/UserTest.cs62
-rw-r--r--Timeline/ClientApp/package.json1
-rw-r--r--Timeline/ClientApp/src/config.ts13
-rw-r--r--Timeline/ClientApp/webpack.config.prod.dev.ts15
-rw-r--r--Timeline/Routes/ApiRoutePrefixConvention.cs3
-rw-r--r--Timeline/SpaServices/SpaDevelopmentServerMiddleware.cs489
-rw-r--r--Timeline/Startup.cs4
9 files changed, 565 insertions, 104 deletions
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<UserInfo>(s, options));
}
@@ -120,15 +120,15 @@ namespace Timeline.Tests.IntegratedTests
public Task<HttpClient> 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<HttpClient> 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<CreateTokenResponse>().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<object[]> 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<object[]> 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<TimelineInfo>().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<List<TimelineInfo>>()
.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<TimelineInfo>().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<TimelineInfo>().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<TimelineInfo>().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<TimelineInfo>().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<TimelineInfo>().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<TimelineInfo>().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<List<TimelineInfo>>()
.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<List<TimelineInfo>>()
.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<List<TimelineInfo>>()
.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<List<TimelineInfo>>()
.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<List<TimelineInfo>>()
.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<List<TimelineInfo>>()
.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<List<TimelineInfo>>()
.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<UserInfo[]>()
.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<UserInfo[]>()
.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<UserInfo[]>()
.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<UserInfo>()
.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<UserInfo>()
.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<UserInfo>()
.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<UserInfo>()
@@ -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<UserInfo>()
.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<UserInfo>()
.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<T> WithTimeout<T>(this Task<T> task, TimeSpan timeoutDelay, string message)
+ {
+ if (task == await Task.WhenAny(task, Task.Delay(timeoutDelay)))
+ {
+ return task.Result;
+ }
+ else
+ {
+ throw new TimeoutException(message);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Wraps a <see cref="StreamReader"/> to expose an evented API, issuing notifications
+ /// when the stream emits partial lines, completed lines, or finally closes.
+ /// </summary>
+ internal class EventedStreamReader
+ {
+ public delegate void OnReceivedChunkHandler(ArraySegment<char> 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<Match> WaitForMatch(Regex regex)
+ {
+ var tcs = new TaskCompletionSource<Match>();
+ 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<char>(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<char> 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();
+ }
+ }
+
+ /// <summary>
+ /// Captures the completed-line notifications from a <see cref="EventedStreamReader"/>,
+ /// combining the data into a single <see cref="string"/>.
+ /// </summary>
+ 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;
+ }
+ }
+ }
+
+ /// <summary>
+ /// Executes the <c>script</c> entries defined in a <c>package.json</c> file,
+ /// capturing any output written to stdio.
+ /// </summary>
+ 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<string, string> 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<ILoggerFactory>();
+ var logger = loggerFactory != null
+ ? loggerFactory.CreateLogger(logCategoryName)
+ : NullLogger.Instance;
+ return logger;
+ }
+ }
+
+ /// <summary>
+ /// Extension methods for enabling React development server middleware support.
+ /// </summary>
+ 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<IHostApplicationLifetime>().ApplicationStopping;
+ var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName);
+ var diagnosticSource = appBuilder.ApplicationServices.GetRequiredService<DiagnosticSource>();
+ 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<string, string>(), 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);
+ }
+ }
+ }
+ }
+
+ /// <summary>
+ /// Extension methods for enabling development server middleware support.
+ /// </summary>
+ public static class SpaDevelopmentServerMiddlewareExtensions
+ {
+ /// <summary>
+ /// 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.
+ /// </summary>
+ /// <param name="spaBuilder">The <see cref="ISpaBuilder"/>.</param>
+ /// <param name="npmScript">The name of the script in your package.json file that launches the create-react-app server.</param>
+ 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);
}
});
}