[backend/masto-client] Implement link pagination response headers
This commit is contained in:
parent
4dd1997f45
commit
ee449ec751
8 changed files with 130 additions and 37 deletions
|
@ -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<string, object?> actionArguments, ActionExecutedContext context) {
|
||||||
|
if (actionArguments.Count == 0) return;
|
||||||
|
|
||||||
|
var query = actionArguments.Values.OfType<PaginationQuery>().FirstOrDefault();
|
||||||
|
if (query == null) return;
|
||||||
|
|
||||||
|
if (context.Result is not OkObjectResult result) return;
|
||||||
|
if (result.Value is not IEnumerable<IEntity> entities) return;
|
||||||
|
var ids = entities.Select(p => p.Id).ToList();
|
||||||
|
if (ids.Count == 0) return;
|
||||||
|
if (query.MinId != null) ids.Reverse();
|
||||||
|
|
||||||
|
List<string> 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Iceshrimp.Backend.Controllers.Attributes;
|
||||||
using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
|
using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
|
||||||
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
|
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
|
||||||
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
|
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
|
||||||
|
@ -14,6 +15,7 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
|
||||||
[Tags("Mastodon")]
|
[Tags("Mastodon")]
|
||||||
[Route("/api/v1/timelines")]
|
[Route("/api/v1/timelines")]
|
||||||
[AuthenticateOauth]
|
[AuthenticateOauth]
|
||||||
|
[LinkPagination(20, 40)]
|
||||||
[EnableRateLimiting("sliding")]
|
[EnableRateLimiting("sliding")]
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
|
||||||
|
@ -32,7 +34,7 @@ public class MastodonTimelineController(DatabaseContext db, NoteRenderer noteRen
|
||||||
.FilterHiddenListMembers(user)
|
.FilterHiddenListMembers(user)
|
||||||
.FilterBlocked(user)
|
.FilterBlocked(user)
|
||||||
.FilterMuted(user)
|
.FilterMuted(user)
|
||||||
.Paginate(query, 20, 40)
|
.Paginate(query, ControllerContext)
|
||||||
.RenderAllForMastodonAsync(noteRenderer);
|
.RenderAllForMastodonAsync(noteRenderer);
|
||||||
|
|
||||||
return Ok(res);
|
return Ok(res);
|
||||||
|
@ -50,7 +52,7 @@ public class MastodonTimelineController(DatabaseContext db, NoteRenderer noteRen
|
||||||
.HasVisibility(Note.NoteVisibility.Public)
|
.HasVisibility(Note.NoteVisibility.Public)
|
||||||
.FilterBlocked(user)
|
.FilterBlocked(user)
|
||||||
.FilterMuted(user)
|
.FilterMuted(user)
|
||||||
.Paginate(query, 20, 40)
|
.Paginate(query, ControllerContext)
|
||||||
.RenderAllForMastodonAsync(noteRenderer);
|
.RenderAllForMastodonAsync(noteRenderer);
|
||||||
|
|
||||||
return Ok(res);
|
return Ok(res);
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
|
using Iceshrimp.Backend.Core.Database;
|
||||||
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
||||||
|
|
||||||
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
|
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
|
||||||
|
|
||||||
public class Account {
|
public class Account : IEntity {
|
||||||
[J("id")] public required string Id { get; set; }
|
|
||||||
[J("username")] public required string Username { get; set; }
|
[J("username")] public required string Username { get; set; }
|
||||||
[J("acct")] public required string Acct { get; set; }
|
[J("acct")] public required string Acct { get; set; }
|
||||||
[J("fqn")] public required string FullyQualifiedName { get; set; }
|
[J("fqn")] public required string FullyQualifiedName { get; set; }
|
||||||
|
@ -26,4 +26,5 @@ public class Account {
|
||||||
[J("source")] public object? Source => null; //FIXME
|
[J("source")] public object? Source => null; //FIXME
|
||||||
[J("fields")] public object[] Fields => []; //FIXME
|
[J("fields")] public object[] Fields => []; //FIXME
|
||||||
[J("emojis")] public object[] Emoji => []; //FIXME
|
[J("emojis")] public object[] Emoji => []; //FIXME
|
||||||
|
[J("id")] public required string Id { get; set; }
|
||||||
}
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using Iceshrimp.Backend.Core.Database;
|
||||||
using Iceshrimp.Backend.Core.Database.Tables;
|
using Iceshrimp.Backend.Core.Database.Tables;
|
||||||
using Iceshrimp.Backend.Core.Middleware;
|
using Iceshrimp.Backend.Core.Middleware;
|
||||||
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
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;
|
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
|
||||||
|
|
||||||
public class Status {
|
public class Status : IEntity {
|
||||||
[J("text")] [JI(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[J("text")] [JI(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public required string? Text;
|
public required string? Text;
|
||||||
|
|
||||||
[J("id")] public required string Id { get; set; }
|
|
||||||
[J("uri")] public required string Uri { get; set; }
|
[J("uri")] public required string Uri { get; set; }
|
||||||
[J("url")] public required string Url { get; set; }
|
[J("url")] public required string Url { get; set; }
|
||||||
[J("account")] public required Account Account { 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("language")] public string? Language => null; //FIXME
|
||||||
|
|
||||||
|
[J("id")] public required string Id { get; set; }
|
||||||
|
|
||||||
public static string EncodeVisibility(Note.NoteVisibility visibility) {
|
public static string EncodeVisibility(Note.NoteVisibility visibility) {
|
||||||
return visibility switch {
|
return visibility switch {
|
||||||
Note.NoteVisibility.Public => "public",
|
Note.NoteVisibility.Public => "public",
|
||||||
|
|
5
Iceshrimp.Backend/Core/Database/IEntity.cs
Normal file
5
Iceshrimp.Backend/Core/Database/IEntity.cs
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
namespace Iceshrimp.Backend.Core.Database;
|
||||||
|
|
||||||
|
public interface IEntity {
|
||||||
|
public string Id { get; }
|
||||||
|
}
|
|
@ -25,7 +25,7 @@ namespace Iceshrimp.Backend.Core.Database.Tables;
|
||||||
[Index("Id", "UserHost")]
|
[Index("Id", "UserHost")]
|
||||||
[Index("Url")]
|
[Index("Url")]
|
||||||
[Index("UserId", "Id")]
|
[Index("UserId", "Id")]
|
||||||
public class Note {
|
public class Note : IEntity {
|
||||||
[PgName("note_visibility_enum")]
|
[PgName("note_visibility_enum")]
|
||||||
public enum NoteVisibility {
|
public enum NoteVisibility {
|
||||||
[PgName("public")] Public,
|
[PgName("public")] Public,
|
||||||
|
@ -35,11 +35,6 @@ public class Note {
|
||||||
[PgName("hidden")] Hidden
|
[PgName("hidden")] Hidden
|
||||||
}
|
}
|
||||||
|
|
||||||
[Key]
|
|
||||||
[Column("id")]
|
|
||||||
[StringLength(32)]
|
|
||||||
public string Id { get; set; } = null!;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The created date of the Note.
|
/// The created date of the Note.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -241,6 +236,11 @@ public class Note {
|
||||||
[InverseProperty(nameof(UserNotePin.Note))]
|
[InverseProperty(nameof(UserNotePin.Note))]
|
||||||
public virtual ICollection<UserNotePin> UserNotePins { get; set; } = new List<UserNotePin>();
|
public virtual ICollection<UserNotePin> UserNotePins { get; set; } = new List<UserNotePin>();
|
||||||
|
|
||||||
|
[Key]
|
||||||
|
[Column("id")]
|
||||||
|
[StringLength(32)]
|
||||||
|
public string Id { get; set; } = null!;
|
||||||
|
|
||||||
[Projectable]
|
[Projectable]
|
||||||
public bool IsVisibleFor(User user) => VisibilityIsPublicOrHome
|
public bool IsVisibleFor(User user) => VisibilityIsPublicOrHome
|
||||||
|| User == user
|
|| User == user
|
||||||
|
|
|
@ -20,12 +20,7 @@ namespace Iceshrimp.Backend.Core.Database.Tables;
|
||||||
[Index("AvatarId", IsUnique = true)]
|
[Index("AvatarId", IsUnique = true)]
|
||||||
[Index("BannerId", IsUnique = true)]
|
[Index("BannerId", IsUnique = true)]
|
||||||
[Index("Token", IsUnique = true)]
|
[Index("Token", IsUnique = true)]
|
||||||
public class User {
|
public class User : IEntity {
|
||||||
[Key]
|
|
||||||
[Column("id")]
|
|
||||||
[StringLength(32)]
|
|
||||||
public string Id { get; set; } = null!;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The created date of the User.
|
/// The created date of the User.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -456,6 +451,11 @@ public class User {
|
||||||
[InverseProperty(nameof(Webhook.User))]
|
[InverseProperty(nameof(Webhook.User))]
|
||||||
public virtual ICollection<Webhook> Webhooks { get; set; } = new List<Webhook>();
|
public virtual ICollection<Webhook> Webhooks { get; set; } = new List<Webhook>();
|
||||||
|
|
||||||
|
[Key]
|
||||||
|
[Column("id")]
|
||||||
|
[StringLength(32)]
|
||||||
|
public string Id { get; set; } = null!;
|
||||||
|
|
||||||
[Projectable]
|
[Projectable]
|
||||||
public bool IsBlockedBy(User user) => BlockedBy.Contains(user);
|
public bool IsBlockedBy(User user) => BlockedBy.Contains(user);
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
|
using Iceshrimp.Backend.Controllers.Attributes;
|
||||||
using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
|
using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
|
||||||
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
|
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
|
||||||
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
|
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
|
||||||
|
using Iceshrimp.Backend.Core.Database;
|
||||||
using Iceshrimp.Backend.Core.Database.Tables;
|
using Iceshrimp.Backend.Core.Database.Tables;
|
||||||
using Iceshrimp.Backend.Core.Middleware;
|
using Iceshrimp.Backend.Core.Middleware;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Iceshrimp.Backend.Core.Extensions;
|
namespace Iceshrimp.Backend.Core.Extensions;
|
||||||
|
@ -21,28 +24,48 @@ public static class NoteQueryableExtensions {
|
||||||
return query.Include(p => p.UserProfile);
|
return query.Include(p => p.UserProfile);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IQueryable<Note> Paginate(this IQueryable<Note> query, PaginationQuery p, int defaultLimit,
|
public static IQueryable<T> Paginate<T>(
|
||||||
int maxLimit) {
|
this IQueryable<T> query,
|
||||||
if (p is { SinceId: not null, MinId: not null })
|
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");
|
throw GracefulException.BadRequest("Can't use sinceId and minId params simultaneously");
|
||||||
|
|
||||||
query = p switch {
|
query = pq switch {
|
||||||
{ SinceId: not null, MaxId: not null } => query
|
{ SinceId: not null, MaxId: not null } => query
|
||||||
.Where(note => note.Id.IsGreaterThan(p.SinceId) &&
|
.Where(p => p.Id.IsGreaterThan(pq.SinceId) &&
|
||||||
note.Id.IsLessThan(p.MaxId))
|
p.Id.IsLessThan(pq.MaxId))
|
||||||
.OrderByDescending(note => note.Id),
|
.OrderByDescending(p => p.Id),
|
||||||
{ MinId: not null, MaxId: not null } => query
|
{ MinId: not null, MaxId: not null } => query
|
||||||
.Where(note => note.Id.IsGreaterThan(p.MinId) &&
|
.Where(p => p.Id.IsGreaterThan(pq.MinId) &&
|
||||||
note.Id.IsLessThan(p.MaxId))
|
p.Id.IsLessThan(pq.MaxId))
|
||||||
.OrderBy(note => note.Id),
|
.OrderBy(p => p.Id),
|
||||||
{ SinceId: not null } => query.Where(note => note.Id.IsGreaterThan(p.SinceId))
|
{ SinceId: not null } => query.Where(note => note.Id.IsGreaterThan(pq.SinceId))
|
||||||
.OrderByDescending(note => note.Id),
|
.OrderByDescending(p => p.Id),
|
||||||
{ MinId: not null } => query.Where(note => note.Id.IsGreaterThan(p.MinId)).OrderBy(note => note.Id),
|
{ MinId: not null } => query.Where(p => p.Id.IsGreaterThan(pq.MinId)).OrderBy(p => p.Id),
|
||||||
{ MaxId: not null } => query.Where(note => note.Id.IsLessThan(p.MaxId)).OrderByDescending(note => note.Id),
|
{ MaxId: not null } => query.Where(p => p.Id.IsLessThan(pq.MaxId)).OrderByDescending(p => p.Id),
|
||||||
_ => query.OrderByDescending(note => note.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<T> Paginate<T>(
|
||||||
|
this IQueryable<T> query,
|
||||||
|
PaginationQuery pq,
|
||||||
|
ControllerContext context
|
||||||
|
) where T : IEntity {
|
||||||
|
var filter = context.ActionDescriptor.FilterDescriptors.Select(p => p.Filter).OfType<LinkPaginationAttribute>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (filter == null)
|
||||||
|
throw new GracefulException("Route doesn't have a LinkPaginationAttribute");
|
||||||
|
|
||||||
|
return Paginate(query, pq, filter.DefaultLimit, filter.MaxLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IQueryable<Note> HasVisibility(this IQueryable<Note> query, Note.NoteVisibility visibility) {
|
public static IQueryable<Note> HasVisibility(this IQueryable<Note> query, Note.NoteVisibility visibility) {
|
||||||
|
|
Loading…
Add table
Reference in a new issue