From 9dbb9e1596968cd7d8b37c224ec7da3af95a2865 Mon Sep 17 00:00:00 2001 From: Toaster Date: Thu, 7 Aug 2025 09:56:19 +0200 Subject: [PATCH 1/7] Refactor API categories --- .../Categories/ApiCategoryResponse.cs | 35 +++++++++++++ .../ApiLevelCategoryResponse.cs | 12 ++--- .../Categories/ApiUserCategoryResponse.cs | 50 +++++++++++++++++++ .../Endpoints/LevelApiEndpoints.cs | 1 + .../Tests/ApiV3/LevelApiTests.cs | 1 + 5 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiCategoryResponse.cs rename Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/{Levels => Categories}/ApiLevelCategoryResponse.cs (79%) create mode 100644 Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiUserCategoryResponse.cs diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiCategoryResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiCategoryResponse.cs new file mode 100644 index 000000000..6fffe81f1 --- /dev/null +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiCategoryResponse.cs @@ -0,0 +1,35 @@ +using Refresh.Core.Types.Categories; +using Refresh.Core.Types.Data; + +namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories; + +[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] +public class ApiCategoryResponse : IApiResponse, IDataConvertableFrom +{ + public required string Name { get; set; } + public required string Description { get; set; } + public required string IconHash { get; set; } + public required string FontAwesomeIcon { get; set; } + public required string ApiRoute { get; set; } + public required bool RequiresUser { get; set; } + public required bool Hidden { get; set; } = false; + + public static ApiCategoryResponse? FromOld(GameCategory? old, DataContext dataContext) + { + if (old == null) return null; + + return new ApiCategoryResponse + { + Name = old.Name, + Description = old.Description, + IconHash = old.IconHash, + FontAwesomeIcon = old.FontAwesomeIcon, + ApiRoute = old.ApiRoute, + RequiresUser = old.RequiresUser, + Hidden = old.Hidden, + }; + } + + public static IEnumerable FromOldList(IEnumerable oldList, DataContext dataContext) + => oldList.Select(old => FromOld(old, dataContext)).ToList()!; +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiLevelCategoryResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiLevelCategoryResponse.cs similarity index 79% rename from Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiLevelCategoryResponse.cs rename to Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiLevelCategoryResponse.cs index b5a184abc..e78fd2633 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiLevelCategoryResponse.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiLevelCategoryResponse.cs @@ -5,20 +5,14 @@ using Refresh.Database.Models.Authentication; using Refresh.Database.Models.Levels; using Refresh.Database.Query; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels; -namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels; +namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories; [JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] -public class ApiLevelCategoryResponse : IApiResponse, IDataConvertableFrom +public class ApiLevelCategoryResponse : ApiCategoryResponse, IApiResponse, IDataConvertableFrom { - public required string Name { get; set; } - public required string Description { get; set; } - public required string IconHash { get; set; } - public required string FontAwesomeIcon { get; set; } - public required string ApiRoute { get; set; } - public required bool RequiresUser { get; set; } public required ApiGameLevelResponse? PreviewLevel { get; set; } - public required bool Hidden { get; set; } = false; public static ApiLevelCategoryResponse? FromOld(GameLevelCategory? old, GameLevel? previewLevel, DataContext dataContext) diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiUserCategoryResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiUserCategoryResponse.cs new file mode 100644 index 000000000..437db6663 --- /dev/null +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiUserCategoryResponse.cs @@ -0,0 +1,50 @@ +using Bunkum.Core; +using Refresh.Core.Types.Categories.Users; +using Refresh.Core.Types.Data; +using Refresh.Database; +using Refresh.Database.Models.Users; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users; + +namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories; + +[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] +public class ApiUserCategoryResponse : ApiCategoryResponse, IApiResponse, IDataConvertableFrom +{ + public required ApiGameUserResponse? PreviewItem { get; set; } + + public static ApiUserCategoryResponse? FromOld(GameUserCategory? old, GameUser? PreviewItem, + DataContext dataContext) + { + if (old == null) return null; + + return new ApiUserCategoryResponse + { + Name = old.Name, + Description = old.Description, + IconHash = old.IconHash, + FontAwesomeIcon = old.FontAwesomeIcon, + ApiRoute = old.ApiRoute, + RequiresUser = old.RequiresUser, + PreviewItem = ApiGameUserResponse.FromOld(PreviewItem, dataContext), + Hidden = old.Hidden, + }; + } + + public static ApiUserCategoryResponse? FromOld(GameUserCategory? old, DataContext dataContext) => FromOld(old, null, dataContext); + + public static IEnumerable FromOldList(IEnumerable oldList, + DataContext dataContext) => oldList.Select(old => FromOld(old, dataContext)).ToList()!; + + public static IEnumerable FromOldList(IEnumerable oldList, + RequestContext context, + DataContext dataContext) + { + return oldList.Select(category => + { + DatabaseList? list = category.Fetch(context, 0, 1, dataContext, dataContext.User); + GameUser? item = list?.Items.FirstOrDefault(); + + return FromOld(category, item, dataContext); + }).ToList()!; + } +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs index d95b26221..fda55e174 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs @@ -18,6 +18,7 @@ using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors; using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories; using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels; using Refresh.Interfaces.APIv3.Extensions; diff --git a/RefreshTests.GameServer/Tests/ApiV3/LevelApiTests.cs b/RefreshTests.GameServer/Tests/ApiV3/LevelApiTests.cs index bbc481b9a..03808f125 100644 --- a/RefreshTests.GameServer/Tests/ApiV3/LevelApiTests.cs +++ b/RefreshTests.GameServer/Tests/ApiV3/LevelApiTests.cs @@ -5,6 +5,7 @@ using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors; using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories; namespace RefreshTests.GameServer.Tests.ApiV3; From 527864bc9e2d15b8d0aa32c8da4a2ba4cdf6de56 Mon Sep 17 00:00:00 2001 From: Toaster Date: Thu, 7 Aug 2025 11:06:08 +0200 Subject: [PATCH 2/7] Refactor API category endpoints, implement user category API endpoints --- .../Endpoints/CategoryApiEndpoints.cs | 128 ++++++++++++++++++ .../Categories/ApiUserCategoryResponse.cs | 7 +- .../Endpoints/LevelApiEndpoints.cs | 60 -------- 3 files changed, 132 insertions(+), 63 deletions(-) create mode 100644 Refresh.Interfaces.APIv3/Endpoints/CategoryApiEndpoints.cs diff --git a/Refresh.Interfaces.APIv3/Endpoints/CategoryApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/CategoryApiEndpoints.cs new file mode 100644 index 000000000..697bf55c0 --- /dev/null +++ b/Refresh.Interfaces.APIv3/Endpoints/CategoryApiEndpoints.cs @@ -0,0 +1,128 @@ +using AttribDoc.Attributes; +using Bunkum.Core; +using Bunkum.Core.Endpoints; +using Refresh.Core.Configuration; +using Refresh.Core.Types.Categories; +using Refresh.Core.Types.Data; +using Refresh.Database; +using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Levels; +using Refresh.Database.Models.Users; +using Refresh.Database.Query; +using Refresh.Interfaces.APIv3.Documentation.Attributes; +using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; +using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users; +using Refresh.Interfaces.APIv3.Extensions; + +namespace Refresh.Interfaces.APIv3.Endpoints; + +public class CategoryApiEndpoints : EndpointGroup +{ + [ApiV3Endpoint("levels"), Authentication(false)] + [ClientCacheResponse(86400 / 2)] // cache for half a day + [DocSummary("Retrieves a list of categories you can use to search levels")] + [DocQueryParam("includePreviews", "If true, a single level will be added to each category representing a level from that category. False by default.")] + [DocError(typeof(ApiValidationError), "The boolean 'includePreviews' could not be parsed by the server.")] + public ApiListResponse GetLevelCategories(RequestContext context, CategoryService categories, + DataContext dataContext) + { + bool result = bool.TryParse(context.QueryString.Get("includePreviews") ?? "false", out bool includePreviews); + if (!result) return ApiValidationError.BooleanParseError; + + IEnumerable resp; + + // ReSharper disable once ConvertIfStatementToConditionalTernaryExpression + if (includePreviews) resp = ApiLevelCategoryResponse.FromOldList(categories.LevelCategories, context, dataContext); + else resp = ApiLevelCategoryResponse.FromOldList(categories.LevelCategories, dataContext); + + return new ApiListResponse(resp); + } + + [ApiV3Endpoint("levels/{route}"), Authentication(false)] + [DocSummary("Retrieves a list of levels from a category")] + [DocError(typeof(ApiNotFoundError), "The level category cannot be found")] + [DocUsesPageData] + [DocQueryParam("game", "Filters levels to a specific game version. Allowed values: lbp1-3, vita, psp, beta")] + [DocQueryParam("seed", "The random seed to use for randomization. Uses 0 if not specified.")] + [DocQueryParam("players", "Filters levels to those accommodating the specified number of players.")] + [DocQueryParam("username", "If set, certain categories like 'hearted' or 'byUser' will return the levels of " + + "the user with this username instead of your own. Optional.")] + public ApiListResponse GetLevels(RequestContext context, CategoryService categories, GameUser? user, + [DocSummary("The name of the category you'd like to retrieve levels from. " + + "Make a request to /levels to see a list of available categories")] + string route, DataContext dataContext) + { + if (string.IsNullOrWhiteSpace(route)) + { + return new ApiError("You didn't specify a route. " + + "You probably meant to use the `/levels` endpoint and left a trailing slash in the URL.", NotFound); + } + + (int skip, int count) = context.GetPageData(); + + DatabaseList? list = categories.LevelCategories + .FirstOrDefault(c => c.ApiRoute.StartsWith(route))? + .Fetch(context, skip, count, dataContext, new LevelFilterSettings(context, TokenGame.Website), user); + + if (list == null) return ApiNotFoundError.Instance; + + DatabaseList levels = DatabaseListExtensions.FromOldList(list, dataContext); + return levels; + } + + [ApiV3Endpoint("users"), Authentication(false)] + [ClientCacheResponse(86400 / 2)] // cache for half a day + [DocSummary("Retrieves a list of categories you can use to search users. Returns an empty list if the instance doesn't allow showing online users.")] + [DocQueryParam("includePreviews", "If true, a single user will be added to each category representing a user from that category. False by default.")] + [DocError(typeof(ApiValidationError), "The boolean 'includePreviews' could not be parsed by the server.")] + public ApiListResponse GetUserCategories(RequestContext context, CategoryService categories, + DataContext dataContext, GameServerConfig config) + { + bool result = bool.TryParse(context.QueryString.Get("includePreviews") ?? "false", out bool includePreviews); + if (!result) return ApiValidationError.BooleanParseError; + + if (!config.PermitShowingOnlineUsers) return new ApiListResponse([]); + IEnumerable resp; + + if (includePreviews) resp = ApiUserCategoryResponse.FromOldList(categories.UserCategories, context, dataContext); + else resp = ApiUserCategoryResponse.FromOldList(categories.UserCategories, dataContext); + + return new ApiListResponse(resp); + } + + // This route can not be called "users/{route}", else Bunkum will route users/me requests to here aswell. + // Having a special case for the "me" route here would be hacky and introduce trouble if another endpoint with the route + // "users/something" (for example) were to ever be implemented in the future. + [ApiV3Endpoint("users/category/{route}"), Authentication(false)] + [DocSummary("Retrieves a list of users from a category.")] + [DocError(typeof(ApiNotFoundError), "The user category cannot be found, or the instance does not allow showing online users.")] + [DocUsesPageData] + [DocQueryParam("username", "If set, certain categories like 'hearted' will return the related users of " + + "the user with this username instead of your own. Optional.")] + public ApiListResponse GetUsers(RequestContext context, CategoryService categories, GameUser? user, + [DocSummary("The name of the category you'd like to retrieve users from. " + + "Make a request to /users to see a list of available categories")] + string route, DataContext dataContext, GameServerConfig config) + { + if (string.IsNullOrWhiteSpace(route)) + { + return new ApiError("You didn't specify a route.", NotFound); + // users/ case won't happen here because of the extra "category" inbetween "users" and the route parameter. + } + + if (!config.PermitShowingOnlineUsers) return ApiNotFoundError.Instance; + (int skip, int count) = context.GetPageData(); + + DatabaseList? list = categories.UserCategories + .FirstOrDefault(c => c.ApiRoute.StartsWith(route))? + .Fetch(context, skip, count, dataContext, user); + + if (list == null) return ApiNotFoundError.Instance; + + DatabaseList levels = DatabaseListExtensions.FromOldList(list, dataContext); + return levels; + } +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiUserCategoryResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiUserCategoryResponse.cs index 437db6663..c3f21552f 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiUserCategoryResponse.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiUserCategoryResponse.cs @@ -30,10 +30,11 @@ public class ApiUserCategoryResponse : ApiCategoryResponse, IApiResponse, IDataC }; } - public static ApiUserCategoryResponse? FromOld(GameUserCategory? old, DataContext dataContext) => FromOld(old, null, dataContext); + public static ApiUserCategoryResponse? FromOld(GameUserCategory? old, DataContext dataContext) + => FromOld(old, null, dataContext); - public static IEnumerable FromOldList(IEnumerable oldList, - DataContext dataContext) => oldList.Select(old => FromOld(old, dataContext)).ToList()!; + public static IEnumerable FromOldList(IEnumerable oldList, DataContext dataContext) + => oldList.Select(old => FromOld(old, dataContext)).ToList()!; public static IEnumerable FromOldList(IEnumerable oldList, RequestContext context, diff --git a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs index fda55e174..7221ca41c 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs @@ -6,80 +6,20 @@ using Refresh.Common.Verification; using Refresh.Core.Authentication.Permission; using Refresh.Core.Services; -using Refresh.Core.Types.Categories; using Refresh.Core.Types.Data; using Refresh.Database; -using Refresh.Database.Models.Authentication; using Refresh.Database.Models.Levels; using Refresh.Database.Models.Pins; using Refresh.Database.Models.Users; -using Refresh.Database.Query; -using Refresh.Interfaces.APIv3.Documentation.Attributes; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors; using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request; -using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories; using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels; -using Refresh.Interfaces.APIv3.Extensions; namespace Refresh.Interfaces.APIv3.Endpoints; public class LevelApiEndpoints : EndpointGroup { - [ApiV3Endpoint("levels"), Authentication(false)] - [ClientCacheResponse(86400 / 2)] // cache for half a day - [DocSummary("Retrieves a list of categories you can use to search levels")] - [DocQueryParam("includePreviews", "If true, a single level will be added to each category representing a level from that category. False by default.")] - [DocError(typeof(ApiValidationError), "The boolean 'includePreviews' could not be parsed by the server.")] - public ApiListResponse GetCategories(RequestContext context, CategoryService categories, - MatchService matchService, GameDatabaseContext database, GameUser? user, IDataStore dataStore, - DataContext dataContext) - { - bool result = bool.TryParse(context.QueryString.Get("includePreviews") ?? "false", out bool includePreviews); - if (!result) return ApiValidationError.BooleanParseError; - - IEnumerable resp; - - // ReSharper disable once ConvertIfStatementToConditionalTernaryExpression - if (includePreviews) resp = ApiLevelCategoryResponse.FromOldList(categories.LevelCategories, context, dataContext); - else resp = ApiLevelCategoryResponse.FromOldList(categories.LevelCategories, dataContext); - - return new ApiListResponse(resp); - } - - [ApiV3Endpoint("levels/{route}"), Authentication(false)] - [DocSummary("Retrieves a list of levels from a category")] - [DocError(typeof(ApiNotFoundError), "The level category cannot be found")] - [DocUsesPageData] - [DocQueryParam("game", "Filters levels to a specific game version. Allowed values: lbp1-3, vita, psp, beta")] - [DocQueryParam("seed", "The random seed to use for randomization. Uses 0 if not specified.")] - [DocQueryParam("players", "Filters levels to those accommodating the specified number of players.")] - [DocQueryParam("username", "If set, certain categories like 'hearted' or 'byUser' will return the levels of " + - "the user with this username instead of your own. Optional.")] - public ApiListResponse GetLevels(RequestContext context, GameDatabaseContext database, - MatchService matchService, CategoryService categories, GameUser? user, IDataStore dataStore, - [DocSummary("The name of the category you'd like to retrieve levels from. " + - "Make a request to /levels to see a list of available categories")] - string route, DataContext dataContext) - { - if (string.IsNullOrWhiteSpace(route)) - { - return new ApiError("You didn't specify a route. " + - "You probably meant to use the `/levels` endpoint and left a trailing slash in the URL.", NotFound); - } - - (int skip, int count) = context.GetPageData(); - - DatabaseList? list = categories.LevelCategories - .FirstOrDefault(c => c.ApiRoute.StartsWith(route))? - .Fetch(context, skip, count, dataContext, new LevelFilterSettings(context, TokenGame.Website), user); - - if (list == null) return ApiNotFoundError.Instance; - - DatabaseList levels = DatabaseListExtensions.FromOldList(list, dataContext); - return levels; - } - [ApiV3Endpoint("levels/id/{id}"), Authentication(false)] [DocSummary("Gets an individual level by a numerical ID")] [DocError(typeof(ApiNotFoundError), "The level cannot be found")] From 478db33419ed38429d83ea3ab3b1994b8d9f4fe1 Mon Sep 17 00:00:00 2001 From: Toaster Date: Thu, 7 Aug 2025 12:12:58 +0200 Subject: [PATCH 3/7] Implement user API category tests --- .../Tests/ApiV3/UserApiTests.cs | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs b/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs index f67be31eb..fb8a3cf04 100644 --- a/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs +++ b/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs @@ -3,6 +3,7 @@ using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors; using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request.Authentication; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories; using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users; using RefreshTests.GameServer.Extensions; @@ -159,4 +160,91 @@ public void CanPatchOwnUser() user = context.Database.GetUserByObjectId(user.UserId)!; Assert.That(user.Description, Is.EqualTo(description)); } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void GetsUserCategories(bool showOnlineUsers) + { + using TestContext context = this.GetServer(); + + // Prepare config + context.Server.Value.GameServerConfig.PermitShowingOnlineUsers = showOnlineUsers; + + ApiListResponse? categories = context.Http.GetList("/api/v3/users"); + Assert.That(categories, Is.Not.Null); + + if (!showOnlineUsers) + { + Assert.That(categories!.ListInfo!.TotalItems, Is.Zero); + return; + } + + Assert.That(categories!.ListInfo!.TotalItems, Is.EqualTo(categories.Data!.Count)); + Assert.That(categories.ListInfo.TotalItems, Is.Not.Zero); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void GetsUserCategoriesWithPreviews(bool showOnlineUsers) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + + // Prepare config + context.Server.Value.GameServerConfig.PermitShowingOnlineUsers = showOnlineUsers; + + ApiListResponse? categories = context.Http.GetList("/api/v3/users?includePreviews=true"); + Assert.That(categories, Is.Not.Null); + + if (!showOnlineUsers) + { + Assert.That(categories!.ListInfo!.TotalItems, Is.Zero); + return; + } + + ApiUserCategoryResponse? category = categories?.Data?.FirstOrDefault(c => c.ApiRoute == "newest"); + Assert.That(category, Is.Not.Null); + + Assert.Multiple(() => + { + Assert.That(category!.PreviewItem, Is.Not.Null); + Assert.That(category.PreviewItem!.UserId, Is.EqualTo(user.UserId.ToString())); + }); + } + + [Test] + public void DoesntGetUserCategoriesWithGarbledPreviews() + { + using TestContext context = this.GetServer(); + + ApiListResponse? categories = context.Http.GetList("/api/v3/users?includePreviews=IIIIIIIIHEHAHAHAHAHAHAHA", false, true); // https://youtu.be/mpAnsf12JkA?t=2 + Assert.That(categories, Is.Not.Null); + categories!.AssertErrorIsEqual(ApiValidationError.BooleanParseError); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void GetsNewestUser(bool showOnlineUsers) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + + // Prepare config + context.Server.Value.GameServerConfig.PermitShowingOnlineUsers = showOnlineUsers; + + if (!showOnlineUsers) + { + HttpResponseMessage message = context.Http.GetAsync("/api/v3/users/category/newest").Result; + Assert.That(message.StatusCode, Is.EqualTo(NotFound)); + return; + } + + ApiListResponse? users = context.Http.GetList("/api/v3/users/category/newest?count=1", false); + Assert.That(users, Is.Not.Null); + Assert.That(users!.Data, Has.Count.EqualTo(1)); + Assert.That(users.Data![0].UserId, Is.EqualTo(user.UserId.ToString())); + } } \ No newline at end of file From e420e84fa940b638660af9fa7723c6c1dbd2d968 Mon Sep 17 00:00:00 2001 From: Toaster Date: Sat, 9 Aug 2025 16:28:47 +0200 Subject: [PATCH 4/7] Shorter API category cache duration --- Refresh.Interfaces.APIv3/Endpoints/CategoryApiEndpoints.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Refresh.Interfaces.APIv3/Endpoints/CategoryApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/CategoryApiEndpoints.cs index 697bf55c0..57ba0f6f2 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/CategoryApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/CategoryApiEndpoints.cs @@ -22,7 +22,7 @@ namespace Refresh.Interfaces.APIv3.Endpoints; public class CategoryApiEndpoints : EndpointGroup { [ApiV3Endpoint("levels"), Authentication(false)] - [ClientCacheResponse(86400 / 2)] // cache for half a day + [ClientCacheResponse(1800)] // cache for half an hour [DocSummary("Retrieves a list of categories you can use to search levels")] [DocQueryParam("includePreviews", "If true, a single level will be added to each category representing a level from that category. False by default.")] [DocError(typeof(ApiValidationError), "The boolean 'includePreviews' could not be parsed by the server.")] @@ -74,7 +74,7 @@ public ApiListResponse GetLevels(RequestContext context, C } [ApiV3Endpoint("users"), Authentication(false)] - [ClientCacheResponse(86400 / 2)] // cache for half a day + [ClientCacheResponse(1800)] // cache for half an hour [DocSummary("Retrieves a list of categories you can use to search users. Returns an empty list if the instance doesn't allow showing online users.")] [DocQueryParam("includePreviews", "If true, a single user will be added to each category representing a user from that category. False by default.")] [DocError(typeof(ApiValidationError), "The boolean 'includePreviews' could not be parsed by the server.")] From 4e59e0e9b430fec92a9849446522cac628dc587e Mon Sep 17 00:00:00 2001 From: Toaster Date: Sat, 9 Aug 2025 17:45:10 +0200 Subject: [PATCH 5/7] Introducing API hacks, have ApiExtendedGameUserResponse extend ApiGameUserResponse, properly handle unauthenticated users/me requests --- .../ApiTypes/Errors/ApiAuthenticationError.cs | 5 +++- .../Endpoints/CategoryApiEndpoints.cs | 24 ++++++++++++------- .../Users/ApiExtendedGameUserResponse.cs | 23 +++++------------- .../Response/Users/ApiGameUserResponse.cs | 3 --- .../Endpoints/UserApiEndpoints.cs | 8 +++++-- 5 files changed, 31 insertions(+), 32 deletions(-) diff --git a/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiAuthenticationError.cs b/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiAuthenticationError.cs index 7d8ae73ac..24d197b2a 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiAuthenticationError.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiAuthenticationError.cs @@ -8,7 +8,10 @@ public class ApiAuthenticationError : ApiError public const string NoPermissionsForCreationWhen = "You lack the permissions to create this type of resource."; public static readonly ApiAuthenticationError NoPermissionsForCreation = new(NoPermissionsForCreationWhen); - + + public const string NotAuthenticatedWhen = "You are not authenticated."; + public static readonly ApiAuthenticationError NotAuthenticated = new(NotAuthenticatedWhen); + public bool Warning { get; init; } public ApiAuthenticationError(string message, bool warning = false) : base(message, Forbidden) diff --git a/Refresh.Interfaces.APIv3/Endpoints/CategoryApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/CategoryApiEndpoints.cs index 57ba0f6f2..95fd7ada9 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/CategoryApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/CategoryApiEndpoints.cs @@ -1,6 +1,8 @@ using AttribDoc.Attributes; using Bunkum.Core; using Bunkum.Core.Endpoints; +using Bunkum.Core.Responses; +using Bunkum.Listener.Protocol; using Refresh.Core.Configuration; using Refresh.Core.Types.Categories; using Refresh.Core.Types.Data; @@ -93,24 +95,28 @@ public ApiListResponse GetUserCategories(RequestContext return new ApiListResponse(resp); } - // This route can not be called "users/{route}", else Bunkum will route users/me requests to here aswell. - // Having a special case for the "me" route here would be hacky and introduce trouble if another endpoint with the route - // "users/something" (for example) were to ever be implemented in the future. - [ApiV3Endpoint("users/category/{route}"), Authentication(false)] + [ApiV3Endpoint("users/{route}"), Authentication(false)] [DocSummary("Retrieves a list of users from a category.")] [DocError(typeof(ApiNotFoundError), "The user category cannot be found, or the instance does not allow showing online users.")] [DocUsesPageData] [DocQueryParam("username", "If set, certain categories like 'hearted' will return the related users of " + "the user with this username instead of your own. Optional.")] - public ApiListResponse GetUsers(RequestContext context, CategoryService categories, GameUser? user, + public Response GetUsers(RequestContext context, CategoryService categories, GameUser? user, [DocSummary("The name of the category you'd like to retrieve users from. " + "Make a request to /users to see a list of available categories")] string route, DataContext dataContext, GameServerConfig config) { + // Bunkum usually routes users/me requests to here aswell, so use this hack to serve those requests properly. + if (route == "me") + { + if (user == null) return ApiAuthenticationError.NotAuthenticated; // Error documented in UserApiEndpoints.GetMyUser() + return new Response(new ApiResponse(ApiExtendedGameUserResponse.FromOld(user, dataContext)!), ContentType.Json); + } + if (string.IsNullOrWhiteSpace(route)) { - return new ApiError("You didn't specify a route.", NotFound); - // users/ case won't happen here because of the extra "category" inbetween "users" and the route parameter. + return new ApiError("You didn't specify a route. " + + "You probably meant to use the `/users` endpoint and left a trailing slash in the URL.", NotFound); } if (!config.PermitShowingOnlineUsers) return ApiNotFoundError.Instance; @@ -122,7 +128,7 @@ public ApiListResponse GetUsers(RequestContext context, Cat if (list == null) return ApiNotFoundError.Instance; - DatabaseList levels = DatabaseListExtensions.FromOldList(list, dataContext); - return levels; + ApiListResponse users = DatabaseListExtensions.FromOldList(list, dataContext); + return new Response(users, ContentType.Json); } } \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiExtendedGameUserResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiExtendedGameUserResponse.cs index 83f6aa1bf..f0cfcf71c 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiExtendedGameUserResponse.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiExtendedGameUserResponse.cs @@ -11,19 +11,8 @@ namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users; /// A user with full information, like current role, ban status, etc. /// [JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] -public class ApiExtendedGameUserResponse : IApiResponse, IDataConvertableFrom +public class ApiExtendedGameUserResponse : ApiGameUserResponse, IApiResponse, IDataConvertableFrom { - public required string UserId { get; set; } - public required string Username { get; set; } - public required string IconHash { get; set; } - public required string VitaIconHash { get; set; } - public required string BetaIconHash { get; set; } - public required string Description { get; set; } - public required ApiGameLocationResponse Location { get; set; } - public required DateTimeOffset JoinDate { get; set; } - public required DateTimeOffset LastLoginDate { get; set; } - public required GameUserRole Role { get; set; } - public required string? BanReason { get; set; } public required DateTimeOffset? BanExpiryDate { get; set; } @@ -45,13 +34,10 @@ public class ApiExtendedGameUserResponse : IApiResponse, IDataConvertableFrom null; user:notnull => notnull")] - public static ApiExtendedGameUserResponse? FromOld(GameUser? user, DataContext dataContext) + public static new ApiExtendedGameUserResponse? FromOld(GameUser? user, DataContext dataContext) { if (user == null) return null; @@ -62,6 +48,9 @@ public class ApiExtendedGameUserResponse : IApiResponse, IDataConvertableFrom FromOldList(IEnumerable oldList, + public static new IEnumerable FromOldList(IEnumerable oldList, DataContext dataContext) => oldList.Select(old => FromOld(old, dataContext)).ToList()!; } \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserResponse.cs index 72dc85d9e..f8edc67f5 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserResponse.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserResponse.cs @@ -9,9 +9,6 @@ namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users; [JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] public class ApiGameUserResponse : IApiResponse, IDataConvertableFrom { - // HEY! When adding fields here, remember to propagate them in ApiExtendedGameUser too! - // Otherwise, they won't show up in the admin panel endpoints or /users/me. Thank you! - public required string UserId { get; set; } public required string Username { get; set; } public required string IconHash { get; set; } diff --git a/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs index 10608e94a..74fb59ae6 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs @@ -47,9 +47,13 @@ public ApiResponse GetUserByUuid(RequestContext context, Ga [ApiV3Endpoint("users/me"), MinimumRole(GameUserRole.Restricted)] [DocSummary("Returns your own user, provided you are authenticated")] - public ApiResponse GetMyUser(RequestContext context, GameUser user, + [DocError(typeof(ApiAuthenticationError), "The user is not authenticated")] + public ApiResponse GetMyUser(RequestContext context, GameUser? user, GameDatabaseContext database, IDataStore dataStore, DataContext dataContext) - => ApiExtendedGameUserResponse.FromOld(user, dataContext); + { + if (user == null) return ApiAuthenticationError.NotAuthenticated; + return ApiExtendedGameUserResponse.FromOld(user, dataContext); + } [ApiV3Endpoint("users/me", HttpMethods.Patch)] [DocSummary("Updates your profile with the given data")] From 86c2df6f745c22dcd86494fdf6c4af747d9e92ae Mon Sep 17 00:00:00 2001 From: Toaster Date: Sat, 9 Aug 2025 18:12:54 +0200 Subject: [PATCH 6/7] Fix and expand GetsNewestUsers test --- .../Tests/ApiV3/UserApiTests.cs | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs b/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs index fb8a3cf04..11a401e5f 100644 --- a/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs +++ b/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs @@ -227,24 +227,41 @@ public void DoesntGetUserCategoriesWithGarbledPreviews() [Test] [TestCase(true)] [TestCase(false)] - public void GetsNewestUser(bool showOnlineUsers) + public void GetsNewestUsers(bool showOnlineUsers) { using TestContext context = this.GetServer(); - GameUser user = context.CreateUser(); + List users = []; + const int usersCount = 10; + // Prepare users + for (int i = 0; i < usersCount; i++) + { + GameUser user = context.CreateUser(); + users.Add(user); + } + // Prepare config context.Server.Value.GameServerConfig.PermitShowingOnlineUsers = showOnlineUsers; if (!showOnlineUsers) { - HttpResponseMessage message = context.Http.GetAsync("/api/v3/users/category/newest").Result; + HttpResponseMessage message = context.Http.GetAsync("/api/v3/users/newest").Result; Assert.That(message.StatusCode, Is.EqualTo(NotFound)); return; } - ApiListResponse? users = context.Http.GetList("/api/v3/users/category/newest?count=1", false); - Assert.That(users, Is.Not.Null); - Assert.That(users!.Data, Has.Count.EqualTo(1)); - Assert.That(users.Data![0].UserId, Is.EqualTo(user.UserId.ToString())); + ApiListResponse? response = context.Http.GetList("/api/v3/users/newest?count=20", false); + Assert.That(response?.Data, Is.Not.Null); + Assert.That(response?.ListInfo, Is.Not.Null); + Assert.That(response!.ListInfo!.TotalItems, Is.EqualTo(usersCount)); + Assert.That(response!.Data!, Has.Count.EqualTo(usersCount)); + + int index = 0; + users = users.OrderByDescending(u => u.JoinDate).ToList(); + foreach(ApiGameUserResponse user in response.Data!) + { + Assert.That(user.UserId, Is.EqualTo(users[index].UserId.ToString())); + index++; + } } } \ No newline at end of file From b5e8a1f1a1f76cab04c14edd757fc02178e2c1f9 Mon Sep 17 00:00:00 2001 From: jvyden Date: Sat, 9 Aug 2025 20:11:06 -0400 Subject: [PATCH 7/7] Update Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs Signed-off-by: jvyden --- Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs index 74fb59ae6..1cc34c86b 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs @@ -47,7 +47,7 @@ public ApiResponse GetUserByUuid(RequestContext context, Ga [ApiV3Endpoint("users/me"), MinimumRole(GameUserRole.Restricted)] [DocSummary("Returns your own user, provided you are authenticated")] - [DocError(typeof(ApiAuthenticationError), "The user is not authenticated")] + [DocError(typeof(ApiAuthenticationError), "You are not authenticated")] public ApiResponse GetMyUser(RequestContext context, GameUser? user, GameDatabaseContext database, IDataStore dataStore, DataContext dataContext) {