Definition looks good now

This commit is contained in:
Jordan Brown
2021-02-10 17:29:39 -05:00
parent 8b1ad11254
commit 6c69d6bca3
38 changed files with 261 additions and 164 deletions
@@ -8,6 +8,7 @@ namespace Tgstation.Server.Api.Models
/// <summary>
/// The ID of the entity.
/// </summary>
[RequestOptions(FieldPresence.Required)]
[RequestOptions(FieldPresence.Ignored, PutOnly = true)]
public virtual long? Id { get; set; }
}
+1 -1
View File
@@ -178,7 +178,7 @@ namespace Tgstation.Server.Api.Models
ConfigurationDirectoryNotEmpty,
/// <summary>
/// Tried to clone a repository with a missing <see cref="RepositoryCloneRequest.Origin"/> property.
/// Tried to clone a repository with a missing <see cref="RepositoryCreateRequest.Origin"/> property.
/// </summary>
[Obsolete("Deprecated")]
[Description("Cannot clone repository with missing origin field!")]
@@ -7,7 +7,7 @@ namespace Tgstation.Server.Api.Models.Internal
/// <summary>
/// Represents a deployment run.
/// </summary>
public class CompileJob : EntityId
public abstract class CompileJob : EntityId
{
/// <summary>
/// The .dme file used for compilation
@@ -19,7 +19,6 @@ namespace Tgstation.Server.Api.Models.Internal
/// Textual output of DM
/// </summary>
[Required]
[ResponseOptions]
public string? Output { get; set; }
/// <summary>
@@ -29,8 +28,9 @@ namespace Tgstation.Server.Api.Models.Internal
public Guid? DirectoryName { get; set; }
/// <summary>
/// The minimum <see cref="DreamDaemonSecurity"/> required to run the <see cref="CompileJob"/>'s output
/// The minimum <see cref="DreamDaemonSecurity"/> required to run the <see cref="CompileJob"/>'s output.
/// </summary>
[ResponseOptions]
public DreamDaemonSecurity? MinimumSecurityLevel { get; set; }
/// <summary>
@@ -11,6 +11,7 @@ namespace Tgstation.Server.Api.Models.Internal
/// <summary>
/// The <see cref="EntityId.Id"/> of the <see cref="PermissionSet"/> the <see cref="InstancePermissionSet"/> belongs to
/// </summary>
[RequestOptions(FieldPresence.Required)]
public long PermissionSetId { get; set; }
/// <summary>
@@ -11,6 +11,7 @@ namespace Tgstation.Server.Api.Models.Internal
/// The branch or tag HEAD points to.
/// </summary>
[StringLength(Limits.MaximumStringLength)]
[ResponseOptions]
public string? Reference { get; set; }
}
}
@@ -13,6 +13,7 @@ namespace Tgstation.Server.Api.Models.Internal
/// <summary>
/// The <see cref="Models.PermissionSet"/> directly associated with the <see cref="UserResponse"/>.
/// </summary>
[ResponseOptions]
public PermissionSet? PermissionSet { get; set; }
/// <summary>
@@ -6,13 +6,12 @@ namespace Tgstation.Server.Api.Models.Internal
/// <summary>
/// Represents a server user.
/// </summary>
public abstract class UserModelBase : NamedEntity
public abstract class UserModelBase : UserName
{
/// <summary>
/// If the <see cref="UserModelBase"/> is enabled since users cannot be deleted. System users cannot be disabled
/// </summary>
[Required]
[ResponseOptions]
public bool? Enabled { get; set; }
/// <summary>
@@ -20,7 +19,6 @@ namespace Tgstation.Server.Api.Models.Internal
/// </summary>
[Required]
[RequestOptions(FieldPresence.Ignored)]
[ResponseOptions]
public DateTimeOffset? CreatedAt { get; set; }
/// <summary>
@@ -5,15 +5,14 @@ namespace Tgstation.Server.Api.Models
/// <summary>
/// Base class for named entities.
/// </summary>
public class NamedEntity : EntityId
public abstract class NamedEntity : EntityId
{
/// <summary>
/// The name of the entity represented by the <see cref="NamedEntity"/>.
/// </summary>
[Required]
[ResponseOptions]
[RequestOptions(FieldPresence.Required, PutOnly = true)]
[StringLength(Limits.MaximumIndexableStringLength, MinimumLength = 1)]
public string? Name { get; set; }
public virtual string? Name { get; set; }
}
}
@@ -1,9 +1,11 @@
using Tgstation.Server.Api.Models.Internal;
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// Represents a request to update a chat bot.
/// </summary>
public sealed class ChatBotCreateRequest : ChatBotUpdateRequest
public sealed class ChatBotCreateRequest : ChatBotApiBase
{
}
}
@@ -3,7 +3,7 @@ namespace Tgstation.Server.Api.Models
/// <summary>
/// A request to create an <see cref="Instance"/>.
/// </summary>
public sealed class InstanceCreateRequest : InstanceUpdateRequest
public sealed class InstanceCreateRequest : Instance
{
}
}
@@ -6,7 +6,7 @@ namespace Tgstation.Server.Api.Models
/// <summary>
/// Represents a request to clone the repository.
/// </summary>
public sealed class RepositoryCloneRequest : RepositoryApiBase
public sealed class RepositoryCreateRequest : RepositoryApiBase
{
/// <summary>
/// The origin URL to clone.
@@ -5,7 +5,7 @@ namespace Tgstation.Server.Api.Models
/// <summary>
/// Indicates the <see cref="FieldPresence"/> for fields in models.
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true, Inherited = false)]
public sealed class RequestOptionsAttribute : Attribute
{
/// <summary>
@@ -9,6 +9,7 @@ namespace Tgstation.Server.Api.Models
public string? Path { get; set; }
/// <inheritdoc />
[ResponseOptions]
public string? LastReadHash { get; set; }
/// <summary>
@@ -24,6 +24,7 @@ namespace Tgstation.Server.Api.Models
/// The current <see cref="WatchdogStatus"/>.
/// </summary>
[EnumDataType(typeof(WatchdogStatus))]
[ResponseOptions]
public WatchdogStatus? Status { get; set; }
/// <summary>
@@ -8,13 +8,13 @@ namespace Tgstation.Server.Api.Models
/// <summary>
/// The <see cref="UserResponse"/> that started the job
/// </summary>
public UserResponse? StartedBy { get; set; }
public UserName? StartedBy { get; set; }
/// <summary>
/// The <see cref="UserResponse"/> that cancelled the job
/// </summary>
[ResponseOptions]
public UserResponse? CancelledBy { get; set; }
public UserName? CancelledBy { get; set; }
/// <summary>
/// Optional progress between 0 and 100 inclusive
@@ -9,6 +9,6 @@ namespace Tgstation.Server.Api.Models
/// <summary>
/// The <see cref="NamedEntity"/>s the <see cref="UserGroupResponse"/> has.
/// </summary>
public ICollection<NamedEntity>? Users { get; set; }
public ICollection<UserName>? Users { get; set; }
}
}
@@ -10,6 +10,6 @@ namespace Tgstation.Server.Api.Models
/// The <see cref="UserResponse"/> who created this <see cref="UserResponse"/>
/// </summary>
[Required]
public NamedEntity? CreatedBy { get; set; }
public UserName? CreatedBy { get; set; }
}
}
@@ -3,7 +3,7 @@ using System;
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// Indicates API fields that may be null on response.
/// Indicates the response <see cref="FieldPresence"/> of API fields. Changes it from <see cref="FieldPresence.Required"/> to <see cref="FieldPresence.Optional"/> by default.
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class ResponseOptionsAttribute : Attribute
+1 -1
View File
@@ -8,6 +8,6 @@ namespace Tgstation.Server.Api.Models
/// <summary>
/// The <see cref="NamedEntity"/> of the user who created the <see cref="TestMerge"/>.
/// </summary>
public NamedEntity? MergedBy { get; set; }
public UserName? MergedBy { get; set; }
}
}
@@ -0,0 +1,33 @@
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// Base class for user names.
/// </summary>
public class UserName : NamedEntity
{
/// <inheritdoc />
[RequestOptions(FieldPresence.Optional)]
public override string? Name
{
get => base.Name;
set => base.Name = value;
}
/// <summary>
/// Create a copy of the <see cref="UserName"/> as a given <typeparamref name="TResultType"/>.
/// </summary>
/// <typeparam name="TResultType">The child of <see cref="UserName"/> to create.</typeparam>
/// <returns>A new <typeparamref name="TResultType"/> copied from <see langword="this"/>.</returns>
protected virtual TResultType CreateUserName<TResultType>() where TResultType : UserName, new() => new TResultType
{
Id = Id,
Name = Name
};
/// <summary>
/// Create a copy of the <see cref="UserName"/>.
/// </summary>
/// <returns>A new <see cref="UserName"/> copied from <see langword="this"/>.</returns>
public UserName CreateUserName() => CreateUserName<UserName>();
}
}
@@ -27,10 +27,10 @@ namespace Tgstation.Server.Client.Components
/// <summary>
/// Clones a <paramref name="repository"/>
/// </summary>
/// <param name="repository">The <see cref="RepositoryCloneRequest"/>.</param>
/// <param name="repository">The <see cref="RepositoryCreateRequest"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="RepositoryResponse"/>/</returns>
Task<RepositoryResponse> Clone(RepositoryCloneRequest repository, CancellationToken cancellationToken);
Task<RepositoryResponse> Clone(RepositoryCreateRequest repository, CancellationToken cancellationToken);
/// <summary>
/// Deletes the repository.
@@ -31,7 +31,7 @@ namespace Tgstation.Server.Client.Components
}
/// <inheritdoc />
public Task<RepositoryResponse> Clone(RepositoryCloneRequest repository, CancellationToken cancellationToken) => apiClient.Create<RepositoryCloneRequest, RepositoryResponse>(Routes.Repository, repository, instance.Id!.Value, cancellationToken);
public Task<RepositoryResponse> Clone(RepositoryCreateRequest repository, CancellationToken cancellationToken) => apiClient.Create<RepositoryCreateRequest, RepositoryResponse>(Routes.Repository, repository, instance.Id!.Value, cancellationToken);
/// <inheritdoc />
public Task<RepositoryResponse> Delete(CancellationToken cancellationToken) => apiClient.Delete<RepositoryResponse>(Routes.Repository, instance.Id!.Value, cancellationToken);
@@ -80,7 +80,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
IJobManager jobManager,
IAssemblyInformationProvider assemblyInformationProvider,
ILogger<DiscordProvider> logger,
Models.ChatBot chatBot)
ChatBot chatBot)
: base(jobManager, logger, chatBot)
{
this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
@@ -19,6 +19,7 @@ namespace Tgstation.Server.Host.Controllers
[Route("/Bridge")]
[Produces(MediaTypeNames.Application.Json)]
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
public class BridgeController : Controller
{
/// <summary>
@@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Tgstation.Server.Api;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Components;
using Tgstation.Server.Host.Database;
@@ -65,14 +66,14 @@ namespace Tgstation.Server.Host.Controllers
/// <summary>
/// Create a new chat bot <paramref name="model"/>.
/// </summary>
/// <param name="model">The <see cref="ChatBotUpdateRequest"/>.</param>
/// <param name="model">The <see cref="ChatBotCreateRequest"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
/// <response code="201">Created <see cref="ChatBot"/> successfully.</response>
[HttpPut]
[TgsAuthorize(ChatBotRights.Create)]
[ProducesResponseType(typeof(ChatBotResponse), 201)]
public async Task<IActionResult> Create([FromBody] ChatBotUpdateRequest model, CancellationToken cancellationToken)
public async Task<IActionResult> Create([FromBody] ChatBotCreateRequest model, CancellationToken cancellationToken)
{
if (model == null)
throw new ArgumentNullException(nameof(model));
@@ -278,7 +279,7 @@ namespace Tgstation.Server.Host.Controllers
bool anySettingsModified = false;
bool CheckModified<T>(Expression<Func<Api.Models.Internal.ChatBotSettings, T>> expression, ChatBotRights requiredRight)
bool CheckModified<T>(Expression<Func<ChatBotSettings, T>> expression, ChatBotRights requiredRight)
{
var memberSelectorExpression = (MemberExpression)expression.Body;
var property = (PropertyInfo)memberSelectorExpression.Member;
@@ -350,10 +351,10 @@ namespace Tgstation.Server.Host.Controllers
/// <summary>
/// Perform some basic validation of a given <paramref name="model"/>.
/// </summary>
/// <param name="model">The <see cref="ChatBotUpdateRequest"/> to validate.</param>
/// <param name="model">The <see cref="ChatBotApiBase"/> to validate.</param>
/// <param name="forCreation">If the <paramref name="model"/> is being created.</param>
/// <returns>An <see cref="IActionResult"/> to respond with or <see langword="null"/>.</returns>
private IActionResult StandardModelChecks(ChatBotUpdateRequest model, bool forCreation)
private IActionResult StandardModelChecks(ChatBotApiBase model, bool forCreation)
{
if (model.ReconnectionInterval == 0)
throw new InvalidOperationException("RecconnectionInterval cannot be zero!");
@@ -17,6 +17,7 @@ namespace Tgstation.Server.Host.Controllers
/// Controller for the web control panel.
/// </summary>
[Route(Application.ControlPanelRoute)]
[ApiExplorerSettings(IgnoreApi = true)]
public class ControlPanelController : Controller
{
/// <summary>
@@ -67,17 +67,17 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="202"><see cref="Api.Models.JobResponse"/> to launch the watchdog started successfully.</response>
/// <response code="202"><see cref="JobResponse"/> to launch the watchdog started successfully.</response>
[HttpPut]
[TgsAuthorize(DreamDaemonRights.Start)]
[ProducesResponseType(typeof(Api.Models.JobResponse), 202)]
[ProducesResponseType(typeof(JobResponse), 202)]
public Task<IActionResult> Create(CancellationToken cancellationToken)
=> WithComponentInstance(async instance =>
{
if (instance.Watchdog.Status != WatchdogStatus.Offline)
return Conflict(new ErrorMessageResponse(ErrorCode.WatchdogRunning));
var job = new Models.Job
var job = new Job
{
Description = "Launch DreamDaemon",
CancelRight = (ulong)DreamDaemonRights.Shutdown,
@@ -298,18 +298,18 @@ namespace Tgstation.Server.Host.Controllers
#pragma warning restore CA1502
/// <summary>
/// Creates a <see cref="Api.Models.JobResponse"/> to restart the Watchdog. It will not start if it wasn't already running.
/// Creates a <see cref="JobResponse"/> to restart the Watchdog. It will not start if it wasn't already running.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request</returns>
/// <response code="202">Restart <see cref="Api.Models.JobResponse"/> started successfully.</response>
/// <response code="202">Restart <see cref="JobResponse"/> started successfully.</response>
[HttpPatch]
[TgsAuthorize(DreamDaemonRights.Restart)]
[ProducesResponseType(typeof(Api.Models.JobResponse), 202)]
[ProducesResponseType(typeof(JobResponse), 202)]
public Task<IActionResult> Restart(CancellationToken cancellationToken)
=> WithComponentInstance(async instance =>
{
var job = new Models.Job
var job = new Job
{
Instance = Instance,
CancelRightsType = RightsType.DreamDaemon,
@@ -332,18 +332,18 @@ namespace Tgstation.Server.Host.Controllers
});
/// <summary>
/// Creates a <see cref="Api.Models.JobResponse"/> to generate a DreamDaemon process dump.
/// Creates a <see cref="JobResponse"/> to generate a DreamDaemon process dump.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request</returns>
/// <response code="202">Dump <see cref="Api.Models.JobResponse"/> started successfully.</response>
/// <response code="202">Dump <see cref="JobResponse"/> started successfully.</response>
[HttpPatch(Routes.Diagnostics)]
[TgsAuthorize(DreamDaemonRights.CreateDump)]
[ProducesResponseType(typeof(Api.Models.JobResponse), 202)]
[ProducesResponseType(typeof(JobResponse), 202)]
public Task<IActionResult> CreateDump(CancellationToken cancellationToken)
=> WithComponentInstance(async instance =>
{
var job = new Models.Job
var job = new Job
{
Instance = Instance,
CancelRightsType = RightsType.DreamDaemon,
@@ -116,7 +116,7 @@ namespace Tgstation.Server.Host.Controllers
swarmConfiguration = swarmConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(swarmConfigurationOptions));
}
async Task<Models.Instance> CreateDefaultInstance(InstanceUpdateRequest initialSettings, CancellationToken cancellationToken)
async Task<Models.Instance> CreateDefaultInstance(InstanceCreateRequest initialSettings, CancellationToken cancellationToken)
{
var ddPort = await portAllocator.GetAvailablePort(1, false, cancellationToken).ConfigureAwait(false);
if (!ddPort.HasValue)
@@ -222,7 +222,7 @@ namespace Tgstation.Server.Host.Controllers
/// <summary>
/// Create or attach an <see cref="Api.Models.Instance"/>.
/// </summary>
/// <param name="model">The <see cref="Api.Models.Instance"/> settings.</param>
/// <param name="model">The <see cref="InstanceCreateRequest"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200">Instance attached successfully.</response>
@@ -231,7 +231,7 @@ namespace Tgstation.Server.Host.Controllers
[TgsAuthorize(InstanceManagerRights.Create)]
[ProducesResponseType(typeof(InstanceResponse), 200)]
[ProducesResponseType(typeof(InstanceResponse), 201)]
public async Task<IActionResult> Create([FromBody] InstanceUpdateRequest model, CancellationToken cancellationToken)
public async Task<IActionResult> Create([FromBody] InstanceCreateRequest model, CancellationToken cancellationToken)
{
if (model == null)
throw new ArgumentNullException(nameof(model));
@@ -130,7 +130,7 @@ namespace Tgstation.Server.Host.Controllers
/// <summary>
/// Begin cloning the repository if it doesn't exist.
/// </summary>
/// <param name="model">The <see cref="RepositoryCloneRequest"/>.</param>
/// <param name="model">The <see cref="RepositoryCreateRequest"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="201">The repository was created successfully and the <see cref="Api.Models.JobResponse"/> to clone it has begun.</response>
@@ -139,7 +139,7 @@ namespace Tgstation.Server.Host.Controllers
[TgsAuthorize(RepositoryRights.SetOrigin)]
[ProducesResponseType(typeof(RepositoryResponse), 201)]
[ProducesResponseType(typeof(ErrorMessageResponse), 410)]
public async Task<IActionResult> Create([FromBody] RepositoryCloneRequest model, CancellationToken cancellationToken)
public async Task<IActionResult> Create([FromBody] RepositoryCreateRequest model, CancellationToken cancellationToken)
{
if (model == null)
throw new ArgumentNullException(nameof(model));
@@ -21,6 +21,7 @@ namespace Tgstation.Server.Host.Controllers
[Route(SwarmConstants.ControllerRoute)]
[Produces(MediaTypeNames.Application.Json)]
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
public sealed class SwarmController : Controller
{
/// <summary>
@@ -66,7 +66,7 @@ namespace Tgstation.Server.Host.Controllers
}
/// <summary>
/// Check if a given <paramref name="model"/> has a valid <see cref="NamedEntity.Name"/> specified.
/// Check if a given <paramref name="model"/> has a valid <see cref="UserName.Name"/> specified.
/// </summary>
/// <param name="model">The <see cref="UserUpdateRequest"/> to check.</param>
/// <param name="newUser">If this is a new <see cref="UserResponse"/>.</param>
@@ -105,7 +105,7 @@ namespace Tgstation.Server.Host.Controllers
/// <summary>
/// Create a new <see cref="User"/>.
/// </summary>
/// <param name="model">The <see cref="UserResponse"/> to create.</param>
/// <param name="model">The <see cref="UserCreateRequest"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="201"><see cref="User"/> created successfully.</response>
@@ -114,7 +114,7 @@ namespace Tgstation.Server.Host.Controllers
[TgsAuthorize(AdministrationRights.WriteUsers)]
[ProducesResponseType(typeof(UserResponse), 201)]
#pragma warning disable CA1502, CA1506
public async Task<IActionResult> Create([FromBody] UserUpdateRequest model, CancellationToken cancellationToken)
public async Task<IActionResult> Create([FromBody] UserCreateRequest model, CancellationToken cancellationToken)
{
if (model == null)
throw new ArgumentNullException(nameof(model));
@@ -180,7 +180,7 @@ namespace Tgstation.Server.Host.Controllers
Logger.LogInformation("Created new user {0} ({1})", dbUser.Name, dbUser.Id);
return Created(dbUser.ToApi(true));
return Created(dbUser.ToApi());
}
#pragma warning restore CA1502, CA1506
@@ -191,11 +191,13 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="200"><see cref="User"/> updated successfully.</response>
/// <response code="200"><see cref="User"/> updated successfully. Not returned due to lack of permissions.</response>
/// <response code="404">Requested <see cref="EntityId.Id"/> does not exist.</response>
/// <response code="410">Requested <see cref="Api.Models.Internal.UserApiBase.Group"/> does not exist.</response>
[HttpPost]
[TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword | AdministrationRights.EditOwnOAuthConnections)]
[ProducesResponseType(typeof(UserResponse), 200)]
[ProducesResponseType(204)]
[ProducesResponseType(typeof(ErrorMessageResponse), 404)]
[ProducesResponseType(typeof(ErrorMessageResponse), 410)]
#pragma warning disable CA1502 // TODO: Decomplexify
@@ -334,14 +336,11 @@ namespace Tgstation.Server.Host.Controllers
Logger.LogInformation("Updated user {0} ({1})", originalUser.Name, originalUser.Id);
// return id only if not a self update and cannot read users
return Json(
AuthenticationContext.User.Id == originalUser.Id
|| callerAdministrationRights.HasFlag(AdministrationRights.ReadUsers)
? originalUser.ToApi(true)
: new UserResponse
{
Id = originalUser.Id
});
var canReadBack = AuthenticationContext.User.Id == originalUser.Id
|| callerAdministrationRights.HasFlag(AdministrationRights.ReadUsers);
return canReadBack
? Json(originalUser.ToApi())
: NoContent();
}
#pragma warning restore CA1506
#pragma warning restore CA1502
@@ -354,7 +353,7 @@ namespace Tgstation.Server.Host.Controllers
[HttpGet]
[TgsAuthorize]
[ProducesResponseType(typeof(UserResponse), 200)]
public IActionResult Read() => Json(AuthenticationContext.User.ToApi(true));
public IActionResult Read() => Json(AuthenticationContext.User.ToApi());
/// <summary>
/// List all <see cref="User"/>s in the server.
@@ -421,7 +420,7 @@ namespace Tgstation.Server.Host.Controllers
if (user.CanonicalName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName))
return Forbid();
return Json(user.ToApi(true));
return Json(user.ToApi());
}
/// <summary>
@@ -9,6 +9,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Mime;
using System.Reflection;
using Tgstation.Server.Api;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Controllers;
@@ -120,12 +121,15 @@ namespace Tgstation.Server.Host.Core
});
}
/// <summary>
/// Generates the OpenAPI schema ID for a given <paramref name="type"/>.
/// </summary>
/// <param name="type">The <see cref="Type"/> to generate a schema ID for.</param>
/// <returns>The generated schema ID for <see cref="Type"/>.</returns>
static string GenerateSchemaId(Type type)
{
if (type == typeof(NamedEntity))
if (type == typeof(UserName))
return "ShallowUserResponse";
if (type == typeof(Api.Models.Internal.UserGroup))
return "ShallowUserGroupResponse";
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(PaginatedResponse<>))
return $"Paginated{type.GenericTypeArguments.First().Name}";
@@ -378,24 +382,12 @@ namespace Tgstation.Server.Host.Core
Schema = productHeaderSchema
});
var pathsToRemove = new List<string>();
var filteredControllers = new string[]
{
nameof(BridgeController),
nameof(ControlPanelController),
nameof(SwarmController),
};
var allSchemas = context
.SchemaRepository
.Schemas;
foreach (var path in swaggerDoc.Paths)
foreach (var operation in path.Value.Operations.Select(x => x.Value))
{
if (filteredControllers.Any(
x => operation.OperationId.StartsWith(x, StringComparison.Ordinal)))
{
pathsToRemove.Add(path.Key);
continue;
}
operation.Parameters.Insert(0, new OpenApiParameter
{
Reference = new OpenApiReference
@@ -415,12 +407,120 @@ namespace Tgstation.Server.Host.Core
});
}
foreach (var filteredPath in pathsToRemove)
swaggerDoc.Paths.Remove(filteredPath);
AddDefaultResponses(swaggerDoc);
}
/// <summary>
/// Applies the <see cref="OpenApiSchema.Nullable"/>, <see cref="OpenApiSchema.ReadOnly"/>, and <see cref="OpenApiSchema.WriteOnly"/> to <see cref="OpenApiSchema.Properties"/> of a given <paramref name="rootSchema"/>.
/// </summary>
/// <param name="rootSchema">The root <see cref="OpenApiSchema"/>.</param>
/// <param name="context">The current <see cref="SchemaFilterContext"/>.</param>
static void ApplyAttributesForRootSchema(OpenApiSchema rootSchema, SchemaFilterContext context)
{
// tune up the descendants
rootSchema.Nullable = false;
var rootSchemaId = GenerateSchemaId(context.Type);
var rootRequestSchema = rootSchemaId.EndsWith("Request", StringComparison.Ordinal);
var rootResponseSchema = rootSchemaId.EndsWith("Response", StringComparison.Ordinal);
var isPutRequest = rootRequestSchema && rootSchemaId.EndsWith("CreateRequest", StringComparison.Ordinal);
Tuple<PropertyInfo, string, OpenApiSchema, IDictionary<string, OpenApiSchema>> GetTypeFromKvp(Type currentType, KeyValuePair<string, OpenApiSchema> kvp, IDictionary<string, OpenApiSchema> schemaDictionary)
{
var propertyInfo = currentType
.GetProperties()
.Single(x => x.Name.Equals(kvp.Key, StringComparison.OrdinalIgnoreCase));
return Tuple.Create(
propertyInfo,
kvp.Key,
kvp.Value,
schemaDictionary);
}
var subSchemaStack = new Stack<Tuple<PropertyInfo, string, OpenApiSchema, IDictionary<string, OpenApiSchema>>>(
rootSchema.Properties.Select(x => GetTypeFromKvp(context.Type, x, rootSchema.Properties)));
while (subSchemaStack.Count > 0)
{
var tuple = subSchemaStack.Pop();
var subSchema = tuple.Item3;
if (subSchema.Reference != null)
// can't mangle references per request
continue;
var subSchemaPropertyInfo = tuple.Item1;
if (subSchema.Properties != null
&& !subSchemaPropertyInfo
.PropertyType
.GetInterfaces()
.Any(x => x == typeof(IEnumerable)))
foreach (var kvp in subSchema.Properties)
subSchemaStack.Push(GetTypeFromKvp(subSchemaPropertyInfo.PropertyType, kvp, subSchema.Properties));
var attributes = subSchemaPropertyInfo
.GetCustomAttributes();
var responsePresence = attributes
.OfType<ResponseOptionsAttribute>()
.FirstOrDefault()
?.Presence
?? FieldPresence.Required;
var requestOptions = attributes
.OfType<RequestOptionsAttribute>()
.OrderBy(x => x.PutOnly) // Process PUTs last
.ToList();
if (requestOptions.Any() && requestOptions.All(x => x.Presence == FieldPresence.Ignored && !x.PutOnly))
subSchema.ReadOnly = true;
var subSchemaId = tuple.Item2;
var subSchemaOwningDictionary = tuple.Item4;
if (rootResponseSchema)
{
subSchema.Nullable = responsePresence == FieldPresence.Optional;
if (responsePresence == FieldPresence.Ignored)
subSchemaOwningDictionary.Remove(subSchemaId);
}
else if (rootRequestSchema)
{
subSchema.Nullable = true;
var lastOptionWasIgnored = false;
foreach (var requestOption in requestOptions)
{
var validForThisRequest = !requestOption.PutOnly || isPutRequest;
if (!validForThisRequest)
continue;
lastOptionWasIgnored = false;
switch (requestOption.Presence)
{
case FieldPresence.Ignored:
lastOptionWasIgnored = true;
break;
case FieldPresence.Optional:
subSchema.Nullable = true;
break;
case FieldPresence.Required:
subSchema.Nullable = false;
break;
default:
throw new InvalidOperationException($"Invalid FieldPresence: {requestOption.Presence}!");
}
}
if (lastOptionWasIgnored)
subSchemaOwningDictionary.Remove(subSchemaId);
}
else if (responsePresence == FieldPresence.Required
&& requestOptions.All(x => x.Presence == FieldPresence.Required && !x.PutOnly))
subSchema.Nullable = false;
// otherwise, we have to assume it's a shared schema
// use what Swagger thinks the nullability is by default
}
}
/// <inheritdoc />
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
@@ -432,53 +532,18 @@ namespace Tgstation.Server.Host.Core
// Nothing is required
schema.Required.Clear();
// Could be nullable type, make sure to get the right one
Type nonNullableType = context.Type.IsConstructedGenericType
? context.Type.GenericTypeArguments.First()
: context.Type;
if (context.MemberInfo != null)
{
var schemaId = GenerateSchemaId(context.MemberInfo.DeclaringType);
var responseOptions = context.MemberInfo.GetCustomAttributes(typeof(ResponseOptionsAttribute), true).OfType<ResponseOptionsAttribute>().FirstOrDefault();
var requestOptions = context.MemberInfo.GetCustomAttributes(typeof(RequestOptionsAttribute), true).OfType<RequestOptionsAttribute>().FirstOrDefault();
if (schemaId.EndsWith("Response", StringComparison.Ordinal))
if (responseOptions?.Presence == FieldPresence.Optional
|| (nonNullableType != context.Type
&& !context.Type.GetInterfaces().Any(x => x == typeof(IEnumerable))))
schema.Nullable = true;
else if (responseOptions?.Presence == FieldPresence.Ignored)
schema.WriteOnly = true;
else
schema.Nullable = false;
else if (requestOptions != null)
if (schemaId.EndsWith("Request", StringComparison.Ordinal))
{
var isPutRequest = schemaId.Contains("Create", StringComparison.OrdinalIgnoreCase);
if (!(requestOptions.PutOnly && !isPutRequest))
if (requestOptions.Presence == FieldPresence.Required)
context.SchemaRepository.Schemas.Remove(schemaId);
else if (requestOptions.Presence == FieldPresence.Ignored)
schema.ReadOnly = true;
}
else if (responseOptions?.Presence != FieldPresence.Optional && requestOptions.Presence == FieldPresence.Required)
// if something has request required and no optional response, it's always required
schema.Nullable = false;
else
schema.Nullable = true;
else
{
if (responseOptions?.Presence == FieldPresence.Ignored)
context.SchemaRepository.Schemas.Remove(schemaId);
schema.Nullable = true;
}
}
if (context.MemberInfo == null)
ApplyAttributesForRootSchema(schema, context);
if (!schema.Enum?.Any() ?? false)
return;
OpenApiEnumVarNamesExtension.Apply(schema, nonNullableType);
// Could be nullable type, make sure to get the right one
Type firstGenericArgumentOrType = context.Type.IsConstructedGenericType
? context.Type.GenericTypeArguments.First()
: context.Type;
OpenApiEnumVarNamesExtension.Apply(schema, firstGenericArgumentOrType);
}
/// <inheritdoc />
+2 -2
View File
@@ -31,13 +31,13 @@ namespace Tgstation.Server.Host.Models
StartedAt = StartedAt,
StoppedAt = StoppedAt,
Cancelled = Cancelled,
CancelledBy = CancelledBy?.ToApi(false),
CancelledBy = CancelledBy?.CreateUserName(),
CancelRight = CancelRight,
CancelRightsType = CancelRightsType,
Description = Description,
ExceptionDetails = ExceptionDetails,
ErrorCode = ErrorCode,
StartedBy = StartedBy.ToApi(false)
StartedBy = StartedBy.CreateUserName()
};
}
}
@@ -37,7 +37,7 @@ namespace Tgstation.Server.Host.Models
TitleAtMerge = TitleAtMerge,
Comment = Comment,
Id = Id,
MergedBy = MergedBy.ToApi(false),
MergedBy = MergedBy.CreateUserName(),
Number = Number,
TargetCommitSha = TargetCommitSha,
Url = Url
+20 -28
View File
@@ -40,7 +40,7 @@ namespace Tgstation.Server.Host.Models
public PermissionSet PermissionSet { get; set; }
/// <summary>
/// The uppercase invariant of <see cref="NamedEntity.Name"/>
/// The uppercase invariant of <see cref="UserName.Name"/>
/// </summary>
[Required]
[StringLength(Limits.MaximumIndexableStringLength, MinimumLength = 1)]
@@ -67,43 +67,35 @@ namespace Tgstation.Server.Host.Models
public ICollection<OAuthConnection> OAuthConnections { get; set; }
/// <summary>
/// Change a <see cref="NamedEntity.Name"/> into a <see cref="CanonicalName"/>.
/// Change a <see cref="UserName.Name"/> into a <see cref="CanonicalName"/>.
/// </summary>
/// <param name="name">The <see cref="NamedEntity.Name"/>.</param>
/// <param name="name">The <see cref="UserName.Name"/>.</param>
/// <returns>The <see cref="CanonicalName"/>.</returns>
public static string CanonicalizeName(string name) => name?.ToUpperInvariant() ?? throw new ArgumentNullException(nameof(name));
/// <summary>
/// See <see cref="ToApi(bool)"/>
/// Generate a <see cref="UserResponse"/> from <see langword="this"/>.
/// </summary>
/// <param name="recursive">If we should recurse on <see cref="CreatedBy"/></param>
/// <param name="showDetails">If rights and system identifier should be shown</param>
/// <param name="recursive">If we should recurse on <see cref="CreatedBy"/>.</param>
/// <returns>A new <see cref="UserResponse"/>.</returns>
UserResponse ToApi(bool recursive, bool showDetails) => new UserResponse
UserResponse CreateUserResponse(bool recursive)
{
CreatedAt = showDetails ? CreatedAt : null,
CreatedBy = showDetails && recursive ? CreatedBy?.ToApi(false, false) : null,
Enabled = showDetails ? Enabled : null,
Id = Id,
Name = Name,
SystemIdentifier = showDetails ? SystemIdentifier : null,
OAuthConnections = showDetails
? OAuthConnections
?.Select(x => x.ToApi())
.ToList()
: null,
Group = showDetails ? Group?.ToApi(false) : null,
PermissionSet = showDetails ? PermissionSet?.ToApi() : null,
};
var result = CreateUserName<UserResponse>();
if (recursive)
result.CreatedBy = CreatedBy?.CreateUserName<UserName>();
/// <summary>
/// Convert the <see cref="User"/> to it's API form
/// </summary>
/// <param name="showDetails">If system identifier, oauth connections, and group/permission set should be shown.</param>
/// <returns>A new <see cref="UserResponse"/></returns>
public UserResponse ToApi(bool showDetails) => ToApi(true, showDetails);
result.CreatedAt = CreatedAt;
result.Enabled = Enabled;
result.SystemIdentifier = SystemIdentifier;
result.OAuthConnections = OAuthConnections
?.Select(x => x.ToApi())
.ToList();
result.Group = Group?.ToApi(false);
result.PermissionSet = PermissionSet?.ToApi();
return result;
}
/// <inheritdoc />
public UserResponse ToApi() => ToApi(true);
public UserResponse ToApi() => CreateUserResponse(true);
}
}
@@ -31,10 +31,9 @@ namespace Tgstation.Server.Host.Models
PermissionSet = PermissionSet.ToApi(),
Users = showUsers
? Users
?.Select(x => x.ToApi(false))
.OfType<NamedEntity>()
?.Select(x => x.CreateUserName())
.ToList()
?? new List<NamedEntity>()
?? new List<UserName>()
: null,
};
@@ -45,7 +45,7 @@ namespace Tgstation.Server.Tests.Instance
Assert.IsNull(initalRepo.ActiveJob);
const string Origin = "https://github.com/tgstation/tgstation-server";
var cloneRequest = new RepositoryCloneRequest
var cloneRequest = new RepositoryCreateRequest
{
Origin = new Uri(Origin),
Reference = workingBranch,
@@ -34,7 +34,7 @@ namespace Tgstation.Server.Tests
ChatBotLimit = 2
}, cancellationToken);
static InstanceCreateRequest FromResponse(InstanceResponse response) => new InstanceCreateRequest
static TRequestType FromResponse<TRequestType>(InstanceResponse response) where TRequestType : Api.Models.Instance, new() => new TRequestType
{
Id = response.Id,
Path = response.Path,
@@ -75,7 +75,7 @@ namespace Tgstation.Server.Tests
Name = "NonEmptyTest"
}, cancellationToken).ConfigureAwait(false);
await Assert.ThrowsExceptionAsync<ConflictException>(() => instanceManagerClient.CreateOrAttach(FromResponse(firstTest), cancellationToken)).ConfigureAwait(false);
await Assert.ThrowsExceptionAsync<ConflictException>(() => instanceManagerClient.CreateOrAttach(FromResponse<InstanceCreateRequest>(firstTest), cancellationToken)).ConfigureAwait(false);
Assert.IsTrue(Directory.Exists(firstTest.Path));
//can't create instances in installation directory
@@ -135,7 +135,7 @@ namespace Tgstation.Server.Tests
//online it for real for component tests
firstTest.Online = true;
firstTest.ConfigurationType = ConfigurationType.HostWrite;
firstTest = await instanceManagerClient.Update(FromResponse(firstTest), cancellationToken).ConfigureAwait(false);
firstTest = await instanceManagerClient.Update(FromResponse<InstanceUpdateRequest>(firstTest), cancellationToken).ConfigureAwait(false);
Assert.AreEqual(true, firstTest.Online);
Assert.AreEqual(ConfigurationType.HostWrite, firstTest.ConfigurationType);
Assert.IsTrue(Directory.Exists(firstTest.Path));
@@ -175,7 +175,7 @@ namespace Tgstation.Server.Tests
await ApiAssert.ThrowsException<ConflictException>(() => instanceManagerClient.Detach(firstTest, cancellationToken), ErrorCode.InstanceDetachOnline).ConfigureAwait(false);
firstTest.Online = false;
firstTest = await instanceManagerClient.Update(FromResponse(firstTest), cancellationToken).ConfigureAwait(false);
firstTest = await instanceManagerClient.Update(FromResponse<InstanceUpdateRequest>(firstTest), cancellationToken).ConfigureAwait(false);
await instanceManagerClient.Detach(firstTest, cancellationToken).ConfigureAwait(false);
@@ -183,7 +183,7 @@ namespace Tgstation.Server.Tests
Assert.IsTrue(File.Exists(attachPath));
//can recreate detached instance
firstTest = await instanceManagerClient.CreateOrAttach(FromResponse(firstTest), cancellationToken).ConfigureAwait(false);
firstTest = await instanceManagerClient.CreateOrAttach(FromResponse<InstanceCreateRequest>(firstTest), cancellationToken).ConfigureAwait(false);
// Test updating only with SetChatBotLimit works
var current = await usersClient.Read(cancellationToken);
@@ -210,7 +210,7 @@ namespace Tgstation.Server.Tests
//but only if the attach file exists
await instanceManagerClient.Detach(firstTest, cancellationToken).ConfigureAwait(false);
File.Delete(attachPath);
await ApiAssert.ThrowsException<ConflictException>(() => instanceManagerClient.CreateOrAttach(FromResponse(firstTest), cancellationToken), ErrorCode.InstanceAtExistingPath).ConfigureAwait(false);
await ApiAssert.ThrowsException<ConflictException>(() => instanceManagerClient.CreateOrAttach(FromResponse<InstanceCreateRequest>(firstTest), cancellationToken), ErrorCode.InstanceAtExistingPath).ConfigureAwait(false);
}
}
}