Skip to content

Commit de483f0

Browse files
authored
Implement user category API endpoints (#922)
Refactors API category models and endpoints similarly to how their game counterparts were refactored (dedicated `CategoryApiEndpoints` class, base `ApiCategoryResponse` and dedicated namespace for models). Also implements API endpoints for the newly introduced user categories and a few tests for those.
2 parents 624d0a5 + b5e8a1f commit de483f0

File tree

11 files changed

+345
-91
lines changed

11 files changed

+345
-91
lines changed

Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiAuthenticationError.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ public class ApiAuthenticationError : ApiError
88

99
public const string NoPermissionsForCreationWhen = "You lack the permissions to create this type of resource.";
1010
public static readonly ApiAuthenticationError NoPermissionsForCreation = new(NoPermissionsForCreationWhen);
11-
11+
12+
public const string NotAuthenticatedWhen = "You are not authenticated.";
13+
public static readonly ApiAuthenticationError NotAuthenticated = new(NotAuthenticatedWhen);
14+
1215
public bool Warning { get; init; }
1316

1417
public ApiAuthenticationError(string message, bool warning = false) : base(message, Forbidden)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
using AttribDoc.Attributes;
2+
using Bunkum.Core;
3+
using Bunkum.Core.Endpoints;
4+
using Bunkum.Core.Responses;
5+
using Bunkum.Listener.Protocol;
6+
using Refresh.Core.Configuration;
7+
using Refresh.Core.Types.Categories;
8+
using Refresh.Core.Types.Data;
9+
using Refresh.Database;
10+
using Refresh.Database.Models.Authentication;
11+
using Refresh.Database.Models.Levels;
12+
using Refresh.Database.Models.Users;
13+
using Refresh.Database.Query;
14+
using Refresh.Interfaces.APIv3.Documentation.Attributes;
15+
using Refresh.Interfaces.APIv3.Endpoints.ApiTypes;
16+
using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors;
17+
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories;
18+
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels;
19+
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users;
20+
using Refresh.Interfaces.APIv3.Extensions;
21+
22+
namespace Refresh.Interfaces.APIv3.Endpoints;
23+
24+
public class CategoryApiEndpoints : EndpointGroup
25+
{
26+
[ApiV3Endpoint("levels"), Authentication(false)]
27+
[ClientCacheResponse(1800)] // cache for half an hour
28+
[DocSummary("Retrieves a list of categories you can use to search levels")]
29+
[DocQueryParam("includePreviews", "If true, a single level will be added to each category representing a level from that category. False by default.")]
30+
[DocError(typeof(ApiValidationError), "The boolean 'includePreviews' could not be parsed by the server.")]
31+
public ApiListResponse<ApiLevelCategoryResponse> GetLevelCategories(RequestContext context, CategoryService categories,
32+
DataContext dataContext)
33+
{
34+
bool result = bool.TryParse(context.QueryString.Get("includePreviews") ?? "false", out bool includePreviews);
35+
if (!result) return ApiValidationError.BooleanParseError;
36+
37+
IEnumerable<ApiLevelCategoryResponse> resp;
38+
39+
// ReSharper disable once ConvertIfStatementToConditionalTernaryExpression
40+
if (includePreviews) resp = ApiLevelCategoryResponse.FromOldList(categories.LevelCategories, context, dataContext);
41+
else resp = ApiLevelCategoryResponse.FromOldList(categories.LevelCategories, dataContext);
42+
43+
return new ApiListResponse<ApiLevelCategoryResponse>(resp);
44+
}
45+
46+
[ApiV3Endpoint("levels/{route}"), Authentication(false)]
47+
[DocSummary("Retrieves a list of levels from a category")]
48+
[DocError(typeof(ApiNotFoundError), "The level category cannot be found")]
49+
[DocUsesPageData]
50+
[DocQueryParam("game", "Filters levels to a specific game version. Allowed values: lbp1-3, vita, psp, beta")]
51+
[DocQueryParam("seed", "The random seed to use for randomization. Uses 0 if not specified.")]
52+
[DocQueryParam("players", "Filters levels to those accommodating the specified number of players.")]
53+
[DocQueryParam("username", "If set, certain categories like 'hearted' or 'byUser' will return the levels of " +
54+
"the user with this username instead of your own. Optional.")]
55+
public ApiListResponse<ApiGameLevelResponse> GetLevels(RequestContext context, CategoryService categories, GameUser? user,
56+
[DocSummary("The name of the category you'd like to retrieve levels from. " +
57+
"Make a request to /levels to see a list of available categories")]
58+
string route, DataContext dataContext)
59+
{
60+
if (string.IsNullOrWhiteSpace(route))
61+
{
62+
return new ApiError("You didn't specify a route. " +
63+
"You probably meant to use the `/levels` endpoint and left a trailing slash in the URL.", NotFound);
64+
}
65+
66+
(int skip, int count) = context.GetPageData();
67+
68+
DatabaseList<GameLevel>? list = categories.LevelCategories
69+
.FirstOrDefault(c => c.ApiRoute.StartsWith(route))?
70+
.Fetch(context, skip, count, dataContext, new LevelFilterSettings(context, TokenGame.Website), user);
71+
72+
if (list == null) return ApiNotFoundError.Instance;
73+
74+
DatabaseList<ApiGameLevelResponse> levels = DatabaseListExtensions.FromOldList<ApiGameLevelResponse, GameLevel>(list, dataContext);
75+
return levels;
76+
}
77+
78+
[ApiV3Endpoint("users"), Authentication(false)]
79+
[ClientCacheResponse(1800)] // cache for half an hour
80+
[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.")]
81+
[DocQueryParam("includePreviews", "If true, a single user will be added to each category representing a user from that category. False by default.")]
82+
[DocError(typeof(ApiValidationError), "The boolean 'includePreviews' could not be parsed by the server.")]
83+
public ApiListResponse<ApiUserCategoryResponse> GetUserCategories(RequestContext context, CategoryService categories,
84+
DataContext dataContext, GameServerConfig config)
85+
{
86+
bool result = bool.TryParse(context.QueryString.Get("includePreviews") ?? "false", out bool includePreviews);
87+
if (!result) return ApiValidationError.BooleanParseError;
88+
89+
if (!config.PermitShowingOnlineUsers) return new ApiListResponse<ApiUserCategoryResponse>([]);
90+
IEnumerable<ApiUserCategoryResponse> resp;
91+
92+
if (includePreviews) resp = ApiUserCategoryResponse.FromOldList(categories.UserCategories, context, dataContext);
93+
else resp = ApiUserCategoryResponse.FromOldList(categories.UserCategories, dataContext);
94+
95+
return new ApiListResponse<ApiUserCategoryResponse>(resp);
96+
}
97+
98+
[ApiV3Endpoint("users/{route}"), Authentication(false)]
99+
[DocSummary("Retrieves a list of users from a category.")]
100+
[DocError(typeof(ApiNotFoundError), "The user category cannot be found, or the instance does not allow showing online users.")]
101+
[DocUsesPageData]
102+
[DocQueryParam("username", "If set, certain categories like 'hearted' will return the related users of " +
103+
"the user with this username instead of your own. Optional.")]
104+
public Response GetUsers(RequestContext context, CategoryService categories, GameUser? user,
105+
[DocSummary("The name of the category you'd like to retrieve users from. " +
106+
"Make a request to /users to see a list of available categories")]
107+
string route, DataContext dataContext, GameServerConfig config)
108+
{
109+
// Bunkum usually routes users/me requests to here aswell, so use this hack to serve those requests properly.
110+
if (route == "me")
111+
{
112+
if (user == null) return ApiAuthenticationError.NotAuthenticated; // Error documented in UserApiEndpoints.GetMyUser()
113+
return new Response(new ApiResponse<ApiExtendedGameUserResponse>(ApiExtendedGameUserResponse.FromOld(user, dataContext)!), ContentType.Json);
114+
}
115+
116+
if (string.IsNullOrWhiteSpace(route))
117+
{
118+
return new ApiError("You didn't specify a route. " +
119+
"You probably meant to use the `/users` endpoint and left a trailing slash in the URL.", NotFound);
120+
}
121+
122+
if (!config.PermitShowingOnlineUsers) return ApiNotFoundError.Instance;
123+
(int skip, int count) = context.GetPageData();
124+
125+
DatabaseList<GameUser>? list = categories.UserCategories
126+
.FirstOrDefault(c => c.ApiRoute.StartsWith(route))?
127+
.Fetch(context, skip, count, dataContext, user);
128+
129+
if (list == null) return ApiNotFoundError.Instance;
130+
131+
ApiListResponse<ApiGameUserResponse> users = DatabaseListExtensions.FromOldList<ApiGameUserResponse, GameUser>(list, dataContext);
132+
return new Response(users, ContentType.Json);
133+
}
134+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using Refresh.Core.Types.Categories;
2+
using Refresh.Core.Types.Data;
3+
4+
namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories;
5+
6+
[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
7+
public class ApiCategoryResponse : IApiResponse, IDataConvertableFrom<ApiCategoryResponse, GameCategory>
8+
{
9+
public required string Name { get; set; }
10+
public required string Description { get; set; }
11+
public required string IconHash { get; set; }
12+
public required string FontAwesomeIcon { get; set; }
13+
public required string ApiRoute { get; set; }
14+
public required bool RequiresUser { get; set; }
15+
public required bool Hidden { get; set; } = false;
16+
17+
public static ApiCategoryResponse? FromOld(GameCategory? old, DataContext dataContext)
18+
{
19+
if (old == null) return null;
20+
21+
return new ApiCategoryResponse
22+
{
23+
Name = old.Name,
24+
Description = old.Description,
25+
IconHash = old.IconHash,
26+
FontAwesomeIcon = old.FontAwesomeIcon,
27+
ApiRoute = old.ApiRoute,
28+
RequiresUser = old.RequiresUser,
29+
Hidden = old.Hidden,
30+
};
31+
}
32+
33+
public static IEnumerable<ApiCategoryResponse> FromOldList(IEnumerable<GameCategory> oldList, DataContext dataContext)
34+
=> oldList.Select(old => FromOld(old, dataContext)).ToList()!;
35+
}

Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiLevelCategoryResponse.cs renamed to Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiLevelCategoryResponse.cs

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,14 @@
55
using Refresh.Database.Models.Authentication;
66
using Refresh.Database.Models.Levels;
77
using Refresh.Database.Query;
8+
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels;
89

9-
namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels;
10+
namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories;
1011

1112
[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
12-
public class ApiLevelCategoryResponse : IApiResponse, IDataConvertableFrom<ApiLevelCategoryResponse, GameLevelCategory>
13+
public class ApiLevelCategoryResponse : ApiCategoryResponse, IApiResponse, IDataConvertableFrom<ApiLevelCategoryResponse, GameLevelCategory>
1314
{
14-
public required string Name { get; set; }
15-
public required string Description { get; set; }
16-
public required string IconHash { get; set; }
17-
public required string FontAwesomeIcon { get; set; }
18-
public required string ApiRoute { get; set; }
19-
public required bool RequiresUser { get; set; }
2015
public required ApiGameLevelResponse? PreviewLevel { get; set; }
21-
public required bool Hidden { get; set; } = false;
2216

2317
public static ApiLevelCategoryResponse? FromOld(GameLevelCategory? old, GameLevel? previewLevel,
2418
DataContext dataContext)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using Bunkum.Core;
2+
using Refresh.Core.Types.Categories.Users;
3+
using Refresh.Core.Types.Data;
4+
using Refresh.Database;
5+
using Refresh.Database.Models.Users;
6+
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users;
7+
8+
namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories;
9+
10+
[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
11+
public class ApiUserCategoryResponse : ApiCategoryResponse, IApiResponse, IDataConvertableFrom<ApiUserCategoryResponse, GameUserCategory>
12+
{
13+
public required ApiGameUserResponse? PreviewItem { get; set; }
14+
15+
public static ApiUserCategoryResponse? FromOld(GameUserCategory? old, GameUser? PreviewItem,
16+
DataContext dataContext)
17+
{
18+
if (old == null) return null;
19+
20+
return new ApiUserCategoryResponse
21+
{
22+
Name = old.Name,
23+
Description = old.Description,
24+
IconHash = old.IconHash,
25+
FontAwesomeIcon = old.FontAwesomeIcon,
26+
ApiRoute = old.ApiRoute,
27+
RequiresUser = old.RequiresUser,
28+
PreviewItem = ApiGameUserResponse.FromOld(PreviewItem, dataContext),
29+
Hidden = old.Hidden,
30+
};
31+
}
32+
33+
public static ApiUserCategoryResponse? FromOld(GameUserCategory? old, DataContext dataContext)
34+
=> FromOld(old, null, dataContext);
35+
36+
public static IEnumerable<ApiUserCategoryResponse> FromOldList(IEnumerable<GameUserCategory> oldList, DataContext dataContext)
37+
=> oldList.Select(old => FromOld(old, dataContext)).ToList()!;
38+
39+
public static IEnumerable<ApiUserCategoryResponse> FromOldList(IEnumerable<GameUserCategory> oldList,
40+
RequestContext context,
41+
DataContext dataContext)
42+
{
43+
return oldList.Select(category =>
44+
{
45+
DatabaseList<GameUser>? list = category.Fetch(context, 0, 1, dataContext, dataContext.User);
46+
GameUser? item = list?.Items.FirstOrDefault();
47+
48+
return FromOld(category, item, dataContext);
49+
}).ToList()!;
50+
}
51+
}

Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiExtendedGameUserResponse.cs

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,8 @@ namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users;
1111
/// A user with full information, like current role, ban status, etc.
1212
/// </summary>
1313
[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
14-
public class ApiExtendedGameUserResponse : IApiResponse, IDataConvertableFrom<ApiExtendedGameUserResponse, GameUser>
14+
public class ApiExtendedGameUserResponse : ApiGameUserResponse, IApiResponse, IDataConvertableFrom<ApiExtendedGameUserResponse, GameUser>
1515
{
16-
public required string UserId { get; set; }
17-
public required string Username { get; set; }
18-
public required string IconHash { get; set; }
19-
public required string VitaIconHash { get; set; }
20-
public required string BetaIconHash { get; set; }
21-
public required string Description { get; set; }
22-
public required ApiGameLocationResponse Location { get; set; }
23-
public required DateTimeOffset JoinDate { get; set; }
24-
public required DateTimeOffset LastLoginDate { get; set; }
25-
public required GameUserRole Role { get; set; }
26-
2716
public required string? BanReason { get; set; }
2817
public required DateTimeOffset? BanExpiryDate { get; set; }
2918

@@ -45,13 +34,10 @@ public class ApiExtendedGameUserResponse : IApiResponse, IDataConvertableFrom<Ap
4534
public required Visibility ProfileVisibility { get; set; }
4635

4736
public required int FilesizeQuotaUsage { get; set; }
48-
49-
public required ApiGameUserStatisticsResponse Statistics { get; set; }
50-
public required ApiGameRoomResponse? ActiveRoom { get; set; }
5137
public required bool ConnectedToPresenceServer { get; set; }
5238

5339
[ContractAnnotation("user:null => null; user:notnull => notnull")]
54-
public static ApiExtendedGameUserResponse? FromOld(GameUser? user, DataContext dataContext)
40+
public static new ApiExtendedGameUserResponse? FromOld(GameUser? user, DataContext dataContext)
5541
{
5642
if (user == null) return null;
5743

@@ -62,6 +48,9 @@ public class ApiExtendedGameUserResponse : IApiResponse, IDataConvertableFrom<Ap
6248
IconHash = dataContext.GetIconFromHash(user.IconHash),
6349
VitaIconHash = dataContext.GetIconFromHash(user.VitaIconHash),
6450
BetaIconHash = dataContext.GetIconFromHash(user.BetaIconHash),
51+
YayFaceHash = dataContext.GetIconFromHash(user.YayFaceHash),
52+
BooFaceHash = dataContext.GetIconFromHash(user.BooFaceHash),
53+
MehFaceHash = dataContext.GetIconFromHash(user.MehFaceHash),
6554
Description = user.Description,
6655
Location = ApiGameLocationResponse.FromLocation(user.LocationX, user.LocationY)!,
6756
JoinDate = user.JoinDate,
@@ -88,6 +77,6 @@ public class ApiExtendedGameUserResponse : IApiResponse, IDataConvertableFrom<Ap
8877
};
8978
}
9079

91-
public static IEnumerable<ApiExtendedGameUserResponse> FromOldList(IEnumerable<GameUser> oldList,
80+
public static new IEnumerable<ApiExtendedGameUserResponse> FromOldList(IEnumerable<GameUser> oldList,
9281
DataContext dataContext) => oldList.Select(old => FromOld(old, dataContext)).ToList()!;
9382
}

Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserResponse.cs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@ namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users;
99
[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
1010
public class ApiGameUserResponse : IApiResponse, IDataConvertableFrom<ApiGameUserResponse, GameUser>
1111
{
12-
// HEY! When adding fields here, remember to propagate them in ApiExtendedGameUser too!
13-
// Otherwise, they won't show up in the admin panel endpoints or /users/me. Thank you!
14-
1512
public required string UserId { get; set; }
1613
public required string Username { get; set; }
1714
public required string IconHash { get; set; }

0 commit comments

Comments
 (0)