From eb709d136b6e0d171e19083dff947a571b9391af Mon Sep 17 00:00:00 2001 From: Jordan Brown Date: Thu, 17 Dec 2020 23:49:06 -0500 Subject: [PATCH] Adds pagination - Add generic Paginated for pagination responses. - Added pagination helper to ApiController. - Implemented pagination for all previously IEnumerable response endpoints. - Added IApiTransformable for host models finally. - Added PaginationSettings to client List endpoints. - Use two deprecated ErrorCodes for page parameter validation. - Re-enabled to OpenAPI root array response lint. - Add some integration tests with users. --- build/OpenApiValidationSettings.json | 2 +- src/Tgstation.Server.Api/Models/ErrorCode.cs | 14 +- src/Tgstation.Server.Api/Models/Paginated.cs | 28 ++++ .../AdministrationClient.cs | 27 ++-- .../Components/ByondClient.cs | 20 +-- .../Components/ChatBotsClient.cs | 26 ++-- .../Components/ConfigurationClient.cs | 33 ++-- .../Components/DreamMakerClient.cs | 26 ++-- .../Components/IByondClient.cs | 3 +- .../Components/IChatBotsClient.cs | 5 +- .../Components/IConfigurationClient.cs | 6 +- .../Components/IDreamMakerClient.cs | 7 +- .../Components/IInstanceUserClient.cs | 7 +- .../Components/IJobsClient.cs | 10 +- .../Components/InstanceUserClient.cs | 28 ++-- .../Components/JobsClient.cs | 25 ++- .../IAdministrationClient.cs | 3 +- .../IInstanceManagerClient.cs | 5 +- src/Tgstation.Server.Client/IUsersClient.cs | 7 +- .../InstanceManagerClient.cs | 33 ++-- .../PaginatedClient.cs | 122 +++++++++++++++ .../PaginationSettings.cs | 24 +++ src/Tgstation.Server.Client/UsersClient.cs | 26 ++-- .../Controllers/AdministrationController.cs | 79 +++++----- .../Controllers/ApiController.cs | 142 +++++++++++++++++- .../Controllers/ByondController.cs | 33 ++-- .../Controllers/ChatController.cs | 38 ++--- .../Controllers/ConfigurationController.cs | 82 ++++++---- .../Controllers/DreamMakerController.cs | 57 ++++--- .../Controllers/InstanceController.cs | 33 ++-- .../Controllers/InstanceUserController.cs | 31 ++-- .../Controllers/JobController.cs | 67 +++++---- .../Controllers/PaginatableResult.cs | 41 +++++ .../Controllers/UserController.cs | 29 ++-- .../Core/SwaggerConfiguration.cs | 5 +- src/Tgstation.Server.Host/Models/ChatBot.cs | 9 +- .../Models/ChatChannel.cs | 9 +- .../Models/CompileJob.cs | 7 +- .../Models/DreamMakerSettings.cs | 9 +- .../Models/IApiTransformable.cs | 15 ++ src/Tgstation.Server.Host/Models/Instance.cs | 12 +- .../Models/InstanceUser.cs | 9 +- src/Tgstation.Server.Host/Models/Job.cs | 7 +- .../Models/OAuthConnection.cs | 7 +- .../Models/RepositorySettings.cs | 7 +- .../Models/RevisionInformation.cs | 9 +- src/Tgstation.Server.Host/Models/TestMerge.cs | 9 +- src/Tgstation.Server.Host/Models/User.cs | 5 +- .../AdministrationTest.cs | 2 +- .../Instance/ByondTest.cs | 2 +- .../Instance/ChatTest.cs | 10 +- .../InstanceManagerTest.cs | 4 +- .../Tgstation.Server.Tests/IntegrationTest.cs | 8 +- tests/Tgstation.Server.Tests/UsersTest.cs | 54 ++++++- 54 files changed, 884 insertions(+), 434 deletions(-) create mode 100644 src/Tgstation.Server.Api/Models/Paginated.cs create mode 100644 src/Tgstation.Server.Client/PaginatedClient.cs create mode 100644 src/Tgstation.Server.Client/PaginationSettings.cs create mode 100644 src/Tgstation.Server.Host/Controllers/PaginatableResult.cs create mode 100644 src/Tgstation.Server.Host/Models/IApiTransformable.cs diff --git a/build/OpenApiValidationSettings.json b/build/OpenApiValidationSettings.json index 731082653b..af87e9cf4b 100644 --- a/build/OpenApiValidationSettings.json +++ b/build/OpenApiValidationSettings.json @@ -4,7 +4,7 @@ "no_operation_id": "error", "operation_id_case_convention": "off", "no_summary": "error", - "no_array_responses": "off", + "no_array_responses": "error", "parameter_order": "error", "undefined_tag": "off", "unused_tag": "error", diff --git a/src/Tgstation.Server.Api/Models/ErrorCode.cs b/src/Tgstation.Server.Api/Models/ErrorCode.cs index cb9a77a786..1b206b6ab3 100644 --- a/src/Tgstation.Server.Api/Models/ErrorCode.cs +++ b/src/Tgstation.Server.Api/Models/ErrorCode.cs @@ -250,18 +250,16 @@ namespace Tgstation.Server.Api.Models RepoWhitespaceCommitterEmail, /// - /// Deprecated. + /// A paginated request asked for too large a page. /// - [Description("Deprecated error code.")] - [Obsolete("With API v7 ", true)] - DreamDaemonDuplicatePorts, + [Description("Requested pageSize is too large!")] + ApiPageTooLarge, /// - /// Deprecated. + /// A paginated request asked for page 0. /// - [Description("Deprecated error code.")] - [Obsolete("With DMAPI-5.0.0, ultrasafe security is now supported.", true)] - InvalidSecurityLevel, + [Description("Cannot request page or pageSize <= 0.")] + ApiInvalidPageOrPageSize, /// /// A requested 's data does not match with its . diff --git a/src/Tgstation.Server.Api/Models/Paginated.cs b/src/Tgstation.Server.Api/Models/Paginated.cs new file mode 100644 index 0000000000..ba4e187494 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Paginated.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Api.Models +{ + /// + /// Represents a paginated set of models. + /// + /// The of the returned model. + public sealed class Paginated + { + /// + /// The of the returned s. + /// + [Required] + public ICollection? Content { get; set; } + + /// + /// The total number of pages in the query. + /// + public int TotalPages { get; set; } + + /// + /// The current size of pages in the query. + /// + public int PageSize { get; set; } + } +} diff --git a/src/Tgstation.Server.Client/AdministrationClient.cs b/src/Tgstation.Server.Client/AdministrationClient.cs index ca00100e8b..e941337ebf 100644 --- a/src/Tgstation.Server.Client/AdministrationClient.cs +++ b/src/Tgstation.Server.Client/AdministrationClient.cs @@ -10,45 +10,40 @@ using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client { /// - sealed class AdministrationClient : IAdministrationClient + sealed class AdministrationClient : PaginatedClient, IAdministrationClient { - /// - /// The for the - /// - readonly IApiClient apiClient; - /// /// Construct an /// - /// The value of + /// The for the . public AdministrationClient(IApiClient apiClient) - { - this.apiClient = apiClient; - } + : base(apiClient) + { } /// - public Task Read(CancellationToken cancellationToken) => apiClient.Read(Routes.Administration, cancellationToken); + public Task Read(CancellationToken cancellationToken) => ApiClient.Read(Routes.Administration, cancellationToken); /// - public Task Update(Administration administration, CancellationToken cancellationToken) => apiClient.Update(Routes.Administration, administration ?? throw new ArgumentNullException(nameof(administration)), cancellationToken); + public Task Update(Administration administration, CancellationToken cancellationToken) => ApiClient.Update(Routes.Administration, administration ?? throw new ArgumentNullException(nameof(administration)), cancellationToken); /// - public Task Restart(CancellationToken cancellationToken) => apiClient.Delete(Routes.Administration, cancellationToken); + public Task Restart(CancellationToken cancellationToken) => ApiClient.Delete(Routes.Administration, cancellationToken); /// - public Task> ListLogs(CancellationToken cancellationToken) => apiClient.Read>(Routes.Logs, cancellationToken); + public Task> ListLogs(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged(paginationSettings, Routes.Logs, null, cancellationToken); /// public async Task> GetLog(LogFile logFile, CancellationToken cancellationToken) { - var resultFile = await apiClient.Read( + var resultFile = await ApiClient.Read( Routes.Logs + Routes.SanitizeGetPath( HttpUtility.UrlEncode( logFile?.Name ?? throw new ArgumentNullException(nameof(logFile)))), cancellationToken) .ConfigureAwait(false); - var stream = await apiClient.Download(resultFile, cancellationToken).ConfigureAwait(false); + var stream = await ApiClient.Download(resultFile, cancellationToken).ConfigureAwait(false); try { return Tuple.Create(resultFile, stream); diff --git a/src/Tgstation.Server.Client/Components/ByondClient.cs b/src/Tgstation.Server.Client/Components/ByondClient.cs index 15eb50bb7d..0a1847b35a 100644 --- a/src/Tgstation.Server.Client/Components/ByondClient.cs +++ b/src/Tgstation.Server.Client/Components/ByondClient.cs @@ -9,13 +9,8 @@ using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client.Components { /// - sealed class ByondClient : IByondClient + sealed class ByondClient : PaginatedClient, IByondClient { - /// - /// The for the - /// - readonly IApiClient apiClient; - /// /// The for the /// @@ -24,24 +19,25 @@ namespace Tgstation.Server.Client.Components /// /// Construct a /// - /// The value of + /// The for the . /// The value of public ByondClient(IApiClient apiClient, Instance instance) + : base(apiClient) { - this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); } /// - public Task ActiveVersion(CancellationToken cancellationToken) => apiClient.Read(Routes.Byond, instance.Id, cancellationToken); + public Task ActiveVersion(CancellationToken cancellationToken) => ApiClient.Read(Routes.Byond, instance.Id, cancellationToken); /// - public Task> InstalledVersions(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.Byond), instance.Id, cancellationToken); + public Task> InstalledVersions(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged(paginationSettings, Routes.ListRoute(Routes.Byond), instance.Id, cancellationToken); /// public async Task SetActiveVersion(Byond byond, Stream zipFileStream, CancellationToken cancellationToken) { - var result = await apiClient.Update( + var result = await ApiClient.Update( Routes.Byond, byond ?? throw new ArgumentNullException(nameof(byond)), instance.Id, @@ -49,7 +45,7 @@ namespace Tgstation.Server.Client.Components .ConfigureAwait(false); if (byond.UploadCustomZip == true) - await apiClient.Upload(result, zipFileStream, cancellationToken).ConfigureAwait(false); + await ApiClient.Upload(result, zipFileStream, cancellationToken).ConfigureAwait(false); return result; } diff --git a/src/Tgstation.Server.Client/Components/ChatBotsClient.cs b/src/Tgstation.Server.Client/Components/ChatBotsClient.cs index 5a05e8c930..fa58da1c8a 100644 --- a/src/Tgstation.Server.Client/Components/ChatBotsClient.cs +++ b/src/Tgstation.Server.Client/Components/ChatBotsClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -8,13 +8,8 @@ using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client.Components { /// - sealed class ChatBotsClient : IChatBotsClient + sealed class ChatBotsClient : PaginatedClient, IChatBotsClient { - /// - /// The for the - /// - readonly IApiClient apiClient; - /// /// The for the /// @@ -23,27 +18,28 @@ namespace Tgstation.Server.Client.Components /// /// Construct a /// - /// The value of + /// The for the . /// The value of public ChatBotsClient(IApiClient apiClient, Instance instance) + : base(apiClient) { - this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); } /// - public Task Create(ChatBot settings, CancellationToken cancellationToken) => apiClient.Create(Routes.Chat, settings ?? throw new ArgumentNullException(nameof(settings)), instance.Id, cancellationToken); + public Task Create(ChatBot settings, CancellationToken cancellationToken) => ApiClient.Create(Routes.Chat, settings ?? throw new ArgumentNullException(nameof(settings)), instance.Id, cancellationToken); /// - public Task Delete(ChatBot settings, CancellationToken cancellationToken) => apiClient.Delete(Routes.SetID(Routes.Chat, settings?.Id ?? throw new ArgumentNullException(nameof(settings))), instance.Id, cancellationToken); + public Task Delete(ChatBot settings, CancellationToken cancellationToken) => ApiClient.Delete(Routes.SetID(Routes.Chat, settings?.Id ?? throw new ArgumentNullException(nameof(settings))), instance.Id, cancellationToken); /// - public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.Chat), instance.Id, cancellationToken); + public Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged(paginationSettings, Routes.ListRoute(Routes.Chat), instance.Id, cancellationToken); /// - public Task Update(ChatBot settings, CancellationToken cancellationToken) => apiClient.Update(Routes.Chat, settings ?? throw new ArgumentNullException(nameof(settings)), instance.Id, cancellationToken); + public Task Update(ChatBot settings, CancellationToken cancellationToken) => ApiClient.Update(Routes.Chat, settings ?? throw new ArgumentNullException(nameof(settings)), instance.Id, cancellationToken); /// - public Task GetId(ChatBot settings, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.Chat, (settings ?? throw new ArgumentNullException(nameof(settings))).Id), instance.Id, cancellationToken); + public Task GetId(ChatBot settings, CancellationToken cancellationToken) => ApiClient.Read(Routes.SetID(Routes.Chat, (settings ?? throw new ArgumentNullException(nameof(settings))).Id), instance.Id, cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/Components/ConfigurationClient.cs b/src/Tgstation.Server.Client/Components/ConfigurationClient.cs index 25f8a0060d..e0fdb69db6 100644 --- a/src/Tgstation.Server.Client/Components/ConfigurationClient.cs +++ b/src/Tgstation.Server.Client/Components/ConfigurationClient.cs @@ -9,13 +9,8 @@ using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client.Components { /// - sealed class ConfigurationClient : IConfigurationClient + sealed class ConfigurationClient : PaginatedClient, IConfigurationClient { - /// - /// The for the - /// - readonly IApiClient apiClient; - /// /// The for the /// @@ -24,34 +19,42 @@ namespace Tgstation.Server.Client.Components /// /// Construct a /// - /// The value of + /// The for the . /// The value of public ConfigurationClient(IApiClient apiClient, Instance instance) + : base(apiClient) { - this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); } /// - public Task DeleteEmptyDirectory(ConfigurationFile directory, CancellationToken cancellationToken) => apiClient.Delete(Routes.Configuration, directory, instance.Id, cancellationToken); + public Task DeleteEmptyDirectory(ConfigurationFile directory, CancellationToken cancellationToken) => ApiClient.Delete(Routes.Configuration, directory, instance.Id, cancellationToken); /// - public Task CreateDirectory(ConfigurationFile directory, CancellationToken cancellationToken) => apiClient.Create(Routes.Configuration, directory, instance.Id, cancellationToken); + public Task CreateDirectory(ConfigurationFile directory, CancellationToken cancellationToken) => ApiClient.Create(Routes.Configuration, directory, instance.Id, cancellationToken); /// - public Task> List(string directory, CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.Configuration) + Routes.SanitizeGetPath(directory), instance.Id, cancellationToken); + public Task> List( + PaginationSettings? paginationSettings, + string directory, + CancellationToken cancellationToken) + => ReadPaged( + paginationSettings, + Routes.ListRoute(Routes.Configuration) + Routes.SanitizeGetPath(directory), + instance.Id, + cancellationToken); /// public async Task> Read(ConfigurationFile file, CancellationToken cancellationToken) { if (file == null) throw new ArgumentNullException(nameof(file)); - var configFile = await apiClient.Read( + var configFile = await ApiClient.Read( Routes.ConfigurationFile + Routes.SanitizeGetPath(file.Path ?? throw new ArgumentException("file.Path should not be null!", nameof(file))), instance.Id, cancellationToken) .ConfigureAwait(false); - var downloadStream = await apiClient.Download(configFile, cancellationToken).ConfigureAwait(false); + var downloadStream = await ApiClient.Download(configFile, cancellationToken).ConfigureAwait(false); try { return Tuple.Create(configFile, downloadStream); @@ -75,7 +78,7 @@ namespace Tgstation.Server.Client.Components using (memoryStream) { - var configFileTask = apiClient.Update( + var configFileTask = ApiClient.Update( Routes.Configuration, file ?? throw new ArgumentNullException(nameof(file)), instance.Id, @@ -88,7 +91,7 @@ namespace Tgstation.Server.Client.Components var streamUsed = memoryStream ?? uploadStream; streamUsed?.Seek(initialStreamPosition, SeekOrigin.Begin); - await apiClient.Upload(configFile, streamUsed, cancellationToken).ConfigureAwait(false); + await ApiClient.Upload(configFile, streamUsed, cancellationToken).ConfigureAwait(false); return configFile; } diff --git a/src/Tgstation.Server.Client/Components/DreamMakerClient.cs b/src/Tgstation.Server.Client/Components/DreamMakerClient.cs index dafa049834..fdce033ef8 100644 --- a/src/Tgstation.Server.Client/Components/DreamMakerClient.cs +++ b/src/Tgstation.Server.Client/Components/DreamMakerClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -8,13 +8,8 @@ using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client.Components { /// - sealed class DreamMakerClient : IDreamMakerClient + sealed class DreamMakerClient : PaginatedClient, IDreamMakerClient { - /// - /// The for the - /// - readonly IApiClient apiClient; - /// /// The for the /// @@ -23,27 +18,28 @@ namespace Tgstation.Server.Client.Components /// /// Construct a /// - /// The value of + /// The for the . /// The value of public DreamMakerClient(IApiClient apiClient, Instance instance) + : base(apiClient) { - this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); } /// - public Task Compile(CancellationToken cancellationToken) => apiClient.Create(Routes.DreamMaker, instance.Id, cancellationToken); + public Task Compile(CancellationToken cancellationToken) => ApiClient.Create(Routes.DreamMaker, instance.Id, cancellationToken); /// - public Task GetCompileJob(EntityId compileJob, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.DreamMaker, compileJob?.Id ?? throw new ArgumentNullException(nameof(compileJob))), instance.Id, cancellationToken); + public Task GetCompileJob(EntityId compileJob, CancellationToken cancellationToken) => ApiClient.Read(Routes.SetID(Routes.DreamMaker, compileJob?.Id ?? throw new ArgumentNullException(nameof(compileJob))), instance.Id, cancellationToken); /// - public Task> GetJobIds(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.DreamMaker), instance.Id, cancellationToken); + public Task> ListCompileJobs(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged(paginationSettings, Routes.ListRoute(Routes.DreamMaker), instance.Id, cancellationToken); /// - public Task Read(CancellationToken cancellationToken) => apiClient.Read(Routes.DreamMaker, instance.Id, cancellationToken); + public Task Read(CancellationToken cancellationToken) => ApiClient.Read(Routes.DreamMaker, instance.Id, cancellationToken); /// - public Task Update(DreamMaker dreamMaker, CancellationToken cancellationToken) => apiClient.Update(Routes.DreamMaker, dreamMaker ?? throw new ArgumentNullException(nameof(dreamMaker)), instance.Id, cancellationToken); + public Task Update(DreamMaker dreamMaker, CancellationToken cancellationToken) => ApiClient.Update(Routes.DreamMaker, dreamMaker ?? throw new ArgumentNullException(nameof(dreamMaker)), instance.Id, cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/Components/IByondClient.cs b/src/Tgstation.Server.Client/Components/IByondClient.cs index 44c90f64c4..ee1befa5c7 100644 --- a/src/Tgstation.Server.Client/Components/IByondClient.cs +++ b/src/Tgstation.Server.Client/Components/IByondClient.cs @@ -21,9 +21,10 @@ namespace Tgstation.Server.Client.Components /// /// Get all installed s /// + /// The optional for the operation. /// The for the operation /// A resulting in an of installed s - Task> InstalledVersions(CancellationToken cancellationToken); + Task> InstalledVersions(PaginationSettings? paginationSettings, CancellationToken cancellationToken); /// /// Updates the information diff --git a/src/Tgstation.Server.Client/Components/IChatBotsClient.cs b/src/Tgstation.Server.Client/Components/IChatBotsClient.cs index c3f223938e..92485c0800 100644 --- a/src/Tgstation.Server.Client/Components/IChatBotsClient.cs +++ b/src/Tgstation.Server.Client/Components/IChatBotsClient.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -13,9 +13,10 @@ namespace Tgstation.Server.Client.Components /// /// List the s /// + /// The optional for the operation. /// The for the operation /// A resulting in a of the of the server - Task> List(CancellationToken cancellationToken); + Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken); /// /// Create a diff --git a/src/Tgstation.Server.Client/Components/IConfigurationClient.cs b/src/Tgstation.Server.Client/Components/IConfigurationClient.cs index e82220c028..9e022fe084 100644 --- a/src/Tgstation.Server.Client/Components/IConfigurationClient.cs +++ b/src/Tgstation.Server.Client/Components/IConfigurationClient.cs @@ -15,10 +15,14 @@ namespace Tgstation.Server.Client.Components /// /// List configuration files /// + /// The optional for the operation. /// The path to the directory to list files in /// The for the operation /// A of s in the - Task> List(string directory, CancellationToken cancellationToken); + Task> List( + PaginationSettings? paginationSettings, + string directory, + CancellationToken cancellationToken); /// /// Read a file diff --git a/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs b/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs index 174634bdb4..24d91af12a 100644 --- a/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs +++ b/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -33,11 +33,12 @@ namespace Tgstation.Server.Client.Components Task Compile(CancellationToken cancellationToken); /// - /// Gets the s of all s for the instance + /// Gets the s for the instance /// + /// The optional for the operation. /// The for the operation /// A resulting in a of s. - Task> GetJobIds(CancellationToken cancellationToken); + Task> ListCompileJobs(PaginationSettings? paginationSettings, CancellationToken cancellationToken); /// /// Get a diff --git a/src/Tgstation.Server.Client/Components/IInstanceUserClient.cs b/src/Tgstation.Server.Client/Components/IInstanceUserClient.cs index 3d1d54e8d7..8035783b12 100644 --- a/src/Tgstation.Server.Client/Components/IInstanceUserClient.cs +++ b/src/Tgstation.Server.Client/Components/IInstanceUserClient.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -28,9 +28,10 @@ namespace Tgstation.Server.Client.Components /// /// Get the s in the /// + /// The optional for the operation. /// The for the operation /// A resulting in a of s in the instance - Task> List(CancellationToken cancellationToken); + Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken); /// /// Update a @@ -56,4 +57,4 @@ namespace Tgstation.Server.Client.Components /// A representing the running operation Task Delete(InstanceUser instanceUser, CancellationToken cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/Components/IJobsClient.cs b/src/Tgstation.Server.Client/Components/IJobsClient.cs index ae879f485e..b3d800b51b 100644 --- a/src/Tgstation.Server.Client/Components/IJobsClient.cs +++ b/src/Tgstation.Server.Client/Components/IJobsClient.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -11,18 +11,20 @@ namespace Tgstation.Server.Client.Components public interface IJobsClient { /// - /// List the s in the + /// List the s in the /// + /// The optional for the operation. /// The for the operation /// A resulting in a of the s in the - Task> List(CancellationToken cancellationToken); + Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken); /// /// List the active s in the /// + /// The optional for the operation. /// The for the operation /// A resulting in a of the active s in the - Task> ListActive(CancellationToken cancellationToken); + Task> ListActive(PaginationSettings? paginationSettings, CancellationToken cancellationToken); /// /// Get a diff --git a/src/Tgstation.Server.Client/Components/InstanceUserClient.cs b/src/Tgstation.Server.Client/Components/InstanceUserClient.cs index 1dd1659ed7..740d94e4e7 100644 --- a/src/Tgstation.Server.Client/Components/InstanceUserClient.cs +++ b/src/Tgstation.Server.Client/Components/InstanceUserClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -8,13 +8,8 @@ using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client.Components { /// - sealed class InstanceUserClient : IInstanceUserClient + sealed class InstanceUserClient : PaginatedClient, IInstanceUserClient { - /// - /// The for the - /// - readonly IApiClient apiClient; - /// /// The for the /// @@ -23,19 +18,19 @@ namespace Tgstation.Server.Client.Components /// /// Construct an /// - /// The value of + /// The for the . /// The value of public InstanceUserClient(IApiClient apiClient, Instance instance) + : base(apiClient) { - this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); } /// - public Task Create(InstanceUser instanceUser, CancellationToken cancellationToken) => apiClient.Create(Routes.InstanceUser, instanceUser ?? throw new ArgumentNullException(nameof(instanceUser)), instance.Id, cancellationToken); + public Task Create(InstanceUser instanceUser, CancellationToken cancellationToken) => ApiClient.Create(Routes.InstanceUser, instanceUser ?? throw new ArgumentNullException(nameof(instanceUser)), instance.Id, cancellationToken); /// - public Task Delete(InstanceUser instanceUser, CancellationToken cancellationToken) => apiClient.Delete( + public Task Delete(InstanceUser instanceUser, CancellationToken cancellationToken) => ApiClient.Delete( Routes.SetID( Routes.InstanceUser, instanceUser.UserId), @@ -43,15 +38,16 @@ namespace Tgstation.Server.Client.Components cancellationToken); /// - public Task Read(CancellationToken cancellationToken) => apiClient.Read(Routes.InstanceUser, instance.Id, cancellationToken); + public Task Read(CancellationToken cancellationToken) => ApiClient.Read(Routes.InstanceUser, instance.Id, cancellationToken); /// - public Task Update(InstanceUser instanceUser, CancellationToken cancellationToken) => apiClient.Update(Routes.InstanceUser, instanceUser ?? throw new ArgumentNullException(nameof(instanceUser)), instance.Id, cancellationToken); + public Task Update(InstanceUser instanceUser, CancellationToken cancellationToken) => ApiClient.Update(Routes.InstanceUser, instanceUser ?? throw new ArgumentNullException(nameof(instanceUser)), instance.Id, cancellationToken); /// - public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.InstanceUser), instance.Id, cancellationToken); + public Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged(paginationSettings, Routes.ListRoute(Routes.InstanceUser), instance.Id, cancellationToken); /// - public Task GetId(InstanceUser instanceUser, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.InstanceUser, instanceUser?.UserId ?? throw new ArgumentNullException(nameof(instanceUser))), instance.Id, cancellationToken); + public Task GetId(InstanceUser instanceUser, CancellationToken cancellationToken) => ApiClient.Read(Routes.SetID(Routes.InstanceUser, instanceUser?.UserId ?? throw new ArgumentNullException(nameof(instanceUser))), instance.Id, cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/Components/JobsClient.cs b/src/Tgstation.Server.Client/Components/JobsClient.cs index 228a2c4a9b..cf6a8875ec 100644 --- a/src/Tgstation.Server.Client/Components/JobsClient.cs +++ b/src/Tgstation.Server.Client/Components/JobsClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -8,13 +8,8 @@ using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client.Components { /// - sealed class JobsClient : IJobsClient + sealed class JobsClient : PaginatedClient, IJobsClient { - /// - /// The for the - /// - readonly IApiClient apiClient; - /// /// The for the /// @@ -23,24 +18,26 @@ namespace Tgstation.Server.Client.Components /// /// Construct a /// - /// The value of + /// The for the . /// The value of public JobsClient(IApiClient apiClient, Instance instance) + : base(apiClient) { - this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); } /// - public Task Cancel(Job job, CancellationToken cancellationToken) => apiClient.Delete(Routes.SetID(Routes.Jobs, job?.Id ?? throw new ArgumentNullException(nameof(job))), instance.Id, cancellationToken); + public Task Cancel(Job job, CancellationToken cancellationToken) => ApiClient.Delete(Routes.SetID(Routes.Jobs, job?.Id ?? throw new ArgumentNullException(nameof(job))), instance.Id, cancellationToken); /// - public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.Jobs), instance.Id, cancellationToken); + public Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged(paginationSettings, Routes.ListRoute(Routes.Jobs), instance.Id, cancellationToken); /// - public Task> ListActive(CancellationToken cancellationToken) => apiClient.Read>(Routes.Jobs, instance.Id, cancellationToken); + public Task> ListActive(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged(paginationSettings, Routes.Jobs, instance.Id, cancellationToken); /// - public Task GetId(EntityId job, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.Jobs, job?.Id ?? throw new ArgumentNullException(nameof(job))), instance.Id, cancellationToken); + public Task GetId(EntityId job, CancellationToken cancellationToken) => ApiClient.Read(Routes.SetID(Routes.Jobs, job?.Id ?? throw new ArgumentNullException(nameof(job))), instance.Id, cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/IAdministrationClient.cs b/src/Tgstation.Server.Client/IAdministrationClient.cs index b77e7475f7..b8abccfc91 100644 --- a/src/Tgstation.Server.Client/IAdministrationClient.cs +++ b/src/Tgstation.Server.Client/IAdministrationClient.cs @@ -37,9 +37,10 @@ namespace Tgstation.Server.Client /// /// Lists the log files available for download. /// + /// The optional for the operation. /// The for the operation /// A resulting in an of metadata. - Task> ListLogs(CancellationToken cancellationToken); + Task> ListLogs(PaginationSettings? paginationSettings, CancellationToken cancellationToken); /// /// Download a given . diff --git a/src/Tgstation.Server.Client/IInstanceManagerClient.cs b/src/Tgstation.Server.Client/IInstanceManagerClient.cs index 52bc42e769..4e53f8f32b 100644 --- a/src/Tgstation.Server.Client/IInstanceManagerClient.cs +++ b/src/Tgstation.Server.Client/IInstanceManagerClient.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -14,9 +14,10 @@ namespace Tgstation.Server.Client /// /// Get all s for s the user can view /// + /// The optional for the operation. /// The for the operation /// A resulting in a of all s the user can view - Task> List(CancellationToken cancellationToken); + Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken); /// /// Create or attach an diff --git a/src/Tgstation.Server.Client/IUsersClient.cs b/src/Tgstation.Server.Client/IUsersClient.cs index 8cf253f1bd..f024eb38ef 100644 --- a/src/Tgstation.Server.Client/IUsersClient.cs +++ b/src/Tgstation.Server.Client/IUsersClient.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -28,9 +28,10 @@ namespace Tgstation.Server.Client /// /// List all s /// + /// The optional for the operation. /// The for the operation /// A resulting in a of all s - Task> List(CancellationToken cancellationToken); + Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken); /// /// Create a new @@ -48,4 +49,4 @@ namespace Tgstation.Server.Client /// The updated Task Update(UserUpdate user, CancellationToken cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/InstanceManagerClient.cs b/src/Tgstation.Server.Client/InstanceManagerClient.cs index 9bd9b4846f..910f09896e 100644 --- a/src/Tgstation.Server.Client/InstanceManagerClient.cs +++ b/src/Tgstation.Server.Client/InstanceManagerClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -9,41 +9,36 @@ using Tgstation.Server.Client.Components; namespace Tgstation.Server.Client { /// - sealed class InstanceManagerClient : IInstanceManagerClient + sealed class InstanceManagerClient : PaginatedClient, IInstanceManagerClient { - /// - /// The for the - /// - readonly IApiClient apiClient; - /// /// Construct an /// - /// The value of + /// The for the . public InstanceManagerClient(IApiClient apiClient) - { - this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); - } + : base(apiClient) + { } /// - public Task CreateOrAttach(Instance instance, CancellationToken cancellationToken) => apiClient.Create(Routes.InstanceManager, instance ?? throw new ArgumentNullException(nameof(instance)), cancellationToken); + public Task CreateOrAttach(Instance instance, CancellationToken cancellationToken) => ApiClient.Create(Routes.InstanceManager, instance ?? throw new ArgumentNullException(nameof(instance)), cancellationToken); /// - public Task Detach(Instance instance, CancellationToken cancellationToken) => apiClient.Delete(Routes.SetID(Routes.InstanceManager, instance?.Id ?? throw new ArgumentNullException(nameof(instance))), cancellationToken); + public Task Detach(Instance instance, CancellationToken cancellationToken) => ApiClient.Delete(Routes.SetID(Routes.InstanceManager, instance?.Id ?? throw new ArgumentNullException(nameof(instance))), cancellationToken); /// - public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.InstanceManager), cancellationToken); + public Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged(paginationSettings, Routes.ListRoute(Routes.InstanceManager), null, cancellationToken); /// - public Task Update(Instance instance, CancellationToken cancellationToken) => apiClient.Update(Routes.InstanceManager, instance ?? throw new ArgumentNullException(nameof(instance)), cancellationToken); + public Task Update(Instance instance, CancellationToken cancellationToken) => ApiClient.Update(Routes.InstanceManager, instance ?? throw new ArgumentNullException(nameof(instance)), cancellationToken); /// - public Task GetId(Instance instance, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.InstanceManager, instance?.Id ?? throw new ArgumentNullException(nameof(instance))), cancellationToken); + public Task GetId(Instance instance, CancellationToken cancellationToken) => ApiClient.Read(Routes.SetID(Routes.InstanceManager, instance?.Id ?? throw new ArgumentNullException(nameof(instance))), cancellationToken); /// - public Task GrantPermissions(Instance instance, CancellationToken cancellationToken) => apiClient.Patch(Routes.SetID(Routes.InstanceManager, instance?.Id ?? throw new ArgumentNullException(nameof(instance))), cancellationToken); + public Task GrantPermissions(Instance instance, CancellationToken cancellationToken) => ApiClient.Patch(Routes.SetID(Routes.InstanceManager, instance?.Id ?? throw new ArgumentNullException(nameof(instance))), cancellationToken); /// - public IInstanceClient CreateClient(Instance instance) => new InstanceClient(apiClient, instance); + public IInstanceClient CreateClient(Instance instance) => new InstanceClient(ApiClient, instance); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/PaginatedClient.cs b/src/Tgstation.Server.Client/PaginatedClient.cs new file mode 100644 index 0000000000..fa9191c3cd --- /dev/null +++ b/src/Tgstation.Server.Client/PaginatedClient.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client +{ + /// + /// Client that deals with getting paginated results. + /// + abstract class PaginatedClient + { + /// + /// The for the + /// + protected IApiClient ApiClient { get; } + + /// + /// Initializes a new instance of the . + /// + /// The value of . + public PaginatedClient(IApiClient apiClient) + { + ApiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + } + + /// + /// Reads a given with paged results. + /// + /// The of result model. + /// The if any. + /// The route. + /// The optional . + /// The for the operation. + /// A resulting in an of the paginated s. + protected async Task> ReadPaged( + PaginationSettings? paginationSettings, + string route, + long? instanceId, + CancellationToken cancellationToken) + { + if (route == null) + throw new ArgumentNullException(nameof(route)); + + var routeFormatter = $"{route}?page={{0}}"; + var currentPage = 1; + if (paginationSettings != null) + { + if (paginationSettings.RetrieveCount == 0) + return new List(); // that was easy + else if (paginationSettings.RetrieveCount < 0) + throw new ArgumentOutOfRangeException("RetrieveCount cannot be less than 0!", nameof(paginationSettings)); + + int? pageSize = null; + if (paginationSettings.PageSize.HasValue) + { + // pagesize validates itself on first request + pageSize = paginationSettings.PageSize.Value; + routeFormatter += $"&pageSize={pageSize}"; + } + + if (paginationSettings.Offset.HasValue) + { + if(paginationSettings.Offset.Value < 0) + throw new ArgumentOutOfRangeException("Offset cannot be less than 0!", nameof(paginationSettings)); + + pageSize ??= paginationSettings.Offset.Value; + currentPage = (paginationSettings.Offset.Value / pageSize.Value) + 1; + } + } + + Task> GetPage() => instanceId.HasValue + ? ApiClient.Read>( + String.Format(routeFormatter, currentPage), + instanceId.Value, + cancellationToken) + : ApiClient.Read>( + String.Format(routeFormatter, currentPage), + cancellationToken); + + var firstPage = await GetPage().ConfigureAwait(false); + + var totalAvailable = firstPage.TotalPages * firstPage.PageSize; + var maximumItems = paginationSettings?.RetrieveCount.HasValue == true + ? Math.Min(paginationSettings.RetrieveCount!.Value, totalAvailable) + : totalAvailable; + + var results = new List(maximumItems); + var currentResults = firstPage; + do + { + // check if first page + if(currentPage > 1) + currentResults = await GetPage().ConfigureAwait(false); + + if (currentResults.Content == null) + throw new ApiConflictException("Paginated results missing content!"); + + IEnumerable rangeToAdd = currentResults.Content; + if (paginationSettings?.Offset.HasValue == true) + { + rangeToAdd = rangeToAdd + .Skip(paginationSettings.Offset!.Value % currentResults.PageSize); + } + + var itemsAvailableInPage = rangeToAdd.Count(); + var itemsStillRequired = maximumItems - results.Count; + if (itemsAvailableInPage > itemsStillRequired) + rangeToAdd = rangeToAdd + .Take(itemsStillRequired); + + results.AddRange(rangeToAdd); + ++currentPage; + } + while (results.Count < maximumItems && currentPage <= currentResults.TotalPages); + + return results; + } + } +} diff --git a/src/Tgstation.Server.Client/PaginationSettings.cs b/src/Tgstation.Server.Client/PaginationSettings.cs new file mode 100644 index 0000000000..8c03734f20 --- /dev/null +++ b/src/Tgstation.Server.Client/PaginationSettings.cs @@ -0,0 +1,24 @@ +namespace Tgstation.Server.Client +{ + /// + /// Settings for a paginated request. + /// + public sealed class PaginationSettings + { + /// + /// The size of a page. Defaults to server settings. + /// + public int? PageSize { get; set; } + + /// + /// The offset to take from. Default 0. + /// + public int? Offset { get; set; } + + /// + /// The maximum amount of items to retrieve. Default everything. + /// + /// Results will be truncated if overflow occurs due to . + public int? RetrieveCount { get; set; } + } +} diff --git a/src/Tgstation.Server.Client/UsersClient.cs b/src/Tgstation.Server.Client/UsersClient.cs index 25ff956856..8d53a487dc 100644 --- a/src/Tgstation.Server.Client/UsersClient.cs +++ b/src/Tgstation.Server.Client/UsersClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -8,35 +8,31 @@ using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client { /// - sealed class UsersClient : IUsersClient + sealed class UsersClient : PaginatedClient, IUsersClient { - /// - /// The for the - /// - readonly IApiClient apiClient; - /// /// Construct an /// - /// The value of + /// The for the . public UsersClient(IApiClient apiClient) + : base(apiClient) { - this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); } /// - public Task Create(UserUpdate user, CancellationToken cancellationToken) => apiClient.Create(Routes.User, user ?? throw new ArgumentNullException(nameof(user)), cancellationToken); + public Task Create(UserUpdate user, CancellationToken cancellationToken) => ApiClient.Create(Routes.User, user ?? throw new ArgumentNullException(nameof(user)), cancellationToken); /// - public Task GetId(Api.Models.Internal.User user, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.User, user?.Id ?? throw new ArgumentNullException(nameof(user))), cancellationToken); + public Task GetId(Api.Models.Internal.User user, CancellationToken cancellationToken) => ApiClient.Read(Routes.SetID(Routes.User, user?.Id ?? throw new ArgumentNullException(nameof(user))), cancellationToken); /// - public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.User), cancellationToken); + public Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged(paginationSettings, Routes.ListRoute(Routes.User), null, cancellationToken); /// - public Task Read(CancellationToken cancellationToken) => apiClient.Read(Routes.User, cancellationToken); + public Task Read(CancellationToken cancellationToken) => ApiClient.Read(Routes.User, cancellationToken); /// - public Task Update(UserUpdate user, CancellationToken cancellationToken) => apiClient.Update(Routes.User, user ?? throw new ArgumentNullException(nameof(user)), cancellationToken); + public Task Update(UserUpdate user, CancellationToken cancellationToken) => ApiClient.Update(Routes.User, user ?? throw new ArgumentNullException(nameof(user)), cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs index 779fa02d99..b309585c15 100644 --- a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs +++ b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs @@ -313,48 +313,59 @@ namespace Tgstation.Server.Host.Controllers /// /// List s present. /// + /// The current page. + /// The page size. /// The for the operation. /// A resulting in the of the request. /// Listed logs successfully. /// An IO error occurred while listing. [HttpGet(Routes.Logs)] [TgsAuthorize(AdministrationRights.DownloadLogs)] - [ProducesResponseType(typeof(List), 200)] + [ProducesResponseType(typeof(Paginated), 200)] [ProducesResponseType(typeof(ErrorMessage), 409)] - public async Task ListLogs(CancellationToken cancellationToken) - { - var path = fileLoggingConfiguration.GetFullLogDirectory(ioManager, assemblyInformationProvider, platformIdentifier); - try - { - var files = await ioManager.GetFiles(path, cancellationToken).ConfigureAwait(false); - var tasks = files.Select( - async file => new LogFile - { - Name = ioManager.GetFileName(file), - LastModified = await ioManager.GetLastModified( - ioManager.ConcatPath(path, file), - cancellationToken) - .ConfigureAwait(false) - }) - .ToList(); - - await Task.WhenAll(tasks).ConfigureAwait(false); - - var result = tasks - .Select(x => x.Result) - .OrderByDescending(x => x.Name) - .ToList(); - - return Ok(result); - } - catch (IOException ex) - { - return Conflict(new ErrorMessage(ErrorCode.IOError) + public Task ListLogs([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) + => Paginated( + async () => { - AdditionalData = ex.ToString() - }); - } - } + var path = fileLoggingConfiguration.GetFullLogDirectory(ioManager, assemblyInformationProvider, platformIdentifier); + try + { + var files = await ioManager.GetFiles(path, cancellationToken).ConfigureAwait(false); + var tasks = files.Select( + async file => new LogFile + { + Name = ioManager.GetFileName(file), + LastModified = await ioManager + .GetLastModified( + ioManager.ConcatPath(path, file), + cancellationToken) + .ConfigureAwait(false) + }) + .ToList(); + + await Task.WhenAll(tasks).ConfigureAwait(false); + + var result = tasks + .Select(x => x.Result) + .OrderByDescending(x => x.Name) + .ToList(); + + return new PaginatableResult( + result.AsQueryable()); + } + catch (IOException ex) + { + return new PaginatableResult( + Conflict(new ErrorMessage(ErrorCode.IOError) + { + AdditionalData = ex.ToString() + })); + } + }, + null, + page, + pageSize, + cancellationToken); /// /// Download a . diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 0a772be319..6b07fbd898 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -1,11 +1,14 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query.Internal; using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; using Octokit; using Serilog.Context; using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Net; @@ -15,6 +18,7 @@ using System.Threading.Tasks; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Controllers @@ -26,6 +30,16 @@ namespace Tgstation.Server.Host.Controllers [ApiController] public abstract class ApiController : Controller { + /// + /// Default size of results. + /// + private const ushort DefaultPageSize = 10; + + /// + /// Maximum size of results. + /// + private const ushort MaximumPageSize = 100; + /// /// The for the operation /// @@ -275,6 +289,132 @@ namespace Tgstation.Server.Host.Controllers await base.OnActionExecutionAsync(context, next).ConfigureAwait(false); } } - #pragma warning restore CA1506 +#pragma warning restore CA1506 + + /// + /// Generates a paginated response. + /// + /// The of model being generated and returned. + /// A resulting in a resulting in the generated . + /// An to transform the s after being queried. + /// The requested page from the query. + /// The requested page size from the query. + /// The for the operation. + /// A resulting in the for the operation. + protected Task Paginated( + Func>> queryGenerator, + Action resultTransformer, + int? pageQuery, + int? pageSizeQuery, + CancellationToken cancellationToken) => PaginatedImpl( + queryGenerator, + resultTransformer, + pageQuery, + pageSizeQuery, + cancellationToken); + + /// + /// Generates a paginated response. + /// + /// The of model being generated. + /// The of model being returned. + /// A resulting in a resulting in the generated . + /// An to transform the s after being queried. + /// The requested page from the query. + /// The requested page size from the query. + /// The for the operation. + /// A resulting in the for the operation. + protected Task Paginated( + Func>> queryGenerator, + Action resultTransformer, + int? pageQuery, + int? pageSizeQuery, + CancellationToken cancellationToken) + where TModel : IApiTransformable + => PaginatedImpl( + queryGenerator, + resultTransformer, + pageQuery, + pageSizeQuery, + cancellationToken); + + /// + /// Generates a paginated response. + /// + /// The of model being generated. If different from , must implement for . + /// The of model being returned. + /// A resulting in a resulting in the generated . + /// An to transform the s after being queried. + /// The requested page from the query. + /// The requested page size from the query. + /// The for the operation. + /// A resulting in the for the operation. + async Task PaginatedImpl( + Func>> queryGenerator, + Action resultTransformer, + int? pageQuery, + int? pageSizeQuery, + CancellationToken cancellationToken) + { + if (queryGenerator == null) + throw new ArgumentNullException(nameof(queryGenerator)); + + if (pageQuery <= 0 || pageSizeQuery <= 0) + return BadRequest(new ErrorMessage(ErrorCode.ApiInvalidPageOrPageSize)); + + var pageSize = pageSizeQuery ?? DefaultPageSize; + if (pageSize > MaximumPageSize) + return BadRequest(new ErrorMessage(ErrorCode.ApiPageTooLarge) + { + AdditionalData = $"Maximum page size: {MaximumPageSize}" + }); + + var page = pageQuery ?? 1; + + var paginationResult = await queryGenerator().ConfigureAwait(false); + if (paginationResult.EarlyOut != null) + return paginationResult.EarlyOut; + + var queriedResults = paginationResult + .Results + .Skip((page - 1) * pageSize) + .Take(pageSize); + + int totalResults; + List pagedResults; + if (queriedResults.Provider is IAsyncQueryProvider) + { + totalResults = await paginationResult.Results.CountAsync(cancellationToken).ConfigureAwait(false); + pagedResults = await queriedResults + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + else + { + totalResults = paginationResult.Results.Count(); + pagedResults = queriedResults.ToList(); + } + + if (resultTransformer != null) + foreach (var I in pagedResults) + resultTransformer(I); + + ICollection finalResults; + if (typeof(TModel) == typeof(TResultModel)) + finalResults = (List)(object)pagedResults; // clearly a safe cast + else + finalResults = pagedResults + .OfType>() + .Select(x => x.ToApi()) + .ToList(); + + return Json( + new Paginated + { + Content = pagedResults, + PageSize = pageSize, + TotalPages = (ushort)((totalResults % pageSize) + 1) + }); + } } } diff --git a/src/Tgstation.Server.Host/Controllers/ByondController.cs b/src/Tgstation.Server.Host/Controllers/ByondController.cs index 8c503ba47b..a03bb563f2 100644 --- a/src/Tgstation.Server.Host/Controllers/ByondController.cs +++ b/src/Tgstation.Server.Host/Controllers/ByondController.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using System; -using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; @@ -79,21 +78,31 @@ namespace Tgstation.Server.Host.Controllers /// /// Lists installed versions. /// + /// The current page. + /// The page size. + /// The for the operation. /// A resulting in the for the operation. /// Retrieved version information successfully. [HttpGet(Routes.List)] [TgsAuthorize(ByondRights.ListInstalled)] - [ProducesResponseType(typeof(IEnumerable), 200)] - public Task List() - => WithComponentInstance(instance => - Task.FromResult( - Json(instance - .ByondManager - .InstalledVersions - .Select(x => new Api.Models.Byond - { - Version = x - })))); + [ProducesResponseType(typeof(Paginated), 200)] + public Task List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) + => WithComponentInstance( + instance => Paginated( + () => Task.FromResult( + new PaginatableResult( + instance + .ByondManager + .InstalledVersions + .Select(x => new Api.Models.Byond + { + Version = x + }) + .AsQueryable())), + null, + page, + pageSize, + cancellationToken)); /// /// Changes the active BYOND version to the one specified in a given . diff --git a/src/Tgstation.Server.Host/Controllers/ChatController.cs b/src/Tgstation.Server.Host/Controllers/ChatController.cs index 45c2db5d74..d328a300eb 100644 --- a/src/Tgstation.Server.Host/Controllers/ChatController.cs +++ b/src/Tgstation.Server.Host/Controllers/ChatController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; @@ -168,29 +168,33 @@ namespace Tgstation.Server.Host.Controllers /// /// List s. /// + /// The current page. + /// The page size. /// The for the operation. /// A resulting in the for the operation. /// Listed chat bots successfully. [HttpGet(Routes.List)] [TgsAuthorize(ChatBotRights.Read)] - [ProducesResponseType(typeof(IEnumerable), 200)] - public async Task List(CancellationToken cancellationToken) + [ProducesResponseType(typeof(Paginated), 200)] + public Task List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) { - var query = DatabaseContext - .ChatBots - .AsQueryable() - .Where(x => x.InstanceId == Instance.Id) - .Include(x => x.Channels); - - var results = await query.ToListAsync(cancellationToken).ConfigureAwait(false); - var connectionStrings = (AuthenticationContext.GetRight(RightsType.ChatBots) & (ulong)ChatBotRights.ReadConnectionString) != 0; - - if (!connectionStrings) - foreach (var I in results) - I.ConnectionString = null; - - return Json(results.Select(x => x.ToApi())); + return Paginated( + () => Task.FromResult( + new PaginatableResult( + DatabaseContext + .ChatBots + .AsQueryable() + .Where(x => x.InstanceId == Instance.Id) + .Include(x => x.Channels))), + chatBot => + { + if (connectionStrings) + chatBot.ConnectionString = null; + }, + page, + pageSize, + cancellationToken); } /// diff --git a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs index 3d82b23fe8..1e07d538ce 100644 --- a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs +++ b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using System; -using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -172,54 +171,75 @@ namespace Tgstation.Server.Host.Controllers /// Get the contents of a directory at a /// /// The path of the directory to get + /// The current page. + /// The page size. /// The for the operation /// A resulting in the for the operation /// Directory listed successfully.> /// Directory does not currently exist. [HttpGet(Routes.List + "/{*directoryPath}")] [TgsAuthorize(ConfigurationRights.List)] - [ProducesResponseType(typeof(IReadOnlyList), 200)] + [ProducesResponseType(typeof(Paginated), 200)] [ProducesResponseType(typeof(ErrorMessage), 410)] - public async Task Directory(string directoryPath, CancellationToken cancellationToken) - { - if (ForbidDueToModeConflicts(directoryPath, out var systemIdentity)) - return Forbid(); + public Task Directory( + string directoryPath, + [FromQuery] int? page, + [FromQuery] int? pageSize, + CancellationToken cancellationToken) + => Paginated( + async () => + { + if (ForbidDueToModeConflicts(directoryPath, out var systemIdentity)) + return new PaginatableResult( + Forbid()); - try - { - return await WithComponentInstance( - async instance => + try { - var result = await instance - .Configuration - .ListDirectory(directoryPath, systemIdentity, cancellationToken) - .ConfigureAwait(false); - if (result == null) - return Gone(); + return new PaginatableResult( + await WithComponentInstance( + async instance => + { + var result = await instance + .Configuration + .ListDirectory(directoryPath, systemIdentity, cancellationToken) + .ConfigureAwait(false); + if (result == null) + return Gone(); - return Json(result); - }) - .ConfigureAwait(false); - } - catch (NotImplementedException) - { - return RequiresPosixSystemIdentity(); - } - catch (UnauthorizedAccessException) - { - return Forbid(); - } - } + return Json(result); + }) + .ConfigureAwait(false)); + } + catch (NotImplementedException) + { + return new PaginatableResult( + RequiresPosixSystemIdentity()); + } + catch (UnauthorizedAccessException) + { + return new PaginatableResult( + Forbid()); + } + }, + null, + page, + pageSize, + cancellationToken); /// /// Get the contents of the root configuration directory. /// + /// The current page. + /// The page size. /// The for the operation. /// A resulting in the for the operation. [HttpGet(Routes.List)] [TgsAuthorize(ConfigurationRights.List)] - [ProducesResponseType(typeof(IReadOnlyList), 200)] - public Task List(CancellationToken cancellationToken) => Directory(null, cancellationToken); + [ProducesResponseType(typeof(Paginated), 200)] + public Task List( + [FromQuery] int? page, + [FromQuery] int? pageSize, + CancellationToken cancellationToken) => Directory(null, page, pageSize, cancellationToken); /// /// Create a configuration directory. diff --git a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs index b1bc9b6432..3ba5e89f1a 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System; -using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -95,43 +94,53 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(ErrorMessage), 404)] public async Task GetId(long id, CancellationToken cancellationToken) { - var compileJob = await DatabaseContext - .CompileJobs - .AsQueryable() - .Where(x => x.Id == id && x.Job.Instance.Id == Instance.Id) - .Include(x => x.Job).ThenInclude(x => x.StartedBy) - .Include(x => x.RevisionInformation).ThenInclude(x => x.PrimaryTestMerge).ThenInclude(x => x.MergedBy) - .Include(x => x.RevisionInformation).ThenInclude(x => x.ActiveTestMerges).ThenInclude(x => x.TestMerge).ThenInclude(x => x.MergedBy) + var compileJob = await BaseCompileJobsQuery() + .Where(x => x.Id == id) .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); if (compileJob == default) return NotFound(); return Json(compileJob.ToApi()); } + /// + /// Base query for pulling in all required fields. + /// + /// An of with all the inclusions. + IQueryable BaseCompileJobsQuery() => DatabaseContext + .CompileJobs + .AsQueryable() + .Include(x => x.Job) + .ThenInclude(x => x.StartedBy) + .Include(x => x.RevisionInformation) + .ThenInclude(x => x.PrimaryTestMerge) + .ThenInclude(x => x.MergedBy) + .Include(x => x.RevisionInformation) + .ThenInclude(x => x.ActiveTestMerges) + .ThenInclude(x => x.TestMerge) + .ThenInclude(x => x.MergedBy) + .Where(x => x.Job.Instance.Id == Instance.Id); + /// /// List all s for the instance. /// + /// The current page. + /// The page size. /// The for the operation. /// A resulting in the of the request. /// Retrieved s successfully. [HttpGet(Routes.List)] [TgsAuthorize(DreamMakerRights.CompileJobs)] - [ProducesResponseType(typeof(List), 200)] - public async Task List(CancellationToken cancellationToken) - { - var compileJobs = await DatabaseContext - .CompileJobs - .AsQueryable() - .Where(x => x.Job.Instance.Id == Instance.Id) - .OrderByDescending(x => x.Job.StoppedAt) - .Select(x => new EntityId - { - Id = x.Id - }) - .ToListAsync(cancellationToken) - .ConfigureAwait(false); - return Json(compileJobs); - } + [ProducesResponseType(typeof(Paginated), 200)] + public Task List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) + => Paginated( + () => Task.FromResult( + new PaginatableResult( + BaseCompileJobsQuery() + .OrderByDescending(x => x.Job.StoppedAt))), + null, + page, + pageSize, + cancellationToken); /// /// Begin deploying repository code. diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index cf475e6836..5b2467380a 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -586,13 +586,18 @@ namespace Tgstation.Server.Host.Controllers /// /// List s. /// + /// The current page. + /// The page size. /// The for the operation. /// A resulting in the of the request. /// Retrieved s successfully. [HttpGet(Routes.List)] [TgsAuthorize(InstanceManagerRights.List | InstanceManagerRights.Read)] - [ProducesResponseType(typeof(IEnumerable), 200)] - public async Task List(CancellationToken cancellationToken) + [ProducesResponseType(typeof(Paginated), 200)] + public async Task List( + [FromQuery] int? page, + [FromQuery] int? pageSize, + CancellationToken cancellationToken) { IQueryable GetBaseQuery() { @@ -622,21 +627,25 @@ namespace Tgstation.Server.Host.Controllers .ToListAsync(cancellationToken) .ConfigureAwait(false); - var instances = await GetBaseQuery() - .ToListAsync(cancellationToken) - .ConfigureAwait(false); - var needsUpdate = false; - foreach (var instance in instances) - needsUpdate |= InstanceRequiredController.ValidateInstanceOnlineStatus(instanceManager, Logger, instance); + var result = await Paginated( + () => Task.FromResult( + new PaginatableResult( + GetBaseQuery())), + instance => + { + needsUpdate |= InstanceRequiredController.ValidateInstanceOnlineStatus(instanceManager, Logger, instance); + instance.MoveJob = moveJobs.FirstOrDefault(x => x.Instance.Id == instance.Id).ToApi(); + }, + page, + pageSize, + cancellationToken) + .ConfigureAwait(false); if (needsUpdate) await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); - var apis = instances.Select(x => x.ToApi()); - foreach(var I in moveJobs) - apis.Where(x => x.Id == I.Instance.Id).First().MoveJob = I.ToApi(); // if this .First() fails i will personally murder kevinz000 because I just know he is somehow responsible - return Json(apis); + return result; } /// diff --git a/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs b/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs index 34586ea5cf..ca27e446b8 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs @@ -1,8 +1,7 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System; -using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -147,23 +146,27 @@ namespace Tgstation.Server.Host.Controllers /// /// Lists s for the instance. /// + /// The current page. + /// The page size. /// The for the operation. /// A resulting in the of the request. /// Retrieved s successfully. [HttpGet(Routes.List)] [TgsAuthorize(InstanceUserRights.ReadUsers)] - [ProducesResponseType(typeof(IEnumerable), 200)] - public async Task List(CancellationToken cancellationToken) - { - var users = await DatabaseContext - .Instances - .AsQueryable() - .Where(x => x.Id == Instance.Id) - .SelectMany(x => x.InstanceUsers) - .ToListAsync(cancellationToken) - .ConfigureAwait(false); - return Json(users.Select(x => x.ToApi())); - } + [ProducesResponseType(typeof(Paginated), 200)] + public Task List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) + => Paginated( + () => Task.FromResult( + new PaginatableResult( + DatabaseContext + .Instances + .AsQueryable() + .Where(x => x.Id == Instance.Id) + .SelectMany(x => x.InstanceUsers))), + null, + page, + pageSize, + cancellationToken); /// /// Gets a specific . diff --git a/src/Tgstation.Server.Host/Controllers/JobController.cs b/src/Tgstation.Server.Host/Controllers/JobController.cs index 6347617699..62f657d9e3 100644 --- a/src/Tgstation.Server.Host/Controllers/JobController.cs +++ b/src/Tgstation.Server.Host/Controllers/JobController.cs @@ -1,8 +1,7 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System; -using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -53,50 +52,54 @@ namespace Tgstation.Server.Host.Controllers /// /// Get active s for the instance. /// + /// The current page. + /// The page size. /// The for the operation. /// A resulting in the of the request. /// Retrieved active s successfully. [HttpGet] [TgsAuthorize] - [ProducesResponseType(typeof(IEnumerable), 200)] - public async Task Read(CancellationToken cancellationToken) - { - var result = await DatabaseContext - .Jobs - .AsQueryable() - .Include(x => x.StartedBy) - .Where(x => x.Instance.Id == Instance.Id && !x.StoppedAt.HasValue) - .OrderByDescending(x => x.StartedAt) - .ToListAsync(cancellationToken) - .ConfigureAwait(false); - return Json(result.Select(x => x.ToApi())); - } + [ProducesResponseType(typeof(Paginated), 200)] + public Task Read([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) + => Paginated( + () => Task.FromResult( + new PaginatableResult( + DatabaseContext + .Jobs + .AsQueryable() + .Include(x => x.StartedBy) + .Where(x => x.Instance.Id == Instance.Id && !x.StoppedAt.HasValue) + .OrderByDescending(x => x.StartedAt))), + null, + page, + pageSize, + cancellationToken); /// /// List all s for the instance in reverse creation order. /// + /// The current page. + /// The page size. /// The for the operation. /// A resulting in the of the request. /// Retrieved s successfully. [HttpGet(Routes.List)] [TgsAuthorize] - [ProducesResponseType(typeof(List), 200)] - public async Task List(CancellationToken cancellationToken) - { - // you KNOW this will need pagination eventually right? - var jobs = await DatabaseContext - .Jobs - .AsQueryable() - .Where(x => x.Instance.Id == Instance.Id) - .OrderByDescending(x => x.StartedAt) - .Select(x => new EntityId - { - Id = x.Id - }) - .ToListAsync(cancellationToken) - .ConfigureAwait(false); - return Json(jobs); - } + [ProducesResponseType(typeof(Paginated), 200)] + public Task List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) + => Paginated( + () => Task.FromResult( + new PaginatableResult( + DatabaseContext + .Jobs + .AsQueryable() + .Include(x => x.StartedBy) + .Where(x => x.Instance.Id == Instance.Id) + .OrderByDescending(x => x.StartedAt))), + null, + page, + pageSize, + cancellationToken); /// /// Cancel a running . diff --git a/src/Tgstation.Server.Host/Controllers/PaginatableResult.cs b/src/Tgstation.Server.Host/Controllers/PaginatableResult.cs new file mode 100644 index 0000000000..4efdb614a5 --- /dev/null +++ b/src/Tgstation.Server.Host/Controllers/PaginatableResult.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Mvc; +using System; +using System.Linq; + +namespace Tgstation.Server.Host.Controllers +{ + /// + /// Helper for returning paginated models. + /// + /// The of model intended to be returned. + public sealed class PaginatableResult + { + /// + /// The results. + /// + public IQueryable Results { get; } + + /// + /// An to return immediately. + /// + public IActionResult EarlyOut { get; } + + /// + /// Initializes a new instance of the . + /// + /// The value of . + public PaginatableResult(IQueryable results) + { + Results = results ?? throw new ArgumentNullException(nameof(results)); + } + + /// + /// Initializes a new instance of the . + /// + /// The value of . + public PaginatableResult(IActionResult earlyOut) + { + EarlyOut = earlyOut ?? throw new ArgumentNullException(nameof(earlyOut)); + } + } +} diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index 3b5a8c5a9d..673a322cb5 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -298,23 +298,28 @@ namespace Tgstation.Server.Host.Controllers /// /// List all s in the server. /// + /// The current page. + /// The page size. /// The for the operation. /// A resulting in the of the operation. /// Retrieved s successfully. [HttpGet(Routes.List)] [TgsAuthorize(AdministrationRights.ReadUsers)] - [ProducesResponseType(typeof(IEnumerable), 200)] - public async Task List(CancellationToken cancellationToken) - { - var users = await DatabaseContext - .Users - .AsQueryable() - .Where(x => x.CanonicalName != Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) - .Include(x => x.CreatedBy) - .Include(x => x.OAuthConnections) - .ToListAsync(cancellationToken).ConfigureAwait(false); - return Json(users.Select(x => x.ToApi(true))); - } + [ProducesResponseType(typeof(Paginated), 200)] + public Task List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) + => Paginated( + () => Task.FromResult( + new PaginatableResult( + DatabaseContext + .Users + .AsQueryable() + .Where(x => x.CanonicalName != Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) + .Include(x => x.CreatedBy) + .Include(x => x.OAuthConnections))), + null, + page, + pageSize, + cancellationToken); /// /// Get a specific . diff --git a/src/Tgstation.Server.Host/Core/SwaggerConfiguration.cs b/src/Tgstation.Server.Host/Core/SwaggerConfiguration.cs index 67d2dccc61..47646b93b9 100644 --- a/src/Tgstation.Server.Host/Core/SwaggerConfiguration.cs +++ b/src/Tgstation.Server.Host/Core/SwaggerConfiguration.cs @@ -164,6 +164,9 @@ namespace Tgstation.Server.Host.Core if (type == typeof(Api.Models.Internal.User)) return "ShallowUser"; + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Paginated<>)) + return $"Paginated{type.GenericTypeArguments.First().Name}"; + return type.Name; }); @@ -241,7 +244,7 @@ namespace Tgstation.Server.Host.Core }; if (typeof(InstanceRequiredController).IsAssignableFrom(context.MethodInfo.DeclaringType)) - operation.Parameters.Add(new OpenApiParameter + operation.Parameters.Insert(0, new OpenApiParameter { Reference = new OpenApiReference { diff --git a/src/Tgstation.Server.Host/Models/ChatBot.cs b/src/Tgstation.Server.Host/Models/ChatBot.cs index e3565be4e8..f190f2f043 100644 --- a/src/Tgstation.Server.Host/Models/ChatBot.cs +++ b/src/Tgstation.Server.Host/Models/ChatBot.cs @@ -1,11 +1,11 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; namespace Tgstation.Server.Host.Models { /// - public sealed class ChatBot : Api.Models.Internal.ChatBot + public sealed class ChatBot : Api.Models.Internal.ChatBot, IApiTransformable { /// /// Default for . @@ -28,10 +28,7 @@ namespace Tgstation.Server.Host.Models /// public ICollection Channels { get; set; } - /// - /// Convert the to it's API form - /// - /// A new + /// public Api.Models.ChatBot ToApi() => new Api.Models.ChatBot { Channels = Channels.Select(x => x.ToApi()).ToList(), diff --git a/src/Tgstation.Server.Host/Models/ChatChannel.cs b/src/Tgstation.Server.Host/Models/ChatChannel.cs index d4b7bca702..e92eb59884 100644 --- a/src/Tgstation.Server.Host/Models/ChatChannel.cs +++ b/src/Tgstation.Server.Host/Models/ChatChannel.cs @@ -1,7 +1,7 @@ -namespace Tgstation.Server.Host.Models +namespace Tgstation.Server.Host.Models { /// - public sealed class ChatChannel : Api.Models.ChatChannel + public sealed class ChatChannel : Api.Models.ChatChannel, IApiTransformable { /// /// The row Id @@ -18,10 +18,7 @@ /// public ChatBot ChatSettings { get; set; } - /// - /// Convert the to it's API form - /// - /// A new + /// public Api.Models.ChatChannel ToApi() => new Api.Models.ChatChannel { DiscordChannelId = DiscordChannelId, diff --git a/src/Tgstation.Server.Host/Models/CompileJob.cs b/src/Tgstation.Server.Host/Models/CompileJob.cs index 58949d3251..5a45c0a85d 100644 --- a/src/Tgstation.Server.Host/Models/CompileJob.cs +++ b/src/Tgstation.Server.Host/Models/CompileJob.cs @@ -5,7 +5,7 @@ using Tgstation.Server.Api.Models; namespace Tgstation.Server.Host.Models { /// - public sealed class CompileJob : Api.Models.Internal.CompileJob + public sealed class CompileJob : Api.Models.Internal.CompileJob, IApiTransformable { /// /// See @@ -78,10 +78,7 @@ namespace Tgstation.Server.Host.Models } } - /// - /// Convert the to it's API form - /// - /// A new + /// public Api.Models.CompileJob ToApi() => new Api.Models.CompileJob { DirectoryName = DirectoryName, diff --git a/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs b/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs index 7ae2ef5c79..eb3ed1bb19 100644 --- a/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs +++ b/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs @@ -1,9 +1,9 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace Tgstation.Server.Host.Models { /// - public sealed class DreamMakerSettings : Api.Models.DreamMaker + public sealed class DreamMakerSettings : Api.Models.DreamMaker, IApiTransformable { /// /// The row Id @@ -21,10 +21,7 @@ namespace Tgstation.Server.Host.Models [Required] public Instance Instance { get; set; } - /// - /// Convert the to it's API form - /// - /// A new + /// public Api.Models.DreamMaker ToApi() => new Api.Models.DreamMaker { ProjectName = ProjectName, diff --git a/src/Tgstation.Server.Host/Models/IApiTransformable.cs b/src/Tgstation.Server.Host/Models/IApiTransformable.cs new file mode 100644 index 0000000000..282e0d2442 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/IApiTransformable.cs @@ -0,0 +1,15 @@ +namespace Tgstation.Server.Host.Models +{ + /// + /// Represents a host-side model that may be transformed into a . + /// + /// The API form of the model. + public interface IApiTransformable + { + /// + /// Convert the to it's . + /// + /// A new based on the . + TApiModel ToApi(); + } +} diff --git a/src/Tgstation.Server.Host/Models/Instance.cs b/src/Tgstation.Server.Host/Models/Instance.cs index 1827a9511d..d22c5f70b3 100644 --- a/src/Tgstation.Server.Host/Models/Instance.cs +++ b/src/Tgstation.Server.Host/Models/Instance.cs @@ -1,11 +1,11 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace Tgstation.Server.Host.Models { /// /// Represents an in the database /// - public sealed class Instance : Api.Models.Instance + public sealed class Instance : Api.Models.Instance, IApiTransformable { /// /// Default for . @@ -47,10 +47,7 @@ namespace Tgstation.Server.Host.Models /// public ICollection Jobs { get; set; } - /// - /// Convert the to it's API form - /// - /// A new + /// public Api.Models.Instance ToApi() => new Api.Models.Instance { AutoUpdateInterval = AutoUpdateInterval, @@ -59,7 +56,8 @@ namespace Tgstation.Server.Host.Models Name = Name, Path = Path, Online = Online, - ChatBotLimit = ChatBotLimit + ChatBotLimit = ChatBotLimit, + MoveJob = MoveJob, }; } } diff --git a/src/Tgstation.Server.Host/Models/InstanceUser.cs b/src/Tgstation.Server.Host/Models/InstanceUser.cs index 2aa74e5042..7b7aea0d4c 100644 --- a/src/Tgstation.Server.Host/Models/InstanceUser.cs +++ b/src/Tgstation.Server.Host/Models/InstanceUser.cs @@ -1,9 +1,9 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace Tgstation.Server.Host.Models { /// - public sealed class InstanceUser : Api.Models.InstanceUser + public sealed class InstanceUser : Api.Models.InstanceUser, IApiTransformable { /// /// The row Id @@ -21,10 +21,7 @@ namespace Tgstation.Server.Host.Models [Required] public Instance Instance { get; set; } - /// - /// Convert the to it's API form - /// - /// A new + /// public Api.Models.InstanceUser ToApi() => new Api.Models.InstanceUser { ByondRights = ByondRights, diff --git a/src/Tgstation.Server.Host/Models/Job.cs b/src/Tgstation.Server.Host/Models/Job.cs index d2ba919450..861981efda 100644 --- a/src/Tgstation.Server.Host/Models/Job.cs +++ b/src/Tgstation.Server.Host/Models/Job.cs @@ -4,7 +4,7 @@ namespace Tgstation.Server.Host.Models { /// #pragma warning disable CA1724 // naming conflict with gitlab package - public sealed class Job : Api.Models.Internal.Job + public sealed class Job : Api.Models.Internal.Job, IApiTransformable #pragma warning restore CA1724 { /// @@ -24,10 +24,7 @@ namespace Tgstation.Server.Host.Models [Required] public Instance Instance { get; set; } - /// - /// Convert the to it's API form - /// - /// A new + /// public Api.Models.Job ToApi() => new Api.Models.Job { Id = Id, diff --git a/src/Tgstation.Server.Host/Models/OAuthConnection.cs b/src/Tgstation.Server.Host/Models/OAuthConnection.cs index 590849a538..441ac0e312 100644 --- a/src/Tgstation.Server.Host/Models/OAuthConnection.cs +++ b/src/Tgstation.Server.Host/Models/OAuthConnection.cs @@ -1,7 +1,7 @@ namespace Tgstation.Server.Host.Models { /// - public sealed class OAuthConnection : Api.Models.OAuthConnection + public sealed class OAuthConnection : Api.Models.OAuthConnection, IApiTransformable { /// /// The row Id. @@ -13,10 +13,7 @@ namespace Tgstation.Server.Host.Models /// public User User { get; set; } - /// - /// Convert the to it's API form. - /// - /// A new . + /// public Api.Models.OAuthConnection ToApi() => new Api.Models.OAuthConnection { Provider = Provider, diff --git a/src/Tgstation.Server.Host/Models/RepositorySettings.cs b/src/Tgstation.Server.Host/Models/RepositorySettings.cs index 6cc2cf436e..25fb37edd7 100644 --- a/src/Tgstation.Server.Host/Models/RepositorySettings.cs +++ b/src/Tgstation.Server.Host/Models/RepositorySettings.cs @@ -4,7 +4,7 @@ using Tgstation.Server.Api.Models; namespace Tgstation.Server.Host.Models { /// - public sealed class RepositorySettings : Api.Models.Internal.RepositorySettings + public sealed class RepositorySettings : Api.Models.Internal.RepositorySettings, IApiTransformable { /// /// The row Id @@ -22,10 +22,7 @@ namespace Tgstation.Server.Host.Models [Required] public Instance Instance { get; set; } - /// - /// Convert the to it's API form - /// - /// A new + /// public Repository ToApi() => new Repository { // AccessToken = AccessToken, // never show this diff --git a/src/Tgstation.Server.Host/Models/RevisionInformation.cs b/src/Tgstation.Server.Host/Models/RevisionInformation.cs index 6c28d3864a..3b99e8c26e 100644 --- a/src/Tgstation.Server.Host/Models/RevisionInformation.cs +++ b/src/Tgstation.Server.Host/Models/RevisionInformation.cs @@ -1,11 +1,11 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; namespace Tgstation.Server.Host.Models { /// - public sealed class RevisionInformation : Api.Models.Internal.RevisionInformation + public sealed class RevisionInformation : Api.Models.Internal.RevisionInformation, IApiTransformable { /// /// The row Id @@ -38,10 +38,7 @@ namespace Tgstation.Server.Host.Models /// public ICollection CompileJobs { get; set; } - /// - /// Convert the to it's API form - /// - /// A new + /// public Api.Models.RevisionInformation ToApi() => new Api.Models.RevisionInformation { CommitSha = CommitSha, diff --git a/src/Tgstation.Server.Host/Models/TestMerge.cs b/src/Tgstation.Server.Host/Models/TestMerge.cs index 6e9fd81880..3ef25c0e91 100644 --- a/src/Tgstation.Server.Host/Models/TestMerge.cs +++ b/src/Tgstation.Server.Host/Models/TestMerge.cs @@ -1,10 +1,10 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace Tgstation.Server.Host.Models { /// - public sealed class TestMerge : Api.Models.Internal.TestMerge + public sealed class TestMerge : Api.Models.Internal.TestMerge, IApiTransformable { /// /// See @@ -28,10 +28,7 @@ namespace Tgstation.Server.Host.Models /// public ICollection RevisonInformations { get; set; } - /// - /// Convert the to it's API form - /// - /// A new + /// public Api.Models.TestMerge ToApi() => new Api.Models.TestMerge { Author = Author, diff --git a/src/Tgstation.Server.Host/Models/User.cs b/src/Tgstation.Server.Host/Models/User.cs index 263112f193..1b896a4284 100644 --- a/src/Tgstation.Server.Host/Models/User.cs +++ b/src/Tgstation.Server.Host/Models/User.cs @@ -6,7 +6,7 @@ using System.Linq; namespace Tgstation.Server.Host.Models { /// - public sealed class User : Api.Models.Internal.User + public sealed class User : Api.Models.Internal.User, IApiTransformable { /// /// Username used when creating jobs automatically. @@ -88,5 +88,8 @@ namespace Tgstation.Server.Host.Models /// If rights and system identifier should be shown /// A new public Api.Models.User ToApi(bool showDetails) => ToApi(true, showDetails); + + /// + public Api.Models.User ToApi() => ToApi(true); } } diff --git a/tests/Tgstation.Server.Tests/AdministrationTest.cs b/tests/Tgstation.Server.Tests/AdministrationTest.cs index cddd0b0aa4..38b3f81224 100644 --- a/tests/Tgstation.Server.Tests/AdministrationTest.cs +++ b/tests/Tgstation.Server.Tests/AdministrationTest.cs @@ -28,7 +28,7 @@ namespace Tgstation.Server.Tests async Task TestLogs(CancellationToken cancellationToken) { - var logs = await client.ListLogs(cancellationToken); + var logs = await client.ListLogs(null, cancellationToken); Assert.AreNotEqual(0, logs.Count); var logFile = logs.First(); Assert.IsNotNull(logFile); diff --git a/tests/Tgstation.Server.Tests/Instance/ByondTest.cs b/tests/Tgstation.Server.Tests/Instance/ByondTest.cs index 11a24484e4..c3eb6e84fe 100644 --- a/tests/Tgstation.Server.Tests/Instance/ByondTest.cs +++ b/tests/Tgstation.Server.Tests/Instance/ByondTest.cs @@ -75,7 +75,7 @@ namespace Tgstation.Server.Tests.Instance async Task TestNoVersion(CancellationToken cancellationToken) { - var allVersionsTask = byondClient.InstalledVersions(cancellationToken); + var allVersionsTask = byondClient.InstalledVersions(null, cancellationToken); var currentShit = await byondClient.ActiveVersion(cancellationToken).ConfigureAwait(false); Assert.IsNotNull(currentShit); Assert.IsNull(currentShit.InstallJob); diff --git a/tests/Tgstation.Server.Tests/Instance/ChatTest.cs b/tests/Tgstation.Server.Tests/Instance/ChatTest.cs index f41c1016d5..ef6b8837e7 100644 --- a/tests/Tgstation.Server.Tests/Instance/ChatTest.cs +++ b/tests/Tgstation.Server.Tests/Instance/ChatTest.cs @@ -52,7 +52,7 @@ namespace Tgstation.Server.Tests.Instance Assert.AreEqual(csb.ToString(), firstBot.ConnectionString); Assert.AreNotEqual(0, firstBot.Id); - var bots = await chatClient.List(cancellationToken); + var bots = await chatClient.List(null, cancellationToken); Assert.AreEqual(firstBot.Id, bots.First(x => x.Provider.Value == ChatProvider.Irc).Id); var retrievedBot = await chatClient.GetId(firstBot, cancellationToken); @@ -115,7 +115,7 @@ namespace Tgstation.Server.Tests.Instance Assert.AreEqual(csb.ToString(), firstBot.ConnectionString); Assert.AreNotEqual(0, firstBot.Id); - var bots = await chatClient.List(cancellationToken); + var bots = await chatClient.List(null, cancellationToken); Assert.AreEqual(firstBot.Id, bots.First(x => x.Provider.Value == ChatProvider.Discord).Id); var retrievedBot = await chatClient.GetId(firstBot, cancellationToken); @@ -154,13 +154,13 @@ namespace Tgstation.Server.Tests.Instance public async Task RunPostTest(CancellationToken cancellationToken) { - var activeBots = await chatClient.List(cancellationToken); + var activeBots = await chatClient.List(null, cancellationToken); Assert.AreEqual(2, activeBots.Count); await Task.WhenAll(activeBots.Select(bot => chatClient.Delete(bot, cancellationToken))); - var nowBots = await chatClient.List(cancellationToken); + var nowBots = await chatClient.List(null, cancellationToken); Assert.AreEqual(0, nowBots.Count); } @@ -173,7 +173,7 @@ namespace Tgstation.Server.Tests.Instance Provider = ChatProvider.Irc }, cancellationToken), ErrorCode.ChatBotMax); - var bots = await chatClient.List(cancellationToken); + var bots = await chatClient.List(null, cancellationToken); var discordBot = bots.First(bot => bot.Provider.Value == ChatProvider.Discord); // We limited chat bots and channels to 1 and 2 respectively, try violating them diff --git a/tests/Tgstation.Server.Tests/InstanceManagerTest.cs b/tests/Tgstation.Server.Tests/InstanceManagerTest.cs index fbab7dec20..ad2c585d9d 100644 --- a/tests/Tgstation.Server.Tests/InstanceManagerTest.cs +++ b/tests/Tgstation.Server.Tests/InstanceManagerTest.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.IO; using System.Linq; @@ -137,7 +137,7 @@ namespace Tgstation.Server.Tests public async Task RunPostTest(CancellationToken cancellationToken) { - var instances = await instanceManagerClient.List(cancellationToken); + var instances = await instanceManagerClient.List(null, cancellationToken); var firstTest = instances.Single(x => x.Name == TestInstanceName); var instanceClient = instanceManagerClient.CreateClient(firstTest); diff --git a/tests/Tgstation.Server.Tests/IntegrationTest.cs b/tests/Tgstation.Server.Tests/IntegrationTest.cs index d0d50482eb..4e6ed50068 100644 --- a/tests/Tgstation.Server.Tests/IntegrationTest.cs +++ b/tests/Tgstation.Server.Tests/IntegrationTest.cs @@ -320,10 +320,10 @@ namespace Tgstation.Server.Tests { var instanceClient = adminClient.Instances.CreateClient(instance); - var jobs = await instanceClient.Jobs.ListActive(cancellationToken); + var jobs = await instanceClient.Jobs.ListActive(null, cancellationToken); if (!jobs.Any()) { - var entities = await instanceClient.Jobs.List(cancellationToken); + var entities = await instanceClient.Jobs.List(null, cancellationToken); var getTasks = entities .Select(e => instanceClient.Jobs.GetId(e, cancellationToken)) .ToList(); @@ -363,10 +363,10 @@ namespace Tgstation.Server.Tests { var instanceClient = adminClient.Instances.CreateClient(instance); - var jobs = await instanceClient.Jobs.ListActive(cancellationToken); + var jobs = await instanceClient.Jobs.ListActive(null, cancellationToken); if (!jobs.Any()) { - var entities = await instanceClient.Jobs.List(cancellationToken); + var entities = await instanceClient.Jobs.List(null, cancellationToken); var getTasks = entities .Select(e => instanceClient.Jobs.GetId(e, cancellationToken)) .ToList(); diff --git a/tests/Tgstation.Server.Tests/UsersTest.cs b/tests/Tgstation.Server.Tests/UsersTest.cs index 0110d8a4dc..0cdb21ebc2 100644 --- a/tests/Tgstation.Server.Tests/UsersTest.cs +++ b/tests/Tgstation.Server.Tests/UsersTest.cs @@ -25,6 +25,8 @@ namespace Tgstation.Server.Tests BasicTests(cancellationToken), TestCreateSysUser(cancellationToken), TestSpamCreation(cancellationToken)).ConfigureAwait(false); + + await TestPagination(cancellationToken); } async Task BasicTests(CancellationToken cancellationToken) @@ -41,7 +43,7 @@ namespace Tgstation.Server.Tests Assert.AreEqual("TGS", systemUser.Name); Assert.AreEqual(false, systemUser.Enabled); - var users = await client.List(cancellationToken); + var users = await client.List(null, cancellationToken); Assert.IsTrue(users.Count > 0); Assert.IsFalse(users.Any(x => x.Id == systemUser.Id)); @@ -133,5 +135,55 @@ namespace Tgstation.Server.Tests Assert.AreEqual(RepeatCount, tasks.Select(task => task.Result.Id).Distinct().Count(), "Did not receive expected number of unique user IDs!"); } + + async Task TestPagination(CancellationToken cancellationToken) + { + // we test pagination here b/c it's the only spot we have a decent amount of entities + var nullSettings = await client.List(null, cancellationToken); + var emptySettings = await client.List( + new PaginationSettings + { + }, cancellationToken); + + Assert.AreEqual(nullSettings.Count, emptySettings.Count); + Assert.IsTrue(nullSettings.All(x => emptySettings.SingleOrDefault(y => x.Id == y.Id) != null)); + + await ApiAssert.ThrowsException(() => client.List( + new PaginationSettings + { + PageSize = -2143 + }, cancellationToken), ErrorCode.ApiInvalidPageOrPageSize); + await ApiAssert.ThrowsException(() => client.List( + new PaginationSettings + { + PageSize = Int32.MaxValue + }, cancellationToken), ErrorCode.ApiPageTooLarge); + + await client.List( + new PaginationSettings + { + PageSize = 50 + }, + cancellationToken); + + var skipped = await client.List(new PaginationSettings + { + Offset = 50, + RetrieveCount = 5 + }, cancellationToken); + Assert.AreEqual(5, skipped.Count); + + var allAfterSkipped = await client.List(new PaginationSettings + { + Offset = 50, + }, cancellationToken); + Assert.IsTrue(5 < allAfterSkipped.Count); + + var limited = await client.List(new PaginationSettings + { + RetrieveCount = 12, + }, cancellationToken); + Assert.AreEqual(12, limited.Count); + } } }