diff --git a/.globalconfig b/.globalconfig index 7f7acc0b..eb1468d8 100644 --- a/.globalconfig +++ b/.globalconfig @@ -33,4 +33,5 @@ dotnet_diagnostic.CA1716.severity = none # CA1716: Identifiers should not dotnet_diagnostic.CA1062.severity = none # CA1062: Validate arguments of public methods (e.g., null checks). dotnet_diagnostic.CA1812.severity = none # CA1812: Avoid uninstantiated internal classes (e.g., utility classes). dotnet_diagnostic.CA1515.severity = none # CA1515: Because an application's API isn't typically referenced from outside the assembly, types can be made internal: -dotnet_diagnostic.CA1034.severity = none # CA1034: Nested types should not be visible (e.g., public nested classes). Incorrect error for extension keyword. \ No newline at end of file +dotnet_diagnostic.CA1034.severity = none # CA1034: Nested types should not be visible (e.g., public nested classes). Incorrect error for extension keyword. +dotnet_diagnostic.CA1822.severity = info # CA1822: Mark members as static (e.g., methods that do not access instance data). \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 6de6eb50..26968d9c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + @@ -17,47 +17,47 @@ - - - - - + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - + - + - + - + - + - + - - + + diff --git a/src/GroundControl.Api.Client/Generated/Contracts/Contracts.cs b/src/GroundControl.Api.Client/Generated/Contracts/Contracts.cs index 3aba71fd..8a97fb61 100644 --- a/src/GroundControl.Api.Client/Generated/Contracts/Contracts.cs +++ b/src/GroundControl.Api.Client/Generated/Contracts/Contracts.cs @@ -1,6 +1,6 @@ //---------------------- // -// Generated using the NSwag toolchain v14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) +// Generated using the NSwag toolchain v14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) // //---------------------- @@ -26,7 +26,7 @@ namespace GroundControl.Api.Client.Contracts { using System = global::System; - [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial interface IGroundControlClient { @@ -63,6 +63,28 @@ public partial interface IGroundControlClient /// A server side error occurred. System.Threading.Tasks.Task ClientHealthHandlerAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get live activity summary + /// + /// + /// Returns the current live client count and activity event rate. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task GetActivitySummaryHandlerAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Stream live activity + /// + /// + /// Opens a Server-Sent Events stream that emits live Tower activity telemetry. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task StreamActivityHandlerAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// List audit records @@ -103,14 +125,14 @@ public partial interface IGroundControlClient /// /// Returns a paginated list of clients for the specified project. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - System.Threading.Tasks.Task ListClientsHandlerAsync(System.Guid projectId, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task ListClientsHandlerAsync(System.Guid projectId, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -163,14 +185,14 @@ public partial interface IGroundControlClient /// /// Returns a paginated list of configuration entries. Optionally decrypts sensitive values. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - System.Threading.Tasks.Task ListConfigEntriesHandlerAsync(System.Guid? ownerId = null, ConfigEntryOwnerType? ownerType = null, string? keyPrefix = null, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task ListConfigEntriesHandlerAsync(System.Guid? ownerId = null, ConfigEntryOwnerType? ownerType = null, string? keyPrefix = null, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -223,14 +245,14 @@ public partial interface IGroundControlClient /// /// Returns a paginated list of user groups. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - System.Threading.Tasks.Task ListGroupsHandlerAsync(int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task ListGroupsHandlerAsync(string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -360,14 +382,14 @@ public partial interface IGroundControlClient /// /// Returns a paginated list of projects. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - System.Threading.Tasks.Task ListProjectsHandlerAsync(System.Guid? groupId = null, string? search = null, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task ListProjectsHandlerAsync(System.Guid? groupId = null, bool? ungrouped = null, string? search = null, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -402,6 +424,17 @@ public partial interface IGroundControlClient /// A server side error occurred. System.Threading.Tasks.Task DeleteProjectHandlerAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// List projects grouped by owning group + /// + /// + /// Returns the first page of projects for every group plus an ungrouped bucket, all sorted by name ascending. Sections whose project list is empty after applying the search filter are omitted. Use the returned per-section cursor with GET /api/projects to fetch the next page. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task ListGroupedProjectsHandlerAsync(string? search = null, int? perGroup = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// Add a template to a project @@ -497,14 +530,14 @@ public partial interface IGroundControlClient /// /// Returns a paginated list of scope definitions. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - System.Threading.Tasks.Task ListScopesHandlerAsync(int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task ListScopesHandlerAsync(string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -557,14 +590,25 @@ public partial interface IGroundControlClient /// /// Returns a paginated list of snapshots for the specified project. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - System.Threading.Tasks.Task ListSnapshotsHandlerAsync(System.Guid projectId, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task ListSnapshotsHandlerAsync(System.Guid projectId, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Preview a snapshot + /// + /// + /// Resolves the project's current configuration into a snapshot-shaped payload without persisting it. Returns a diff hash that publish will use to detect drift. + /// + /// OK + /// A server side error occurred. + System.Threading.Tasks.Task PreviewSnapshotHandlerAsync(System.Guid projectId, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -606,14 +650,14 @@ public partial interface IGroundControlClient /// /// Returns a paginated list of configuration templates. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - System.Threading.Tasks.Task ListTemplatesHandlerAsync(System.Guid? groupId = null, bool? globalOnly = null, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task ListTemplatesHandlerAsync(System.Guid? groupId = null, bool? globalOnly = null, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -666,14 +710,14 @@ public partial interface IGroundControlClient /// /// Returns a paginated list of users. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - System.Threading.Tasks.Task ListUsersHandlerAsync(int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task ListUsersHandlerAsync(string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -737,14 +781,14 @@ public partial interface IGroundControlClient /// /// Returns a paginated list of variables. Optionally decrypts sensitive values. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - System.Threading.Tasks.Task ListVariablesHandlerAsync(VariableScope? scope = null, System.Guid? groupId = null, System.Guid? projectId = null, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task ListVariablesHandlerAsync(VariableScope? scope = null, System.Guid? groupId = null, System.Guid? projectId = null, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -781,7 +825,28 @@ public partial interface IGroundControlClient } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record ActivitySummaryResponse + { + + [System.Text.Json.Serialization.JsonPropertyName("clients")] + public int Clients { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("rate")] + public double Rate { get; set; } = default!; + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record AuditRecordResponse { @@ -826,7 +891,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for changing a user's password. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ChangePasswordRequest { @@ -856,7 +921,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the resolved configuration payload returned to an authenticated client. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ClientConfigResponse { @@ -892,7 +957,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the API response body for a client. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ClientResponse { @@ -982,7 +1047,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Defines the owner type for a configuration entry. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public enum ConfigEntryOwnerType { @@ -995,7 +1060,7 @@ public enum ConfigEntryOwnerType /// /// Represents the API response body for a configuration entry. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ConfigEntryResponse { @@ -1092,7 +1157,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a single resolved configuration value returned to an authenticated client, including a flag that marks it as sensitive. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ConfigValue { @@ -1122,7 +1187,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for creating a client. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateClientRequest { @@ -1158,7 +1223,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the API response body for a newly created client, including the raw secret. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateClientResponse { @@ -1236,12 +1301,13 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for creating a configuration entry. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateConfigEntryRequest { /// - /// Gets the configuration key. + /// Gets the configuration key. Must start with a letter and contain only letters, digits, or + ///
the separators `.`, `:`, `_`, `-`. ///
[System.Text.Json.Serialization.JsonPropertyName("key")] public string Key { get; set; } = default!; @@ -1296,7 +1362,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for creating a group. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateGroupRequest { @@ -1323,7 +1389,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreatePatRequest { @@ -1356,7 +1422,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreatePatResponse { @@ -1395,7 +1461,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for creating a project. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateProjectRequest { @@ -1437,7 +1503,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for creating a role. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateRoleRequest { @@ -1473,7 +1539,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for creating a scope definition. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateScopeRequest { @@ -1509,7 +1575,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for creating a template. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateTemplateRequest { @@ -1545,7 +1611,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for creating a user. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateUserRequest { @@ -1584,7 +1650,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record CreateVariableRequest { @@ -1620,7 +1686,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record FieldChangeResponse { @@ -1647,7 +1713,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a role grant in request and response contracts. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record GrantDto { @@ -1680,10 +1746,93 @@ public System.Collections.Generic.IDictionary AdditionalProperti } + /// + /// Represents a project listing partitioned by owning group, with a separate bucket for ungrouped projects. + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record GroupedProjectsResponse + { + + /// + /// Gets the per-group sections, sorted by group name ascending. Sections whose project list is empty + ///
after applying the search filter are omitted. + ///
+ [System.Text.Json.Serialization.JsonPropertyName("groups")] + public System.Collections.Generic.ICollection Groups { get; set; } = new System.Collections.ObjectModel.Collection(); + + [System.Text.Json.Serialization.JsonPropertyName("ungrouped")] + public UngroupedProjects? Ungrouped { get; set; } = default!; + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + /// + /// Represents a single group section within a grouped project listing. + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record GroupProjects + { + + /// + /// Gets the group identifier. + /// + [System.Text.Json.Serialization.JsonPropertyName("id")] + public System.Guid Id { get; set; } = default!; + + /// + /// Gets the group display name. + /// + [System.Text.Json.Serialization.JsonPropertyName("name")] + public string Name { get; set; } = default!; + + /// + /// Gets the optional group description. + /// + [System.Text.Json.Serialization.JsonPropertyName("description")] + public string? Description { get; set; } = default!; + + /// + /// Gets the total number of projects in this group that match the current filter. + /// + [System.Text.Json.Serialization.JsonPropertyName("totalCount")] + public long TotalCount { get; set; } = default!; + + /// + /// Gets the first page of matching projects in this group, sorted by name ascending. + /// + [System.Text.Json.Serialization.JsonPropertyName("projects")] + public System.Collections.Generic.ICollection Projects { get; set; } = new System.Collections.ObjectModel.Collection(); + + /// + /// Gets the cursor used to fetch the next page via `GET /api/projects?groupId=&after=`, or + ///
`null` when no more pages exist. + ///
+ [System.Text.Json.Serialization.JsonPropertyName("nextCursor")] + public string? NextCursor { get; set; } = default!; + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + /// /// Represents the API response body for a group. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record GroupResponse { @@ -1746,7 +1895,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record HttpValidationProblemDetails : ProblemDetails { @@ -1758,7 +1907,7 @@ public partial record HttpValidationProblemDetails : ProblemDetails /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfAuditRecordResponse { @@ -1800,7 +1949,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfClientResponse { @@ -1842,7 +1991,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfConfigEntryResponse { @@ -1884,7 +2033,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfGroupResponse { @@ -1926,7 +2075,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfProjectResponse { @@ -1968,7 +2117,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfScopeResponse { @@ -2010,7 +2159,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfSnapshotSummaryResponse { @@ -2052,7 +2201,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfTemplateResponse { @@ -2094,7 +2243,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfUserResponse { @@ -2136,7 +2285,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a paginated response envelope for list endpoints. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PaginatedResponseOfVariableResponse { @@ -2175,7 +2324,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PatResponse { @@ -2214,7 +2363,61 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + /// + /// Represents the API response body for a snapshot preview. Mirrors the shape of a published + ///
snapshot so callers can diff the preview's IReadOnlyList<ResolvedEntryResponse> PreviewSnapshotResponse.Entries against an active snapshot's + ///
entries directly. + ///
+ [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record PreviewSnapshotResponse + { + + /// + /// Gets the project identifier the preview was computed against. + /// + [System.Text.Json.Serialization.JsonPropertyName("projectId")] + public System.Guid ProjectId { get; set; } = default!; + + /// + /// Gets the snapshot version that would be assigned if a publish call were made now. + /// + [System.Text.Json.Serialization.JsonPropertyName("nextVersion")] + public long NextVersion { get; set; } = default!; + + /// + /// Gets the BSON size of the would-be snapshot in bytes. Surfaces the same 16MB limit that + ///
publish would enforce so callers can fail fast. + ///
+ [System.Text.Json.Serialization.JsonPropertyName("bsonSizeBytes")] + public long BsonSizeBytes { get; set; } = default!; + + /// + /// Gets a deterministic SHA-256 hex digest over the preview's resolved entries. Pass back to + ///
the publish endpoint as `expectedHash` to detect drift between preview and publish. + ///
+ [System.Text.Json.Serialization.JsonPropertyName("diffHash")] + public string DiffHash { get; set; } = default!; + + /// + /// Gets the resolved entries that would be written to the snapshot, masking sensitive values + ///
unless the caller has the `SensitiveValuesDecrypt` permission and supplied + ///
`?decrypt=true`. + ///
+ [System.Text.Json.Serialization.JsonPropertyName("entries")] + public System.Collections.Generic.ICollection Entries { get; set; } = new System.Collections.ObjectModel.Collection(); + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ProblemDetails { @@ -2247,7 +2450,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the API response body for a project. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ProjectResponse { @@ -2331,7 +2534,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for publishing a new snapshot. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record PublishSnapshotRequest { @@ -2341,6 +2544,14 @@ public partial record PublishSnapshotRequest [System.Text.Json.Serialization.JsonPropertyName("description")] public string? Description { get; set; } = default!; + /// + /// Gets the optional diff hash returned by a prior preview call. When supplied, the publish call + ///
fails with 409 if the resolved configuration's hash differs at publish time, indicating that + ///
the project was mutated since the preview was generated. + ///
+ [System.Text.Json.Serialization.JsonPropertyName("expectedHash")] + public string? ExpectedHash { get; set; } = default!; + private System.Collections.Generic.IDictionary? _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] @@ -2355,7 +2566,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a resolved configuration entry in a snapshot response. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ResolvedEntryResponse { @@ -2397,7 +2608,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the API response body for a role. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record RoleResponse { @@ -2469,7 +2680,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a value variant for a specific scope combination. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ScopedValue { @@ -2499,7 +2710,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a scope-specific value in a configuration entry request. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ScopedValueRequest { @@ -2529,7 +2740,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a scope-specific value in a snapshot response. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ScopedValueResponse { @@ -2559,7 +2770,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the API response body for a scope definition. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record ScopeResponse { @@ -2631,7 +2842,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for adding a user as a group member with a specific role. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record SetGroupMemberRequest { @@ -2655,7 +2866,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the full API response body for a snapshot, including resolved entries. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record SnapshotResponse { @@ -2715,7 +2926,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents a summary API response body for a snapshot, without resolved entries. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record SnapshotSummaryResponse { @@ -2775,7 +2986,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the API response body for a template. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record TemplateResponse { @@ -2844,10 +3055,47 @@ public System.Collections.Generic.IDictionary AdditionalProperti } + /// + /// Represents the bucket of projects that have no owning group. + /// + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial record UngroupedProjects + { + + /// + /// Gets the total number of ungrouped projects that match the current filter. + /// + [System.Text.Json.Serialization.JsonPropertyName("totalCount")] + public long TotalCount { get; set; } = default!; + + /// + /// Gets the first page of matching ungrouped projects, sorted by name ascending. + /// + [System.Text.Json.Serialization.JsonPropertyName("projects")] + public System.Collections.Generic.ICollection Projects { get; set; } = new System.Collections.ObjectModel.Collection(); + + /// + /// Gets the cursor used to fetch the next page via `GET /api/projects?ungrouped=true&after=`, + ///
or `null` when no more pages exist. + ///
+ [System.Text.Json.Serialization.JsonPropertyName("nextCursor")] + public string? NextCursor { get; set; } = default!; + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + /// /// Represents the request body for updating a client. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UpdateClientRequest { @@ -2863,6 +3111,12 @@ public partial record UpdateClientRequest [System.Text.Json.Serialization.JsonPropertyName("isActive")] public bool IsActive { get; set; } = default!; + /// + /// Gets the fixed scope assignments for the client. When provided, replaces the existing scope context. + /// + [System.Text.Json.Serialization.JsonPropertyName("scopes")] + public System.Collections.Generic.IDictionary? Scopes { get; set; } = default!; + /// /// Gets the optional expiration timestamp. /// @@ -2883,10 +3137,17 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for updating a configuration entry. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UpdateConfigEntryRequest { + /// + /// Gets the configuration key. Must start with a letter and contain only letters, digits, or + ///
the separators `.`, `:`, `_`, `-`. + ///
+ [System.Text.Json.Serialization.JsonPropertyName("key")] + public string Key { get; set; } = default!; + /// /// Gets the value type name. /// @@ -2925,7 +3186,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for updating a group. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UpdateGroupRequest { @@ -2955,7 +3216,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for updating a project. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UpdateProjectRequest { @@ -2997,7 +3258,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for updating a role. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UpdateRoleRequest { @@ -3033,7 +3294,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for updating a scope definition. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UpdateScopeRequest { @@ -3069,7 +3330,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for updating a template. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UpdateTemplateRequest { @@ -3105,7 +3366,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the request body for updating a user. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UpdateUserRequest { @@ -3144,7 +3405,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UpdateVariableRequest { @@ -3171,7 +3432,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Represents the API response body for a user. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record UserResponse { @@ -3252,7 +3513,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial record VariableResponse { @@ -3309,7 +3570,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti /// /// Defines the ownership scope for a variable. /// - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public enum VariableScope { @@ -3321,7 +3582,7 @@ public enum VariableScope - [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial class GroundControlApiClientException : System.Exception { public int StatusCode { get; private set; } @@ -3344,7 +3605,7 @@ public override string ToString() } } - [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial class GroundControlApiClientException : GroundControlApiClientException { public TResult Result { get; private set; } diff --git a/src/GroundControl.Api.Client/Generated/GroundControlApiClient.cs b/src/GroundControl.Api.Client/Generated/GroundControlApiClient.cs index 58a71077..4fe21049 100644 --- a/src/GroundControl.Api.Client/Generated/GroundControlApiClient.cs +++ b/src/GroundControl.Api.Client/Generated/GroundControlApiClient.cs @@ -1,6 +1,6 @@ //---------------------- // -// Generated using the NSwag toolchain v14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) +// Generated using the NSwag toolchain v14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) // //---------------------- @@ -28,7 +28,7 @@ namespace GroundControl.Api.Client { using System = global::System; - [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.0.0 (NJsonSchema v11.6.0.0 (Newtonsoft.Json v13.0.0.0))")] + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial class GroundControlClient : IGroundControlClient { private System.Net.Http.HttpClient _httpClient; @@ -315,6 +315,174 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() } } + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get live activity summary + /// + /// + /// Returns the current live client count and activity event rate. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetActivitySummaryHandlerAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "activity/summary" + urlBuilder_.Append("activity/summary"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new GroundControlApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Stream live activity + /// + /// + /// Opens a Server-Sent Events stream that emits live Tower activity telemetry. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task StreamActivityHandlerAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "activity/stream" + urlBuilder_.Append("activity/stream"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + return; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new GroundControlApiClientException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 403) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new GroundControlApiClientException("Forbidden", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new GroundControlApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// List audit records @@ -633,14 +801,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// /// Returns a paginated list of clients for the specified project. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ListClientsHandlerAsync(System.Guid projectId, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task ListClientsHandlerAsync(System.Guid projectId, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { if (projectId == null) throw new System.ArgumentNullException("projectId"); @@ -661,10 +829,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(projectId, System.Globalization.CultureInfo.InvariantCulture))); urlBuilder_.Append("/clients"); urlBuilder_.Append('?'); - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } if (after != null) { urlBuilder_.Append(System.Uri.EscapeDataString("After")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(after, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -673,6 +837,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("Before")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(before, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } if (sortField != null) { urlBuilder_.Append(System.Uri.EscapeDataString("SortField")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sortField, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -915,6 +1083,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else + if (status_ == 400) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new GroundControlApiClientException("Bad Request", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else if (status_ == 404) { var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); @@ -1185,14 +1363,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// /// Returns a paginated list of configuration entries. Optionally decrypts sensitive values. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ListConfigEntriesHandlerAsync(System.Guid? ownerId = null, ConfigEntryOwnerType? ownerType = null, string? keyPrefix = null, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task ListConfigEntriesHandlerAsync(System.Guid? ownerId = null, ConfigEntryOwnerType? ownerType = null, string? keyPrefix = null, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -1220,10 +1398,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("KeyPrefix")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(keyPrefix, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } if (after != null) { urlBuilder_.Append(System.Uri.EscapeDataString("After")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(after, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -1232,6 +1406,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("Before")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(before, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } if (sortField != null) { urlBuilder_.Append(System.Uri.EscapeDataString("SortField")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sortField, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -1739,14 +1917,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// /// Returns a paginated list of user groups. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ListGroupsHandlerAsync(int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task ListGroupsHandlerAsync(string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -1762,10 +1940,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() // Operation Path: "api/groups" urlBuilder_.Append("api/groups"); urlBuilder_.Append('?'); - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } if (after != null) { urlBuilder_.Append(System.Uri.EscapeDataString("After")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(after, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -1774,6 +1948,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("Before")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(before, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } if (sortField != null) { urlBuilder_.Append(System.Uri.EscapeDataString("SortField")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sortField, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -2967,14 +3145,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// /// Returns a paginated list of projects. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ListProjectsHandlerAsync(System.Guid? groupId = null, string? search = null, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task ListProjectsHandlerAsync(System.Guid? groupId = null, bool? ungrouped = null, string? search = null, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -2994,13 +3172,13 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("GroupId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(groupId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } - if (search != null) + if (ungrouped != null) { - urlBuilder_.Append(System.Uri.EscapeDataString("Search")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(search, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Append(System.Uri.EscapeDataString("Ungrouped")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(ungrouped, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } - if (limit != null) + if (search != null) { - urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Append(System.Uri.EscapeDataString("Search")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(search, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } if (after != null) { @@ -3010,6 +3188,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("Before")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(before, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } if (sortField != null) { urlBuilder_.Append(System.Uri.EscapeDataString("SortField")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sortField, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -3406,6 +3588,103 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() } } + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// List projects grouped by owning group + /// + /// + /// Returns the first page of projects for every group plus an ungrouped bucket, all sorted by name ascending. Sections whose project list is empty after applying the search filter are omitted. Use the returned per-section cursor with GET /api/projects to fetch the next page. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task ListGroupedProjectsHandlerAsync(string? search = null, int? perGroup = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/projects/grouped" + urlBuilder_.Append("api/projects/grouped"); + urlBuilder_.Append('?'); + if (search != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Search")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(search, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (perGroup != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("PerGroup")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(perGroup, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 400) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new GroundControlApiClientException("Bad Request", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new GroundControlApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// Add a template to a project @@ -4235,14 +4514,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// /// Returns a paginated list of scope definitions. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ListScopesHandlerAsync(int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task ListScopesHandlerAsync(string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -4258,10 +4537,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() // Operation Path: "api/scopes" urlBuilder_.Append("api/scopes"); urlBuilder_.Append('?'); - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } if (after != null) { urlBuilder_.Append(System.Uri.EscapeDataString("After")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(after, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -4270,6 +4545,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("Before")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(before, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } if (sortField != null) { urlBuilder_.Append(System.Uri.EscapeDataString("SortField")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sortField, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -4746,6 +5025,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() throw new GroundControlApiClientException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); } else + if (status_ == 409) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new GroundControlApiClientException("Conflict", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else if (status_ == 422) { var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); @@ -4782,14 +5071,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// /// Returns a paginated list of snapshots for the specified project. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ListSnapshotsHandlerAsync(System.Guid projectId, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task ListSnapshotsHandlerAsync(System.Guid projectId, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { if (projectId == null) throw new System.ArgumentNullException("projectId"); @@ -4810,10 +5099,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(projectId, System.Globalization.CultureInfo.InvariantCulture))); urlBuilder_.Append("/snapshots"); urlBuilder_.Append('?'); - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } if (after != null) { urlBuilder_.Append(System.Uri.EscapeDataString("After")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(after, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -4822,6 +5107,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("Before")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(before, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } if (sortField != null) { urlBuilder_.Append(System.Uri.EscapeDataString("SortField")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sortField, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -4894,6 +5183,115 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() } } + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Preview a snapshot + /// + /// + /// Resolves the project's current configuration into a snapshot-shaped payload without persisting it. Returns a diff hash that publish will use to detect drift. + /// + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task PreviewSnapshotHandlerAsync(System.Guid projectId, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + { + if (projectId == null) + throw new System.ArgumentNullException("projectId"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + + // Operation Path: "api/projects/{projectId}/snapshots/preview" + urlBuilder_.Append("api/projects/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(projectId, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/snapshots/preview"); + urlBuilder_.Append('?'); + if (decrypt != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("decrypt")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(decrypt, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new GroundControlApiClientException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 422) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new GroundControlApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new GroundControlApiClientException("Unprocessable Entity", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + { + var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new GroundControlApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// Activate a snapshot @@ -5205,14 +5603,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// /// Returns a paginated list of configuration templates. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ListTemplatesHandlerAsync(System.Guid? groupId = null, bool? globalOnly = null, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task ListTemplatesHandlerAsync(System.Guid? groupId = null, bool? globalOnly = null, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -5236,10 +5634,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("GlobalOnly")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(globalOnly, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } if (after != null) { urlBuilder_.Append(System.Uri.EscapeDataString("After")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(after, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -5248,6 +5642,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("Before")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(before, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } if (sortField != null) { urlBuilder_.Append(System.Uri.EscapeDataString("SortField")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sortField, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -5755,14 +6153,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// /// Returns a paginated list of users. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ListUsersHandlerAsync(int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task ListUsersHandlerAsync(string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -5778,10 +6176,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() // Operation Path: "api/users" urlBuilder_.Append("api/users"); urlBuilder_.Append('?'); - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } if (after != null) { urlBuilder_.Append(System.Uri.EscapeDataString("After")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(after, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -5790,6 +6184,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("Before")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(before, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } if (sortField != null) { urlBuilder_.Append(System.Uri.EscapeDataString("SortField")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sortField, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -6430,14 +6828,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// /// Returns a paginated list of variables. Optionally decrypts sensitive values. /// - /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the cursor pointing to the item after which results should begin (forward pagination). /// Gets the cursor pointing to the item before which results should end (backward pagination). + /// Gets the maximum number of items to return. Must be between 1 and 100. /// Gets the name of the field to sort results by. /// Gets the sort direction (e.g., `asc` or `desc`). /// OK /// A server side error occurred. - public virtual async System.Threading.Tasks.Task ListVariablesHandlerAsync(VariableScope? scope = null, System.Guid? groupId = null, System.Guid? projectId = null, int? limit = null, string? after = null, string? before = null, string? sortField = null, string? sortOrder = null, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task ListVariablesHandlerAsync(VariableScope? scope = null, System.Guid? groupId = null, System.Guid? projectId = null, string? after = null, string? before = null, int? limit = null, string? sortField = null, string? sortOrder = null, bool? decrypt = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -6465,10 +6863,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("ProjectId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(projectId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } if (after != null) { urlBuilder_.Append(System.Uri.EscapeDataString("After")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(after, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); @@ -6477,6 +6871,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { urlBuilder_.Append(System.Uri.EscapeDataString("Before")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(before, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("Limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } if (sortField != null) { urlBuilder_.Append(System.Uri.EscapeDataString("SortField")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(sortField, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); diff --git a/src/GroundControl.Api/Features/ConfigEntries/ConfigEntriesModule.cs b/src/GroundControl.Api/Features/ConfigEntries/ConfigEntriesModule.cs index 8a0df298..8bdc2348 100644 --- a/src/GroundControl.Api/Features/ConfigEntries/ConfigEntriesModule.cs +++ b/src/GroundControl.Api/Features/ConfigEntries/ConfigEntriesModule.cs @@ -7,6 +7,8 @@ internal sealed class ConfigEntriesModule : IWebApiModule { public void OnServiceConfiguration(WebApplicationBuilder builder) { + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/GroundControl.Api/Features/ConfigEntries/ConfigEntryValidation.cs b/src/GroundControl.Api/Features/ConfigEntries/ConfigEntryValidation.cs index 2859ff44..fcecc44a 100644 --- a/src/GroundControl.Api/Features/ConfigEntries/ConfigEntryValidation.cs +++ b/src/GroundControl.Api/Features/ConfigEntries/ConfigEntryValidation.cs @@ -6,7 +6,10 @@ namespace GroundControl.Api.Features.ConfigEntries; -internal static partial class ConfigEntryValidation +/// +/// Shared validation and canonicalization logic for config entry write paths. +/// +internal sealed partial class ConfigEntryValidation { /// /// Allowed shape for a config entry key: starts with a letter, then any mix of letters, @@ -15,15 +18,10 @@ internal static partial class ConfigEntryValidation public const string KeyPattern = "^[A-Za-z][A-Za-z0-9.:_-]*$"; /// - /// Human-readable description of , surfaced verbatim in 400 responses. + /// Human-readable description of , surfaced verbatim in 400 responses. /// public const string KeyPatternErrorMessage = "Key must start with a letter and contain only letters, digits, '.', ':', '_', or '-'."; - [GeneratedRegex(KeyPattern, RegexOptions.Compiled)] - private static partial Regex KeyRegex { get; } - - public static bool IsValidKey(string key) => !string.IsNullOrEmpty(key) && KeyRegex.IsMatch(key); - private static readonly HashSet AllowedValueTypes = new(StringComparer.OrdinalIgnoreCase) { "String", @@ -38,55 +36,109 @@ internal static partial class ConfigEntryValidation "TimeOnly" }; - public static bool IsValidValueType(string valueType) => AllowedValueTypes.Contains(valueType); + private readonly IScopeStore _scopeStore; - public static string? ValidateValue(string value, string valueType) + public ConfigEntryValidation(IScopeStore scopeStore) { - return valueType.ToUpperInvariant() switch - { - "STRING" => null, - "INT32" => int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _) ? null : $"Value '{value}' is not a valid Int32.", - "INT64" => long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _) ? null : $"Value '{value}' is not a valid Int64.", - "DOUBLE" => double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _) ? null : $"Value '{value}' is not a valid Double.", - "DECIMAL" => decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out _) ? null : $"Value '{value}' is not a valid Decimal.", - "BOOLEAN" => bool.TryParse(value, out _) ? null : $"Value '{value}' is not a valid Boolean.", - "DATETIME" => DateTime.TryParse(value, CultureInfo.InvariantCulture, out _) ? null : $"Value '{value}' is not a valid DateTime.", - "DATETIMEOFFSET" => DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, out _) ? null : $"Value '{value}' is not a valid DateTimeOffset.", - "DATEONLY" => DateOnly.TryParse(value, CultureInfo.InvariantCulture, out _) ? null : $"Value '{value}' is not a valid DateOnly.", - "TIMEONLY" => TimeOnly.TryParse(value, CultureInfo.InvariantCulture, out _) ? null : $"Value '{value}' is not a valid TimeOnly.", - _ => $"ValueType '{valueType}' is not supported." - }; + _scopeStore = scopeStore ?? throw new ArgumentNullException(nameof(scopeStore)); } - public static async Task ValidateScopesAsync( - IReadOnlyList values, - IScopeStore scopeStore, - CancellationToken cancellationToken) + [GeneratedRegex(KeyPattern, RegexOptions.Compiled)] + private static partial Regex KeyRegex { get; } + + public bool IsValidKey(string key) => !string.IsNullOrEmpty(key) && KeyRegex.IsMatch(key); + + public bool IsValidValueType(string valueType) => AllowedValueTypes.Contains(valueType); + + /// + /// Validates every scope reference (dimension exists + value is in ) + /// and rewrites each scope key to the canonical casing in a single + /// pass. Each unique dimension is looked up once via . + /// On failure, returns the first error; on success, returns the canonicalized list. + /// + public async Task ValidateAndCanonicalizeScopesAsync(IReadOnlyList values, CancellationToken cancellationToken) { - var dimensionCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + var scopeCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + var canonical = new List(values.Count); foreach (var scopedValue in values) { + if (scopedValue.Scopes.Count == 0) + { + canonical.Add(scopedValue); + continue; + } + + var normalizedScopes = new Dictionary(scopedValue.Scopes.Count); foreach (var (dimension, value) in scopedValue.Scopes) { - if (!dimensionCache.TryGetValue(dimension, out var scope)) + if (!scopeCache.TryGetValue(dimension, out var scope)) { - scope = await scopeStore.GetByDimensionAsync(dimension, cancellationToken).ConfigureAwait(false); - dimensionCache[dimension] = scope; + scope = await _scopeStore.GetByDimensionAsync(dimension, cancellationToken).ConfigureAwait(false); + scopeCache[dimension] = scope; } if (scope is null) { - return $"Scope dimension '{dimension}' does not exist."; + return ScopeValidationResult.Failure($"Scope dimension '{dimension}' does not exist."); } if (scope.AllowedValues.Count > 0 && !scope.AllowedValues.Contains(value)) { - return $"Value '{value}' is not allowed for scope dimension '{dimension}'."; + return ScopeValidationResult.Failure($"Value '{value}' is not allowed for scope dimension '{dimension}'."); + } + + if (!normalizedScopes.TryAdd(scope.Dimension, value)) + { + return ScopeValidationResult.Failure( + $"Scope dimension '{scope.Dimension}' is specified more than once (case-insensitive) in the same scoped value."); } } + + canonical.Add(scopedValue with { Scopes = normalizedScopes }); } - return null; + return ScopeValidationResult.Success(canonical); + } + + public string? ValidateValue(string value, string valueType) + { + return valueType.ToUpperInvariant() switch + { + "STRING" => null, + "INT32" => int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _) ? null : $"Value '{value}' is not a valid Int32.", + "INT64" => long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _) ? null : $"Value '{value}' is not a valid Int64.", + "DOUBLE" => double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _) ? null : $"Value '{value}' is not a valid Double.", + "DECIMAL" => decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out _) ? null : $"Value '{value}' is not a valid Decimal.", + "BOOLEAN" => bool.TryParse(value, out _) ? null : $"Value '{value}' is not a valid Boolean.", + "DATETIME" => DateTime.TryParse(value, CultureInfo.InvariantCulture, out _) ? null : $"Value '{value}' is not a valid DateTime.", + "DATETIMEOFFSET" => DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, out _) ? null : $"Value '{value}' is not a valid DateTimeOffset.", + "DATEONLY" => DateOnly.TryParse(value, CultureInfo.InvariantCulture, out _) ? null : $"Value '{value}' is not a valid DateOnly.", + "TIMEONLY" => TimeOnly.TryParse(value, CultureInfo.InvariantCulture, out _) ? null : $"Value '{value}' is not a valid TimeOnly.", + _ => $"ValueType '{valueType}' is not supported." + }; + } +} + +/// +/// Result of : either a +/// validation error message, or a canonicalized list of scoped values. +/// +internal readonly record struct ScopeValidationResult +{ + private ScopeValidationResult(string? error, List? canonical) + { + Error = error; + Canonical = canonical; } + + /// Gets the canonicalized scoped values on success, or null on failure. + public List? Canonical { get; } + + /// Gets the first validation error, or null on success. + public string? Error { get; } + + public static ScopeValidationResult Failure(string error) => new(error, null); + + public static ScopeValidationResult Success(List canonical) => new(null, canonical); } \ No newline at end of file diff --git a/src/GroundControl.Api/Features/ConfigEntries/Contracts/UpdateConfigEntryRequest.cs b/src/GroundControl.Api/Features/ConfigEntries/Contracts/UpdateConfigEntryRequest.cs index 3812cc96..8584fe88 100644 --- a/src/GroundControl.Api/Features/ConfigEntries/Contracts/UpdateConfigEntryRequest.cs +++ b/src/GroundControl.Api/Features/ConfigEntries/Contracts/UpdateConfigEntryRequest.cs @@ -7,6 +7,16 @@ namespace GroundControl.Api.Features.ConfigEntries.Contracts; /// internal sealed record UpdateConfigEntryRequest { + /// + /// Gets the configuration key. Must start with a letter and contain only letters, digits, or + /// the separators ., :, _, -. + /// + /// Maximum length: 500 characters. + [Required] + [MaxLength(500)] + [RegularExpression(ConfigEntryValidation.KeyPattern, ErrorMessage = ConfigEntryValidation.KeyPatternErrorMessage)] + public required string Key { get; init; } + /// /// Gets the value type name. /// diff --git a/src/GroundControl.Api/Features/ConfigEntries/CreateConfigEntryHandler.cs b/src/GroundControl.Api/Features/ConfigEntries/CreateConfigEntryHandler.cs index 3e1dcd93..e9737acf 100644 --- a/src/GroundControl.Api/Features/ConfigEntries/CreateConfigEntryHandler.cs +++ b/src/GroundControl.Api/Features/ConfigEntries/CreateConfigEntryHandler.cs @@ -12,12 +12,14 @@ namespace GroundControl.Api.Features.ConfigEntries; internal sealed class CreateConfigEntryHandler : IEndpointHandler { private readonly IConfigEntryStore _store; + private readonly ConfigEntryValidation _validation; private readonly AuditRecorder _audit; private readonly SensitiveSourceValueProtector _protector; - public CreateConfigEntryHandler(IConfigEntryStore store, AuditRecorder audit, SensitiveSourceValueProtector protector) + public CreateConfigEntryHandler(IConfigEntryStore store, ConfigEntryValidation validation, AuditRecorder audit, SensitiveSourceValueProtector protector) { _store = store ?? throw new ArgumentNullException(nameof(store)); + _validation = validation ?? throw new ArgumentNullException(nameof(validation)); _audit = audit ?? throw new ArgumentNullException(nameof(audit)); _protector = protector ?? throw new ArgumentNullException(nameof(protector)); } @@ -41,7 +43,13 @@ public static void Endpoint(IEndpointRouteBuilder endpoints) private async Task HandleAsync(CreateConfigEntryRequest request, CancellationToken cancellationToken = default) { var timestamp = DateTimeOffset.UtcNow; - var plaintextValues = request.Values.Select(v => new ScopedValue(v.Value, v.Scopes)); + var scopes = await _validation.ValidateAndCanonicalizeScopesAsync(request.Values, cancellationToken).ConfigureAwait(false); + if (scopes.Error is not null) + { + return TypedResults.ValidationProblem(new Dictionary { [nameof(request.Values)] = [scopes.Error] }); + } + + var plaintextValues = scopes.Canonical!.Select(v => new ScopedValue(v.Value, v.Scopes)); var protectedValues = _protector.ProtectValues(plaintextValues, request.IsSensitive); var entry = new ConfigEntry diff --git a/src/GroundControl.Api/Features/ConfigEntries/CreateConfigEntryValidator.cs b/src/GroundControl.Api/Features/ConfigEntries/CreateConfigEntryValidator.cs index a6bd56fd..6600ef6b 100644 --- a/src/GroundControl.Api/Features/ConfigEntries/CreateConfigEntryValidator.cs +++ b/src/GroundControl.Api/Features/ConfigEntries/CreateConfigEntryValidator.cs @@ -1,28 +1,27 @@ using GroundControl.Api.Features.ConfigEntries.Contracts; using GroundControl.Api.Shared.Security.Protection; -using GroundControl.Persistence.Stores; namespace GroundControl.Api.Features.ConfigEntries; internal sealed class CreateConfigEntryValidator : IAsyncValidator { - private readonly IScopeStore _scopeStore; + private readonly ConfigEntryValidation _validation; - public CreateConfigEntryValidator(IScopeStore scopeStore) + public CreateConfigEntryValidator(ConfigEntryValidation validation) { - _scopeStore = scopeStore ?? throw new ArgumentNullException(nameof(scopeStore)); + _validation = validation ?? throw new ArgumentNullException(nameof(validation)); } - public async Task ValidateAsync(CreateConfigEntryRequest instance, ValidationContext context, CancellationToken cancellationToken = default) + public Task ValidateAsync(CreateConfigEntryRequest instance, ValidationContext context, CancellationToken cancellationToken = default) { - if (!ConfigEntryValidation.IsValidKey(instance.Key)) + if (!_validation.IsValidKey(instance.Key)) { - return ValidatorResult.Fail(ConfigEntryValidation.KeyPatternErrorMessage, nameof(instance.Key)); + return Task.FromResult(ValidatorResult.Fail(ConfigEntryValidation.KeyPatternErrorMessage, nameof(instance.Key))); } - if (!ConfigEntryValidation.IsValidValueType(instance.ValueType)) + if (!_validation.IsValidValueType(instance.ValueType)) { - return ValidatorResult.Fail($"ValueType '{instance.ValueType}' is not supported.", nameof(instance.ValueType)); + return Task.FromResult(ValidatorResult.Fail($"ValueType '{instance.ValueType}' is not supported.", nameof(instance.ValueType))); } var result = new ValidatorResult(); @@ -37,19 +36,13 @@ public async Task ValidateAsync(CreateConfigEntryRequest instan continue; } - var valueError = ConfigEntryValidation.ValidateValue(scopedValue.Value, instance.ValueType); + var valueError = _validation.ValidateValue(scopedValue.Value, instance.ValueType); if (valueError is not null) { result.AddError(valueError, nameof(instance.Values)); } } - if (result.IsFailed) - { - return result; - } - - var scopeError = await ConfigEntryValidation.ValidateScopesAsync(instance.Values, _scopeStore, cancellationToken).ConfigureAwait(false); - return scopeError is null ? ValidatorResult.Success : result.AddError(scopeError, nameof(instance.Values)); + return Task.FromResult(result.IsFailed ? result : ValidatorResult.Success); } } \ No newline at end of file diff --git a/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryHandler.cs b/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryHandler.cs index cafa758e..a83dfb53 100644 --- a/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryHandler.cs +++ b/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryHandler.cs @@ -2,6 +2,7 @@ using GroundControl.Api.Shared.Audit; using GroundControl.Api.Shared.Security; using GroundControl.Api.Shared.Security.Protection; +using GroundControl.Persistence; using GroundControl.Persistence.Contracts; using GroundControl.Persistence.Stores; using Microsoft.AspNetCore.Mvc; @@ -11,12 +12,14 @@ namespace GroundControl.Api.Features.ConfigEntries; internal sealed class UpdateConfigEntryHandler : IEndpointHandler { private readonly IConfigEntryStore _store; + private readonly ConfigEntryValidation _validation; private readonly AuditRecorder _audit; private readonly SensitiveSourceValueProtector _protector; - public UpdateConfigEntryHandler(IConfigEntryStore store, AuditRecorder audit, SensitiveSourceValueProtector protector) + public UpdateConfigEntryHandler(IConfigEntryStore store, ConfigEntryValidation validation, AuditRecorder audit, SensitiveSourceValueProtector protector) { _store = store ?? throw new ArgumentNullException(nameof(store)); + _validation = validation ?? throw new ArgumentNullException(nameof(validation)); _audit = audit ?? throw new ArgumentNullException(nameof(audit)); _protector = protector ?? throw new ArgumentNullException(nameof(protector)); } @@ -57,6 +60,7 @@ private async Task HandleAsync(Guid id, UpdateConfigEntryRequest reques return problem; } + var oldKey = entry.Key; var oldValueType = entry.ValueType; var oldIsSensitive = entry.IsSensitive; var oldDescription = entry.Description; @@ -67,9 +71,16 @@ private async Task HandleAsync(Guid id, UpdateConfigEntryRequest reques // raw stored bytes always differ even when the underlying plaintext is identical). var oldPlaintextValues = _protector.UnprotectValues(entry.Values, oldIsSensitive); - var newPlaintextValues = request.Values.Select(v => new ScopedValue(v.Value, v.Scopes)).ToList(); + var scopes = await _validation.ValidateAndCanonicalizeScopesAsync(request.Values, cancellationToken).ConfigureAwait(false); + if (scopes.Error is not null) + { + return TypedResults.ValidationProblem(new Dictionary { [nameof(request.Values)] = [scopes.Error] }); + } + + var newPlaintextValues = scopes.Canonical!.Select(v => new ScopedValue(v.Value, v.Scopes)).ToList(); var protectedValues = _protector.ProtectValues(newPlaintextValues, request.IsSensitive); + entry.Key = request.Key; entry.ValueType = request.ValueType; entry.Values.Clear(); foreach (var v in protectedValues) @@ -82,13 +93,25 @@ private async Task HandleAsync(Guid id, UpdateConfigEntryRequest reques entry.UpdatedAt = DateTimeOffset.UtcNow; entry.UpdatedBy = Guid.Empty; - var updated = await _store.UpdateAsync(entry, expectedVersion, cancellationToken).ConfigureAwait(false); + bool updated; + try + { + updated = await _store.UpdateAsync(entry, expectedVersion, cancellationToken).ConfigureAwait(false); + } + catch (DuplicateKeyException) + { + return TypedResults.Problem( + detail: $"A config entry with key '{request.Key}' already exists for this owner.", + statusCode: StatusCodes.Status409Conflict); + } + if (!updated) { return TypedResults.Problem(detail: "Version conflict.", statusCode: StatusCodes.Status409Conflict); } List changes = [ + .. AuditRecorder.CompareFields("Key", oldKey, entry.Key), .. AuditRecorder.CompareFields("ValueType", oldValueType, entry.ValueType), .. AuditRecorder.CompareCollections("Values", [.. oldPlaintextValues], newPlaintextValues, auditIsSensitive), .. AuditRecorder.CompareFields("IsSensitive", oldIsSensitive.ToString(), entry.IsSensitive.ToString()), diff --git a/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryValidator.cs b/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryValidator.cs index 48318ad6..bf33c69f 100644 --- a/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryValidator.cs +++ b/src/GroundControl.Api/Features/ConfigEntries/UpdateConfigEntryValidator.cs @@ -1,23 +1,27 @@ using GroundControl.Api.Features.ConfigEntries.Contracts; using GroundControl.Api.Shared.Security.Protection; -using GroundControl.Persistence.Stores; namespace GroundControl.Api.Features.ConfigEntries; internal sealed class UpdateConfigEntryValidator : IAsyncValidator { - private readonly IScopeStore _scopeStore; + private readonly ConfigEntryValidation _validation; - public UpdateConfigEntryValidator(IScopeStore scopeStore) + public UpdateConfigEntryValidator(ConfigEntryValidation validation) { - _scopeStore = scopeStore ?? throw new ArgumentNullException(nameof(scopeStore)); + _validation = validation ?? throw new ArgumentNullException(nameof(validation)); } - public async Task ValidateAsync(UpdateConfigEntryRequest instance, ValidationContext context, CancellationToken cancellationToken = default) + public Task ValidateAsync(UpdateConfigEntryRequest instance, ValidationContext context, CancellationToken cancellationToken = default) { - if (!ConfigEntryValidation.IsValidValueType(instance.ValueType)) + if (!_validation.IsValidKey(instance.Key)) { - return ValidatorResult.Fail($"ValueType '{instance.ValueType}' is not supported.", nameof(instance.ValueType)); + return Task.FromResult(ValidatorResult.Fail(ConfigEntryValidation.KeyPatternErrorMessage, nameof(instance.Key))); + } + + if (!_validation.IsValidValueType(instance.ValueType)) + { + return Task.FromResult(ValidatorResult.Fail($"ValueType '{instance.ValueType}' is not supported.", nameof(instance.ValueType))); } var result = new ValidatorResult(); @@ -31,19 +35,13 @@ public async Task ValidateAsync(UpdateConfigEntryRequest instan continue; } - var valueError = ConfigEntryValidation.ValidateValue(scopedValue.Value, instance.ValueType); + var valueError = _validation.ValidateValue(scopedValue.Value, instance.ValueType); if (valueError is not null) { result.AddError(valueError, nameof(instance.Values)); } } - if (result.IsFailed) - { - return result; - } - - var scopeError = await ConfigEntryValidation.ValidateScopesAsync(instance.Values, _scopeStore, cancellationToken).ConfigureAwait(false); - return scopeError is null ? ValidatorResult.Success : result.AddError(scopeError, nameof(instance.Values)); + return Task.FromResult(result.IsFailed ? result : ValidatorResult.Success); } } \ No newline at end of file diff --git a/src/GroundControl.Api/OpenApi.json b/src/GroundControl.Api/OpenApi.json index b650dbdf..9f352497 100644 --- a/src/GroundControl.Api/OpenApi.json +++ b/src/GroundControl.Api/OpenApi.json @@ -4555,8 +4555,9 @@ "properties": { "key": { "maxLength": 500, + "pattern": "^[A-Za-z][A-Za-z0-9.:_-]*$", "type": "string", - "description": "Gets the configuration key." + "description": "Gets the configuration key. Must start with a letter and contain only letters, digits, or\r\nthe separators `.`, `:`, `_`, `-`." }, "ownerId": { "type": "string", @@ -6272,11 +6273,18 @@ }, "UpdateConfigEntryRequest": { "required": [ + "key", "valueType", "values" ], "type": "object", "properties": { + "key": { + "maxLength": 500, + "pattern": "^[A-Za-z][A-Za-z0-9.:_-]*$", + "type": "string", + "description": "Gets the configuration key. Must start with a letter and contain only letters, digits, or\r\nthe separators `.`, `:`, `_`, `-`." + }, "valueType": { "maxLength": 50, "type": "string", diff --git a/src/GroundControl.AppHost/GroundControl.AppHost.csproj b/src/GroundControl.AppHost/GroundControl.AppHost.csproj index 08014b81..3f10aedc 100644 --- a/src/GroundControl.AppHost/GroundControl.AppHost.csproj +++ b/src/GroundControl.AppHost/GroundControl.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryCommand.cs b/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryCommand.cs index 92d640b8..7cc925b7 100644 --- a/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryCommand.cs +++ b/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryCommand.cs @@ -8,6 +8,7 @@ public UpdateConfigEntryCommand() : base("update", "Update a configuration entry") { var idArgument = new Argument("id") { Description = "The configuration entry ID" }; + var keyOption = new Option("--key") { Description = "The new configuration key. Must start with a letter and contain only letters, digits, '.', ':', '_', or '-'." }; var valueTypeOption = new Option("--value-type") { Description = "The new value type name. Allowed: String, Int32, Int64, Double, Decimal, Boolean, DateTime, DateTimeOffset, DateOnly, TimeOnly." }; var sensitiveOption = new Option("--sensitive") { Description = "Whether the entry contains sensitive data" }; var descriptionOption = new Option("--description") { Description = "The new description" }; @@ -20,6 +21,7 @@ public UpdateConfigEntryCommand() var versionOption = new Option("--version") { Description = "The expected version for optimistic concurrency" }; Arguments.Add(idArgument); + Options.Add(keyOption); Options.Add(valueTypeOption); Options.Add(sensitiveOption); Options.Add(descriptionOption); @@ -30,6 +32,7 @@ public UpdateConfigEntryCommand() ConfigureOptions((parseResult, options) => { options.Id = parseResult.GetValue(idArgument); + options.Key = parseResult.GetValue(keyOption); options.ValueType = parseResult.GetValue(valueTypeOption); options.Sensitive = parseResult.GetValue(sensitiveOption); options.Description = parseResult.GetValue(descriptionOption); diff --git a/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryHandler.cs b/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryHandler.cs index ef7bab6b..dfa89b9c 100644 --- a/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryHandler.cs +++ b/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryHandler.cs @@ -86,6 +86,7 @@ public async Task HandleAsync(CancellationToken cancellationToken) var request = new UpdateConfigEntryRequest { + Key = _options.Key ?? existing.Key, ValueType = _options.ValueType ?? existing.ValueType, Values = scopedValues, IsSensitive = _options.Sensitive, diff --git a/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryOptions.cs b/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryOptions.cs index 6fae54f7..f7f9247b 100644 --- a/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryOptions.cs +++ b/src/GroundControl.Cli/Features/ConfigEntries/Update/UpdateConfigEntryOptions.cs @@ -4,6 +4,8 @@ internal sealed class UpdateConfigEntryOptions { public Guid Id { get; set; } + public string? Key { get; set; } + public string? ValueType { get; set; } public bool? Sensitive { get; set; } diff --git a/src/GroundControl.Cli/Features/Tui/ViewModels/ConfigEntryViewModel.cs b/src/GroundControl.Cli/Features/Tui/ViewModels/ConfigEntryViewModel.cs index fa405d73..f7f76b4e 100644 --- a/src/GroundControl.Cli/Features/Tui/ViewModels/ConfigEntryViewModel.cs +++ b/src/GroundControl.Cli/Features/Tui/ViewModels/ConfigEntryViewModel.cs @@ -90,6 +90,7 @@ internal override async Task UpdateAsync(ConfigEntryResponse item, Dictionary _refreshables = []; + private readonly Tabs _tabView; + private readonly Dictionary _refreshables = []; public MainWindow(IApplication app, string serverUrl, string authMethod, IGroundControlClient client) { _app = app; Title = "GroundControl"; - _tabView = new TabView + _tabView = new Tabs { X = 0, Y = 0, @@ -41,9 +41,10 @@ public MainWindow(IApplication app, string serverUrl, string authMethod, IGround AddResourceTab("PATs", new PatViewModel(client)); AddResourceTab("Audit", new AuditViewModel(client)); - if (_tabView.Tabs.Count > 0) + var firstTab = _tabView.TabCollection.FirstOrDefault(); + if (firstTab is not null) { - _tabView.SelectedTab = _tabView.Tabs.First(); + _tabView.Value = firstTab; } Add(_tabView); @@ -82,7 +83,8 @@ private void AddResourceTab(string name, ResourceViewModel viewModel) Width = Dim.Fill(), Height = Dim.Fill(), CanFocus = true, - TabStop = TabBehavior.TabGroup + TabStop = TabBehavior.TabGroup, + Title = name }; var listView = new ResourceListView(viewModel, _app); @@ -92,21 +94,15 @@ private void AddResourceTab(string name, ResourceViewModel viewModel) container.Add(listView, detailView); - var tab = new Tab - { - DisplayText = name, - View = container - }; - - _tabView.AddTab(tab, false); - _refreshables[tab] = listView; + _tabView.Add(container); + _refreshables[container] = listView; listView.RefreshList(); } private void RefreshActiveTab() { - if (_tabView.SelectedTab is { } selectedTab && _refreshables.TryGetValue(selectedTab, out var refreshable)) + if (_tabView.Value is { } selectedTab && _refreshables.TryGetValue(selectedTab, out var refreshable)) { refreshable.Refresh(); } diff --git a/src/GroundControl.Persistence.MongoDb/Stores/ConfigEntryStore.cs b/src/GroundControl.Persistence.MongoDb/Stores/ConfigEntryStore.cs index d599064d..2e531a70 100644 --- a/src/GroundControl.Persistence.MongoDb/Stores/ConfigEntryStore.cs +++ b/src/GroundControl.Persistence.MongoDb/Stores/ConfigEntryStore.cs @@ -66,6 +66,7 @@ public async Task UpdateAsync(ConfigEntry entry, long expectedVersion, Can Builders.Filter.Eq(entity => entity.Version, expectedVersion)); var update = Builders.Update + .Set(entity => entity.Key, entry.Key) .Set(entity => entity.ValueType, entry.ValueType) .Set(entity => entity.Values, entry.Values) .Set(entity => entity.IsSensitive, entry.IsSensitive) @@ -74,7 +75,18 @@ public async Task UpdateAsync(ConfigEntry entry, long expectedVersion, Can .Set(entity => entity.UpdatedBy, entry.UpdatedBy) .Set(entity => entity.Version, nextVersion); - var result = await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false); + UpdateResult result; + try + { + result = await _collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + throw new DuplicateKeyException( + $"A config entry with key '{entry.Key}' already exists for this owner.", + ex); + } + if (result.ModifiedCount != 1) { return false; diff --git a/src/GroundControl.Tower/design-tokens/tokens.json b/src/GroundControl.Tower/design-tokens/tokens.json index 27f3d157..6fcd6cb8 100644 --- a/src/GroundControl.Tower/design-tokens/tokens.json +++ b/src/GroundControl.Tower/design-tokens/tokens.json @@ -94,10 +94,10 @@ "sensitiveBg": { "$value": { "light": "{color.warning.50}", "dark": "rgba(242, 213, 141, 0.14)" }, "$description": "Tint behind the trailing 'sensitive' marker pill on a masked JSON line." }, "scopeDimension": { "$value": { "light": "#267f99", "dark": "#4ec9b0" }, "$description": "Dimension portion of a scoped JSON key (e.g. 'environment' in 'environment=prod')." }, "scopeValue": { "$value": { "light": "#795e26", "dark": "#dcdcaa" }, "$description": "Value portion of a scoped JSON key (e.g. 'prod' in 'environment=prod')." }, - "diffAddBg": { "$value": { "light": "rgba(34, 197, 94, 0.16)", "dark": "rgba(34, 197, 94, 0.22)" }, "$description": "Line background for additions in diff views." }, - "diffAddFg": { "$value": { "light": "{color.success.800}", "dark": "#86efac" } }, - "diffDelBg": { "$value": { "light": "rgba(244, 63, 94, 0.14)", "dark": "rgba(244, 63, 94, 0.22)" }, "$description": "Line background for deletions in diff views." }, - "diffDelFg": { "$value": { "light": "{color.critical.700}", "dark": "#fca5a5" } } + "diffAddBg": { "$value": { "light": "rgba(34, 197, 94, 0.16)", "dark": "rgba(63, 185, 80, 0.22)" }, "$description": "Line background for additions in diff views." }, + "diffAddFg": { "$value": { "light": "{color.success.800}", "dark": "#7ee787" } }, + "diffDelBg": { "$value": { "light": "rgba(244, 63, 94, 0.14)", "dark": "rgba(248, 81, 73, 0.22)" }, "$description": "Line background for deletions in diff views." }, + "diffDelFg": { "$value": { "light": "{color.critical.700}", "dark": "#ff8682" } } }, "overlay": { diff --git a/src/GroundControl.Tower/src/api/types.ts b/src/GroundControl.Tower/src/api/types.ts index 2589882d..c45353af 100644 --- a/src/GroundControl.Tower/src/api/types.ts +++ b/src/GroundControl.Tower/src/api/types.ts @@ -1084,7 +1084,10 @@ export interface components { }; /** @description Represents the request body for creating a configuration entry. */ CreateConfigEntryRequest: { - /** @description Gets the configuration key. */ + /** + * @description Gets the configuration key. Must start with a letter and contain only letters, digits, or + * the separators `.`, `:`, `_`, `-`. + */ key: string; /** * Format: uuid @@ -1817,6 +1820,11 @@ export interface components { }; /** @description Represents the request body for updating a configuration entry. */ UpdateConfigEntryRequest: { + /** + * @description Gets the configuration key. Must start with a letter and contain only letters, digits, or + * the separators `.`, `:`, `_`, `-`. + */ + key: string; /** @description Gets the value type name. */ valueType: string; /** @description Gets the scope-specific values. */ diff --git a/src/GroundControl.Tower/src/components/tower/audit/AuditDetailsDialog.tsx b/src/GroundControl.Tower/src/components/tower/audit/AuditDetailsDialog.tsx new file mode 100644 index 00000000..440f3984 --- /dev/null +++ b/src/GroundControl.Tower/src/components/tower/audit/AuditDetailsDialog.tsx @@ -0,0 +1,56 @@ +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { JsonDiff } from '@/components/tower/code/JsonDiff'; +import { Badge } from '@/components/tower/data/Badge'; +import { formatDateTime } from '@/lib/date-time'; +import { formatUserId } from '@/lib/user'; +import type { AuditRecord } from '@/queries/useAuditRecords'; + +interface AuditDetailsDialogProps { + onOpenChange: (open: boolean) => void; + open: boolean; + record: AuditRecord | null; +} + +export function AuditDetailsDialog({ onOpenChange, open, record }: AuditDetailsDialogProps) { + return ( + + + + Audit details + {record ? `${record.entityType}.${record.action}` : 'Loading…'} + + {record ? ( +
+
+ {formatDateTime(record.performedAt)} + {formatUserId(record.performedBy)} + {record.entityType} + {record.entityId} +
+
+
Changes
+ {record.changes.length === 0 ? ( +
No field-level changes recorded.
+ ) : ( + + )} +
+
+ ) : null} +
+
+ ); +} + +function Field({ children, label }: { children: React.ReactNode; label: string }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function changesToObject(changes: AuditRecord['changes'], valueKey: 'newValue' | 'oldValue') { + return Object.fromEntries(changes.map((change) => [change.field, change[valueKey] ?? null])); +} diff --git a/src/GroundControl.Tower/src/components/tower/audit/AuditFilterPopover.tsx b/src/GroundControl.Tower/src/components/tower/audit/AuditFilterPopover.tsx new file mode 100644 index 00000000..ec00088b --- /dev/null +++ b/src/GroundControl.Tower/src/components/tower/audit/AuditFilterPopover.tsx @@ -0,0 +1,141 @@ +import { ChevronDown } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { FilterButton } from '@/components/tower/data/FilterButton'; +import { cn } from '@/lib/utils'; + +export interface AuditFilters { + entityTypes: string[]; + from: string; + to: string; +} + +interface AuditFilterPopoverProps { + filters: AuditFilters; + onApply: (filters: AuditFilters) => void; + options: readonly { label: string; value: string }[]; +} + +export function AuditFilterPopover({ filters, onApply, options }: AuditFilterPopoverProps) { + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(filters); + const activeCount = filters.entityTypes.length + (filters.from ? 1 : 0) + (filters.to ? 1 : 0); + const dateRangeInvalid = !!draft.from && !!draft.to && draft.from > draft.to; + + useEffect(() => { + if (open) { + setDraft(filters); + } + }, [open, filters]); + + function toggleEntityType(value: string) { + setDraft((current) => ({ + ...current, + entityTypes: current.entityTypes.includes(value) + ? current.entityTypes.filter((entry) => entry !== value) + : [...current.entityTypes, value], + })); + } + + function applyAndClose() { + if (dateRangeInvalid) { + return; + } + + onApply(draft); + setOpen(false); + } + + function clearAll() { + const cleared: AuditFilters = { entityTypes: [], from: '', to: '' }; + setDraft(cleared); + onApply(cleared); + setOpen(false); + } + + const triggerLabel = entityTriggerLabel(draft.entityTypes, options); + + return ( + + + + + + +
+
+
Entity
+ + + + + + {options.map((option) => ( + toggleEntityType(option.value)} + onSelect={(event) => event.preventDefault()} + > + {option.label} + + ))} + + +
+ +
+
Date range
+
+ + +
+ {dateRangeInvalid ?
From date must be before or equal to To date.
: null} +
+ +
+ + +
+
+
+
+ ); +} + +function entityTriggerLabel(selected: string[], options: readonly { label: string; value: string }[]) { + if (selected.length === 0) { + return 'All entities'; + } + + if (selected.length === 1) { + return options.find((option) => option.value === selected[0])?.label ?? selected[0]; + } + + return `${selected.length} selected`; +} diff --git a/src/GroundControl.Tower/src/components/tower/config/ConfigFlatView.tsx b/src/GroundControl.Tower/src/components/tower/config/ConfigListView.tsx similarity index 68% rename from src/GroundControl.Tower/src/components/tower/config/ConfigFlatView.tsx rename to src/GroundControl.Tower/src/components/tower/config/ConfigListView.tsx index 701ad2c7..185a783e 100644 --- a/src/GroundControl.Tower/src/components/tower/config/ConfigFlatView.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/ConfigListView.tsx @@ -14,17 +14,17 @@ import { EntryModal } from './EntryModal'; const columnHelper = createColumnHelper(); -interface ConfigFlatViewProps { +interface ConfigListViewProps { controlsPlacement?: 'external' | 'internal'; owner: ConfigOwner; search?: string; } -export interface ConfigFlatViewHandle { +export interface ConfigListViewHandle { openCreate: () => void; } -export const ConfigFlatView = forwardRef(function ConfigFlatView( +export const ConfigListView = forwardRef(function ConfigListView( { controlsPlacement = 'internal', owner, search }, ref, ) { @@ -41,17 +41,17 @@ export const ConfigFlatView = forwardRef { const baseColumns = [ - columnHelper.accessor((row) => row.entry.key, { cell: (info) => {info.getValue()}, header: 'Key', id: 'key' }), - columnHelper.accessor((row) => row.entry.valueType, { cell: (info) => {info.getValue()}, header: 'Type', id: 'valueType' }), - columnHelper.display({ cell: (info) => , header: 'Default Value', id: 'defaultValue' }), - columnHelper.display({ cell: (info) => {scopeCount(info.row.original.entry)} scopes, header: 'Scopes', id: 'scopes' }), + columnHelper.accessor((row) => row.entry.key, { cell: (info) => {info.getValue()}, header: 'Key', id: 'key', size: 400, minSize: 120 }), + columnHelper.accessor((row) => row.entry.valueType, { cell: (info) => {info.getValue()}, header: 'Type', id: 'valueType', size: 110, minSize: 80 }), + columnHelper.display({ cell: (info) => , header: 'Default Value', id: 'defaultValue', size: 280, minSize: 120 }), + columnHelper.display({ cell: (info) => {scopeCount(info.row.original.entry)} scopes, header: 'Scopes', id: 'scopes', size: 110, minSize: 80 }), ]; if (owner.kind === 'project') { - baseColumns.push(columnHelper.display({ cell: (info) => , header: 'Owner', id: 'owner' })); + baseColumns.push(columnHelper.display({ cell: (info) => , header: 'Owner', id: 'owner', size: 170, minSize: 100 })); } - baseColumns.push(columnHelper.accessor((row) => row.entry.updatedAt, { cell: (info) => relativeDate(info.getValue()), header: 'Updated', id: 'updatedAt' })); + baseColumns.push(columnHelper.accessor((row) => row.entry.updatedAt, { cell: (info) => relativeDate(info.getValue()), header: 'Updated', id: 'updatedAt', size: 130, minSize: 90 })); baseColumns.push(columnHelper.display({ cell: (info) => { const item = info.row.original; @@ -65,13 +65,25 @@ export const ConfigFlatView = forwardRef ); }, + enableResizing: false, header: '', id: 'actions', + size: 80, })); return baseColumns; }, [owner.kind]); - const table = useReactTable({ columns, data, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), onSortingChange: setSorting, state: { sorting } }); + const table = useReactTable({ + columnResizeMode: 'onChange', + columns, + data, + defaultColumn: { minSize: 60, size: 150 }, + enableColumnResizing: true, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + onSortingChange: setSorting, + state: { sorting }, + }); useImperativeHandle(ref, () => ({ openCreate: () => setCreating(true), @@ -93,11 +105,28 @@ export const ConfigFlatView = forwardRef
- +
{table.getHeaderGroups().map((headerGroup) => ( - {headerGroup.headers.map((header) => {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())})} + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : ( +
{flexRender(header.column.columnDef.header, header.getContext())}
+ )} + {header.column.getCanResize() ? ( + + ) : null} +
+ ))}
))}
@@ -111,7 +140,11 @@ export const ConfigFlatView = forwardRef setEditingEntry(row.original.entry)} > - {row.getVisibleCells().map((cell) => {flexRender(cell.column.columnDef.cell, cell.getContext())})} + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} ); })} @@ -141,7 +174,12 @@ function OwnerBadge({ source }: { source: EntrySource }) { return ( - + + + + Inherited from {source.templateName} template @@ -151,7 +189,11 @@ function OwnerBadge({ source }: { source: EntrySource }) { return ( - Overrides · {source.templateName} + + + Overrides · {source.templateName} + + Project entry overrides {source.templateName} diff --git a/src/GroundControl.Tower/src/components/tower/config/ConfigTreeView.tsx b/src/GroundControl.Tower/src/components/tower/config/ConfigTreeView.tsx index bbaac8c8..dc033ae7 100644 --- a/src/GroundControl.Tower/src/components/tower/config/ConfigTreeView.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/ConfigTreeView.tsx @@ -1,3 +1,4 @@ +import { Link } from '@tanstack/react-router'; import { ChevronDown, ChevronRight, ChevronsDown, ChevronsUp, Layers3, Folder, FolderOpen, Hash, Lock, Pencil, Plus } from 'lucide-react'; import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; @@ -43,7 +44,7 @@ export const ConfigTreeView = forwardRef>(() => new Set()); const [selectedEntryId, setSelectedEntryId] = useState(null); const [editingEntry, setEditingEntry] = useState(); - const [creating, setCreating] = useState(false); + const [creatingKey, setCreatingKey] = useState(null); const [internalFilter, setInternalFilter] = useState(''); const resolvedFilter = filter ?? internalFilter; const sourceById = useMemo(() => new Map(effective.entries.map((item) => [item.entry.id, item.source])), [effective.entries]); @@ -86,7 +87,7 @@ export const ConfigTreeView = forwardRef ({ collapseAll: () => setCollapsed(new Set(allPrefixes)), expandAll: () => setCollapsed(new Set()), - openCreate: () => setCreating(true), + openCreate: () => setCreatingKey(''), }), [allPrefixes]); return ( @@ -105,7 +106,7 @@ export const ConfigTreeView = forwardRef - + ) : null} @@ -117,6 +118,7 @@ export const ConfigTreeView = forwardRef - + !open && setCreatingKey(null)} open={creatingKey !== null} ownerId={owner.id} ownerType={ownerType} /> !open && setEditingEntry(undefined)} open={Boolean(editingEntry)} ownerId={owner.id} ownerType={ownerType} /> @@ -143,6 +145,7 @@ interface TreeRowProps { collapsed: Set; depth?: number; node: TreeNode; + onCreate: (initialKey: string) => void; onEdit: (entry: ConfigEntry) => void; onSelect: (id: string) => void; selectedEntryId: null | string; @@ -150,33 +153,52 @@ interface TreeRowProps { sourceById: Map; } -function TreeRow({ collapsed, depth = 0, node, onEdit, onSelect, selectedEntryId, setCollapsed, sourceById }: TreeRowProps) { +function TreeRow({ collapsed, depth = 0, node, onCreate, onEdit, onSelect, selectedEntryId, setCollapsed, sourceById }: TreeRowProps) { if (node.kind === 'group') { const isCollapsed = collapsed.has(node.prefix); const segmentName = lastSegment(node.prefix); return (
- +
+ +
+ + + + + Add entry under {node.prefix} + +
+
{!isCollapsed ? node.children.map((child) => ( onSelect(node.entry.id)} @@ -209,19 +231,29 @@ function TreeRow({ collapsed, depth = 0, node, onEdit, onSelect, selectedEntryId : isInherited ?
); } diff --git a/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx b/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx index 39554b0f..a6e3b063 100644 --- a/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx @@ -2,21 +2,23 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { ReactNode } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { EntryModal } from './EntryModal'; import type { ConfigEntry } from '@/queries/useConfigEntries'; +const scopesMock = vi.fn(); vi.mock('@/queries/useScopes', () => ({ - useScopes: () => ({ data: { data: [] } }), + useScopes: () => scopesMock(), })); +const updateEntryMock = vi.fn(); vi.mock('@/queries/useConfigEntries', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useCreateEntry: () => ({ isPending: false, mutateAsync: vi.fn() }), useDeleteEntry: () => ({ isPending: false, mutateAsync: vi.fn() }), - useUpdateEntry: () => ({ isPending: false, mutateAsync: vi.fn() }), + useUpdateEntry: () => ({ isPending: false, mutateAsync: updateEntryMock }), }; }); @@ -32,7 +34,26 @@ function renderWithClient(ui: ReactNode) { const SENSITIVE_MASK = '***'; -function buildSensitiveEntry(): ConfigEntry { +function buildEntry(overrides: Partial = {}): ConfigEntry { + return { + createdAt: '2026-01-01T00:00:00Z', + createdBy: '00000000-0000-0000-0000-000000000000', + description: null, + id: '33333333-3333-3333-3333-333333333333', + isSensitive: false, + key: 'App:ServiceName', + ownerId: '22222222-2222-2222-2222-222222222222', + ownerType: 1, + updatedAt: '2026-01-01T00:00:00Z', + updatedBy: '00000000-0000-0000-0000-000000000000', + valueType: 'String', + values: [{ scopes: {}, value: 'checkout-api' }], + version: '1', + ...overrides, + } as ConfigEntry; +} + +function buildSensitiveEntry(overrides: Partial = {}): ConfigEntry { return { createdAt: '2026-01-01T00:00:00Z', createdBy: '00000000-0000-0000-0000-000000000000', @@ -47,10 +68,139 @@ function buildSensitiveEntry(): ConfigEntry { valueType: 'String', values: [{ scopes: {}, value: SENSITIVE_MASK }], version: '1', + ...overrides, } as ConfigEntry; } describe('EntryModal', () => { + beforeEach(() => { + updateEntryMock.mockReset(); + updateEntryMock.mockResolvedValue(undefined); + getConfigEntryMock.mockReset(); + scopesMock.mockReset(); + scopesMock.mockReturnValue({ data: { data: [] }, isSuccess: true }); + }); + + it('renders the canonical dimension name when the stored key only differs by case', async () => { + // Backend stores the scope as "Environment" but the entry was written with a lowercase + // dimension key ("environment"). The Select trigger should still display "Environment" since + // the dimensions are case-insensitive on the server. + scopesMock.mockReturnValue({ + data: { + data: [ + { id: 'dim-env', dimension: 'Environment', allowedValues: ['dev', 'prod'] }, + ], + }, + isSuccess: true, + }); + + const entry = buildSensitiveEntry({ + values: [ + { scopes: {}, value: SENSITIVE_MASK }, + { scopes: { environment: 'prod' }, value: SENSITIVE_MASK }, + ], + }); + + renderWithClient( + , + ); + + await waitFor(() => { + const triggers = screen.getAllByRole('combobox'); + const triggerTexts = triggers.map((trigger) => trigger.textContent?.trim()); + expect(triggerTexts).toContain('Environment'); + expect(triggerTexts.some((text) => text?.includes('(deleted)'))).toBe(false); + expect(triggerTexts).toContain('prod'); + }); + }); + + it('does not label the dimension as deleted while the scopes query is still loading', async () => { + // While useScopes() is in-flight (isSuccess === false), the dimensions list is empty but we + // cannot conclude the scope is gone — the stored value should render plainly until the query + // resolves. + scopesMock.mockReturnValue({ data: undefined, isSuccess: false }); + + const entry = buildSensitiveEntry({ + values: [ + { scopes: {}, value: SENSITIVE_MASK }, + { scopes: { environment: 'prod' }, value: SENSITIVE_MASK }, + ], + }); + + renderWithClient( + , + ); + + await waitFor(() => { + const triggers = screen.getAllByRole('combobox'); + const triggerTexts = triggers.map((trigger) => trigger.textContent?.trim()); + expect(triggerTexts.some((text) => text === 'environment')).toBe(true); + expect(triggerTexts.some((text) => text?.includes('(deleted)'))).toBe(false); + }); + }); + + it('keeps the scoped row visible when the referenced scope is no longer in the scopes list', async () => { + // The entry was created when "environment" was a defined scope. The scope has since been + // deleted or renamed in /api/scopes, so the dimensions list returned by useScopes() doesn't + // include it. The stored dimension/value must still render so the user can see and clean it up. + scopesMock.mockReturnValue({ + data: { + data: [ + { id: 'dim-region', dimension: 'region', allowedValues: ['us', 'eu'] }, + ], + }, + isSuccess: true, + }); + + const entry = buildSensitiveEntry({ + values: [ + { scopes: {}, value: SENSITIVE_MASK }, + { scopes: { environment: 'prod' }, value: SENSITIVE_MASK }, + ], + }); + + renderWithClient( + , + ); + + await waitFor(() => { + const triggers = screen.getAllByRole('combobox'); + const triggerTexts = triggers.map((trigger) => trigger.textContent?.trim()); + expect(triggerTexts.some((text) => text?.includes('environment'))).toBe(true); + expect(triggerTexts.some((text) => text?.includes('prod'))).toBe(true); + }); + }); + + it('renders dimension and scope value for a sensitive entry that has scoped values', async () => { + scopesMock.mockReturnValue({ + data: { + data: [ + { id: 'dim-env', dimension: 'environment', allowedValues: ['dev', 'prod'] }, + ], + }, + isSuccess: true, + }); + + const entry = buildSensitiveEntry({ + values: [ + { scopes: {}, value: SENSITIVE_MASK }, + { scopes: { environment: 'prod' }, value: SENSITIVE_MASK }, + ], + }); + + renderWithClient( + , + ); + + // Drain the open-useEffect that calls form.reset(formValues) — the bug surfaces after that fires. + await waitFor(() => { + const triggers = screen.getAllByRole('combobox'); + const triggerTexts = triggers.map((trigger) => trigger.textContent?.trim()); + expect(triggerTexts).toContain('environment'); + expect(triggerTexts).toContain('prod'); + }); + }); + it('rejects invalid key characters', async () => { const user = userEvent.setup(); @@ -59,7 +209,44 @@ describe('EntryModal', () => { await user.type(screen.getByLabelText('Key'), 'bad key!'); await user.click(screen.getByRole('button', { name: 'Create entry' })); - expect(await screen.findByText('Use letters, numbers, colons, dots, underscores, and hyphens only')).toBeInTheDocument(); + expect( + await screen.findByText('Key must start with a letter and contain only letters, digits, colons, dots, underscores, or hyphens'), + ).toBeInTheDocument(); + }); + + it('rejects keys that do not start with a letter', async () => { + const user = userEvent.setup(); + + renderWithClient(); + + await user.type(screen.getByLabelText('Key'), '1leading-digit'); + await user.click(screen.getByRole('button', { name: 'Create entry' })); + + expect( + await screen.findByText('Key must start with a letter and contain only letters, digits, colons, dots, underscores, or hyphens'), + ).toBeInTheDocument(); + }); + + it('submits the renamed key when editing an entry', async () => { + const user = userEvent.setup(); + + renderWithClient( + , + ); + + const keyInput = screen.getByLabelText('Key') as HTMLInputElement; + expect(keyInput).not.toBeDisabled(); + expect(keyInput.value).toBe('App:ServiceName'); + + await user.clear(keyInput); + await user.type(keyInput, 'App:RenamedServiceName'); + await user.click(screen.getByRole('button', { name: 'Save entry' })); + + await waitFor(() => expect(updateEntryMock).toHaveBeenCalledTimes(1)); + const [variables] = updateEntryMock.mock.calls[0]; + expect(variables.body.key).toBe('App:RenamedServiceName'); + expect(variables.id).toBe('33333333-3333-3333-3333-333333333333'); + expect(variables.version).toBe('1'); }); it('locks the form for masked sensitive entries until reveal succeeds', async () => { diff --git a/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx b/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx index 51bf275f..d12d1bf2 100644 --- a/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx @@ -23,7 +23,7 @@ const entrySchema = z.object({ defaultValue: z.string(), description: z.string().max(500, 'Use 500 characters or fewer').optional(), isSensitive: z.boolean(), - key: z.string().min(1, 'Entry key is required').regex(/^[a-zA-Z0-9.:_-]+$/, 'Use letters, numbers, colons, dots, underscores, and hyphens only'), + key: z.string().min(1, 'Entry key is required').regex(/^[a-zA-Z][a-zA-Z0-9.:_-]*$/, 'Key must start with a letter and contain only letters, digits, colons, dots, underscores, or hyphens'), scopedValues: z.array(z.object({ dimension: z.string().optional(), scopeValue: z.string().optional(), value: z.string() })), type: z.enum(valueTypes), }); @@ -32,6 +32,7 @@ type EntryFormValues = z.infer; interface EntryModalProps { entry?: ConfigEntry; + initialKey?: string; mode: 'create' | 'edit'; onOpenChange: (open: boolean) => void; open: boolean; @@ -40,12 +41,12 @@ interface EntryModalProps { projectId?: string; } -export function EntryModal({ entry, mode, onOpenChange, open, ownerId, ownerType = 1, projectId }: EntryModalProps) { +export function EntryModal({ entry, initialKey, mode, onOpenChange, open, ownerId, ownerType = 1, projectId }: EntryModalProps) { const resolvedOwnerId = ownerId ?? projectId ?? ''; const createEntry = useCreateEntry(resolvedOwnerId, ownerType); const updateEntry = useUpdateEntry(resolvedOwnerId, ownerType); const [confirmingDelete, setConfirmingDelete] = useState(false); - const formValues = useMemo(() => toFormValues(entry), [entry]); + const formValues = useMemo(() => toFormValues(entry, initialKey), [entry, initialKey]); const form = useForm({ defaultValues: formValues, resolver: zodResolver(entrySchema), @@ -109,7 +110,7 @@ export function EntryModal({ entry, mode, onOpenChange, open, ownerId, ownerType const body = toRequest(values); if (mode === 'create') { - await createEntry.mutateAsync({ ...body, key: values.key, ownerId: resolvedOwnerId, ownerType }); + await createEntry.mutateAsync({ ...body, ownerId: resolvedOwnerId, ownerType }); } else if (entry) { await updateEntry.mutateAsync({ body, id: entry.id, version: entry.version.toString() }); } @@ -129,7 +130,7 @@ export function EntryModal({ entry, mode, onOpenChange, open, ownerId, ownerType
- + {form.formState.errors.key ?

{form.formState.errors.key.message}

: null}
@@ -184,7 +185,6 @@ export function EntryModal({ entry, mode, onOpenChange, open, ownerId, ownerType