diff --git a/Iceshrimp.Backend/Controllers/Federation/ActivityPubController.cs b/Iceshrimp.Backend/Controllers/Federation/ActivityPubController.cs index 64d154a8..298015c5 100644 --- a/Iceshrimp.Backend/Controllers/Federation/ActivityPubController.cs +++ b/Iceshrimp.Backend/Controllers/Federation/ActivityPubController.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Text; using Iceshrimp.Backend.Controllers.Federation.Attributes; using Iceshrimp.Backend.Controllers.Shared.Attributes; @@ -9,17 +10,17 @@ using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Queues; using Iceshrimp.Backend.Core.Services; -using Iceshrimp.Shared.Schemas.Web; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using Newtonsoft.Json.Linq; namespace Iceshrimp.Backend.Controllers.Federation; [FederationApiController] [FederationSemaphore] [UseNewtonsoftJson] -[Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] +[ProducesActivityStreamsPayload] public class ActivityPubController( DatabaseContext db, QueueService queues, @@ -31,71 +32,78 @@ public class ActivityPubController( [HttpGet("/notes/{id}")] [AuthorizedFetch] [MediaTypeRouteFilter("application/activity+json", "application/ld+json")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASNote))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task GetNote(string id) + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK, HttpStatusCode.MovedPermanently)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task> GetNote(string id) { var actor = HttpContext.GetActor(); var note = await db.Notes .IncludeCommonProperties() .EnsureVisibleFor(actor) .FirstOrDefaultAsync(p => p.Id == id); - if (note == null) return NotFound(); + if (note == null) throw GracefulException.NotFound("Note not found"); if (note.User.IsRemoteUser) return RedirectPermanent(note.Uri ?? throw new Exception("Refusing to render remote note without uri")); - var rendered = await noteRenderer.RenderAsync(note); - var compacted = rendered.Compact(); - return Ok(compacted); + var rendered = await noteRenderer.RenderAsync(note); + return rendered.Compact(); } [HttpGet("/notes/{id}/activity")] [AuthorizedFetch] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActivity))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task GetRenote(string id) + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task GetRenote(string id) { var actor = HttpContext.GetActor(); + var note = await db.Notes .IncludeCommonProperties() .EnsureVisibleFor(actor) - .FirstOrDefaultAsync(p => p.Id == id && p.UserHost == null); + .Where(p => p.Id == id && p.UserHost == null && p.IsPureRenote && p.Renote != null) + .FirstOrDefaultAsync() ?? + throw GracefulException.NotFound("Note not found"); - if (note is not { IsPureRenote: true, Renote: not null }) return NotFound(); - - var rendered = ActivityPub.ActivityRenderer.RenderAnnounce(noteRenderer.RenderLite(note.Renote), - note.GetPublicUri(config.Value), - userRenderer.RenderLite(note.User), - note.Visibility, - note.User.GetPublicUri(config.Value) + "/followers"); - var compacted = rendered.Compact(); - return Ok(compacted); + return ActivityPub.ActivityRenderer + .RenderAnnounce(noteRenderer.RenderLite(note.Renote!), + note.GetPublicUri(config.Value), + userRenderer.RenderLite(note.User), + note.Visibility, + note.User.GetPublicUri(config.Value) + "/followers") + .Compact(); } [HttpGet("/users/{id}")] [AuthorizedFetch] [MediaTypeRouteFilter("application/activity+json", "application/ld+json")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task GetUser(string id) + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK, HttpStatusCode.MovedPermanently)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task> GetUser(string id) { var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id); - if (user == null) return NotFound(); - if (user.IsRemoteUser) return user.Uri != null ? RedirectPermanent(user.Uri) : NotFound(); - var rendered = await userRenderer.RenderAsync(user); - var compacted = LdHelpers.Compact(rendered); - return Ok(compacted); + if (user == null) throw GracefulException.NotFound("User not found"); + if (user.IsRemoteUser) + { + if (user.Uri != null) + return RedirectPermanent(user.Uri); + throw GracefulException.NotFound("User not found"); + } + + var rendered = await userRenderer.RenderAsync(user); + return ((ASObject)rendered).Compact(); } [HttpGet("/users/{id}/collections/featured")] [AuthorizedFetch] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task GetUserFeatured(string id) + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task GetUserFeatured(string id) { var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && p.IsLocalUser); - if (user == null) return NotFound(); + if (user == null) throw GracefulException.NotFound("User not found"); var pins = await db.UserNotePins.Where(p => p.User == user) .OrderByDescending(p => p.Id) @@ -114,65 +122,72 @@ public class ActivityPubController( Items = rendered.Cast().ToList() }; - var compacted = res.Compact(); - return Ok(compacted); + return res.Compact(); } [HttpGet("/@{acct}")] [AuthorizedFetch] [MediaTypeRouteFilter("application/activity+json", "application/ld+json")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task GetUserByUsername(string acct) + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK, HttpStatusCode.MovedPermanently)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task> GetUserByUsername(string acct) { var split = acct.Split('@'); - if (acct.Split('@').Length > 2) return NotFound(); + if (acct.Split('@').Length > 2) throw GracefulException.NotFound("User not found"); if (split.Length == 2) { - var remoteUser = await db.Users.IncludeCommonProperties() + var remoteUser = await db.Users + .IncludeCommonProperties() .FirstOrDefaultAsync(p => p.UsernameLower == split[0].ToLowerInvariant() && p.Host == split[1].ToLowerInvariant().ToPunycode()); - return remoteUser?.Uri != null ? RedirectPermanent(remoteUser.Uri) : NotFound(); + + if (remoteUser?.Uri != null) + return RedirectPermanent(remoteUser.Uri); + throw GracefulException.NotFound("User not found"); } - var user = await db.Users.IncludeCommonProperties() + var user = await db.Users + .IncludeCommonProperties() .FirstOrDefaultAsync(p => p.UsernameLower == acct.ToLowerInvariant() && p.IsLocalUser); - if (user == null) return NotFound(); - var rendered = await userRenderer.RenderAsync(user); - var compacted = LdHelpers.Compact(rendered); - return Ok(compacted); + + if (user == null) throw GracefulException.NotFound("User not found"); + var rendered = await userRenderer.RenderAsync(user); + return ((ASObject)rendered).Compact(); } [HttpPost("/inbox")] [HttpPost("/users/{id}/inbox")] [InboxValidation] [EnableRequestBuffering(1024 * 1024)] - [Produces("text/plain")] - [Consumes("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] - public async Task Inbox(string? id) + [ConsumesActivityStreamsPayload] + [ProducesResults(HttpStatusCode.Accepted)] + public async Task Inbox(string? id) { using var reader = new StreamReader(Request.Body, Encoding.UTF8, true, 1024, true); var body = await reader.ReadToEndAsync(); Request.Body.Position = 0; + await queues.InboxQueue.EnqueueAsync(new InboxJobData { Body = body, InboxUserId = id, AuthenticatedUserId = HttpContext.GetActor()?.Id }); + return Accepted(); } [HttpGet("/emoji/{name}")] [AuthorizedFetch] [MediaTypeRouteFilter("application/activity+json", "application/ld+json")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASEmoji))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task GetEmoji(string name) + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task> GetEmoji(string name) { var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Name == name && p.Host == null); - if (emoji == null) return NotFound(); + if (emoji == null) throw GracefulException.NotFound("Emoji not found"); var rendered = new ASEmoji { @@ -181,7 +196,6 @@ public class ActivityPubController( Image = new ASImage { Url = new ASLink(emoji.PublicUrl) } }; - var compacted = LdHelpers.Compact(rendered); - return Ok(compacted); + return LdHelpers.Compact(rendered); } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Federation/NodeInfoController.cs b/Iceshrimp.Backend/Controllers/Federation/NodeInfoController.cs index c0410439..37f9d938 100644 --- a/Iceshrimp.Backend/Controllers/Federation/NodeInfoController.cs +++ b/Iceshrimp.Backend/Controllers/Federation/NodeInfoController.cs @@ -1,5 +1,7 @@ +using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Federation.Attributes; +using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Federation.WebFinger; @@ -18,8 +20,8 @@ public class NodeInfoController(IOptions config, Databas { [HttpGet("2.1")] [HttpGet("2.0")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(WebFingerResponse))] - public async Task GetNodeInfo() + [ProducesResults(HttpStatusCode.OK)] + public async Task GetNodeInfo() { var cutoffMonth = DateTime.UtcNow - TimeSpan.FromDays(30); var cutoffHalfYear = DateTime.UtcNow - TimeSpan.FromDays(180); @@ -36,7 +38,7 @@ public class NodeInfoController(IOptions config, Databas p.LastActiveDate > cutoffHalfYear); var localPosts = await db.Notes.LongCountAsync(p => p.UserHost == null); - var result = new NodeInfoResponse + return new NodeInfoResponse { Version = Request.Path.Value?.EndsWith("2.1") ?? false ? "2.1" : "2.0", Software = new NodeInfoResponse.NodeInfoSoftware @@ -90,7 +92,5 @@ public class NodeInfoController(IOptions config, Databas }, OpenRegistrations = false }; - - return Ok(result); } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Federation/WellKnownController.cs b/Iceshrimp.Backend/Controllers/Federation/WellKnownController.cs index 162378e6..9ab7e730 100644 --- a/Iceshrimp.Backend/Controllers/Federation/WellKnownController.cs +++ b/Iceshrimp.Backend/Controllers/Federation/WellKnownController.cs @@ -1,14 +1,16 @@ +using System.Net; using System.Net.Http.Headers; using System.Net.Mime; using System.Text; using System.Xml.Serialization; using Iceshrimp.Backend.Controllers.Federation.Attributes; using Iceshrimp.Backend.Controllers.Federation.Schemas; +using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Federation.WebFinger; -using Iceshrimp.Shared.Schemas.Web; +using Iceshrimp.Backend.Core.Middleware; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -23,9 +25,9 @@ public class WellKnownController(IOptions config, Databa { [HttpGet("webfinger")] [Produces(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(WebFingerResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task WebFinger([FromQuery] string resource) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task WebFinger([FromQuery] string resource) { User? user; if (resource.StartsWith($"https://{config.Value.WebDomain}/users/")) @@ -39,20 +41,20 @@ public class WellKnownController(IOptions config, Databa resource = resource[5..]; var split = resource.TrimStart('@').Split('@'); - if (split.Length > 2) return NotFound(); + if (split.Length > 2) throw GracefulException.NotFound("User not found"); if (split.Length == 2) { List domains = [config.Value.AccountDomain, config.Value.WebDomain]; - if (!domains.Contains(split[1])) return NotFound(); + if (!domains.Contains(split[1])) throw GracefulException.NotFound("User not found"); } user = await db.Users.FirstOrDefaultAsync(p => p.UsernameLower == split[0].ToLowerInvariant() && p.IsLocalUser); } - if (user == null) return NotFound(); + if (user == null) throw GracefulException.NotFound("User not found"); - var response = new WebFingerResponse + return new WebFingerResponse { Subject = $"acct:{user.Username}@{config.Value.AccountDomain}", Links = @@ -76,16 +78,14 @@ public class WellKnownController(IOptions config, Databa } ] }; - - return Ok(response); } [HttpGet("nodeinfo")] [Produces(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(NodeInfoIndexResponse))] - public IActionResult NodeInfo() + [ProducesResults(HttpStatusCode.OK)] + public NodeInfoIndexResponse NodeInfo() { - var response = new NodeInfoIndexResponse + return new NodeInfoIndexResponse { Links = [ @@ -101,14 +101,12 @@ public class WellKnownController(IOptions config, Databa } ] }; - - return Ok(response); } [HttpGet("host-meta")] [Produces("application/xrd+xml", "application/jrd+json")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(HostMetaJsonResponse))] - public IActionResult HostMeta() + [ProducesResults(HttpStatusCode.OK)] + public ActionResult HostMeta() { var accept = Request.Headers.Accept.OfType() .SelectMany(p => p.Split(",")) @@ -117,7 +115,7 @@ public class WellKnownController(IOptions config, Databa .ToList(); if (accept.Contains("application/jrd+json") || accept.Contains("application/json")) - return HostMetaJson(); + return Ok(HostMetaJson()); var obj = new HostMetaXmlResponse(config.Value.WebDomain); var serializer = new XmlSerializer(obj.GetType()); @@ -129,10 +127,10 @@ public class WellKnownController(IOptions config, Databa [HttpGet("host-meta.json")] [Produces("application/jrd+json")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(HostMetaJsonResponse))] - public IActionResult HostMetaJson() + [ProducesResults(HttpStatusCode.OK)] + public HostMetaJsonResponse HostMetaJson() { - return Ok(new HostMetaJsonResponse(config.Value.WebDomain)); + return new HostMetaJsonResponse(config.Value.WebDomain); } private class Utf8StringWriter : StringWriter diff --git a/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs b/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs index 78bc73ed..e6f1717c 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Renderers; @@ -37,18 +38,17 @@ public class AccountController( { [HttpGet("verify_credentials")] [Authorize("read:accounts")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AccountEntity))] - public async Task VerifyUserCredentials() + [ProducesResults(HttpStatusCode.OK)] + public async Task VerifyUserCredentials() { var user = HttpContext.GetUserOrFail(); - var res = await userRenderer.RenderAsync(user, user.UserProfile, source: true); - return Ok(res); + return await userRenderer.RenderAsync(user, user.UserProfile, source: true); } [HttpPatch("update_credentials")] [Authorize("write:accounts")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AccountEntity))] - public async Task UpdateUserCredentials([FromHybrid] AccountSchemas.AccountUpdateRequest request) + [ProducesResults(HttpStatusCode.OK)] + public async Task UpdateUserCredentials([FromHybrid] AccountSchemas.AccountUpdateRequest request) { var user = HttpContext.GetUserOrFail(); if (user.UserProfile == null) @@ -124,15 +124,13 @@ public class AccountController( } user = await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); - - var res = await userRenderer.RenderAsync(user, user.UserProfile, source: true); - return Ok(res); + return await userRenderer.RenderAsync(user, user.UserProfile, source: true); } [HttpDelete("/api/v1/profile/avatar")] [Authorize("write:accounts")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AccountEntity))] - public async Task DeleteUserAvatar() + [ProducesResults(HttpStatusCode.OK)] + public async Task DeleteUserAvatar() { var user = HttpContext.GetUserOrFail(); if (user.AvatarId != null) @@ -153,8 +151,8 @@ public class AccountController( [HttpDelete("/api/v1/profile/header")] [Authorize("write:accounts")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AccountEntity))] - public async Task DeleteUserBanner() + [ProducesResults(HttpStatusCode.OK)] + public async Task DeleteUserBanner() { var user = HttpContext.GetUserOrFail(); if (user.BannerId != null) @@ -174,9 +172,9 @@ public class AccountController( } [HttpGet("{id}")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AccountEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetUser(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task GetUser(string id) { var localUser = HttpContext.GetUser(); if (config.Value.PublicPreview == Enums.PublicPreview.Lockdown && localUser == null) @@ -188,16 +186,15 @@ public class AccountController( if (config.Value.PublicPreview <= Enums.PublicPreview.Restricted && user.IsRemoteUser && localUser == null) throw GracefulException.Forbidden("Public preview is disabled on this instance"); - var res = await userRenderer.RenderAsync(await userResolver.GetUpdatedUser(user)); - - return Ok(res); + return await userRenderer.RenderAsync(await userResolver.GetUpdatedUser(user)); } [HttpPost("{id}/follow")] [Authorize("write:follows")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] //TODO: [FromHybrid] request (bool reblogs, bool notify, bool languages) - public async Task FollowUser(string id) + public async Task FollowUser(string id) { var user = HttpContext.GetUserOrFail(); if (user.Id == id) @@ -222,15 +219,14 @@ public class AccountController( followee.PrecomputedIsFollowedBy = true; } - var res = RenderRelationship(followee); - - return Ok(res); + return RenderRelationship(followee); } [HttpPost("{id}/unfollow")] [Authorize("write:follows")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] - public async Task UnfollowUser(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest)] + public async Task UnfollowUser(string id) { var user = HttpContext.GetUserOrFail(); if (user.Id == id) @@ -244,16 +240,14 @@ public class AccountController( throw GracefulException.RecordNotFound(); await userSvc.UnfollowUserAsync(user, followee); - - var res = RenderRelationship(followee); - - return Ok(res); + return RenderRelationship(followee); } [HttpPost("{id}/mute")] [Authorize("write:mutes")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] - public async Task MuteUser(string id, [FromHybrid] AccountSchemas.AccountMuteRequest request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest)] + public async Task MuteUser(string id, [FromHybrid] AccountSchemas.AccountMuteRequest request) { var user = HttpContext.GetUserOrFail(); if (user.Id == id) @@ -269,16 +263,14 @@ public class AccountController( //TODO: handle notifications parameter DateTime? expiration = request.Duration == 0 ? null : DateTime.UtcNow + TimeSpan.FromSeconds(request.Duration); await userSvc.MuteUserAsync(user, mutee, expiration); - - var res = RenderRelationship(mutee); - - return Ok(res); + return RenderRelationship(mutee); } [HttpPost("{id}/unmute")] [Authorize("write:mutes")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] - public async Task UnmuteUser(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest)] + public async Task UnmuteUser(string id) { var user = HttpContext.GetUserOrFail(); if (user.Id == id) @@ -292,16 +284,14 @@ public class AccountController( throw GracefulException.RecordNotFound(); await userSvc.UnmuteUserAsync(user, mutee); - - var res = RenderRelationship(mutee); - - return Ok(res); + return RenderRelationship(mutee); } [HttpPost("{id}/block")] [Authorize("write:blocks")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] - public async Task BlockUser(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest)] + public async Task BlockUser(string id) { var user = HttpContext.GetUserOrFail(); if (user.Id == id) @@ -315,16 +305,14 @@ public class AccountController( throw GracefulException.RecordNotFound(); await userSvc.BlockUserAsync(user, blockee); - - var res = RenderRelationship(blockee); - - return Ok(res); + return RenderRelationship(blockee); } [HttpPost("{id}/unblock")] [Authorize("write:blocks")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] - public async Task UnblockUser(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest)] + public async Task UnblockUser(string id) { var user = HttpContext.GetUserOrFail(); if (user.Id == id) @@ -338,16 +326,13 @@ public class AccountController( throw GracefulException.RecordNotFound(); await userSvc.UnblockUserAsync(user, blockee); - - var res = RenderRelationship(blockee); - - return Ok(res); + return RenderRelationship(blockee); } [HttpGet("relationships")] [Authorize("read:follows")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity[]))] - public async Task GetRelationships([FromQuery(Name = "id")] List ids) + [ProducesResults(HttpStatusCode.OK)] + public async Task> GetRelationships([FromQuery(Name = "id")] List ids) { var user = HttpContext.GetUserOrFail(); @@ -357,41 +342,38 @@ public class AccountController( .PrecomputeRelationshipData(user) .ToListAsync(); - var res = users.Select(RenderRelationship); - - return Ok(res); + return users.Select(RenderRelationship); } [HttpGet("{id}/statuses")] [Authorize("read:statuses")] [LinkPagination(20, 40)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - public async Task GetUserStatuses( + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task> GetUserStatuses( string id, AccountSchemas.AccountStatusesRequest request, MastodonPaginationQuery query ) { var user = HttpContext.GetUserOrFail(); var account = await db.Users.FirstOrDefaultAsync(p => p.Id == id) ?? throw GracefulException.RecordNotFound(); - var res = await db.Notes - .IncludeCommonProperties() - .FilterByUser(account) - .FilterByAccountStatusesRequest(request) - .EnsureVisibleFor(user) - .FilterHidden(user, db, except: id) - .Paginate(query, ControllerContext) - .PrecomputeVisibilities(user) - .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Accounts); - - return Ok(res); + return await db.Notes + .IncludeCommonProperties() + .FilterByUser(account) + .FilterByAccountStatusesRequest(request) + .EnsureVisibleFor(user) + .FilterHidden(user, db, except: id) + .Paginate(query, ControllerContext) + .PrecomputeVisibilities(user) + .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Accounts); } [HttpGet("{id}/followers")] [Authenticate("read:accounts")] [LinkPagination(40, 80)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetUserFollowers(string id, MastodonPaginationQuery query) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] + public async Task> GetUserFollowers(string id, MastodonPaginationQuery query) { var user = HttpContext.GetUser(); if (config.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) @@ -408,28 +390,26 @@ public class AccountController( if (user == null || user.Id != account.Id) { if (account.UserProfile?.FFVisibility == UserProfile.UserProfileFFVisibility.Private) - return Ok((List) []); + return []; if (account.UserProfile?.FFVisibility == UserProfile.UserProfileFFVisibility.Followers) if (user == null || !await db.Users.AnyAsync(p => p == account && p.Followers.Contains(user))) - return Ok((List) []); + return []; } - var res = await db.Users - .Where(p => p == account) - .SelectMany(p => p.Followers) - .IncludeCommonProperties() - .Paginate(query, ControllerContext) - .RenderAllForMastodonAsync(userRenderer); - - return Ok(res); + return await db.Users + .Where(p => p == account) + .SelectMany(p => p.Followers) + .IncludeCommonProperties() + .Paginate(query, ControllerContext) + .RenderAllForMastodonAsync(userRenderer); } [HttpGet("{id}/following")] [Authenticate("read:accounts")] [LinkPagination(40, 80)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetUserFollowing(string id, MastodonPaginationQuery query) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] + public async Task> GetUserFollowing(string id, MastodonPaginationQuery query) { var user = HttpContext.GetUser(); if (config.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) @@ -446,41 +426,38 @@ public class AccountController( if (user == null || user.Id != account.Id) { if (account.UserProfile?.FFVisibility == UserProfile.UserProfileFFVisibility.Private) - return Ok((List) []); + return []; if (account.UserProfile?.FFVisibility == UserProfile.UserProfileFFVisibility.Followers) if (user == null || !await db.Users.AnyAsync(p => p == account && p.Followers.Contains(user))) - return Ok((List) []); + return []; } - var res = await db.Users - .Where(p => p == account) - .SelectMany(p => p.Following) - .IncludeCommonProperties() - .Paginate(query, ControllerContext) - .RenderAllForMastodonAsync(userRenderer); - - return Ok(res); + return await db.Users + .Where(p => p == account) + .SelectMany(p => p.Following) + .IncludeCommonProperties() + .Paginate(query, ControllerContext) + .RenderAllForMastodonAsync(userRenderer); } [HttpGet("{id}/featured_tags")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetUserFeaturedTags(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task> GetUserFeaturedTags(string id) { _ = await db.Users .Include(p => p.UserProfile) .FirstOrDefaultAsync(p => p.Id == id) ?? throw GracefulException.RecordNotFound(); - var res = Array.Empty(); - return Ok(res); + return []; } [HttpGet("/api/v1/follow_requests")] [Authorize("read:follows")] [LinkPagination(40, 80)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - public async Task GetFollowRequests(MastodonPaginationQuery query) + [ProducesResults(HttpStatusCode.OK)] + public async Task> GetFollowRequests(MastodonPaginationQuery query) { var user = HttpContext.GetUserOrFail(); var requests = await db.FollowRequests @@ -491,17 +468,15 @@ public class AccountController( .ToListAsync(); HttpContext.SetPaginationData(requests); - var res = await userRenderer.RenderManyAsync(requests.Select(p => p.Entity)); - - return Ok(res); + return await userRenderer.RenderManyAsync(requests.Select(p => p.Entity)); } [HttpGet("/api/v1/favourites")] [Authorize("read:favourites")] [LinkPagination(20, 40)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] + [ProducesResults(HttpStatusCode.OK)] [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")] - public async Task GetLikedNotes(MastodonPaginationQuery query) + public async Task> GetLikedNotes(MastodonPaginationQuery query) { var user = HttpContext.GetUserOrFail(); var likes = await db.NoteLikes @@ -515,16 +490,15 @@ public class AccountController( .ToListAsync(); HttpContext.SetPaginationData(likes); - var res = await noteRenderer.RenderManyAsync(likes.Select(p => p.Entity).EnforceRenoteReplyVisibility(), user); - return Ok(res); + return await noteRenderer.RenderManyAsync(likes.Select(p => p.Entity).EnforceRenoteReplyVisibility(), user); } [HttpGet("/api/v1/bookmarks")] [Authorize("read:bookmarks")] [LinkPagination(20, 40)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] + [ProducesResults(HttpStatusCode.OK)] [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")] - public async Task GetBookmarkedNotes(MastodonPaginationQuery query) + public async Task> GetBookmarkedNotes(MastodonPaginationQuery query) { var user = HttpContext.GetUserOrFail(); var bookmarks = await db.NoteBookmarks @@ -538,17 +512,15 @@ public class AccountController( .ToListAsync(); HttpContext.SetPaginationData(bookmarks); - var res = - await noteRenderer.RenderManyAsync(bookmarks.Select(p => p.Entity).EnforceRenoteReplyVisibility(), user); - return Ok(res); + return await noteRenderer.RenderManyAsync(bookmarks.Select(p => p.Entity).EnforceRenoteReplyVisibility(), user); } [HttpGet("/api/v1/blocks")] [Authorize("read:blocks")] [LinkPagination(40, 80)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] + [ProducesResults(HttpStatusCode.OK)] [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")] - public async Task GetBlockedUsers(MastodonPaginationQuery pq) + public async Task> GetBlockedUsers(MastodonPaginationQuery pq) { var user = HttpContext.GetUserOrFail(); var blocks = await db.Blockings @@ -559,17 +531,15 @@ public class AccountController( .ToListAsync(); HttpContext.SetPaginationData(blocks); - var res = await userRenderer.RenderManyAsync(blocks.Select(p => p.Entity)); - - return Ok(res); + return await userRenderer.RenderManyAsync(blocks.Select(p => p.Entity)); } [HttpGet("/api/v1/mutes")] [Authorize("read:mutes")] [LinkPagination(40, 80)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] + [ProducesResults(HttpStatusCode.OK)] [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")] - public async Task GetMutedUsers(MastodonPaginationQuery pq) + public async Task> GetMutedUsers(MastodonPaginationQuery pq) { var user = HttpContext.GetUserOrFail(); var mutes = await db.Mutings @@ -580,16 +550,14 @@ public class AccountController( .ToListAsync(); HttpContext.SetPaginationData(mutes); - var res = await userRenderer.RenderManyAsync(mutes.Select(p => p.Entity)); - - return Ok(res); + return await userRenderer.RenderManyAsync(mutes.Select(p => p.Entity)); } [HttpPost("/api/v1/follow_requests/{id}/authorize")] [Authorize("write:follows")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task AcceptFollowRequest(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task AcceptFollowRequest(string id) { var user = HttpContext.GetUserOrFail(); var request = await db.FollowRequests.Where(p => p.Followee == user && p.FollowerId == id) @@ -600,23 +568,19 @@ public class AccountController( if (request != null) await userSvc.AcceptFollowRequestAsync(request); - var relationship = await db.Users.Where(p => id == p.Id) - .IncludeCommonProperties() - .PrecomputeRelationshipData(user) - .Select(u => RenderRelationship(u)) - .FirstOrDefaultAsync(); - - if (relationship == null) - throw GracefulException.RecordNotFound(); - - return Ok(relationship); + return await db.Users.Where(p => id == p.Id) + .IncludeCommonProperties() + .PrecomputeRelationshipData(user) + .Select(u => RenderRelationship(u)) + .FirstOrDefaultAsync() ?? + throw GracefulException.RecordNotFound(); } [HttpPost("/api/v1/follow_requests/{id}/reject")] [Authorize("write:follows")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task RejectFollowRequest(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task RejectFollowRequest(string id) { var user = HttpContext.GetUserOrFail(); var request = await db.FollowRequests.Where(p => p.Followee == user && p.FollowerId == id) @@ -627,26 +591,21 @@ public class AccountController( if (request != null) await userSvc.RejectFollowRequestAsync(request); - var relationship = await db.Users.Where(p => id == p.Id) - .IncludeCommonProperties() - .PrecomputeRelationshipData(user) - .Select(u => RenderRelationship(u)) - .FirstOrDefaultAsync(); - - if (relationship == null) - throw GracefulException.RecordNotFound(); - - return Ok(relationship); + return await db.Users.Where(p => id == p.Id) + .IncludeCommonProperties() + .PrecomputeRelationshipData(user) + .Select(u => RenderRelationship(u)) + .FirstOrDefaultAsync() ?? + throw GracefulException.RecordNotFound(); } [HttpGet("lookup")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AccountEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task LookupUser([FromQuery] string acct) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task LookupUser([FromQuery] string acct) { var user = await userResolver.LookupAsync(acct) ?? throw GracefulException.RecordNotFound(); - var res = await userRenderer.RenderAsync(user); - return Ok(res); + return await userRenderer.RenderAsync(user); } private static RelationshipEntity RenderRelationship(User u) diff --git a/Iceshrimp.Backend/Controllers/Mastodon/AnnouncementController.cs b/Iceshrimp.Backend/Controllers/Mastodon/AnnouncementController.cs index 847c5aeb..3916e265 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/AnnouncementController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/AnnouncementController.cs @@ -2,8 +2,8 @@ using System.Diagnostics.CodeAnalysis; using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Mastodon.Attributes; -using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; +using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; @@ -27,17 +27,17 @@ public class AnnouncementController(DatabaseContext db, MfmConverter mfmConverte { [HttpGet] [Authorize] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] + [ProducesResults(HttpStatusCode.OK)] [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")] - public async Task GetAnnouncements([FromQuery(Name = "with_dismissed")] bool withDismissed) + public async Task> GetAnnouncements( + [FromQuery(Name = "with_dismissed")] bool withDismissed + ) { var user = HttpContext.GetUserOrFail(); var announcements = db.Announcements.AsQueryable(); if (!withDismissed) - { announcements = announcements.Where(p => p.IsReadBy(user)); - } var res = await announcements.OrderByDescending(p => p.UpdatedAt ?? p.CreatedAt) .Select(p => new AnnouncementEntity @@ -56,20 +56,20 @@ public class AnnouncementController(DatabaseContext db, MfmConverter mfmConverte .ToListAsync(); await res.Select(async p => p.Content = await mfmConverter.ToHtmlAsync(p.Content, [], null)).AwaitAllAsync(); - - return Ok(res); + return res; } [HttpPost("{id}/dismiss")] [Authorize("write:accounts")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")] - public async Task DismissAnnouncement(string id) + public async Task DismissAnnouncement(string id) { var user = HttpContext.GetUserOrFail(); var announcement = await db.Announcements.FirstOrDefaultAsync(p => p.Id == id) ?? - throw GracefulException.RecordNotFound(); + throw GracefulException.NotFound("Announcement not found"); if (await db.Announcements.AnyAsync(p => p == announcement && !p.IsReadBy(user))) { @@ -84,19 +84,19 @@ public class AnnouncementController(DatabaseContext db, MfmConverter mfmConverte await db.SaveChangesAsync(); } - return Ok(new object()); + return new object(); } [HttpPut("{id}/reactions/{name}")] [Authorize("write:favourites")] - [ProducesResponseType(StatusCodes.Status501NotImplemented, Type = typeof(MastodonErrorResponse))] + [ProducesErrors(HttpStatusCode.NotImplemented)] public IActionResult ReactToAnnouncement(string id, string name) => throw new GracefulException(HttpStatusCode.NotImplemented, "Iceshrimp.NET does not support this endpoint due to database schema differences to Mastodon"); [HttpDelete("{id}/reactions/{name}")] [Authorize("write:favourites")] - [ProducesResponseType(StatusCodes.Status501NotImplemented, Type = typeof(MastodonErrorResponse))] + [ProducesErrors(HttpStatusCode.NotImplemented)] public IActionResult RemoveAnnouncementReaction(string id, string name) => throw new GracefulException(HttpStatusCode.NotImplemented, "Iceshrimp.NET does not support this endpoint due to database schema differences to Mastodon"); diff --git a/Iceshrimp.Backend/Controllers/Mastodon/AuthController.cs b/Iceshrimp.Backend/Controllers/Mastodon/AuthController.cs index 1652c3c9..a43b8164 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/AuthController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/AuthController.cs @@ -1,6 +1,7 @@ +using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Mastodon.Attributes; -using Iceshrimp.Backend.Controllers.Mastodon.Schemas; +using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; @@ -11,6 +12,7 @@ using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; +using static Iceshrimp.Backend.Controllers.Mastodon.Schemas.AuthSchemas; namespace Iceshrimp.Backend.Controllers.Mastodon; @@ -22,27 +24,24 @@ public class AuthController(DatabaseContext db, MetaService meta) : ControllerBa { [HttpGet("/api/v1/apps/verify_credentials")] [Authenticate] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthSchemas.VerifyAppCredentialsResponse))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] - public async Task VerifyAppCredentials() + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.Unauthorized)] + public async Task VerifyAppCredentials() { - var token = HttpContext.GetOauthToken(); - if (token == null) throw GracefulException.Unauthorized("The access token is invalid"); + var token = HttpContext.GetOauthToken() ?? throw GracefulException.Unauthorized("The access token is invalid"); - var res = new AuthSchemas.VerifyAppCredentialsResponse + return new VerifyAppCredentialsResponse { App = token.App, VapidKey = await meta.Get(MetaEntity.VapidPublicKey) }; - - return Ok(res); } [HttpPost("/api/v1/apps")] [EnableRateLimiting("auth")] [ConsumesHybrid] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthSchemas.RegisterAppResponse))] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))] - public async Task RegisterApp([FromHybrid] AuthSchemas.RegisterAppRequest request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest)] + public async Task RegisterApp([FromHybrid] RegisterAppRequest request) { if (request.RedirectUris.Count == 0) throw GracefulException.BadRequest("Invalid redirect_uris parameter"); @@ -79,19 +78,14 @@ public class AuthController(DatabaseContext db, MetaService meta) : ControllerBa await db.AddAsync(app); await db.SaveChangesAsync(); - var res = new AuthSchemas.RegisterAppResponse - { - App = app, VapidKey = await meta.Get(MetaEntity.VapidPublicKey) - }; - - return Ok(res); + return new RegisterAppResponse { App = app, VapidKey = await meta.Get(MetaEntity.VapidPublicKey) }; } [HttpPost("/oauth/token")] [ConsumesHybrid] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthSchemas.OauthTokenResponse))] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))] - public async Task GetOauthToken([FromHybrid] AuthSchemas.OauthTokenRequest request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest)] + public async Task GetOauthToken([FromHybrid] OauthTokenRequest request) { //TODO: app-level access (grant_type = "client_credentials") if (request.GrantType != "authorization_code") @@ -99,49 +93,46 @@ public class AuthController(DatabaseContext db, MetaService meta) : ControllerBa var token = await db.OauthTokens.FirstOrDefaultAsync(p => p.Code == request.Code && p.App.ClientId == request.ClientId && p.App.ClientSecret == request.ClientSecret); + // @formatter:off if (token == null) - throw GracefulException - .Unauthorized("Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method."); - + throw GracefulException.Unauthorized("Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method."); if (token.Active) - throw GracefulException - .BadRequest("The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client."); + throw GracefulException.BadRequest("The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client."); + // @formatter:on - if (MastodonOauthHelpers.ExpandScopes(request.Scopes ?? []) - .Except(MastodonOauthHelpers.ExpandScopes(token.Scopes)) - .Any()) + var invalidScope = MastodonOauthHelpers.ExpandScopes(request.Scopes ?? []) + .Except(MastodonOauthHelpers.ExpandScopes(token.Scopes)) + .Any(); + if (invalidScope) throw GracefulException.BadRequest("The requested scope is invalid, unknown, or malformed."); token.Scopes = request.Scopes ?? token.Scopes; token.Active = true; await db.SaveChangesAsync(); - var res = new AuthSchemas.OauthTokenResponse + return new OauthTokenResponse { CreatedAt = token.CreatedAt, Scopes = token.Scopes, AccessToken = token.Token }; - - return Ok(res); } [HttpPost("/oauth/revoke")] [ConsumesHybrid] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] - public async Task RevokeOauthToken([FromHybrid] AuthSchemas.OauthTokenRevocationRequest request) + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Forbidden)] + public async Task RevokeOauthToken([FromHybrid] OauthTokenRevocationRequest request) { var token = await db.OauthTokens.FirstOrDefaultAsync(p => p.Token == request.Token && p.App.ClientId == request.ClientId && - p.App.ClientSecret == request.ClientSecret); - if (token == null) - throw GracefulException.Forbidden("You are not authorized to revoke this token"); + p.App.ClientSecret == request.ClientSecret) ?? + throw GracefulException.Forbidden("You are not authorized to revoke this token"); db.Remove(token); await db.SaveChangesAsync(); - return Ok(new object()); + return new object(); } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Mastodon/ConversationsController.cs b/Iceshrimp.Backend/Controllers/Mastodon/ConversationsController.cs index ada76740..d65c2a76 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/ConversationsController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/ConversationsController.cs @@ -31,8 +31,8 @@ public class ConversationsController( [HttpGet] [Authorize("read:statuses")] [LinkPagination(20, 40)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - public async Task GetConversations(MastodonPaginationQuery pq) + [ProducesResults(HttpStatusCode.OK)] + public async Task> GetConversations(MastodonPaginationQuery pq) { var user = HttpContext.GetUserOrFail(); @@ -69,7 +69,7 @@ public class ConversationsController( var notes = await noteRenderer.RenderManyAsync(conversations.Select(p => p.LastNote), user, accounts: accounts); - var res = conversations.Select(p => new ConversationEntity + return conversations.Select(p => new ConversationEntity { Id = p.Id, Unread = p.Unread, @@ -78,21 +78,19 @@ public class ConversationsController( .DefaultIfEmpty(accounts.First(a => a.Id == user.Id)) .ToList() }); - - return Ok(res); } [HttpDelete("{id}")] [Authorize("write:conversations")] - [ProducesResponseType(StatusCodes.Status501NotImplemented, Type = typeof(MastodonErrorResponse))] + [ProducesErrors(HttpStatusCode.NotImplemented)] public IActionResult RemoveConversation(string id) => throw new GracefulException(HttpStatusCode.NotImplemented, "Iceshrimp.NET does not support this endpoint due to database schema differences to Mastodon"); [HttpPost("{id}/read")] [Authorize("write:conversations")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ConversationEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task MarkRead(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task MarkRead(string id) { var user = HttpContext.GetUserOrFail(); var conversation = await db.Conversations(user) @@ -137,15 +135,13 @@ public class ConversationsController( var noteRendererDto = new NoteRenderer.NoteRendererDto { Accounts = accounts }; - var res = new ConversationEntity + return new ConversationEntity { Id = conversation.Id, Unread = conversation.Unread, LastStatus = await noteRenderer.RenderAsync(conversation.LastNote, user, data: noteRendererDto), Accounts = accounts }; - - return Ok(res); } private class Conversation diff --git a/Iceshrimp.Backend/Controllers/Mastodon/FilterController.cs b/Iceshrimp.Backend/Controllers/Mastodon/FilterController.cs index ac42844a..769b39f1 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/FilterController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/FilterController.cs @@ -1,8 +1,10 @@ +using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Renderers; using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; +using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; @@ -26,33 +28,32 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe { [HttpGet] [Authorize("read:filters")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - public async Task GetFilters() + [ProducesResults(HttpStatusCode.OK)] + public async Task> GetFilters() { var user = HttpContext.GetUserOrFail(); var filters = await db.Filters.Where(p => p.User == user).ToListAsync(); - var res = filters.Select(FilterRenderer.RenderOne); - - return Ok(res); + return filters.Select(FilterRenderer.RenderOne); } [HttpGet("{id:long}")] [Authorize("read:filters")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetFilter(long id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task GetFilter(long id) { var user = HttpContext.GetUserOrFail(); var filter = await db.Filters.Where(p => p.User == user && p.Id == id).FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); - return Ok(FilterRenderer.RenderOne(filter)); + return FilterRenderer.RenderOne(filter); } [HttpPost] [Authorize("write:filters")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - public async Task CreateFilter([FromHybrid] FilterSchemas.CreateFilterRequest request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest)] + public async Task CreateFilter([FromHybrid] FilterSchemas.CreateFilterRequest request) { var user = HttpContext.GetUserOrFail(); var action = request.Action switch @@ -102,14 +103,14 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe await queueSvc.BackgroundTaskQueue.ScheduleAsync(data, expiry.Value); } - return Ok(FilterRenderer.RenderOne(filter)); + return FilterRenderer.RenderOne(filter); } [HttpPut("{id:long}")] [Authorize("write:filters")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task UpdateFilter(long id, [FromHybrid] FilterSchemas.UpdateFilterRequest request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task UpdateFilter(long id, [FromHybrid] FilterSchemas.UpdateFilterRequest request) { var user = HttpContext.GetUserOrFail(); var filter = await db.Filters.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ?? @@ -166,14 +167,15 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe await queueSvc.BackgroundTaskQueue.ScheduleAsync(data, expiry.Value); } - return Ok(FilterRenderer.RenderOne(filter)); + return FilterRenderer.RenderOne(filter); } [HttpDelete("{id:long}")] [Authorize("write:filters")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task DeleteFilter(long id) + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task DeleteFilter(long id) { var user = HttpContext.GetUserOrFail(); var filter = await db.Filters.Where(p => p.User == user && p.Id == id).FirstOrDefaultAsync() ?? @@ -183,27 +185,27 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe await db.SaveChangesAsync(); eventSvc.RaiseFilterRemoved(this, filter); - return Ok(new object()); + return new object(); } [HttpGet("{id:long}/keywords")] [Authorize("read:filters")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetFilterKeywords(long id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task> GetFilterKeywords(long id) { var user = HttpContext.GetUserOrFail(); var filter = await db.Filters.Where(p => p.User == user && p.Id == id).FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); - return Ok(filter.Keywords.Select((p, i) => new FilterKeyword(p, filter.Id, i))); + return filter.Keywords.Select((p, i) => new FilterKeyword(p, filter.Id, i)); } [HttpPost("{id:long}/keywords")] [Authorize("write:filters")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(FilterKeyword))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task AddFilterKeyword( + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task AddFilterKeyword( long id, [FromHybrid] FilterSchemas.FilterKeywordsAttributes request ) { @@ -218,14 +220,14 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe await db.SaveChangesAsync(); eventSvc.RaiseFilterUpdated(this, filter); - return Ok(new FilterKeyword(keyword, filter.Id, filter.Keywords.Count - 1)); + return new FilterKeyword(keyword, filter.Id, filter.Keywords.Count - 1); } [HttpGet("keywords/{filterId:long}-{keywordId:int}")] [Authorize("read:filters")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(FilterKeyword))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetFilterKeyword(long filterId, int keywordId) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task GetFilterKeyword(long filterId, int keywordId) { var user = HttpContext.GetUserOrFail(); var filter = await db.Filters.Where(p => p.User == user && p.Id == filterId).FirstOrDefaultAsync() ?? @@ -234,14 +236,14 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe if (filter.Keywords.Count < keywordId) throw GracefulException.RecordNotFound(); - return Ok(new FilterKeyword(filter.Keywords[keywordId], filter.Id, keywordId)); + return new FilterKeyword(filter.Keywords[keywordId], filter.Id, keywordId); } [HttpPut("keywords/{filterId:long}-{keywordId:int}")] [Authorize("write:filters")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(FilterKeyword))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task UpdateFilterKeyword( + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task UpdateFilterKeyword( long filterId, int keywordId, [FromHybrid] FilterSchemas.FilterKeywordsAttributes request ) { @@ -257,14 +259,15 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe await db.SaveChangesAsync(); eventSvc.RaiseFilterUpdated(this, filter); - return Ok(new FilterKeyword(filter.Keywords[keywordId], filter.Id, keywordId)); + return new FilterKeyword(filter.Keywords[keywordId], filter.Id, keywordId); } [HttpDelete("keywords/{filterId:long}-{keywordId:int}")] [Authorize("write:filters")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task DeleteFilterKeyword(long filterId, int keywordId) + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task DeleteFilterKeyword(long filterId, int keywordId) { var user = HttpContext.GetUserOrFail(); var filter = await db.Filters.Where(p => p.User == user && p.Id == filterId).FirstOrDefaultAsync() ?? @@ -278,7 +281,7 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe await db.SaveChangesAsync(); eventSvc.RaiseFilterUpdated(this, filter); - return Ok(new object()); + return new object(); } //TODO: status filters (first: what are they even for?) diff --git a/Iceshrimp.Backend/Controllers/Mastodon/InstanceController.cs b/Iceshrimp.Backend/Controllers/Mastodon/InstanceController.cs index 6d6269ef..74a2e8e5 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/InstanceController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/InstanceController.cs @@ -1,7 +1,9 @@ +using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; +using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Extensions; @@ -21,8 +23,8 @@ namespace Iceshrimp.Backend.Controllers.Mastodon; public class InstanceController(DatabaseContext db, MetaService meta) : ControllerBase { [HttpGet("/api/v1/instance")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(InstanceInfoV1Response))] - public async Task GetInstanceInfoV1([FromServices] IOptionsSnapshot config) + [ProducesResults(HttpStatusCode.OK)] + public async Task GetInstanceInfoV1([FromServices] IOptionsSnapshot config) { var userCount = await db.Users.LongCountAsync(p => p.IsLocalUser && !Constants.SystemUsers.Contains(p.UsernameLower)); @@ -32,17 +34,15 @@ public class InstanceController(DatabaseContext db, MetaService meta) : Controll var (instanceName, instanceDescription, adminContact) = await meta.GetMany(MetaEntity.InstanceName, MetaEntity.InstanceDescription, MetaEntity.AdminContactEmail); - var res = new InstanceInfoV1Response(config.Value, instanceName, instanceDescription, adminContact) + return new InstanceInfoV1Response(config.Value, instanceName, instanceDescription, adminContact) { Stats = new InstanceStats(userCount, noteCount, instanceCount) }; - - return Ok(res); } [HttpGet("/api/v2/instance")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(InstanceInfoV2Response))] - public async Task GetInstanceInfoV2([FromServices] IOptionsSnapshot config) + [ProducesResults(HttpStatusCode.OK)] + public async Task GetInstanceInfoV2([FromServices] IOptionsSnapshot config) { var cutoff = DateTime.UtcNow - TimeSpan.FromDays(30); var activeMonth = await db.Users.LongCountAsync(p => p.IsLocalUser && @@ -52,46 +52,38 @@ public class InstanceController(DatabaseContext db, MetaService meta) : Controll var (instanceName, instanceDescription, adminContact) = await meta.GetMany(MetaEntity.InstanceName, MetaEntity.InstanceDescription, MetaEntity.AdminContactEmail); - var res = new InstanceInfoV2Response(config.Value, instanceName, instanceDescription, adminContact) + return new InstanceInfoV2Response(config.Value, instanceName, instanceDescription, adminContact) { Usage = new InstanceUsage { Users = new InstanceUsersUsage { ActiveMonth = activeMonth } } }; - - return Ok(res); } [HttpGet("/api/v1/custom_emojis")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - public async Task GetCustomEmojis() + [ProducesResults(HttpStatusCode.OK)] + public async Task> GetCustomEmojis() { - var res = await db.Emojis.Where(p => p.Host == null) - .Select(p => new EmojiEntity - { - Id = p.Id, - Shortcode = p.Name, - Url = p.PublicUrl, - StaticUrl = p.PublicUrl, //TODO - VisibleInPicker = true, - Category = p.Category - }) - .ToListAsync(); - - return Ok(res); + return await db.Emojis.Where(p => p.Host == null) + .Select(p => new EmojiEntity + { + Id = p.Id, + Shortcode = p.Name, + Url = p.PublicUrl, + StaticUrl = p.PublicUrl, //TODO + VisibleInPicker = true, + Category = p.Category + }) + .ToListAsync(); } [HttpGet("/api/v1/instance/translation_languages")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Dictionary>))] - public IActionResult GetTranslationLanguages() - { - return Ok(new Dictionary>()); - } + [ProducesResults(HttpStatusCode.OK)] + public Dictionary> GetTranslationLanguages() => new(); [HttpGet("/api/v1/instance/extended_description")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(InstanceExtendedDescription))] - public async Task GetExtendedDescription() + [ProducesResults(HttpStatusCode.OK)] + public async Task GetExtendedDescription() { var description = await meta.Get(MetaEntity.InstanceDescription); - var res = new InstanceExtendedDescription(description); - return Ok(res); + return new InstanceExtendedDescription(description); } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Mastodon/ListController.cs b/Iceshrimp.Backend/Controllers/Mastodon/ListController.cs index 5618f270..ee6f294e 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/ListController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/ListController.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Renderers; @@ -28,51 +29,47 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event { [HttpGet] [Authorize("read:lists")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - public async Task GetLists() + [ProducesResults(HttpStatusCode.OK)] + public async Task> GetLists() { var user = HttpContext.GetUserOrFail(); - var res = await db.UserLists - .Where(p => p.User == user) - .Select(p => new ListEntity - { - Id = p.Id, - Title = p.Name, - Exclusive = p.HideFromHomeTl - }) - .ToListAsync(); - - return Ok(res); + return await db.UserLists + .Where(p => p.User == user) + .Select(p => new ListEntity + { + Id = p.Id, + Title = p.Name, + Exclusive = p.HideFromHomeTl + }) + .ToListAsync(); } [HttpGet("{id}")] [Authorize("read:lists")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ListEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetList(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task GetList(string id) { var user = HttpContext.GetUserOrFail(); - var res = await db.UserLists - .Where(p => p.User == user && p.Id == id) - .Select(p => new ListEntity - { - Id = p.Id, - Title = p.Name, - Exclusive = p.HideFromHomeTl - }) - .FirstOrDefaultAsync() ?? - throw GracefulException.RecordNotFound(); - - return Ok(res); + return await db.UserLists + .Where(p => p.User == user && p.Id == id) + .Select(p => new ListEntity + { + Id = p.Id, + Title = p.Name, + Exclusive = p.HideFromHomeTl + }) + .FirstOrDefaultAsync() ?? + throw GracefulException.RecordNotFound(); } [HttpPost] [Authorize("write:lists")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ListEntity))] - [ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(MastodonErrorResponse))] - public async Task CreateList([FromHybrid] ListSchemas.ListCreationRequest request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.UnprocessableEntity)] + public async Task CreateList([FromHybrid] ListSchemas.ListCreationRequest request) { if (string.IsNullOrWhiteSpace(request.Title)) throw GracefulException.UnprocessableEntity("Validation failed: Title can't be blank"); @@ -90,21 +87,19 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event await db.AddAsync(list); await db.SaveChangesAsync(); - var res = new ListEntity + return new ListEntity { Id = list.Id, Title = list.Name, Exclusive = list.HideFromHomeTl }; - return Ok(res); } [HttpPut("{id}")] [Authorize("write:lists")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ListEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(MastodonErrorResponse))] - public async Task UpdateList(string id, [FromHybrid] ListSchemas.ListUpdateRequest request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound, HttpStatusCode.UnprocessableEntity)] + public async Task UpdateList(string id, [FromHybrid] ListSchemas.ListUpdateRequest request) { var user = HttpContext.GetUserOrFail(); var list = await db.UserLists @@ -121,20 +116,20 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event db.Update(list); await db.SaveChangesAsync(); - var res = new ListEntity + return new ListEntity { Id = list.Id, Title = list.Name, Exclusive = list.HideFromHomeTl }; - return Ok(res); } [HttpDelete("{id}")] [Authorize("write:lists")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task DeleteList(string id) + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task DeleteList(string id) { var user = HttpContext.GetUserOrFail(); var list = await db.UserLists @@ -145,15 +140,15 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event db.Remove(list); await db.SaveChangesAsync(); eventSvc.RaiseListMembersUpdated(this, list); - return Ok(new object()); + return new object(); } [LinkPagination(40, 80)] [HttpGet("{id}/accounts")] [Authorize("read:lists")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetListMembers(string id, MastodonPaginationQuery pq) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task> GetListMembers(string id, MastodonPaginationQuery pq) { var user = HttpContext.GetUserOrFail(); var list = await db.UserLists @@ -161,7 +156,7 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event .FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); - var res = pq.Limit == 0 + return pq.Limit == 0 ? await db.UserListMembers .Where(p => p.UserList == list) .Include(p => p.User.UserProfile) @@ -173,17 +168,15 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event .Include(p => p.User.UserProfile) .Select(p => p.User) .RenderAllForMastodonAsync(userRenderer); - - return Ok(res); } [HttpPost("{id}/accounts")] [Authorize("write:lists")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(MastodonErrorResponse))] + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound, HttpStatusCode.UnprocessableEntity)] [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")] - public async Task AddListMember(string id, [FromHybrid] ListSchemas.ListUpdateMembersRequest request) + public async Task AddListMember(string id, [FromHybrid] ListSchemas.ListUpdateMembersRequest request) { var user = HttpContext.GetUserOrFail(); var list = await db.UserLists @@ -214,14 +207,15 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event eventSvc.RaiseListMembersUpdated(this, list); - return Ok(new object()); + return new object(); } [HttpDelete("{id}/accounts")] [Authorize("write:lists")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task RemoveListMember( + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task RemoveListMember( string id, [FromHybrid] ListSchemas.ListUpdateMembersRequest request ) { @@ -237,6 +231,6 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event eventSvc.RaiseListMembersUpdated(this, list); - return Ok(new object()); + return new object(); } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Mastodon/MarkerController.cs b/Iceshrimp.Backend/Controllers/Mastodon/MarkerController.cs index 8e9644cf..39f90a2c 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/MarkerController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/MarkerController.cs @@ -3,6 +3,7 @@ using System.Net.Mime; using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; +using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; @@ -24,28 +25,29 @@ public class MarkerController(DatabaseContext db) : ControllerBase { [HttpGet] [Authorize("read:statuses")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Dictionary))] - public async Task GetMarkers([FromQuery(Name = "timeline")] List types) + [ProducesResults(HttpStatusCode.OK)] + public async Task> GetMarkers([FromQuery(Name = "timeline")] List types) { var user = HttpContext.GetUserOrFail(); var markers = await db.Markers.Where(p => p.User == user && types.Select(DecodeType).Contains(p.Type)) .ToListAsync(); - var res = markers.ToDictionary(p => EncodeType(p.Type), - p => new MarkerEntity - { - Position = p.Position, - Version = p.Version, - UpdatedAt = p.LastUpdatedAt.ToStringIso8601Like() - }); - - return Ok(res); + return markers.ToDictionary(p => EncodeType(p.Type), + p => new MarkerEntity + { + Position = p.Position, + Version = p.Version, + UpdatedAt = p.LastUpdatedAt.ToStringIso8601Like() + }); } [HttpPost] [Authorize("write:statuses")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Dictionary))] - public async Task SetMarkers([FromHybrid] Dictionary request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.Conflict)] + public async Task> SetMarkers( + [FromHybrid] Dictionary request + ) { var user = HttpContext.GetUserOrFail(); try diff --git a/Iceshrimp.Backend/Controllers/Mastodon/MediaController.cs b/Iceshrimp.Backend/Controllers/Mastodon/MediaController.cs index fbd50b0a..95bd1029 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/MediaController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/MediaController.cs @@ -1,8 +1,10 @@ using System.Diagnostics.CodeAnalysis; +using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; +using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; @@ -21,13 +23,12 @@ namespace Iceshrimp.Backend.Controllers.Mastodon; [EnableCors("mastodon")] [EnableRateLimiting("sliding")] [Produces(MediaTypeNames.Application.Json)] -[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AttachmentEntity))] public class MediaController(DriveService driveSvc, DatabaseContext db) : ControllerBase { [HttpPost("/api/v1/media")] [HttpPost("/api/v2/media")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AttachmentEntity))] - public async Task UploadAttachment(MediaSchemas.UploadMediaRequest request) + [ProducesResults(HttpStatusCode.OK)] + public async Task UploadAttachment(MediaSchemas.UploadMediaRequest request) { var user = HttpContext.GetUserOrFail(); var rq = new DriveFileCreationRequest @@ -38,15 +39,15 @@ public class MediaController(DriveService driveSvc, DatabaseContext db) : Contro MimeType = request.File.ContentType }; var file = await driveSvc.StoreFile(request.File.OpenReadStream(), user, rq); - var res = RenderAttachment(file); - - return Ok(res); + return RenderAttachment(file); } [HttpPut("/api/v1/media/{id}")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AttachmentEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task UpdateAttachment(string id, [FromHybrid] MediaSchemas.UpdateMediaRequest request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task UpdateAttachment( + string id, [FromHybrid] MediaSchemas.UpdateMediaRequest request + ) { var user = HttpContext.GetUserOrFail(); var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user) ?? @@ -54,26 +55,24 @@ public class MediaController(DriveService driveSvc, DatabaseContext db) : Contro file.Comment = request.Description; await db.SaveChangesAsync(); - var res = RenderAttachment(file); - return Ok(res); + return RenderAttachment(file); } [HttpGet("/api/v1/media/{id}")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AttachmentEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetAttachment(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task GetAttachment(string id) { var user = HttpContext.GetUserOrFail(); var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user) ?? throw GracefulException.RecordNotFound(); - var res = RenderAttachment(file); - return Ok(res); + return RenderAttachment(file); } [HttpPut("/api/v2/media/{id}")] [HttpGet("/api/v2/media/{id}")] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] + [ProducesErrors(HttpStatusCode.NotFound)] public IActionResult FallbackMediaRoute([SuppressMessage("ReSharper", "UnusedParameter.Global")] string id) => throw GracefulException.NotFound("This endpoint is not implemented, but some clients expect a 404 here."); diff --git a/Iceshrimp.Backend/Controllers/Mastodon/NotificationController.cs b/Iceshrimp.Backend/Controllers/Mastodon/NotificationController.cs index 9df2eeef..960b6899 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/NotificationController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/NotificationController.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Renderers; @@ -26,41 +27,39 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not [HttpGet] [Authorize("read:notifications")] [LinkPagination(40, 80)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetNotifications( + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task> GetNotifications( MastodonPaginationQuery query, NotificationSchemas.GetNotificationsRequest request ) { var user = HttpContext.GetUserOrFail(); - var res = await db.Notifications - .IncludeCommonProperties() - .Where(p => p.Notifiee == user) - .Where(p => p.Notifier != null) - .Where(p => p.Type == NotificationType.Follow || - p.Type == NotificationType.Mention || - p.Type == NotificationType.Reply || - p.Type == NotificationType.Renote || - p.Type == NotificationType.Quote || - p.Type == NotificationType.Like || - p.Type == NotificationType.PollEnded || - p.Type == NotificationType.FollowRequestReceived || - p.Type == NotificationType.Edit) - .FilterByGetNotificationsRequest(request) - .EnsureNoteVisibilityFor(p => p.Note, user) - .FilterHiddenNotifications(user, db) - .Paginate(p => p.MastoId, query, ControllerContext) - .PrecomputeNoteVisibilities(user) - .RenderAllForMastodonAsync(notificationRenderer, user); - - return Ok(res); + return await db.Notifications + .IncludeCommonProperties() + .Where(p => p.Notifiee == user) + .Where(p => p.Notifier != null) + .Where(p => p.Type == NotificationType.Follow || + p.Type == NotificationType.Mention || + p.Type == NotificationType.Reply || + p.Type == NotificationType.Renote || + p.Type == NotificationType.Quote || + p.Type == NotificationType.Like || + p.Type == NotificationType.PollEnded || + p.Type == NotificationType.FollowRequestReceived || + p.Type == NotificationType.Edit) + .FilterByGetNotificationsRequest(request) + .EnsureNoteVisibilityFor(p => p.Note, user) + .FilterHiddenNotifications(user, db) + .Paginate(p => p.MastoId, query, ControllerContext) + .PrecomputeNoteVisibilities(user) + .RenderAllForMastodonAsync(notificationRenderer, user); } [HttpGet("{id:long}")] [Authorize("read:notifications")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetNotification(long id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task GetNotification(long id) { var user = HttpContext.GetUserOrFail(); var notification = await db.Notifications @@ -72,7 +71,6 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not throw GracefulException.RecordNotFound(); var res = await notificationRenderer.RenderAsync(notification.EnforceRenoteReplyVisibility(p => p.Note), user); - - return Ok(res); + return res; } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Mastodon/PollController.cs b/Iceshrimp.Backend/Controllers/Mastodon/PollController.cs index c7af1ff5..9dfac2a3 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/PollController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/PollController.cs @@ -1,8 +1,10 @@ +using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Renderers; using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; +using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; @@ -32,9 +34,9 @@ public class PollController( ) : ControllerBase { [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PollEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetPoll(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task GetPoll(string id) { var user = HttpContext.GetUser(); if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) @@ -44,16 +46,14 @@ public class PollController( throw GracefulException.RecordNotFound(); var poll = await db.Polls.Where(p => p.Note == note).FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); - var res = await pollRenderer.RenderAsync(poll, user); - return Ok(res); + return await pollRenderer.RenderAsync(poll, user); } [HttpPost("votes")] [Authorize("read:statuses")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PollEntity))] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task VotePoll(string id, [FromHybrid] PollSchemas.PollVoteRequest request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)] + public async Task VotePoll(string id, [FromHybrid] PollSchemas.PollVoteRequest request) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id) @@ -118,7 +118,6 @@ public class PollController( await pollSvc.RegisterPollVote(vote, poll, note, votes.IndexOf(vote) == 0); await db.ReloadEntityAsync(poll); - var res = await pollRenderer.RenderAsync(poll, user); - return Ok(res); + return await pollRenderer.RenderAsync(poll, user); } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Mastodon/PushController.cs b/Iceshrimp.Backend/Controllers/Mastodon/PushController.cs index fc5dc6c1..3c14b559 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/PushController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/PushController.cs @@ -1,8 +1,9 @@ +using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Schemas; +using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Database; -using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Middleware; @@ -11,6 +12,8 @@ using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; +using static Iceshrimp.Backend.Controllers.Mastodon.Schemas.PushSchemas; +using PushSubscription = Iceshrimp.Backend.Core.Database.Tables.PushSubscription; namespace Iceshrimp.Backend.Controllers.Mastodon; @@ -24,11 +27,13 @@ namespace Iceshrimp.Backend.Controllers.Mastodon; public class PushController(DatabaseContext db, MetaService meta) : ControllerBase { [HttpPost] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PushSchemas.PushSubscription))] - public async Task RegisterSubscription([FromHybrid] PushSchemas.RegisterPushRequest request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.Unauthorized)] + public async Task RegisterSubscription( + [FromHybrid] RegisterPushRequest request + ) { - var token = HttpContext.GetOauthToken(); - if (token == null) throw GracefulException.Unauthorized("The access token is invalid"); + var token = HttpContext.GetOauthToken() ?? throw GracefulException.Unauthorized("The access token is invalid"); var pushSubscription = await db.PushSubscriptions.FirstOrDefaultAsync(p => p.OauthToken == token); if (pushSubscription == null) { @@ -49,54 +54,46 @@ public class PushController(DatabaseContext db, MetaService meta) : ControllerBa await db.SaveChangesAsync(); } - var res = await RenderSubscription(pushSubscription); - - return Ok(res); + return await RenderSubscription(pushSubscription); } [HttpPut] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PushSchemas.PushSubscription))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task EditSubscription([FromHybrid] PushSchemas.EditPushRequest request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.Unauthorized, HttpStatusCode.NotFound)] + public async Task EditSubscription([FromHybrid] EditPushRequest request) { - var token = HttpContext.GetOauthToken(); - if (token == null) throw GracefulException.Unauthorized("The access token is invalid"); + var token = HttpContext.GetOauthToken() ?? throw GracefulException.Unauthorized("The access token is invalid"); var pushSubscription = await db.PushSubscriptions.FirstOrDefaultAsync(p => p.OauthToken == token) ?? - throw GracefulException.RecordNotFound(); + throw GracefulException.NotFound("Push subscription not found"); pushSubscription.Types = GetTypes(request.Data.Alerts); pushSubscription.Policy = GetPolicy(request.Data.Policy); await db.SaveChangesAsync(); - var res = await RenderSubscription(pushSubscription); - - return Ok(res); + return await RenderSubscription(pushSubscription); } [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PushSchemas.PushSubscription))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetSubscription() + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.Unauthorized, HttpStatusCode.NotFound)] + public async Task GetSubscription() { - var token = HttpContext.GetOauthToken(); - if (token == null) throw GracefulException.Unauthorized("The access token is invalid"); + var token = HttpContext.GetOauthToken() ?? throw GracefulException.Unauthorized("The access token is invalid"); var pushSubscription = await db.PushSubscriptions.FirstOrDefaultAsync(p => p.OauthToken == token) ?? - throw GracefulException.RecordNotFound(); + throw GracefulException.NotFound("Push subscription not found"); - var res = await RenderSubscription(pushSubscription); - - return Ok(res); + return await RenderSubscription(pushSubscription); } [HttpDelete] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] - public async Task DeleteSubscription() + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.Unauthorized)] + public async Task DeleteSubscription() { - var token = HttpContext.GetOauthToken(); - if (token == null) throw GracefulException.Unauthorized("The access token is invalid"); + var token = HttpContext.GetOauthToken() ?? throw GracefulException.Unauthorized("The access token is invalid"); await db.PushSubscriptions.Where(p => p.OauthToken == token).ExecuteDeleteAsync(); - - return Ok(new object()); + return new object(); } private static PushSubscription.PushPolicy GetPolicy(string policy) @@ -123,7 +120,7 @@ public class PushController(DatabaseContext db, MetaService meta) : ControllerBa }; } - private static List GetTypes(PushSchemas.Alerts alerts) + private static List GetTypes(Alerts alerts) { List types = []; @@ -155,7 +152,7 @@ public class PushController(DatabaseContext db, MetaService meta) : ControllerBa Endpoint = sub.Endpoint, ServerKey = await meta.Get(MetaEntity.VapidPublicKey), Policy = GetPolicyString(sub.Policy), - Alerts = new PushSchemas.Alerts + Alerts = new Alerts { Favourite = sub.Types.Contains("favourite"), Follow = sub.Types.Contains("follow"), diff --git a/Iceshrimp.Backend/Controllers/Mastodon/SearchController.cs b/Iceshrimp.Backend/Controllers/Mastodon/SearchController.cs index 3a9299e3..ae4ca2ea 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/SearchController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/SearchController.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Net; using System.Net.Mime; using System.Text.RegularExpressions; using Iceshrimp.Backend.Controllers.Mastodon.Attributes; @@ -37,38 +38,36 @@ public class SearchController( [HttpGet("/api/v2/search")] [Authorize("read:search")] [LinkPagination(20, 40)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(SearchSchemas.SearchResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task Search(SearchSchemas.SearchRequest search, MastodonPaginationQuery pagination) - { - if (search.Query == null) - throw GracefulException.BadRequest("Query is missing or invalid"); - - var result = new SearchSchemas.SearchResponse - { - Accounts = search.Type is null or "accounts" ? await SearchUsersAsync(search, pagination) : [], - Statuses = search.Type is null or "statuses" ? await SearchNotesAsync(search, pagination) : [], - Hashtags = search.Type is null or "hashtags" ? await SearchTagsAsync(search, pagination) : [] - }; - - return Ok(result); - } - - [HttpGet("/api/v1/accounts/search")] - [Authorize("read:accounts")] - [LinkPagination(20, 40, true)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task SearchAccounts( + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task Search( SearchSchemas.SearchRequest search, MastodonPaginationQuery pagination ) { if (search.Query == null) throw GracefulException.BadRequest("Query is missing or invalid"); - var result = await SearchUsersAsync(search, pagination); + return new SearchSchemas.SearchResponse + { + Accounts = search.Type is null or "accounts" ? await SearchUsersAsync(search, pagination) : [], + Statuses = search.Type is null or "statuses" ? await SearchNotesAsync(search, pagination) : [], + Hashtags = search.Type is null or "hashtags" ? await SearchTagsAsync(search, pagination) : [] + }; + } - return Ok(result); + [HttpGet("/api/v1/accounts/search")] + [Authorize("read:accounts")] + [LinkPagination(20, 40, true)] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task> SearchAccounts( + SearchSchemas.SearchRequest search, MastodonPaginationQuery pagination + ) + { + if (search.Query == null) + throw GracefulException.BadRequest("Query is missing or invalid"); + + return await SearchUsersAsync(search, pagination); } [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", diff --git a/Iceshrimp.Backend/Controllers/Mastodon/StatusController.cs b/Iceshrimp.Backend/Controllers/Mastodon/StatusController.cs index 06980115..4f55e216 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/StatusController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/StatusController.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Net.Mime; using AsyncKeyedLock; using Iceshrimp.Backend.Controllers.Mastodon.Attributes; @@ -47,9 +48,9 @@ public class StatusController( [HttpGet("{id}")] [Authenticate("read:statuses")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetNote(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] + public async Task GetNote(string id) { var user = HttpContext.GetUser(); if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) @@ -68,15 +69,14 @@ public class StatusController( if (security.Value.PublicPreview <= Enums.PublicPreview.Restricted && note.User.IsRemoteUser && user == null) throw GracefulException.Forbidden("Public preview is disabled on this instance"); - var res = await noteRenderer.RenderAsync(note.EnforceRenoteReplyVisibility(), user); - return Ok(res); + return await noteRenderer.RenderAsync(note.EnforceRenoteReplyVisibility(), user); } [HttpGet("{id}/context")] [Authenticate("read:statuses")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusContext))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetStatusContext(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] + public async Task GetStatusContext(string id) { var user = HttpContext.GetUser(); if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) @@ -102,7 +102,7 @@ public class StatusController( .AnyAsync(); if (!shouldShowContext) - return Ok(new StatusContext { Ancestors = [], Descendants = [] }); + return new StatusContext { Ancestors = [], Descendants = [] }; var ancestors = await db.NoteAncestors(id, maxAncestors) .IncludeCommonProperties() @@ -119,19 +119,17 @@ public class StatusController( .PrecomputeVisibilities(user) .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Threads); - var res = new StatusContext + return new StatusContext { Ancestors = ancestors.OrderAncestors(), Descendants = descendants.OrderDescendants() }; - - return Ok(res); } [HttpPost("{id}/favourite")] [Authorize("write:favourites")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task LikeNote(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task LikeNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id) @@ -150,9 +148,9 @@ public class StatusController( [HttpPost("{id}/unfavourite")] [Authorize("write:favourites")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task UnlikeNote(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task UnlikeNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id) @@ -171,9 +169,9 @@ public class StatusController( [HttpPost("{id}/react/{reaction}")] [Authorize("write:favourites")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task ReactNote(string id, string reaction) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task ReactNote(string id, string reaction) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id) @@ -192,9 +190,9 @@ public class StatusController( [HttpPost("{id}/unreact/{reaction}")] [Authorize("write:favourites")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task UnreactNote(string id, string reaction) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task UnreactNote(string id, string reaction) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id) @@ -213,9 +211,9 @@ public class StatusController( [HttpPost("{id}/bookmark")] [Authorize("write:bookmarks")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task BookmarkNote(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task BookmarkNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id) @@ -231,9 +229,9 @@ public class StatusController( [HttpPost("{id}/unbookmark")] [Authorize("write:bookmarks")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task UnbookmarkNote(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task UnbookmarkNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id) @@ -249,10 +247,9 @@ public class StatusController( [HttpPost("{id}/pin")] [Authorize("write:accounts")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(MastodonErrorResponse))] - public async Task PinNote(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.UnprocessableEntity)] + public async Task PinNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id) @@ -267,10 +264,9 @@ public class StatusController( [HttpPost("{id}/unpin")] [Authorize("write:accounts")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(MastodonErrorResponse))] - public async Task UnpinNote(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task UnpinNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id).FirstOrDefaultAsync() ?? @@ -282,9 +278,9 @@ public class StatusController( [HttpPost("{id}/reblog")] [Authorize("write:favourites")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task Renote(string id, [FromHybrid] StatusSchemas.ReblogRequest? request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task Renote(string id, [FromHybrid] StatusSchemas.ReblogRequest? request) { var user = HttpContext.GetUserOrFail(); var renote = await db.Notes.IncludeCommonProperties() @@ -312,9 +308,9 @@ public class StatusController( [HttpPost("{id}/unreblog")] [Authorize("write:favourites")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task UndoRenote(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task UndoRenote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id) @@ -330,9 +326,9 @@ public class StatusController( [HttpPost] [Authorize("write:statuses")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))] - public async Task PostNote([FromHybrid] StatusSchemas.PostStatusRequest request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.UnprocessableEntity)] + public async Task PostNote([FromHybrid] StatusSchemas.PostStatusRequest request) { var token = HttpContext.GetOauthToken() ?? throw new Exception("Token must not be null at this stage"); var user = token.User; @@ -454,17 +450,14 @@ public class StatusController( if (idempotencyKey != null) await cache.SetAsync($"idempotency:{user.Id}:{idempotencyKey}", note.Id, TimeSpan.FromHours(24)); - var res = await noteRenderer.RenderAsync(note, user); - - return Ok(res); + return await noteRenderer.RenderAsync(note, user); } [HttpPut("{id}")] [Authorize("write:statuses")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task EditNote(string id, [FromHybrid] StatusSchemas.EditStatusRequest request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)] + public async Task EditNote(string id, [FromHybrid] StatusSchemas.EditStatusRequest request) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes @@ -502,16 +495,14 @@ public class StatusController( } note = await noteSvc.UpdateNoteAsync(note, request.Text, request.Cw, attachments, poll); - var res = await noteRenderer.RenderAsync(note, user); - - return Ok(res); + return await noteRenderer.RenderAsync(note, user); } [HttpDelete("{id}")] [Authorize("write:statuses")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task DeleteNote(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task DeleteNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && p.User == user) ?? @@ -519,36 +510,33 @@ public class StatusController( var res = await noteRenderer.RenderAsync(note, user, data: new NoteRenderer.NoteRendererDto { Source = true }); await noteSvc.DeleteNoteAsync(note); - - return Ok(res); + return res; } [HttpGet("{id}/source")] [Authorize("read:statuses")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusSource))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetNoteSource(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task GetNoteSource(string id) { var user = HttpContext.GetUserOrFail(); - var res = await db.Notes.Where(p => p.Id == id && p.User == user) - .Select(p => new StatusSource - { - Id = p.Id, - ContentWarning = p.Cw ?? "", - Text = p.Text ?? "" - }) - .FirstOrDefaultAsync() ?? - throw GracefulException.RecordNotFound(); - - return Ok(res); + return await db.Notes.Where(p => p.Id == id && p.User == user) + .Select(p => new StatusSource + { + Id = p.Id, + ContentWarning = p.Cw ?? "", + Text = p.Text ?? "" + }) + .FirstOrDefaultAsync() ?? + throw GracefulException.RecordNotFound(); } [HttpGet("{id}/favourited_by")] [Authenticate("read:statuses")] [LinkPagination(40, 80)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetNoteLikes(string id, MastodonPaginationQuery pq) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] + public async Task> GetNoteLikes(string id, MastodonPaginationQuery pq) { var user = HttpContext.GetUser(); if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) @@ -570,16 +558,15 @@ public class StatusController( .ToListAsync(); HttpContext.SetPaginationData(likes); - var res = await userRenderer.RenderManyAsync(likes.Select(p => p.Entity)); - return Ok(res); + return await userRenderer.RenderManyAsync(likes.Select(p => p.Entity)); } [HttpGet("{id}/reblogged_by")] [Authenticate("read:statuses")] [LinkPagination(40, 80)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetNoteRenotes(string id, MastodonPaginationQuery pq) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] + public async Task> GetNoteRenotes(string id, MastodonPaginationQuery pq) { var user = HttpContext.GetUser(); if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) @@ -604,15 +591,14 @@ public class StatusController( .ToListAsync(); HttpContext.SetPaginationData(renotes); - var res = await userRenderer.RenderManyAsync(renotes.Select(p => p.Entity)); - return Ok(res); + return await userRenderer.RenderManyAsync(renotes.Select(p => p.Entity)); } [HttpGet("{id}/history")] [Authenticate("read:statuses")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] - public async Task GetNoteEditHistory(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] + public async Task> GetNoteEditHistory(string id) { var user = HttpContext.GetUser(); if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) @@ -629,7 +615,6 @@ public class StatusController( if (security.Value.PublicPreview <= Enums.PublicPreview.Restricted && note.User.IsRemoteUser && user == null) throw GracefulException.Forbidden("Public preview is disabled on this instance"); - var res = await noteRenderer.RenderHistoryAsync(note); - return Ok(res); + return await noteRenderer.RenderHistoryAsync(note); } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Mastodon/TimelineController.cs b/Iceshrimp.Backend/Controllers/Mastodon/TimelineController.cs index 90d7209e..21de80da 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/TimelineController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/TimelineController.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Renderers; @@ -27,84 +28,73 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C { [Authorize("read:statuses")] [HttpGet("home")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - public async Task GetHomeTimeline(MastodonPaginationQuery query) + [ProducesResults(HttpStatusCode.OK)] + public async Task> GetHomeTimeline(MastodonPaginationQuery query) { var user = HttpContext.GetUserOrFail(); var heuristic = await QueryableTimelineExtensions.GetHeuristic(user, db, cache); - - var res = await db.Notes - .IncludeCommonProperties() - .FilterByFollowingAndOwn(user, db, heuristic) - .EnsureVisibleFor(user) - .FilterHidden(user, db, filterHiddenListMembers: true) - .Paginate(query, ControllerContext) - .PrecomputeVisibilities(user) - .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Home); - - return Ok(res); + return await db.Notes + .IncludeCommonProperties() + .FilterByFollowingAndOwn(user, db, heuristic) + .EnsureVisibleFor(user) + .FilterHidden(user, db, filterHiddenListMembers: true) + .Paginate(query, ControllerContext) + .PrecomputeVisibilities(user) + .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Home); } [Authorize("read:statuses")] [HttpGet("public")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - public async Task GetPublicTimeline( + [ProducesResults(HttpStatusCode.OK)] + public async Task> GetPublicTimeline( MastodonPaginationQuery query, TimelineSchemas.PublicTimelineRequest request ) { var user = HttpContext.GetUserOrFail(); - - var res = await db.Notes - .IncludeCommonProperties() - .HasVisibility(Note.NoteVisibility.Public) - .FilterByPublicTimelineRequest(request) - .FilterHidden(user, db) - .Paginate(query, ControllerContext) - .PrecomputeVisibilities(user) - .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Public); - - return Ok(res); + return await db.Notes + .IncludeCommonProperties() + .HasVisibility(Note.NoteVisibility.Public) + .FilterByPublicTimelineRequest(request) + .FilterHidden(user, db) + .Paginate(query, ControllerContext) + .PrecomputeVisibilities(user) + .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Public); } [Authorize("read:statuses")] [HttpGet("tag/{hashtag}")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - public async Task GetHashtagTimeline( + [ProducesResults(HttpStatusCode.OK)] + public async Task> GetHashtagTimeline( string hashtag, MastodonPaginationQuery query, TimelineSchemas.HashtagTimelineRequest request ) { var user = HttpContext.GetUserOrFail(); - - var res = await db.Notes - .IncludeCommonProperties() - .Where(p => p.Tags.Contains(hashtag.ToLowerInvariant())) - .FilterByHashtagTimelineRequest(request) - .FilterHidden(user, db) - .Paginate(query, ControllerContext) - .PrecomputeVisibilities(user) - .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Public); - - return Ok(res); + return await db.Notes + .IncludeCommonProperties() + .Where(p => p.Tags.Contains(hashtag.ToLowerInvariant())) + .FilterByHashtagTimelineRequest(request) + .FilterHidden(user, db) + .Paginate(query, ControllerContext) + .PrecomputeVisibilities(user) + .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Public); } [Authorize("read:lists")] [HttpGet("list/{id}")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - public async Task GetListTimeline(string id, MastodonPaginationQuery query) + [ProducesResults(HttpStatusCode.OK)] + public async Task> GetListTimeline(string id, MastodonPaginationQuery query) { var user = HttpContext.GetUserOrFail(); if (!await db.UserLists.AnyAsync(p => p.Id == id && p.User == user)) throw GracefulException.RecordNotFound(); - var res = await db.Notes - .IncludeCommonProperties() - .Where(p => db.UserListMembers.Any(l => l.UserListId == id && l.UserId == p.UserId)) - .EnsureVisibleFor(user) - .FilterHidden(user, db) - .Paginate(query, ControllerContext) - .PrecomputeVisibilities(user) - .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Lists); - - return Ok(res); + return await db.Notes + .IncludeCommonProperties() + .Where(p => db.UserListMembers.Any(l => l.UserListId == id && l.UserId == p.UserId)) + .EnsureVisibleFor(user) + .FilterHidden(user, db) + .Paginate(query, ControllerContext) + .PrecomputeVisibilities(user) + .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Lists); } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Shared/Attributes/ConsumesAttributes.cs b/Iceshrimp.Backend/Controllers/Shared/Attributes/ConsumesAttributes.cs new file mode 100644 index 00000000..7b4e8da6 --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Shared/Attributes/ConsumesAttributes.cs @@ -0,0 +1,6 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Iceshrimp.Backend.Controllers.Shared.Attributes; + +public class ConsumesActivityStreamsPayload() : ConsumesAttribute("application/activity+json", + "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""); \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Shared/Attributes/ResultAttributes.cs b/Iceshrimp.Backend/Controllers/Shared/Attributes/ResultAttributes.cs new file mode 100644 index 00000000..6004907b --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Shared/Attributes/ResultAttributes.cs @@ -0,0 +1,30 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc; + +namespace Iceshrimp.Backend.Controllers.Shared.Attributes; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public class ProducesErrorsAttribute(HttpStatusCode statusCode, params HttpStatusCode[] additional) + : ProducesResponseTypeAttribute((int)statusCode) +{ + public IEnumerable StatusCodes => additional.Prepend(statusCode); +} + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public class ProducesResultsAttribute(HttpStatusCode statusCode, params HttpStatusCode[] additional) + : ProducesResponseTypeAttribute((int)statusCode) +{ + public IEnumerable StatusCodes => additional.Prepend(statusCode); +} + +public abstract class OverrideResultTypeAttribute(Type type) : Attribute +{ + public Type Type => type; +} + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public class OverrideResultTypeAttribute() : OverrideResultTypeAttribute(typeof(T)); + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public class ProducesActivityStreamsPayload() : ProducesAttribute("application/activity+json", + "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""); \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Web/AdminController.cs b/Iceshrimp.Backend/Controllers/Web/AdminController.cs index 3e4e9404..1b5d8a65 100644 --- a/Iceshrimp.Backend/Controllers/Web/AdminController.cs +++ b/Iceshrimp.Backend/Controllers/Web/AdminController.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Federation; using Iceshrimp.Backend.Controllers.Shared.Attributes; @@ -15,6 +16,7 @@ using Iceshrimp.Shared.Schemas.Web; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using Newtonsoft.Json.Linq; namespace Iceshrimp.Backend.Controllers.Web; @@ -28,13 +30,16 @@ public class AdminController( DatabaseContext db, ActivityPubController apController, ActivityPub.ActivityFetcherService fetchSvc, + ActivityPub.NoteRenderer noteRenderer, + ActivityPub.UserRenderer userRenderer, + IOptions config, QueueService queueSvc ) : ControllerBase { [HttpPost("invites/generate")] [Produces(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(InviteResponse))] - public async Task GenerateInvite() + [ProducesResults(HttpStatusCode.OK)] + public async Task GenerateInvite() { var invite = new RegistrationInvite { @@ -46,18 +51,15 @@ public class AdminController( await db.AddAsync(invite); await db.SaveChangesAsync(); - var res = new InviteResponse { Code = invite.Code }; - - return Ok(res); + return new InviteResponse { Code = invite.Code }; } [HttpPost("users/{id}/reset-password")] [Produces(MediaTypeNames.Application.Json)] [Consumes(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task ResetPassword(string id, [FromBody] ResetPasswordRequest request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)] + public async Task ResetPassword(string id, [FromBody] ResetPasswordRequest request) { var profile = await db.UserProfiles.FirstOrDefaultAsync(p => p.UserId == id && p.UserHost == null) ?? throw GracefulException.RecordNotFound(); @@ -67,16 +69,12 @@ public class AdminController( profile.Password = AuthHelpers.HashPassword(request.Password); await db.SaveChangesAsync(); - - return Ok(); } [HttpPost("instances/{host}/force-state/{state}")] - [Produces(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task ForceInstanceState(string host, AdminSchemas.InstanceState state) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task ForceInstanceState(string host, AdminSchemas.InstanceState state) { var instance = await db.Instances.FirstOrDefaultAsync(p => p.Host == host.ToLowerInvariant()) ?? throw GracefulException.NotFound("Instance not found"); @@ -97,93 +95,97 @@ public class AdminController( } await db.SaveChangesAsync(); - return Ok(); } [HttpPost("queue/jobs/{id::guid}/retry")] [Produces(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task RetryQueueJob(Guid id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)] + public async Task RetryQueueJob(Guid id) { var job = await db.Jobs.FirstOrDefaultAsync(p => p.Id == id) ?? throw GracefulException.NotFound($"Job {id} was not found."); await queueSvc.RetryJobAsync(job); - return Ok(); } [UseNewtonsoftJson] [HttpGet("activities/notes/{id}")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASNote))] + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] [Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] - public async Task GetNoteActivity(string id, [FromServices] ActivityPub.NoteRenderer noteRenderer) + public async Task GetNoteActivity(string id) { var note = await db.Notes .IncludeCommonProperties() .FirstOrDefaultAsync(p => p.Id == id && p.UserHost == null); - if (note == null) return NotFound(); - var rendered = await noteRenderer.RenderAsync(note); - var compacted = rendered.Compact(); - return Ok(compacted); + if (note == null) throw GracefulException.NotFound("Note not found"); + var rendered = await noteRenderer.RenderAsync(note); + return rendered.Compact() ?? throw new Exception("Failed to compact JSON-LD payload"); } [UseNewtonsoftJson] [HttpGet("activities/notes/{id}/activity")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASAnnounce))] - [Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + [ProducesActivityStreamsPayload] [SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage")] [SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery")] - public async Task GetRenoteActivity( - string id, [FromServices] ActivityPub.NoteRenderer noteRenderer, - [FromServices] ActivityPub.UserRenderer userRenderer, [FromServices] IOptions config - ) + public async Task GetRenoteActivity(string id) { var note = await db.Notes .IncludeCommonProperties() - .FirstOrDefaultAsync(p => p.Id == id && p.UserHost == null); - if (note is not { IsPureRenote: true, Renote: not null }) return NotFound(); - var rendered = ActivityPub.ActivityRenderer.RenderAnnounce(noteRenderer.RenderLite(note.Renote), - note.GetPublicUri(config.Value), - userRenderer.RenderLite(note.User), - note.Visibility, - note.User.GetPublicUri(config.Value) + "/followers"); - var compacted = rendered.Compact(); - return Ok(compacted); + .Where(p => p.Id == id && p.UserHost == null && p.IsPureRenote && p.Renote != null) + .FirstOrDefaultAsync() ?? + throw GracefulException.NotFound("Note not found"); + + return ActivityPub.ActivityRenderer + .RenderAnnounce(noteRenderer.RenderLite(note.Renote!), + note.GetPublicUri(config.Value), + userRenderer.RenderLite(note.User), + note.Visibility, + note.User.GetPublicUri(config.Value) + "/followers") + .Compact(); } [UseNewtonsoftJson] [HttpGet("activities/users/{id}")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))] - [Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] - public async Task GetUserActivity(string id) + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + [ProducesActivityStreamsPayload] + public async Task> GetUserActivity(string id) { return await apController.GetUser(id); } [UseNewtonsoftJson] [HttpGet("activities/users/{id}/collections/featured")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))] - [Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] - public async Task GetUserFeaturedActivity(string id) + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK)] + [ProducesActivityStreamsPayload] + public async Task GetUserFeaturedActivity(string id) { return await apController.GetUserFeatured(id); } [UseNewtonsoftJson] [HttpGet("activities/users/@{acct}")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))] - [Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] - public async Task GetUserActivityByUsername(string acct) + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK)] + [ProducesActivityStreamsPayload] + public async Task> GetUserActivityByUsername(string acct) { return await apController.GetUserByUsername(acct); } [UseNewtonsoftJson] [HttpGet("activities/fetch")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASObject))] - [Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK)] + [ProducesActivityStreamsPayload] public async Task FetchActivityAsync([FromQuery] string uri, [FromQuery] string? userId) { var user = userId != null ? await db.Users.FirstOrDefaultAsync(p => p.Id == userId && p.IsLocalUser) : null; @@ -194,9 +196,10 @@ public class AdminController( [UseNewtonsoftJson] [HttpGet("activities/fetch-raw")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASObject))] - [ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(ErrorResponse))] - [Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] + [OverrideResultType] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.UnprocessableEntity)] + [ProducesActivityStreamsPayload] public async Task FetchRawActivityAsync([FromQuery] string uri, [FromQuery] string? userId) { var user = userId != null ? await db.Users.FirstOrDefaultAsync(p => p.Id == userId && p.IsLocalUser) : null; diff --git a/Iceshrimp.Backend/Controllers/Web/AuthController.cs b/Iceshrimp.Backend/Controllers/Web/AuthController.cs index 2024afbc..eb4b7185 100644 --- a/Iceshrimp.Backend/Controllers/Web/AuthController.cs +++ b/Iceshrimp.Backend/Controllers/Web/AuthController.cs @@ -1,5 +1,7 @@ using System.Diagnostics.CodeAnalysis; +using System.Net; using System.Net.Mime; +using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Web.Renderers; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; @@ -22,34 +24,31 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere { [HttpGet] [Authenticate] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))] - public async Task GetAuthStatus() + [ProducesResults(HttpStatusCode.OK)] + public async Task GetAuthStatus() { var session = HttpContext.GetSession(); + if (session == null) return new AuthResponse { Status = AuthStatusEnum.Guest }; - if (session == null) - return Ok(new AuthResponse { Status = AuthStatusEnum.Guest }); - - return Ok(new AuthResponse + return new AuthResponse { Status = session.Active ? AuthStatusEnum.Authenticated : AuthStatusEnum.TwoFactor, Token = session.Token, IsAdmin = session.User.IsAdmin, IsModerator = session.User.IsModerator, User = await userRenderer.RenderOne(session.User) - }); + }; } [HttpPost("login")] [HideRequestDuration] [EnableRateLimiting("auth")] [Consumes(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(ErrorResponse))] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Forbidden)] [SuppressMessage("ReSharper.DPA", "DPA0011: High execution time of MVC action", Justification = "Argon2 is execution time-heavy by design")] - public async Task Login([FromBody] AuthRequest request) + public async Task Login([FromBody] AuthRequest request) { var user = await db.Users.FirstOrDefaultAsync(p => p.IsLocalUser && p.UsernameLower == request.Username.ToLowerInvariant()); @@ -76,24 +75,22 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere await db.SaveChangesAsync(); } - return Ok(new AuthResponse + return new AuthResponse { Status = session.Active ? AuthStatusEnum.Authenticated : AuthStatusEnum.TwoFactor, Token = session.Token, IsAdmin = session.User.IsAdmin, IsModerator = session.User.IsModerator, User = await userRenderer.RenderOne(user) - }); + }; } [HttpPost("register")] [EnableRateLimiting("auth")] [Consumes(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(ErrorResponse))] - public async Task Register([FromBody] RegistrationRequest request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden)] + public async Task Register([FromBody] RegistrationRequest request) { //TODO: captcha support @@ -106,11 +103,11 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere [Authorize] [EnableRateLimiting("auth")] [Consumes(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest)] [SuppressMessage("ReSharper.DPA", "DPA0011: High execution time of MVC action", Justification = "Argon2 is execution time-heavy by design")] - public async Task ChangePassword([FromBody] ChangePasswordRequest request) + public async Task ChangePassword([FromBody] ChangePasswordRequest request) { var user = HttpContext.GetUser() ?? throw new GracefulException("HttpContext.GetUser() was null"); var userProfile = await db.UserProfiles.FirstOrDefaultAsync(p => p.User == user); diff --git a/Iceshrimp.Backend/Controllers/Web/DriveController.cs b/Iceshrimp.Backend/Controllers/Web/DriveController.cs index 54f5b573..b7093a1e 100644 --- a/Iceshrimp.Backend/Controllers/Web/DriveController.cs +++ b/Iceshrimp.Backend/Controllers/Web/DriveController.cs @@ -1,5 +1,6 @@ -using System.Diagnostics.CodeAnalysis; +using System.Net; using System.Net.Mime; +using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Middleware; @@ -17,7 +18,6 @@ namespace Iceshrimp.Backend.Controllers.Web; public class DriveController( DatabaseContext db, ObjectStorageService objectStorage, - [SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")] IOptionsSnapshot options, ILogger logger, DriveService driveSvc @@ -25,6 +25,8 @@ public class DriveController( { [EnableCors("drive")] [HttpGet("/files/{accessKey}")] + [ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)] + [ProducesErrors(HttpStatusCode.NotFound)] public async Task GetFileByAccessKey(string accessKey) { var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.AccessKey == accessKey || @@ -33,7 +35,7 @@ public class DriveController( if (file == null) { Response.Headers.CacheControl = "max-age=86400"; - return NotFound(); + throw GracefulException.NotFound("File not found"); } if (file.StoredInternal) @@ -42,7 +44,7 @@ public class DriveController( if (string.IsNullOrWhiteSpace(pathBase)) { logger.LogError("Failed to get file {accessKey} from local storage: path does not exist", accessKey); - return NotFound(); + throw GracefulException.NotFound("File not found"); } var path = Path.Join(pathBase, accessKey); @@ -64,7 +66,7 @@ public class DriveController( if (stream == null) { logger.LogError("Failed to get file {accessKey} from object storage", accessKey); - return NotFound(); + throw GracefulException.NotFound("File not found"); } Response.Headers.CacheControl = "max-age=31536000, immutable"; @@ -76,8 +78,9 @@ public class DriveController( [HttpPost] [Authenticate] [Authorize] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DriveFileResponse))] - public async Task UploadFile(IFormFile file) + [Produces(MediaTypeNames.Application.Json)] + [ProducesResults(HttpStatusCode.OK)] + public async Task UploadFile(IFormFile file) { var user = HttpContext.GetUserOrFail(); var request = new DriveFileCreationRequest @@ -93,15 +96,16 @@ public class DriveController( [HttpGet("{id}")] [Authenticate] [Authorize] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DriveFileResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task GetFileById(string id) + [Produces(MediaTypeNames.Application.Json)] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task GetFileById(string id) { var user = HttpContext.GetUserOrFail(); - var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id); - if (file == null) return NotFound(); + var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ?? + throw GracefulException.NotFound("File not found"); - var res = new DriveFileResponse + return new DriveFileResponse { Id = file.Id, Url = file.PublicUrl, @@ -111,21 +115,20 @@ public class DriveController( Description = file.Comment, Sensitive = file.IsSensitive }; - - return Ok(res); } [HttpPatch("{id}")] [Authenticate] [Authorize] [Consumes(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DriveFileResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task UpdateFile(string id, UpdateDriveFileRequest request) + [Produces(MediaTypeNames.Application.Json)] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task UpdateFile(string id, UpdateDriveFileRequest request) { var user = HttpContext.GetUserOrFail(); - var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id); - if (file == null) return NotFound(); + var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ?? + throw GracefulException.NotFound("File not found"); file.Name = request.Filename ?? file.Name; file.IsSensitive = request.Sensitive ?? file.IsSensitive; diff --git a/Iceshrimp.Backend/Controllers/Web/EmojiController.cs b/Iceshrimp.Backend/Controllers/Web/EmojiController.cs index d0491881..2c48dd94 100644 --- a/Iceshrimp.Backend/Controllers/Web/EmojiController.cs +++ b/Iceshrimp.Backend/Controllers/Web/EmojiController.cs @@ -1,4 +1,6 @@ +using System.Net; using System.Net.Mime; +using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Services; @@ -22,36 +24,33 @@ public class EmojiController( ) : ControllerBase { [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - public async Task GetAllEmoji() + [ProducesResults(HttpStatusCode.OK)] + public async Task> GetAllEmoji() { - var res = await db.Emojis - .Where(p => p.Host == null) - .Select(p => new EmojiResponse - { - Id = p.Id, - Name = p.Name, - Uri = p.Uri, - Aliases = p.Aliases, - Category = p.Category, - PublicUrl = p.PublicUrl, - License = p.License - }) - .ToListAsync(); - - return Ok(res); + return await db.Emojis + .Where(p => p.Host == null) + .Select(p => new EmojiResponse + { + Id = p.Id, + Name = p.Name, + Uri = p.Uri, + Aliases = p.Aliases, + Category = p.Category, + PublicUrl = p.PublicUrl, + License = p.License + }) + .ToListAsync(); } [HttpGet("{id}")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(EmojiResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task GetEmoji(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task GetEmoji(string id) { - var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == id); + var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == id) ?? + throw GracefulException.NotFound("Emoji not found"); - if (emoji == null) return NotFound(); - - var res = new EmojiResponse + return new EmojiResponse { Id = emoji.Id, Name = emoji.Name, @@ -61,19 +60,17 @@ public class EmojiController( PublicUrl = emoji.PublicUrl, License = emoji.License }; - - return Ok(res); } [HttpPost] [Authorize("role:admin")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(EmojiResponse))] - [ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(ErrorResponse))] - public async Task UploadEmoji(IFormFile file) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.Conflict)] + public async Task UploadEmoji(IFormFile file) { var emoji = await emojiSvc.CreateEmojiFromStream(file.OpenReadStream(), file.FileName, file.ContentType); - var res = new EmojiResponse + return new EmojiResponse { Id = emoji.Id, Name = emoji.Name, @@ -83,25 +80,22 @@ public class EmojiController( PublicUrl = emoji.PublicUrl, License = null }; - - return Ok(res); } [HttpPost("clone/{name}@{host}")] [Authorize("role:admin")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(EmojiResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(ErrorResponse))] - public async Task CloneEmoji(string name, string host) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound, HttpStatusCode.Conflict)] + public async Task CloneEmoji(string name, string host) { var localEmojo = await db.Emojis.FirstOrDefaultAsync(e => e.Name == name && e.Host == null); - if (localEmojo != null) return Conflict(); + if (localEmojo != null) throw GracefulException.Conflict("An emoji with that name already exists"); var emojo = await db.Emojis.FirstOrDefaultAsync(e => e.Name == name && e.Host == host); - if (emojo == null) return NotFound(); + if (emojo == null) throw GracefulException.NotFound("Emoji not found"); - var cloned = await emojiSvc.CloneEmoji(emojo); - var response = new EmojiResponse + var cloned = await emojiSvc.CloneEmoji(emojo); + return new EmojiResponse { Id = cloned.Id, Name = cloned.Name, @@ -111,33 +105,30 @@ public class EmojiController( PublicUrl = cloned.PublicUrl, License = null }; - - return Ok(response); } [HttpPost("import")] [Authorize("role:admin")] - [ProducesResponseType(StatusCodes.Status202Accepted)] - public async Task ImportEmoji(IFormFile file) + [ProducesResults(HttpStatusCode.Accepted)] + public async Task ImportEmoji(IFormFile file) { var zip = await emojiImportSvc.Parse(file.OpenReadStream()); await emojiImportSvc.Import(zip); // TODO: run in background. this will take a while - return Accepted(); } [HttpPatch("{id}")] [Authorize("role:admin")] [Consumes(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(EmojiResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task UpdateEmoji(string id, UpdateEmojiRequest request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task UpdateEmoji(string id, UpdateEmojiRequest request) { var emoji = await emojiSvc.UpdateLocalEmoji(id, request.Name, request.Aliases, request.Category, - request.License); - if (emoji == null) return NotFound(); + request.License) ?? + throw GracefulException.NotFound("Emoji not found"); - var res = new EmojiResponse + return new EmojiResponse { Id = emoji.Id, Name = emoji.Name, @@ -147,17 +138,14 @@ public class EmojiController( PublicUrl = emoji.PublicUrl, License = emoji.License }; - - return Ok(res); } [HttpDelete("{id}")] [Authorize("role:admin")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task DeleteEmoji(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task DeleteEmoji(string id) { await emojiSvc.DeleteEmoji(id); - return Ok(); } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Web/FallbackController.cs b/Iceshrimp.Backend/Controllers/Web/FallbackController.cs index 88aa87c4..6341d01f 100644 --- a/Iceshrimp.Backend/Controllers/Web/FallbackController.cs +++ b/Iceshrimp.Backend/Controllers/Web/FallbackController.cs @@ -1,7 +1,7 @@ using System.Net; using System.Net.Mime; +using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Middleware; -using Iceshrimp.Shared.Schemas.Web; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; @@ -11,10 +11,10 @@ namespace Iceshrimp.Backend.Controllers.Web; public class FallbackController : ControllerBase { [EnableCors("fallback")] - [ProducesResponseType(StatusCodes.Status501NotImplemented, Type = typeof(ErrorResponse))] + [ProducesErrors(HttpStatusCode.NotImplemented)] public IActionResult FallbackAction() { - throw new GracefulException(HttpStatusCode.NotImplemented, - "This API method has not been implemented", Request.Path); + throw new GracefulException(HttpStatusCode.NotImplemented, "This API method has not been implemented", + Request.Path); } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Web/FollowRequestController.cs b/Iceshrimp.Backend/Controllers/Web/FollowRequestController.cs index 8d4416c7..8d8bf441 100644 --- a/Iceshrimp.Backend/Controllers/Web/FollowRequestController.cs +++ b/Iceshrimp.Backend/Controllers/Web/FollowRequestController.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Schemas; @@ -27,8 +28,8 @@ public class FollowRequestController( { [HttpGet] [LinkPagination(20, 40)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] - public async Task GetFollowRequests(PaginationQuery pq) + [ProducesResults(HttpStatusCode.OK)] + public async Task> GetFollowRequests(PaginationQuery pq) { var user = HttpContext.GetUserOrFail(); var requests = await db.FollowRequests @@ -39,17 +40,16 @@ public class FollowRequestController( .ToListAsync(); var users = await userRenderer.RenderMany(requests.Select(p => p.Follower)); - var res = requests.Select(p => new FollowRequestResponse + return requests.Select(p => new FollowRequestResponse { Id = p.Id, User = users.First(u => u.Id == p.Follower.Id) }); - return Ok(res.ToList()); } [HttpPost("{id}/accept")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task AcceptFollowRequest(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task AcceptFollowRequest(string id) { var user = HttpContext.GetUserOrFail(); var request = await db.FollowRequests @@ -58,13 +58,12 @@ public class FollowRequestController( throw GracefulException.NotFound("Follow request not found"); await userSvc.AcceptFollowRequestAsync(request); - return Ok(); } [HttpPost("{id}/reject")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task RejectFollowRequest(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task RejectFollowRequest(string id) { var user = HttpContext.GetUserOrFail(); var request = await db.FollowRequests @@ -73,6 +72,5 @@ public class FollowRequestController( throw GracefulException.NotFound("Follow request not found"); await userSvc.RejectFollowRequestAsync(request); - return Ok(); } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Web/IdenticonController.cs b/Iceshrimp.Backend/Controllers/Web/IdenticonController.cs index 57954e11..f677edf9 100644 --- a/Iceshrimp.Backend/Controllers/Web/IdenticonController.cs +++ b/Iceshrimp.Backend/Controllers/Web/IdenticonController.cs @@ -1,6 +1,8 @@ using System.IO.Hashing; +using System.Net; using System.Net.Mime; using System.Text; +using Iceshrimp.Backend.Controllers.Shared.Attributes; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using SixLabors.ImageSharp; @@ -15,10 +17,10 @@ namespace Iceshrimp.Backend.Controllers.Web; [Route("/identicon/{id}")] [Route("/identicon/{id}.png")] [Produces(MediaTypeNames.Image.Png)] -[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] public class IdenticonController : ControllerBase { [HttpGet] + [ProducesResults(HttpStatusCode.OK)] public async Task GetIdenticon(string id) { using var image = new Image(Size, Size); diff --git a/Iceshrimp.Backend/Controllers/Web/NoteController.cs b/Iceshrimp.Backend/Controllers/Web/NoteController.cs index 218d8dd3..dd8857a3 100644 --- a/Iceshrimp.Backend/Controllers/Web/NoteController.cs +++ b/Iceshrimp.Backend/Controllers/Web/NoteController.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; +using System.Net; using System.Net.Mime; using AsyncKeyedLock; using Iceshrimp.Backend.Controllers.Shared.Attributes; @@ -37,9 +38,9 @@ public class NoteController( [HttpGet("{id}")] [Authenticate] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(NoteResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task GetNote(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task GetNote(string id) { var user = HttpContext.GetUser(); var note = await db.Notes.Where(p => p.Id == id) @@ -50,29 +51,28 @@ public class NoteController( .FirstOrDefaultAsync() ?? throw GracefulException.NotFound("Note not found"); - return Ok(await noteRenderer.RenderOne(note.EnforceRenoteReplyVisibility(), user)); + return await noteRenderer.RenderOne(note.EnforceRenoteReplyVisibility(), user); } [HttpDelete("{id}")] [Authenticate] [Authorize] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task DeleteNote(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task DeleteNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.FirstOrDefaultAsync(p => p.Id == id && p.User == user) ?? throw GracefulException.NotFound("Note not found"); await noteSvc.DeleteNoteAsync(note); - return Ok(); } [HttpGet("{id}/ascendants")] [Authenticate] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task GetNoteAscendants( + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task> GetNoteAscendants( string id, [FromQuery] [DefaultValue(20)] [Range(1, 100)] int? limit ) { @@ -94,14 +94,14 @@ public class NoteController( var res = await noteRenderer.RenderMany(notes.EnforceRenoteReplyVisibility(), user, Filter.FilterContext.Threads); - return Ok(res.ToList().OrderAncestors()); + return res.ToList().OrderAncestors(); } [HttpGet("{id}/descendants")] [Authenticate] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task GetNoteDescendants( + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task> GetNoteDescendants( string id, [FromQuery] [DefaultValue(20)] [Range(1, 100)] int? depth ) { @@ -123,16 +123,16 @@ public class NoteController( var notes = hits.EnforceRenoteReplyVisibility(); var res = await noteRenderer.RenderMany(notes, user, Filter.FilterContext.Threads); - return Ok(res.ToList().OrderDescendants()); + return res.ToList().OrderDescendants(); } [HttpGet("{id}/reactions/{name}")] [Authenticate] [Authorize] [LinkPagination(20, 40)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task GetNoteReactions(string id, string name) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task> GetNoteReactions(string id, string name) { var user = HttpContext.GetUser(); var note = await db.Notes @@ -147,15 +147,15 @@ public class NoteController( .Select(p => p.User) .ToListAsync(); - return Ok(await userRenderer.RenderMany(users)); + return await userRenderer.RenderMany(users); } [HttpPost("{id}/like")] [Authenticate] [Authorize] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ValueResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task LikeNote(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task LikeNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes @@ -167,15 +167,15 @@ public class NoteController( var success = await noteSvc.LikeNoteAsync(note, user); - return Ok(new ValueResponse(success ? ++note.LikeCount : note.LikeCount)); + return new ValueResponse(success ? ++note.LikeCount : note.LikeCount); } [HttpPost("{id}/unlike")] [Authenticate] [Authorize] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ValueResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task UnlikeNote(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task UnlikeNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes @@ -187,15 +187,15 @@ public class NoteController( var success = await noteSvc.UnlikeNoteAsync(note, user); - return Ok(new ValueResponse(success ? --note.LikeCount : note.LikeCount)); + return new ValueResponse(success ? --note.LikeCount : note.LikeCount); } [HttpPost("{id}/renote")] [Authenticate] [Authorize] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ValueResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task RenoteNote(string id, [FromQuery] NoteVisibility? visibility = null) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task RenoteNote(string id, [FromQuery] NoteVisibility? visibility = null) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes @@ -206,15 +206,15 @@ public class NoteController( throw GracefulException.NotFound("Note not found"); var success = await noteSvc.RenoteNoteAsync(note, user, (Note.NoteVisibility?)visibility); - return Ok(new ValueResponse(success != null ? ++note.RenoteCount : note.RenoteCount)); + return new ValueResponse(success != null ? ++note.RenoteCount : note.RenoteCount); } [HttpPost("{id}/unrenote")] [Authenticate] [Authorize] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ValueResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task UnrenoteNote(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task UnrenoteNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes @@ -225,15 +225,15 @@ public class NoteController( throw GracefulException.NotFound("Note not found"); var count = await noteSvc.UnrenoteNoteAsync(note, user); - return Ok(new ValueResponse(note.RenoteCount - count)); + return new ValueResponse(note.RenoteCount - count); } [HttpPost("{id}/react/{name}")] [Authenticate] [Authorize] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ValueResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task ReactToNote(string id, string name) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task ReactToNote(string id, string name) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes @@ -245,15 +245,15 @@ public class NoteController( var res = await noteSvc.ReactToNoteAsync(note, user, name); note.Reactions.TryGetValue(res.name, out var count); - return Ok(new ValueResponse(res.success ? ++count : count)); + return new ValueResponse(res.success ? ++count : count); } [HttpPost("{id}/unreact/{name}")] [Authenticate] [Authorize] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ValueResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task RemoveReactionFromNote(string id, string name) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task RemoveReactionFromNote(string id, string name) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id) @@ -264,16 +264,16 @@ public class NoteController( var res = await noteSvc.RemoveReactionFromNoteAsync(note, user, name); note.Reactions.TryGetValue(res.name, out var count); - return Ok(new ValueResponse(res.success ? --count : count)); + return new ValueResponse(res.success ? --count : count); } [HttpPost("{id}/refetch")] [Authenticate] [Authorize] [EnableRateLimiting("strict")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(NoteRefetchResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task RefetchNote(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task RefetchNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id && p.User.Host != null && p.Uri != null) @@ -330,19 +330,19 @@ public class NoteController( .FirstOrDefaultAsync() ?? throw new Exception("Note disappeared during refetch"); - var res = new NoteRefetchResponse + return new NoteRefetchResponse { Note = await noteRenderer.RenderOne(note.EnforceRenoteReplyVisibility(), user), Errors = errors }; - return Ok(res); } [HttpPost] [Authenticate] [Authorize] [Consumes(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(NoteResponse))] - public async Task CreateNote(NoteCreateRequest request) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task CreateNote(NoteCreateRequest request) { var user = HttpContext.GetUserOrFail(); @@ -397,6 +397,6 @@ public class NoteController( if (request.IdempotencyKey != null) await cache.SetAsync($"idempotency:{user.Id}:{request.IdempotencyKey}", note.Id, TimeSpan.FromHours(24)); - return Ok(await noteRenderer.RenderOne(note, user)); + return await noteRenderer.RenderOne(note, user); } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Web/NotificationController.cs b/Iceshrimp.Backend/Controllers/Web/NotificationController.cs index 02c563f6..cfaf971e 100644 --- a/Iceshrimp.Backend/Controllers/Web/NotificationController.cs +++ b/Iceshrimp.Backend/Controllers/Web/NotificationController.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Schemas; @@ -22,8 +23,8 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not { [HttpGet] [LinkPagination(20, 80)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] - public async Task GetNotifications(PaginationQuery query) + [ProducesResults(HttpStatusCode.OK)] + public async Task> GetNotifications(PaginationQuery query) { var user = HttpContext.GetUserOrFail(); var notifications = await db.Notifications @@ -35,13 +36,13 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not .PrecomputeNoteVisibilities(user) .ToListAsync(); - return Ok(await notificationRenderer.RenderMany(notifications.EnforceRenoteReplyVisibility(p => p.Note), user)); + return await notificationRenderer.RenderMany(notifications.EnforceRenoteReplyVisibility(p => p.Note), user); } [HttpPost("{id}/read")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task MarkNotificationAsRead(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task MarkNotificationAsRead(string id) { var user = HttpContext.GetUserOrFail(); var notification = await db.Notifications.FirstOrDefaultAsync(p => p.Notifiee == user && p.Id == id) ?? @@ -52,25 +53,21 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not notification.IsRead = true; await db.SaveChangesAsync(); } - - return Ok(new object()); } [HttpPost("read")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] - public async Task MarkAllNotificationsAsRead() + [ProducesResults(HttpStatusCode.OK)] + public async Task MarkAllNotificationsAsRead() { var user = HttpContext.GetUserOrFail(); await db.Notifications.Where(p => p.Notifiee == user && !p.IsRead) .ExecuteUpdateAsync(p => p.SetProperty(n => n.IsRead, true)); - - return Ok(new object()); } [HttpDelete("{id}")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task DeleteNotification(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task DeleteNotification(string id) { var user = HttpContext.GetUserOrFail(); var notification = await db.Notifications.FirstOrDefaultAsync(p => p.Notifiee == user && p.Id == id) ?? @@ -78,18 +75,14 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not db.Remove(notification); await db.SaveChangesAsync(); - - return Ok(new object()); } [HttpDelete] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] - public async Task DeleteAllNotifications() + [ProducesResults(HttpStatusCode.OK)] + public async Task DeleteAllNotifications() { var user = HttpContext.GetUserOrFail(); await db.Notifications.Where(p => p.Notifiee == user) .ExecuteDeleteAsync(); - - return Ok(new object()); } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Web/SearchController.cs b/Iceshrimp.Backend/Controllers/Web/SearchController.cs index d502818a..4167a798 100644 --- a/Iceshrimp.Backend/Controllers/Web/SearchController.cs +++ b/Iceshrimp.Backend/Controllers/Web/SearchController.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Schemas; @@ -30,13 +31,14 @@ public class SearchController( UserRenderer userRenderer, ActivityPub.UserResolver userResolver, IOptions config -) - : ControllerBase +) : ControllerBase { [HttpGet("notes")] [LinkPagination(20, 80)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] - public async Task SearchNotes([FromQuery(Name = "q")] string query, PaginationQuery pagination) + [ProducesResults(HttpStatusCode.OK)] + public async Task> SearchNotes( + [FromQuery(Name = "q")] string query, PaginationQuery pagination + ) { var user = HttpContext.GetUserOrFail(); var notes = await db.Notes @@ -48,15 +50,17 @@ public class SearchController( .PrecomputeVisibilities(user) .ToListAsync(); - return Ok(await noteRenderer.RenderMany(notes.EnforceRenoteReplyVisibility(), user)); + return await noteRenderer.RenderMany(notes.EnforceRenoteReplyVisibility(), user); } [HttpGet("users")] [LinkPagination(20, 80)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + [ProducesResults(HttpStatusCode.OK)] [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Inspection doesn't know about the Projectable attribute")] - public async Task SearchUsers([FromQuery(Name = "q")] string query, PaginationQuery pagination) + public async Task> SearchUsers( + [FromQuery(Name = "q")] string query, PaginationQuery pagination + ) { var users = await db.Users .IncludeCommonProperties() @@ -66,14 +70,13 @@ public class SearchController( .OrderByDescending(p => p.NotesCount) .ToListAsync(); - return Ok(await userRenderer.RenderMany(users)); + return await userRenderer.RenderMany(users); } [HttpGet("lookup")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RedirectResponse))] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task Lookup([FromQuery(Name = "target")] string target) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)] + public async Task Lookup([FromQuery(Name = "target")] string target) { target = target.Trim(); @@ -85,7 +88,7 @@ public class SearchController( if (target.StartsWith('@') || target.StartsWith(userPrefixAlt)) { var hit = await userResolver.ResolveAsyncOrNull(target); - if (hit != null) return Ok(new RedirectResponse { TargetUrl = $"/users/{hit.Id}" }); + if (hit != null) return new RedirectResponse { TargetUrl = $"/users/{hit.Id}" }; throw GracefulException.NotFound("No result found"); } @@ -112,18 +115,18 @@ public class SearchController( } noteHit ??= await db.Notes.FirstOrDefaultAsync(p => p.Uri == target || p.Url == target); - if (noteHit != null) return Ok(new RedirectResponse { TargetUrl = $"/notes/{noteHit.Id}" }); + if (noteHit != null) return new RedirectResponse { TargetUrl = $"/notes/{noteHit.Id}" }; userHit ??= await db.Users.FirstOrDefaultAsync(p => p.Uri == target || (p.UserProfile != null && p.UserProfile.Url == target)); - if (userHit != null) return Ok(new RedirectResponse { TargetUrl = $"/users/{userHit.Id}" }); + if (userHit != null) return new RedirectResponse { TargetUrl = $"/users/{userHit.Id}" }; noteHit = await noteSvc.ResolveNoteAsync(target); - if (noteHit != null) return Ok(new RedirectResponse { TargetUrl = $"/notes/{noteHit.Id}" }); + if (noteHit != null) return new RedirectResponse { TargetUrl = $"/notes/{noteHit.Id}" }; userHit = await userResolver.ResolveAsyncOrNull(target); - if (userHit != null) return Ok(new RedirectResponse { TargetUrl = $"/users/{userHit.Id}" }); + if (userHit != null) return new RedirectResponse { TargetUrl = $"/users/{userHit.Id}" }; throw GracefulException.NotFound("No result found"); } diff --git a/Iceshrimp.Backend/Controllers/Web/SettingsController.cs b/Iceshrimp.Backend/Controllers/Web/SettingsController.cs index 1ca2dab2..434bc5bd 100644 --- a/Iceshrimp.Backend/Controllers/Web/SettingsController.cs +++ b/Iceshrimp.Backend/Controllers/Web/SettingsController.cs @@ -1,4 +1,6 @@ +using System.Net; using System.Net.Mime; +using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Middleware; @@ -17,26 +19,23 @@ namespace Iceshrimp.Backend.Controllers.Web; public class SettingsController(DatabaseContext db) : ControllerBase { [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserSettingsEntity))] - public async Task GetSettings() + [ProducesResults(HttpStatusCode.OK)] + public async Task GetSettings() { var settings = await GetOrInitUserSettings(); - - var res = new UserSettingsEntity + return new UserSettingsEntity { FilterInaccessible = settings.FilterInaccessible, PrivateMode = settings.PrivateMode, DefaultNoteVisibility = (NoteVisibility)settings.DefaultNoteVisibility, DefaultRenoteVisibility = (NoteVisibility)settings.DefaultNoteVisibility }; - - return Ok(res); } [HttpPut] [Consumes(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] - public async Task UpdateSettings(UserSettingsEntity newSettings) + [ProducesResults(HttpStatusCode.OK)] + public async Task UpdateSettings(UserSettingsEntity newSettings) { var settings = await GetOrInitUserSettings(); @@ -46,21 +45,18 @@ public class SettingsController(DatabaseContext db) : ControllerBase settings.DefaultRenoteVisibility = (Note.NoteVisibility)newSettings.DefaultRenoteVisibility; await db.SaveChangesAsync(); - return Ok(new object()); } private async Task GetOrInitUserSettings() { var user = HttpContext.GetUserOrFail(); var settings = user.UserSettings; - if (settings == null) - { - settings = new UserSettings { User = user }; - db.Add(settings); - await db.SaveChangesAsync(); - await db.ReloadEntityAsync(settings); - } + if (settings != null) return settings; + settings = new UserSettings { User = user }; + db.Add(settings); + await db.SaveChangesAsync(); + await db.ReloadEntityAsync(settings); return settings; } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Web/TimelineController.cs b/Iceshrimp.Backend/Controllers/Web/TimelineController.cs index 022b4214..753b9908 100644 --- a/Iceshrimp.Backend/Controllers/Web/TimelineController.cs +++ b/Iceshrimp.Backend/Controllers/Web/TimelineController.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Schemas; @@ -24,9 +25,8 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C [HttpGet("home")] [Authenticate] [Authorize] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task GetHomeTimeline(PaginationQuery pq) + [ProducesResults(HttpStatusCode.OK)] + public async Task> GetHomeTimeline(PaginationQuery pq) { var user = HttpContext.GetUserOrFail(); var heuristic = await QueryableTimelineExtensions.GetHeuristic(user, db, cache); @@ -38,6 +38,6 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C .PrecomputeVisibilities(user) .ToListAsync(); - return Ok(await noteRenderer.RenderMany(notes.EnforceRenoteReplyVisibility(), user, Filter.FilterContext.Home)); + return await noteRenderer.RenderMany(notes.EnforceRenoteReplyVisibility(), user, Filter.FilterContext.Home); } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Controllers/Web/UserController.cs b/Iceshrimp.Backend/Controllers/Web/UserController.cs index 63e3929b..dd870baf 100644 --- a/Iceshrimp.Backend/Controllers/Web/UserController.cs +++ b/Iceshrimp.Backend/Controllers/Web/UserController.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Schemas; @@ -33,21 +34,21 @@ public class UserController( ) : ControllerBase { [HttpGet("{id}")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task GetUser(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task GetUser(string id) { var user = await db.Users.IncludeCommonProperties() .FirstOrDefaultAsync(p => p.Id == id) ?? throw GracefulException.NotFound("User not found"); - return Ok(await userRenderer.RenderOne(await userResolver.GetUpdatedUser(user))); + return await userRenderer.RenderOne(await userResolver.GetUpdatedUser(user)); } [HttpGet("lookup")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task LookupUser([FromQuery] string username, [FromQuery] string? host) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task LookupUser([FromQuery] string username, [FromQuery] string? host) { username = username.ToLowerInvariant(); host = host?.ToLowerInvariant(); @@ -59,27 +60,27 @@ public class UserController( .FirstOrDefaultAsync(p => p.UsernameLower == username && p.Host == host) ?? throw GracefulException.NotFound("User not found"); - return Ok(await userRenderer.RenderOne(await userResolver.GetUpdatedUser(user))); + return await userRenderer.RenderOne(await userResolver.GetUpdatedUser(user)); } [HttpGet("{id}/profile")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserProfileResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task GetUserProfile(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task GetUserProfile(string id) { var localUser = HttpContext.GetUserOrFail(); var user = await db.Users.IncludeCommonProperties() .FirstOrDefaultAsync(p => p.Id == id) ?? throw GracefulException.NotFound("User not found"); - return Ok(await userProfileRenderer.RenderOne(await userResolver.GetUpdatedUser(user), localUser)); + return await userProfileRenderer.RenderOne(await userResolver.GetUpdatedUser(user), localUser); } [HttpGet("{id}/notes")] [LinkPagination(20, 80)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task GetUserNotes(string id, PaginationQuery pq) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task> GetUserNotes(string id, PaginationQuery pq) { var localUser = HttpContext.GetUserOrFail(); var user = await db.Users.FirstOrDefaultAsync(p => p.Id == id) ?? @@ -92,20 +93,18 @@ public class UserController( .FilterHidden(localUser, db, filterMutes: false) .Paginate(pq, ControllerContext) .PrecomputeVisibilities(localUser) - .ToListAsync(); + .ToListAsync() + .ContinueWith(n => n.Result.EnforceRenoteReplyVisibility()); - return Ok(await noteRenderer.RenderMany(notes.EnforceRenoteReplyVisibility(), localUser, - Filter.FilterContext.Accounts)); + return await noteRenderer.RenderMany(notes, localUser, Filter.FilterContext.Accounts); } [HttpPost("{id}/follow")] [Authenticate] [Authorize] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task FollowUser(string id) + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] + public async Task FollowUser(string id) { var user = HttpContext.GetUserOrFail(); if (user.Id == id) @@ -115,22 +114,18 @@ public class UserController( .Where(p => p.Id == id) .PrecomputeRelationshipData(user) .FirstOrDefaultAsync() ?? - throw GracefulException.RecordNotFound(); + throw GracefulException.NotFound("User not found"); if ((followee.PrecomputedIsBlockedBy ?? true) || (followee.PrecomputedIsBlocking ?? true)) throw GracefulException.Forbidden("This action is not allowed"); if (!(followee.PrecomputedIsFollowedBy ?? false) && !(followee.PrecomputedIsRequestedBy ?? false)) await userSvc.FollowUserAsync(user, followee); - - return Ok(); } [HttpPost("{id}/unfollow")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] - public async Task UnfollowUser(string id) + [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)] + public async Task UnfollowUser(string id) { var user = HttpContext.GetUserOrFail(); if (user.Id == id) @@ -144,7 +139,5 @@ public class UserController( throw GracefulException.RecordNotFound(); await userSvc.UnfollowUserAsync(user, followee); - - return Ok(); } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Extensions/SwaggerGenOptionsExtensions.cs b/Iceshrimp.Backend/Core/Extensions/SwaggerGenOptionsExtensions.cs index d2c6186a..b136c367 100644 --- a/Iceshrimp.Backend/Core/Extensions/SwaggerGenOptionsExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/SwaggerGenOptionsExtensions.cs @@ -2,8 +2,12 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; using Iceshrimp.Backend.Controllers.Federation.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes; +using Iceshrimp.Backend.Controllers.Mastodon.Schemas; +using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Middleware; +using Iceshrimp.Shared.Schemas.Web; using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; @@ -18,8 +22,11 @@ public static class SwaggerGenOptionsExtensions options.SupportNonNullableReferenceTypes(); // Sets Nullable flags appropriately. options.UseAllOfToExtendReferenceSchemas(); // Allows $ref enums to be nullable options.UseAllOfForInheritance(); // Allows $ref objects to be nullable - options.OperationFilter(); + options.OperationFilter(); options.OperationFilter(); + options.OperationFilter(); + options.OperationFilter(); + options.DocumentFilter(); options.DocInclusionPredicate(DocInclusionPredicate); } @@ -63,8 +70,91 @@ public static class SwaggerGenOptionsExtensions [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local", Justification = "SwaggerGenOptions.OperationFilter instantiates this class at runtime")] - private class AuthorizeCheckOperationFilter : IOperationFilter + private class AuthorizeCheckOperationDocumentFilter : IOperationFilter, IDocumentFilter { + private const string Web401 = + """ + { + "statusCode": 401, + "error": "Unauthorized", + "message": "This method requires an authenticated user" + } + """; + + private const string Web403 = + """ + { + "statusCode": 403, + "error": "Forbidden", + "message": "This action is outside the authorized scopes" + } + """; + + private const string Masto401 = + """ + { + "error": "This method requires an authenticated user" + } + """; + + private const string Masto403 = + """ + { + "message": "This action is outside the authorized scopes" + } + """; + + private static readonly OpenApiString MastoExample401 = new(Masto401); + private static readonly OpenApiString MastoExample403 = new(Masto403); + private static readonly OpenApiString WebExample401 = new(Web401); + private static readonly OpenApiString WebExample403 = new(Web403); + + private static readonly OpenApiReference Ref401 = + new() { Type = ReferenceType.Response, Id = "error-401" }; + + private static readonly OpenApiReference Ref403 = + new() { Type = ReferenceType.Response, Id = "error-403" }; + + private static readonly OpenApiResponse MastoRes401 = new() + { + Reference = Ref401, + Description = "Unauthorized", + Content = new Dictionary + { + { "application/json", new OpenApiMediaType { Example = MastoExample401 } } + } + }; + + private static readonly OpenApiResponse MastoRes403 = new() + { + Reference = Ref403, + Description = "Forbidden", + Content = new Dictionary + { + { "application/json", new OpenApiMediaType { Example = MastoExample403 } } + } + }; + + private static readonly OpenApiResponse WebRes401 = new() + { + Reference = Ref401, + Description = "Unauthorized", + Content = new Dictionary + { + { "application/json", new OpenApiMediaType { Example = WebExample401 } } + } + }; + + private static readonly OpenApiResponse WebRes403 = new() + { + Reference = Ref403, + Description = "Forbidden", + Content = new Dictionary + { + { "application/json", new OpenApiMediaType { Example = WebExample403 } } + } + }; + public void Apply(OpenApiOperation operation, OperationFilterContext context) { if (context.MethodInfo.DeclaringType is null) @@ -102,69 +192,125 @@ public static class SwaggerGenOptionsExtensions if (authorizeAttribute == null) return; - const string web401 = - """ - { - "statusCode": 401, - "error": "Unauthorized", - "message": "This method requires an authenticated user" - } - """; - - const string web403 = - """ - { - "statusCode": 403, - "error": "Forbidden", - "message": "This action is outside the authorized scopes" - } - """; - - const string masto401 = - """ - { - "error": "This method requires an authenticated user" - } - """; - - const string masto403 = - """ - { - "message": "This action is outside the authorized scopes" - } - """; - - var example401 = new OpenApiString(isMastodonController ? masto401 : web401); - - var res401 = new OpenApiResponse - { - Description = "Unauthorized", - Content = new Dictionary - { - { "application/json", new OpenApiMediaType { Example = example401 } } - } - }; - operation.Responses.Remove("401"); - operation.Responses.Add("401", res401); + operation.Responses.Add("401", new OpenApiResponse { Reference = Ref401 }); if (authorizeAttribute is { AdminRole: false, ModeratorRole: false, Scopes.Length: 0 } && authenticateAttribute is { AdminRole: false, ModeratorRole: false, Scopes.Length: 0 }) return; operation.Responses.Remove("403"); + operation.Responses.Add("403", new OpenApiResponse { Reference = Ref403 }); + } - var example403 = new OpenApiString(isMastodonController ? masto403 : web403); - - var res403 = new OpenApiResponse + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + if (swaggerDoc.Info.Title == "Mastodon") { - Description = "Forbidden", - Content = new Dictionary + swaggerDoc.Components.Responses.Add(Ref401.Id, MastoRes401); + swaggerDoc.Components.Responses.Add(Ref403.Id, MastoRes403); + } + else + { + swaggerDoc.Components.Responses.Add(Ref401.Id, WebRes401); + swaggerDoc.Components.Responses.Add(Ref403.Id, WebRes403); + } + } + } + + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local", + Justification = "SwaggerGenOptions.OperationFilter instantiates this class at runtime")] + private class PossibleErrorsOperationFilter : IOperationFilter + { + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + if (context.MethodInfo.DeclaringType is null) + return; + + var attribute = context.MethodInfo.GetCustomAttributes(true) + .OfType() + .FirstOrDefault() ?? + context.MethodInfo.DeclaringType.GetCustomAttributes(true) + .OfType() + .FirstOrDefault(); + + if (attribute == null) return; + + var isMastodonController = context.MethodInfo.DeclaringType.GetCustomAttributes(true) + .OfType() + .Any(); + + var type = isMastodonController ? typeof(MastodonErrorResponse) : typeof(ErrorResponse); + var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository); + + foreach (var status in attribute.StatusCodes.Distinct()) + { + var res = new OpenApiResponse { - { "application/json", new OpenApiMediaType { Example = example403 } } - } - }; - operation.Responses.Add("403", res403); + Description = ReasonPhrases.GetReasonPhrase((int)status), + Content = new Dictionary + { + { "application/json", new OpenApiMediaType { Schema = schema } } + } + }; + + operation.Responses.Remove(((int)status).ToString()); + operation.Responses.Add(((int)status).ToString(), res); + } + } + } + + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local", + Justification = "SwaggerGenOptions.OperationFilter instantiates this class at runtime")] + private class PossibleResultsOperationFilter : IOperationFilter + { + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + if (context.MethodInfo.DeclaringType is null) + return; + + var attribute = context.MethodInfo.GetCustomAttributes(true) + .OfType() + .FirstOrDefault() ?? + context.MethodInfo.DeclaringType.GetCustomAttributes(true) + .OfType() + .FirstOrDefault(); + + if (attribute == null) return; + + var overrideType = context.MethodInfo.GetCustomAttributes(true) + .OfType() + .FirstOrDefault() ?? + context.MethodInfo.DeclaringType.GetCustomAttributes(true) + .OfType() + .FirstOrDefault(); + + var type = overrideType?.Type ?? + context.ApiDescription.SupportedResponseTypes.FirstOrDefault(p => p.Type != typeof(void))?.Type; + + var schema = type != null + ? context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository) + : null; + + var openApiMediaType = new OpenApiMediaType { Schema = schema }; + foreach (var status in attribute.StatusCodes.Distinct()) + { + var content = schema != null + ? context.ApiDescription.SupportedResponseTypes + .Where(p => p.StatusCode == (int)status) + .SelectMany(p => p.ApiResponseFormats.Select(i => i.MediaType)) + .Distinct() + .ToDictionary(contentType => contentType, _ => openApiMediaType) + : null; + + var res = new OpenApiResponse + { + Description = ReasonPhrases.GetReasonPhrase((int)status), Content = content + }; + + operation.Responses.Remove(((int)status).ToString()); + operation.Responses.Add(((int)status).ToString(), res); + } } } diff --git a/Iceshrimp.Backend/Core/Federation/ActivityStreams/LdHelpers.cs b/Iceshrimp.Backend/Core/Federation/ActivityStreams/LdHelpers.cs index 2fefd39b..f7cd4b72 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityStreams/LdHelpers.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/LdHelpers.cs @@ -114,27 +114,29 @@ public static class LdHelpers return payload; } - public static JObject? Compact(this ASObject obj) + public static JObject Compact(this ASObject obj) { - return Compact((object)obj); + return Compact((object)obj) ?? throw new Exception("Failed to compact JSON-LD paylaod"); } - public static JObject? Compact(object obj) + public static JObject Compact(object obj) { - return Compact(JToken.FromObject(obj, JsonSerializer)); + return Compact(JToken.FromObject(obj, JsonSerializer)) ?? + throw new Exception("Failed to compact JSON-LD paylaod"); } - public static JArray? Expand(object obj) + public static JArray Expand(object obj) { - return Expand(JToken.FromObject(obj, JsonSerializer)); + return Expand(JToken.FromObject(obj, JsonSerializer)) ?? + throw new Exception("Failed to expand JSON-LD paylaod"); } - public static JObject? Compact(JToken? json) + public static JObject Compact(JToken? json) { return JsonLdProcessor.Compact(json, FederationContext, Options); } - public static JArray? Expand(JToken? json) + public static JArray Expand(JToken? json) { return JsonLdProcessor.Expand(json, Options); } diff --git a/Iceshrimp.Backend/Core/Federation/Cryptography/LdSignature.cs b/Iceshrimp.Backend/Core/Federation/Cryptography/LdSignature.cs index 368f6433..8112ad85 100644 --- a/Iceshrimp.Backend/Core/Federation/Cryptography/LdSignature.cs +++ b/Iceshrimp.Backend/Core/Federation/Cryptography/LdSignature.cs @@ -77,13 +77,13 @@ public static class LdSignature activity.Add($"{Constants.W3IdSecurityNs}#signature", JToken.FromObject(options)); - return LdHelpers.Expand(activity)?[0] as JObject ?? + return LdHelpers.Expand(activity)[0] as JObject ?? throw new GracefulException(HttpStatusCode.UnprocessableEntity, "Failed to expand signed activity"); } private static Task GetSignatureDataAsync(JToken data, SignatureOptions options) { - return GetSignatureDataAsync(data, LdHelpers.Expand(JObject.FromObject(options))!); + return GetSignatureDataAsync(data, LdHelpers.Expand(JObject.FromObject(options))); } private static async Task GetSignatureDataAsync(JToken data, JToken options) diff --git a/Iceshrimp.NET.sln.DotSettings b/Iceshrimp.NET.sln.DotSettings index ebecac59..d9ad1dc0 100644 --- a/Iceshrimp.NET.sln.DotSettings +++ b/Iceshrimp.NET.sln.DotSettings @@ -367,6 +367,79 @@ True True True + True + True + ProducesResultsAndErrors + True + 1 + True + 0 + True + 2.0 + InCSharpTypeMember + prese + True + [ProducesResults(HttpStatusCode.$OK$)] +[ProducesErrors(HttpStatusCode.$BadRequest$)]$SELECTION$$END$ + True + True + ProducesResults + True + 0 + False + + False + True + 2.0 + InCSharpTypeMember + pres + True + [ProducesResults(HttpStatusCode.$OK$)]$SELECTION$$END$ + True + True + ProducesOkResult + True + 2.0 + InCSharpTypeMember + pro + True + [ProducesResults(HttpStatusCode.OK)]$SELECTION$$END$ + True + True + ProducesOkResult + True + 2.0 + InCSharpTypeMember + presok + True + [ProducesResults(HttpStatusCode.OK)]$SELECTION$$END$ + True + True + OverrideResultType + True + 0 + True + 2.0 + InCSharpTypeMember + ort + True + [OverrideResultType<$object$>]$SELECTION$$END$ + True + True + ProducesErrors + True + 0 + False + + False + + False + True + 2.0 + InCSharpTypeMember + perr + True + [ProducesErrors(HttpStatusCode.$BadRequest$)]$SELECTION$$END$ True True True diff --git a/Iceshrimp.Tests/Cryptography/HttpSignatureTests.cs b/Iceshrimp.Tests/Cryptography/HttpSignatureTests.cs index 44ab91fd..cbea7517 100644 --- a/Iceshrimp.Tests/Cryptography/HttpSignatureTests.cs +++ b/Iceshrimp.Tests/Cryptography/HttpSignatureTests.cs @@ -21,7 +21,7 @@ public class HttpSignatureTests [TestInitialize] public void Initialize() { - _expanded = LdHelpers.Expand(_actor)!; + _expanded = LdHelpers.Expand(_actor); _expanded.Should().NotBeNull(); } diff --git a/Iceshrimp.Tests/Cryptography/LdSignatureTests.cs b/Iceshrimp.Tests/Cryptography/LdSignatureTests.cs index 8c7d49c5..1124b233 100644 --- a/Iceshrimp.Tests/Cryptography/LdSignatureTests.cs +++ b/Iceshrimp.Tests/Cryptography/LdSignatureTests.cs @@ -18,7 +18,7 @@ public class LdSignatureTests [TestInitialize] public async Task Initialize() { - _expanded = LdHelpers.Expand(_actor)!; + _expanded = LdHelpers.Expand(_actor); _signed = await LdSignature.SignAsync(_expanded, _keypair.ExportRSAPrivateKeyPem(), _actor.Id + "#main-key"); _expanded.Should().NotBeNull(); @@ -47,7 +47,7 @@ public class LdSignatureTests data.Should().NotBeNull(); data.Add("https://example.org/ns#test", JToken.FromObject("value")); - var expanded = LdHelpers.Expand(data)!; + var expanded = LdHelpers.Expand(data); expanded.Should().NotBeNull(); var verify = await LdSignature.VerifyAsync(expanded, expanded, _keypair.ExportRSAPublicKeyPem()); diff --git a/Iceshrimp.Tests/Serialization/JsonLdTests.cs b/Iceshrimp.Tests/Serialization/JsonLdTests.cs index f7d723a0..fd952990 100644 --- a/Iceshrimp.Tests/Serialization/JsonLdTests.cs +++ b/Iceshrimp.Tests/Serialization/JsonLdTests.cs @@ -11,7 +11,7 @@ public class JsonLdTests [TestMethod] public void RoundtripTest() { - var expanded = LdHelpers.Expand(_actor)!; + var expanded = LdHelpers.Expand(_actor); expanded.Should().NotBeNull(); var canonicalized = LdHelpers.Canonicalize(expanded); @@ -20,11 +20,11 @@ public class JsonLdTests var compacted = LdHelpers.Compact(expanded); compacted.Should().NotBeNull(); - var expanded2 = LdHelpers.Expand(compacted)!; + var expanded2 = LdHelpers.Expand(compacted); expanded2.Should().NotBeNull(); expanded2.Should().BeEquivalentTo(expanded); - var compacted2 = LdHelpers.Compact(expanded2)!; + var compacted2 = LdHelpers.Compact(expanded2); compacted2.Should().NotBeNull(); compacted2.Should().BeEquivalentTo(compacted);