aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Timeline.Tests/Helpers/CacheTestHelper.cs64
-rw-r--r--Timeline.Tests/IntegratedTests/TimelineTest.cs4
-rw-r--r--Timeline.Tests/IntegratedTests/UserAvatarTest.cs45
-rw-r--r--Timeline/Controllers/TimelineController.cs9
-rw-r--r--Timeline/Controllers/UserAvatarController.cs1
-rw-r--r--Timeline/Helpers/DataCacheHelper.cs25
-rw-r--r--Timeline/Services/TimelineService.cs49
7 files changed, 145 insertions, 52 deletions
diff --git a/Timeline.Tests/Helpers/CacheTestHelper.cs b/Timeline.Tests/Helpers/CacheTestHelper.cs
new file mode 100644
index 00000000..b3709a28
--- /dev/null
+++ b/Timeline.Tests/Helpers/CacheTestHelper.cs
@@ -0,0 +1,64 @@
+using FluentAssertions;
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading.Tasks;
+using Timeline.Models.Http;
+
+namespace Timeline.Tests.Helpers
+{
+ public static class CacheTestHelper
+ {
+ public static async Task TestCache(HttpClient client, string getUrl)
+ {
+ EntityTagHeaderValue eTag;
+ {
+ var res = await client.GetAsync(getUrl);
+ res.Should().HaveStatusCode(200);
+ var cacheControlHeader = res.Headers.CacheControl;
+ cacheControlHeader.NoCache.Should().BeTrue();
+ cacheControlHeader.NoStore.Should().BeFalse();
+ cacheControlHeader.Private.Should().BeTrue();
+ cacheControlHeader.Public.Should().BeFalse();
+ cacheControlHeader.MustRevalidate.Should().BeTrue();
+ cacheControlHeader.MaxAge.Should().NotBeNull().And.Be(TimeSpan.FromDays(14));
+ eTag = res.Headers.ETag;
+ }
+
+ {
+ using var request = new HttpRequestMessage()
+ {
+ RequestUri = new Uri(client.BaseAddress, getUrl),
+ Method = HttpMethod.Get,
+ };
+ request.Headers.TryAddWithoutValidation("If-None-Match", "\"dsdfd");
+ var res = await client.SendAsync(request);
+ res.Should().HaveStatusCode(HttpStatusCode.BadRequest)
+ .And.HaveCommonBody(ErrorCodes.Common.Header.IfNonMatch_BadFormat);
+ }
+
+ {
+ using var request = new HttpRequestMessage()
+ {
+ RequestUri = new Uri(client.BaseAddress, getUrl),
+ Method = HttpMethod.Get,
+ };
+ request.Headers.TryAddWithoutValidation("If-None-Match", "\"aaa\"");
+ var res = await client.SendAsync(request);
+ res.Should().HaveStatusCode(HttpStatusCode.OK);
+ }
+
+ {
+ using var request = new HttpRequestMessage()
+ {
+ RequestUri = new Uri(client.BaseAddress, getUrl),
+ Method = HttpMethod.Get,
+ };
+ request.Headers.Add("If-None-Match", eTag.ToString());
+ var res = await client.SendAsync(request);
+ res.Should().HaveStatusCode(HttpStatusCode.NotModified);
+ }
+ }
+ }
+}
diff --git a/Timeline.Tests/IntegratedTests/TimelineTest.cs b/Timeline.Tests/IntegratedTests/TimelineTest.cs
index 682cfd7c..845208e8 100644
--- a/Timeline.Tests/IntegratedTests/TimelineTest.cs
+++ b/Timeline.Tests/IntegratedTests/TimelineTest.cs
@@ -1091,6 +1091,10 @@ namespace Timeline.Tests.IntegratedTests
}
{
+ await CacheTestHelper.TestCache(client, generator(1, $"posts/{postId}/data"));
+ }
+
+ {
var res = await client.DeleteAsync(generator(1, $"posts/{postId}"));
res.Should().BeDelete(true);
}
diff --git a/Timeline.Tests/IntegratedTests/UserAvatarTest.cs b/Timeline.Tests/IntegratedTests/UserAvatarTest.cs
index fa0120f1..fbf34490 100644
--- a/Timeline.Tests/IntegratedTests/UserAvatarTest.cs
+++ b/Timeline.Tests/IntegratedTests/UserAvatarTest.cs
@@ -6,7 +6,6 @@ using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Gif;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Formats.Png;
-using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
@@ -58,55 +57,17 @@ namespace Timeline.Tests.IntegratedTests
body.Should().Equal(defaultAvatarData);
}
- EntityTagHeaderValue eTag;
{
- var res = await client.GetAsync($"users/user1/avatar");
+ var res = await client.GetAsync("users/user1/avatar");
res.Should().HaveStatusCode(200);
res.Content.Headers.ContentType.MediaType.Should().Be("image/png");
var body = await res.Content.ReadAsByteArrayAsync();
body.Should().Equal(defaultAvatarData);
- var cacheControl = res.Headers.CacheControl;
- cacheControl.NoCache.Should().BeTrue();
- cacheControl.NoStore.Should().BeFalse();
- cacheControl.MaxAge.Should().NotBeNull().And.Be(TimeSpan.Zero);
- eTag = res.Headers.ETag;
}
- await GetReturnDefault("admin");
-
- {
- using var request = new HttpRequestMessage()
- {
- RequestUri = new Uri(client.BaseAddress, "users/user1/avatar"),
- Method = HttpMethod.Get,
- };
- request.Headers.TryAddWithoutValidation("If-None-Match", "\"dsdfd");
- var res = await client.SendAsync(request);
- res.Should().HaveStatusCode(HttpStatusCode.BadRequest)
- .And.HaveCommonBody().Which.Code.Should().Be(ErrorCodes.Common.Header.IfNonMatch_BadFormat);
- }
-
- {
- using var request = new HttpRequestMessage()
- {
- RequestUri = new Uri(client.BaseAddress, "users/user1/avatar"),
- Method = HttpMethod.Get,
- };
- request.Headers.TryAddWithoutValidation("If-None-Match", "\"aaa\"");
- var res = await client.SendAsync(request);
- res.Should().HaveStatusCode(HttpStatusCode.OK);
- }
+ await CacheTestHelper.TestCache(client, "users/user1/avatar");
- {
- using var request = new HttpRequestMessage()
- {
- RequestUri = new Uri(client.BaseAddress, "users/user1/avatar"),
- Method = HttpMethod.Get,
- };
- request.Headers.Add("If-None-Match", eTag.ToString());
- var res = await client.SendAsync(request);
- res.Should().HaveStatusCode(HttpStatusCode.NotModified);
- }
+ await GetReturnDefault("admin");
{
using var content = new ByteArrayContent(new[] { (byte)0x00 });
diff --git a/Timeline/Controllers/TimelineController.cs b/Timeline/Controllers/TimelineController.cs
index 58390c29..8bc0345f 100644
--- a/Timeline/Controllers/TimelineController.cs
+++ b/Timeline/Controllers/TimelineController.cs
@@ -9,6 +9,7 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Timeline.Filters;
+using Timeline.Helpers;
using Timeline.Models;
using Timeline.Models.Http;
using Timeline.Models.Validation;
@@ -114,7 +115,6 @@ namespace Timeline.Controllers
return result;
}
- // TODO: Make cache available.
[HttpGet("timelines/{name}/posts/{id}/data")]
public async Task<ActionResult<List<TimelinePostInfo>>> PostDataGet([FromRoute][GeneralTimelineName] string name, [FromRoute] long id)
{
@@ -125,8 +125,11 @@ namespace Timeline.Controllers
try
{
- var data = await _service.GetPostData(name, id);
- return File(data.Data, data.Type, data.LastModified, new EntityTagHeaderValue($"\"{data.ETag}\""));
+ return await DataCacheHelper.GenerateActionResult(this, () => _service.GetPostDataETag(name, id), async () =>
+ {
+ var data = await _service.GetPostData(name, id);
+ return data;
+ });
}
catch (TimelinePostNotExistException)
{
diff --git a/Timeline/Controllers/UserAvatarController.cs b/Timeline/Controllers/UserAvatarController.cs
index f78dcb08..b5f4be1e 100644
--- a/Timeline/Controllers/UserAvatarController.cs
+++ b/Timeline/Controllers/UserAvatarController.cs
@@ -32,7 +32,6 @@ namespace Timeline.Controllers
}
[HttpGet("users/{username}/avatar")]
- [ResponseCache(NoStore = false, Location = ResponseCacheLocation.None, Duration = 0)]
public async Task<IActionResult> Get([FromRoute][Username] string username)
{
long id;
diff --git a/Timeline/Helpers/DataCacheHelper.cs b/Timeline/Helpers/DataCacheHelper.cs
index c13aaddb..574d90b4 100644
--- a/Timeline/Helpers/DataCacheHelper.cs
+++ b/Timeline/Helpers/DataCacheHelper.cs
@@ -66,11 +66,25 @@ namespace Timeline.Helpers
public static class DataCacheHelper
{
- public static async Task<ActionResult> GenerateActionResult(Controller controller, ICacheableDataProvider provider)
+ public static async Task<ActionResult> GenerateActionResult(Controller controller, ICacheableDataProvider provider, TimeSpan? maxAge = null)
{
+ const string CacheControlHeaderKey = "Cache-Control";
const string IfNonMatchHeaderKey = "If-None-Match";
const string ETagHeaderKey = "ETag";
+ string GenerateCacheControlHeaderValue()
+ {
+ var cacheControlHeader = new CacheControlHeaderValue()
+ {
+ NoCache = true,
+ NoStore = false,
+ MaxAge = maxAge ?? TimeSpan.FromDays(14),
+ Private = true,
+ MustRevalidate = true
+ };
+ return cacheControlHeader.ToString();
+ }
+
var loggerFactory = controller.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger(typeof(DataCacheHelper));
@@ -89,20 +103,23 @@ namespace Timeline.Helpers
if (eTagList.FirstOrDefault(e => e.Equals(eTag)) != null)
{
- controller.Response.Headers.Add(ETagHeaderKey, eTagValue);
logger.LogInformation(LogResultNotModified);
+ controller.Response.Headers.Add(ETagHeaderKey, eTagValue);
+ controller.Response.Headers.Add(CacheControlHeaderKey, GenerateCacheControlHeaderValue());
+
return controller.StatusCode(StatusCodes.Status304NotModified);
}
}
var data = await provider.GetData();
logger.LogInformation(LogResultData);
+ controller.Response.Headers.Add(CacheControlHeaderKey, GenerateCacheControlHeaderValue());
return controller.File(data.Data, data.Type, data.LastModified, eTag);
}
- public static Task<ActionResult> GenerateActionResult(Controller controller, Func<Task<string>> getDataETagDelegate, Func<Task<ICacheableData>> getDataDelegate)
+ public static Task<ActionResult> GenerateActionResult(Controller controller, Func<Task<string>> getDataETagDelegate, Func<Task<ICacheableData>> getDataDelegate, TimeSpan? maxAge = null)
{
- return GenerateActionResult(controller, new DelegateCacheableDataProvider(getDataETagDelegate, getDataDelegate));
+ return GenerateActionResult(controller, new DelegateCacheableDataProvider(getDataETagDelegate, getDataDelegate), maxAge);
}
}
}
diff --git a/Timeline/Services/TimelineService.cs b/Timeline/Services/TimelineService.cs
index 301a1d97..b26016e5 100644
--- a/Timeline/Services/TimelineService.cs
+++ b/Timeline/Services/TimelineService.cs
@@ -7,6 +7,7 @@ using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Timeline.Entities;
+using Timeline.Helpers;
using Timeline.Models;
using Timeline.Models.Validation;
using static Timeline.Resources.Services.TimelineService;
@@ -32,14 +33,14 @@ namespace Timeline.Services
public long UserId { get; set; }
}
- public class PostData
+ public class PostData : ICacheableData
{
#pragma warning disable CA1819 // Properties should not return arrays
public byte[] Data { get; set; } = default!;
#pragma warning restore CA1819 // Properties should not return arrays
public string Type { get; set; } = default!;
public string ETag { get; set; } = default!;
- public DateTime LastModified { get; set; } = default!;
+ public DateTime? LastModified { get; set; }
}
/// <summary>
@@ -92,6 +93,20 @@ namespace Timeline.Services
Task<List<TimelinePost>> GetPosts(string name);
/// <summary>
+ /// Get the etag of data of a post.
+ /// </summary>
+ /// <param name="name">See remarks of <see cref="IBaseTimelineService"/>.</param>
+ /// <param name="postId">The id of the post.</param>
+ /// <returns>The etag of the data.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> is null.</exception>
+ /// <exception cref="ArgumentException">See remarks of <see cref="IBaseTimelineService"/>.</exception>
+ /// <exception cref="TimelineNotExistException">See remarks of <see cref="IBaseTimelineService"/>.</exception>
+ /// <exception cref="TimelinePostNotExistException">Thrown when post of <paramref name="postId"/> does not exist or has been deleted.</exception>
+ /// <exception cref="InvalidOperationException">Thrown when post has no data. See remarks.</exception>
+ /// <seealso cref="GetPostData(string, long)"/>
+ Task<string> GetPostDataETag(string name, long postId);
+
+ /// <summary>
/// Get the data of a post.
/// </summary>
/// <param name="name">See remarks of <see cref="IBaseTimelineService"/>.</param>
@@ -105,6 +120,7 @@ namespace Timeline.Services
/// <remarks>
/// Use this method to retrieve the image of image post.
/// </remarks>
+ /// <seealso cref="GetPostDataETag(string, long)"/>
Task<PostData> GetPostData(string name, long postId);
/// <summary>
@@ -402,6 +418,29 @@ namespace Timeline.Services
}
return posts;
}
+
+ public async Task<string> GetPostDataETag(string name, long postId)
+ {
+ if (name == null)
+ throw new ArgumentNullException(nameof(name));
+
+ var timelineId = await FindTimelineId(name);
+ var postEntity = await Database.TimelinePosts.Where(p => p.LocalId == postId).SingleOrDefaultAsync();
+
+ if (postEntity == null)
+ throw new TimelinePostNotExistException(name, postId);
+
+ if (postEntity.Content == null)
+ throw new TimelinePostNotExistException(name, postId, true);
+
+ if (postEntity.ContentType != TimelinePostContentTypes.Image)
+ throw new InvalidOperationException(ExceptionGetDataNonImagePost);
+
+ var tag = postEntity.Content;
+
+ return tag;
+ }
+
public async Task<PostData> GetPostData(string name, long postId)
{
if (name == null)
@@ -1014,6 +1053,12 @@ namespace Timeline.Services
return s.GetPosts(realName);
}
+ public Task<string> GetPostDataETag(string name, long postId)
+ {
+ var s = BranchName(name, out var realName);
+ return s.GetPostDataETag(realName, postId);
+ }
+
public Task<PostData> GetPostData(string name, long postId)
{
var s = BranchName(name, out var realName);