From ee449ec75153d56b4d3f6161063d48f358000bca Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Sat, 3 Feb 2024 21:35:34 +0100 Subject: [PATCH] [backend/masto-client] Implement link pagination response headers --- .../Attributes/LinkPaginationAttribute.cs | 60 +++++++++++++++++++ .../Mastodon/MastodonTimelineController.cs | 6 +- .../Mastodon/Schemas/Entities/Account.cs | 11 ++-- .../Mastodon/Schemas/Entities/Status.cs | 6 +- Iceshrimp.Backend/Core/Database/IEntity.cs | 5 ++ .../Core/Database/Tables/Note.cs | 12 ++-- .../Core/Database/Tables/User.cs | 12 ++-- .../Core/Extensions/QueryableExtensions.cs | 55 ++++++++++++----- 8 files changed, 130 insertions(+), 37 deletions(-) create mode 100644 Iceshrimp.Backend/Controllers/Attributes/LinkPaginationAttribute.cs create mode 100644 Iceshrimp.Backend/Core/Database/IEntity.cs diff --git a/Iceshrimp.Backend/Controllers/Attributes/LinkPaginationAttribute.cs b/Iceshrimp.Backend/Controllers/Attributes/LinkPaginationAttribute.cs new file mode 100644 index 00000000..75747da7 --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Attributes/LinkPaginationAttribute.cs @@ -0,0 +1,60 @@ +using Iceshrimp.Backend.Controllers.Mastodon.Schemas; +using Iceshrimp.Backend.Core.Database; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Iceshrimp.Backend.Controllers.Attributes; + +public class LinkPaginationAttribute(int defaultLimit, int maxLimit) : ActionFilterAttribute { + public int DefaultLimit => defaultLimit; + public int MaxLimit => maxLimit; + + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { + ArgumentNullException.ThrowIfNull(context, nameof(context)); + ArgumentNullException.ThrowIfNull(next, nameof(next)); + OnActionExecuting(context); + if (context.Result != null) return; + var result = await next(); + HandlePagination(context.ActionArguments, result); + OnActionExecuted(result); + } + + private void HandlePagination(IDictionary actionArguments, ActionExecutedContext context) { + if (actionArguments.Count == 0) return; + + var query = actionArguments.Values.OfType().FirstOrDefault(); + if (query == null) return; + + if (context.Result is not OkObjectResult result) return; + if (result.Value is not IEnumerable entities) return; + var ids = entities.Select(p => p.Id).ToList(); + if (ids.Count == 0) return; + if (query.MinId != null) ids.Reverse(); + + List links = []; + + var limit = Math.Min(query.Limit ?? defaultLimit, maxLimit); + var request = context.HttpContext.Request; + + if (ids.Count >= limit) { + var next = new QueryBuilder { + { "limit", limit.ToString() }, + { "max_id", ids.Last() } + }; + links.Add($"<{GetUrl(request, next.ToQueryString())}>; rel=\"next\""); + } + + var prev = new QueryBuilder { + { "limit", limit.ToString() }, + { "min_id", ids.First() } + }; + links.Add($"<{GetUrl(request, prev.ToQueryString())}>; rel=\"prev\""); + + context.HttpContext.Response.Headers.Link = string.Join(", ", links); + } + + private static string GetUrl(HttpRequest request, QueryString query) { + return UriHelper.BuildAbsolute("https", request.Host, request.PathBase, request.Path, query); + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Mastodon/MastodonTimelineController.cs b/Iceshrimp.Backend/Controllers/Mastodon/MastodonTimelineController.cs index bb8f2c54..31e0af2f 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/MastodonTimelineController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/MastodonTimelineController.cs @@ -1,3 +1,4 @@ +using Iceshrimp.Backend.Controllers.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Renderers; using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; @@ -14,6 +15,7 @@ namespace Iceshrimp.Backend.Controllers.Mastodon; [Tags("Mastodon")] [Route("/api/v1/timelines")] [AuthenticateOauth] +[LinkPagination(20, 40)] [EnableRateLimiting("sliding")] [Produces("application/json")] [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] @@ -32,7 +34,7 @@ public class MastodonTimelineController(DatabaseContext db, NoteRenderer noteRen .FilterHiddenListMembers(user) .FilterBlocked(user) .FilterMuted(user) - .Paginate(query, 20, 40) + .Paginate(query, ControllerContext) .RenderAllForMastodonAsync(noteRenderer); return Ok(res); @@ -50,7 +52,7 @@ public class MastodonTimelineController(DatabaseContext db, NoteRenderer noteRen .HasVisibility(Note.NoteVisibility.Public) .FilterBlocked(user) .FilterMuted(user) - .Paginate(query, 20, 40) + .Paginate(query, ControllerContext) .RenderAllForMastodonAsync(noteRenderer); return Ok(res); diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Account.cs b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Account.cs index 3a1c9b24..203ed398 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Account.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Account.cs @@ -1,9 +1,9 @@ +using Iceshrimp.Backend.Core.Database; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; -public class Account { - [J("id")] public required string Id { get; set; } +public class Account : IEntity { [J("username")] public required string Username { get; set; } [J("acct")] public required string Acct { get; set; } [J("fqn")] public required string FullyQualifiedName { get; set; } @@ -23,7 +23,8 @@ public class Account { [J("bot")] public required bool IsBot { get; set; } [J("discoverable")] public required bool IsDiscoverable { get; set; } - [J("source")] public object? Source => null; //FIXME - [J("fields")] public object[] Fields => []; //FIXME - [J("emojis")] public object[] Emoji => []; //FIXME + [J("source")] public object? Source => null; //FIXME + [J("fields")] public object[] Fields => []; //FIXME + [J("emojis")] public object[] Emoji => []; //FIXME + [J("id")] public required string Id { get; set; } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Status.cs b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Status.cs index 303de583..075428f5 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Status.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/Status.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Middleware; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; @@ -6,11 +7,10 @@ using JI = System.Text.Json.Serialization.JsonIgnoreAttribute; namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; -public class Status { +public class Status : IEntity { [J("text")] [JI(Condition = JsonIgnoreCondition.WhenWritingNull)] public required string? Text; - [J("id")] public required string Id { get; set; } [J("uri")] public required string Uri { get; set; } [J("url")] public required string Url { get; set; } [J("account")] public required Account Account { get; set; } @@ -52,6 +52,8 @@ public class Status { [J("language")] public string? Language => null; //FIXME + [J("id")] public required string Id { get; set; } + public static string EncodeVisibility(Note.NoteVisibility visibility) { return visibility switch { Note.NoteVisibility.Public => "public", diff --git a/Iceshrimp.Backend/Core/Database/IEntity.cs b/Iceshrimp.Backend/Core/Database/IEntity.cs new file mode 100644 index 00000000..a9c30876 --- /dev/null +++ b/Iceshrimp.Backend/Core/Database/IEntity.cs @@ -0,0 +1,5 @@ +namespace Iceshrimp.Backend.Core.Database; + +public interface IEntity { + public string Id { get; } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Database/Tables/Note.cs b/Iceshrimp.Backend/Core/Database/Tables/Note.cs index 6d32fe88..de45fa58 100644 --- a/Iceshrimp.Backend/Core/Database/Tables/Note.cs +++ b/Iceshrimp.Backend/Core/Database/Tables/Note.cs @@ -25,7 +25,7 @@ namespace Iceshrimp.Backend.Core.Database.Tables; [Index("Id", "UserHost")] [Index("Url")] [Index("UserId", "Id")] -public class Note { +public class Note : IEntity { [PgName("note_visibility_enum")] public enum NoteVisibility { [PgName("public")] Public, @@ -35,11 +35,6 @@ public class Note { [PgName("hidden")] Hidden } - [Key] - [Column("id")] - [StringLength(32)] - public string Id { get; set; } = null!; - /// /// The created date of the Note. /// @@ -241,6 +236,11 @@ public class Note { [InverseProperty(nameof(UserNotePin.Note))] public virtual ICollection UserNotePins { get; set; } = new List(); + [Key] + [Column("id")] + [StringLength(32)] + public string Id { get; set; } = null!; + [Projectable] public bool IsVisibleFor(User user) => VisibilityIsPublicOrHome || User == user diff --git a/Iceshrimp.Backend/Core/Database/Tables/User.cs b/Iceshrimp.Backend/Core/Database/Tables/User.cs index 7939b0a8..734c255b 100644 --- a/Iceshrimp.Backend/Core/Database/Tables/User.cs +++ b/Iceshrimp.Backend/Core/Database/Tables/User.cs @@ -20,12 +20,7 @@ namespace Iceshrimp.Backend.Core.Database.Tables; [Index("AvatarId", IsUnique = true)] [Index("BannerId", IsUnique = true)] [Index("Token", IsUnique = true)] -public class User { - [Key] - [Column("id")] - [StringLength(32)] - public string Id { get; set; } = null!; - +public class User : IEntity { /// /// The created date of the User. /// @@ -456,6 +451,11 @@ public class User { [InverseProperty(nameof(Webhook.User))] public virtual ICollection Webhooks { get; set; } = new List(); + [Key] + [Column("id")] + [StringLength(32)] + public string Id { get; set; } = null!; + [Projectable] public bool IsBlockedBy(User user) => BlockedBy.Contains(user); diff --git a/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs b/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs index 6f1d622d..4d82475c 100644 --- a/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs @@ -1,8 +1,11 @@ +using Iceshrimp.Backend.Controllers.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Renderers; using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; +using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Middleware; +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Iceshrimp.Backend.Core.Extensions; @@ -21,28 +24,48 @@ public static class NoteQueryableExtensions { return query.Include(p => p.UserProfile); } - public static IQueryable Paginate(this IQueryable query, PaginationQuery p, int defaultLimit, - int maxLimit) { - if (p is { SinceId: not null, MinId: not null }) + public static IQueryable Paginate( + this IQueryable query, + PaginationQuery pq, + int defaultLimit, + int maxLimit + ) where T : IEntity { + if (pq.Limit is < 1) + throw GracefulException.BadRequest("Limit cannot be less than 1"); + + if (pq is { SinceId: not null, MinId: not null }) throw GracefulException.BadRequest("Can't use sinceId and minId params simultaneously"); - query = p switch { + query = pq switch { { SinceId: not null, MaxId: not null } => query - .Where(note => note.Id.IsGreaterThan(p.SinceId) && - note.Id.IsLessThan(p.MaxId)) - .OrderByDescending(note => note.Id), + .Where(p => p.Id.IsGreaterThan(pq.SinceId) && + p.Id.IsLessThan(pq.MaxId)) + .OrderByDescending(p => p.Id), { MinId: not null, MaxId: not null } => query - .Where(note => note.Id.IsGreaterThan(p.MinId) && - note.Id.IsLessThan(p.MaxId)) - .OrderBy(note => note.Id), - { SinceId: not null } => query.Where(note => note.Id.IsGreaterThan(p.SinceId)) - .OrderByDescending(note => note.Id), - { MinId: not null } => query.Where(note => note.Id.IsGreaterThan(p.MinId)).OrderBy(note => note.Id), - { MaxId: not null } => query.Where(note => note.Id.IsLessThan(p.MaxId)).OrderByDescending(note => note.Id), - _ => query.OrderByDescending(note => note.Id) + .Where(p => p.Id.IsGreaterThan(pq.MinId) && + p.Id.IsLessThan(pq.MaxId)) + .OrderBy(p => p.Id), + { SinceId: not null } => query.Where(note => note.Id.IsGreaterThan(pq.SinceId)) + .OrderByDescending(p => p.Id), + { MinId: not null } => query.Where(p => p.Id.IsGreaterThan(pq.MinId)).OrderBy(p => p.Id), + { MaxId: not null } => query.Where(p => p.Id.IsLessThan(pq.MaxId)).OrderByDescending(p => p.Id), + _ => query.OrderByDescending(p => p.Id) }; - return query.Take(Math.Min(p.Limit ?? defaultLimit, maxLimit)); + return query.Take(Math.Min(pq.Limit ?? defaultLimit, maxLimit)); + } + + public static IQueryable Paginate( + this IQueryable query, + PaginationQuery pq, + ControllerContext context + ) where T : IEntity { + var filter = context.ActionDescriptor.FilterDescriptors.Select(p => p.Filter).OfType() + .FirstOrDefault(); + if (filter == null) + throw new GracefulException("Route doesn't have a LinkPaginationAttribute"); + + return Paginate(query, pq, filter.DefaultLimit, filter.MaxLimit); } public static IQueryable HasVisibility(this IQueryable query, Note.NoteVisibility visibility) {