diff --git a/playground/Stress/Stress.ApiService/Program.cs b/playground/Stress/Stress.ApiService/Program.cs index 5e6be438ca1..9806fdae36f 100644 --- a/playground/Stress/Stress.ApiService/Program.cs +++ b/playground/Stress/Stress.ApiService/Program.cs @@ -161,6 +161,36 @@ return $"Sent requests to {string.Join(';', urls)}"; }); +app.MapGet("/http-command-json-result", () => +{ + return Results.Json(new + { + token = Convert.ToBase64String(Guid.NewGuid().ToByteArray()), + issuedAt = DateTimeOffset.UtcNow, + expiresIn = 3600 + }); +}); + +app.MapGet("/http-command-auto-result", () => +{ + if (Random.Shared.Next(2) == 0) + { + return Results.Json(new + { + token = Convert.ToBase64String(Guid.NewGuid().ToByteArray()), + issuedAt = DateTimeOffset.UtcNow, + expiresIn = 3600 + }); + } + + return Results.Text($"Generated text token: {Convert.ToBase64String(Guid.NewGuid().ToByteArray())}"); +}); + +app.MapGet("/http-command-text-result", () => +{ + return Results.Text($"Generated text token: {Convert.ToBase64String(Guid.NewGuid().ToByteArray())}"); +}); + app.MapGet("/log-message-limit", async ([FromServices] ILogger logger) => { const int LogCount = 10_000; diff --git a/playground/Stress/Stress.AppHost/Program.cs b/playground/Stress/Stress.AppHost/Program.cs index 7c621547f37..95a0b501318 100644 --- a/playground/Stress/Stress.AppHost/Program.cs +++ b/playground/Stress/Stress.AppHost/Program.cs @@ -84,6 +84,9 @@ serviceBuilder.WithHttpCommand("/log-message", "Log message", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); serviceBuilder.WithHttpCommand("/log-message-limit", "Log message limit", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); serviceBuilder.WithHttpCommand("/log-message-limit-large", "Log message limit large", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); +serviceBuilder.WithHttpCommand("/http-command-auto-result", "HTTP command auto result", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning", ResultMode = HttpCommandResultMode.Auto, Description = "Run an HTTP command and infer the result format from the response content type" }); +serviceBuilder.WithHttpCommand("/http-command-json-result", "HTTP command JSON result", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning", ResultMode = HttpCommandResultMode.Json, Description = "Run an HTTP command and flow the JSON response back to the caller" }); +serviceBuilder.WithHttpCommand("/http-command-text-result", "HTTP command text result", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning", ResultMode = HttpCommandResultMode.Text, Description = "Run an HTTP command and flow the plain-text response back to the caller" }); serviceBuilder.WithHttpCommand("/multiple-traces-linked", "Multiple traces linked", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); serviceBuilder.WithHttpCommand("/overflow-counter", "Overflow counter", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); serviceBuilder.WithHttpCommand("/nested-trace-spans", "Out of order nested spans", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs index 8d2b152a7e7..573f46e9246 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs @@ -237,9 +237,7 @@ private string GetWrapperOrHandleName(string typeId) /// private static string GetDtoInterfaceName(string typeId) { - // Extract simple type name and use as interface name - var simpleTypeName = ExtractSimpleTypeName(typeId); - return simpleTypeName; + return ExtractSimpleTypeName(typeId); } /// @@ -517,7 +515,7 @@ private string GenerateAspireSdk(AtsContext context) foreach (var cap in builder.Capabilities) { var (_, optionalParams) = SeparateParameters(cap.Parameters); - if (optionalParams.Count > 0) + if (optionalParams.Count > 0 && !TryGetDirectOptionsParameter(optionalParams, out _)) { RegisterOptionsInterface(cap.CapabilityId, cap.MethodName, optionalParams); } @@ -742,6 +740,28 @@ private static (List Required, List Optional return (required, optional); } + private static bool TryGetDirectOptionsParameter(List optionalParams, out AtsParameterInfo? directOptionsParam) + { + directOptionsParam = null; + + // When ATS already exposes a single DTO parameter named "options", reuse that DTO type + // directly so the generated TypeScript API stays flat instead of wrapping it in another + // generated options object. + if (optionalParams.Count != 1) + { + return false; + } + + var candidate = optionalParams[0]; + if (!string.Equals(candidate.Name, "options", StringComparison.Ordinal) || candidate.Type?.Category != AtsTypeCategory.Dto) + { + return false; + } + + directOptionsParam = candidate; + return true; + } + /// /// Registers an options interface to be generated later. /// Uses method name to create the interface name. When methods share a name but have @@ -974,7 +994,8 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab // Separate required and optional parameters var (requiredParams, optionalParams) = SeparateParameters(capability.Parameters); var hasOptionals = optionalParams.Count > 0; - var optionsInterfaceName = ResolveOptionsInterfaceName(capability); + var hasDirectOptionsParameter = TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam); + var optionsTypeName = hasDirectOptionsParameter ? MapParameterToTypeScript(directOptionsParam!) : ResolveOptionsInterfaceName(capability); // Build parameter list for public method var publicParamDefs = new List(); @@ -985,7 +1006,7 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab } if (hasOptionals) { - publicParamDefs.Add($"options?: {optionsInterfaceName}"); + publicParamDefs.Add($"options?: {optionsTypeName}"); } var publicParamsString = string.Join(", ", publicParamDefs); @@ -1039,7 +1060,7 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab WriteLine($"): Promise<{returnType}> {{"); // Extract optional params from options object - foreach (var param in optionalParams) + foreach (var param in hasDirectOptionsParameter ? [] : optionalParams) { WriteLine($" const {param.Name} = options?.{param.Name};"); } @@ -1122,7 +1143,7 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab WriteLine(); // Extract optional params from options object and forward to internal method - foreach (var param in optionalParams) + foreach (var param in hasDirectOptionsParameter ? [] : optionalParams) { WriteLine($" const {param.Name} = options?.{param.Name};"); } @@ -1195,7 +1216,8 @@ private void GenerateThenableClass(BuilderModel builder) // Separate required and optional parameters var (requiredParams, optionalParams) = SeparateParameters(capability.Parameters); var hasOptionals = optionalParams.Count > 0; - var optionsInterfaceName = ResolveOptionsInterfaceName(capability); + var hasDirectOptionsParameter = TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam); + var optionsTypeName = hasDirectOptionsParameter ? MapParameterToTypeScript(directOptionsParam!) : ResolveOptionsInterfaceName(capability); // Build parameter list using options pattern var publicParamDefs = new List(); @@ -1206,7 +1228,7 @@ private void GenerateThenableClass(BuilderModel builder) } if (hasOptionals) { - publicParamDefs.Add($"options?: {optionsInterfaceName}"); + publicParamDefs.Add($"options?: {optionsTypeName}"); } var paramsString = string.Join(", ", publicParamDefs); diff --git a/src/Aspire.Hosting/ApplicationModel/HttpCommandOptions.cs b/src/Aspire.Hosting/ApplicationModel/HttpCommandOptions.cs index 0db1d14d386..e8af61e375f 100644 --- a/src/Aspire.Hosting/ApplicationModel/HttpCommandOptions.cs +++ b/src/Aspire.Hosting/ApplicationModel/HttpCommandOptions.cs @@ -4,7 +4,33 @@ namespace Aspire.Hosting.ApplicationModel; /// -/// Optional configuration for resource HTTP commands added with ."/> +/// Specifies how an HTTP command should surface the HTTP response body as command result data. +/// +public enum HttpCommandResultMode +{ + /// + /// Do not capture the HTTP response body as command result data. + /// + None, + + /// + /// Infer the command result format from the HTTP response content type. + /// + Auto, + + /// + /// Return the HTTP response body as JSON command result data. + /// + Json, + + /// + /// Return the HTTP response body as plain text command result data. + /// + Text +} + +/// +/// Optional configuration for resource HTTP commands added with . /// public class HttpCommandOptions : CommandOptions { @@ -34,4 +60,62 @@ public class HttpCommandOptions : CommandOptions /// Gets or sets a callback to be invoked after the response is received to determine the result of the command invocation. /// public Func>? GetCommandResult { get; set; } + + /// + /// Gets or sets how the HTTP response content should be returned as command result data + /// when is not specified. The default is . + /// + public HttpCommandResultMode ResultMode { get; set; } +} + +/// +/// ATS-friendly configuration for resource HTTP commands. +/// +[AspireDto] +internal sealed class HttpCommandExportOptions +{ + /// + /// Optional description of the command, to be shown in the UI. + /// + public string? Description { get; set; } + + /// + /// When a confirmation message is specified, the UI will prompt with an OK/Cancel dialog before starting the command. + /// + public string? ConfirmationMessage { get; set; } + + /// + /// The icon name for the command. + /// + public string? IconName { get; set; } + + /// + /// The icon variant. + /// + public IconVariant? IconVariant { get; set; } + + /// + /// A flag indicating whether the command is highlighted in the UI. + /// + public bool IsHighlighted { get; set; } + + /// + /// Gets or sets the command name. + /// + public string? CommandName { get; set; } + + /// + /// Gets or sets the HTTP endpoint name to send the request to when the command is invoked. + /// + public string? EndpointName { get; set; } + + /// + /// Gets or sets the HTTP method name to use when sending the request. + /// + public string? MethodName { get; set; } + + /// + /// Gets or sets how the HTTP response content should be returned as command result data. + /// + public HttpCommandResultMode ResultMode { get; set; } } diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 3b5f0410a4a..7fb6dc99d0c 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Headers; using System.Net.Sockets; using System.Reflection; using System.Runtime.CompilerServices; @@ -2334,7 +2335,8 @@ public static IResourceBuilder WithCommand( /// /// /// The callback will be invoked after the response is received to determine the result of the command invocation. If this callback - /// is not specified, the command will be considered succesful if the response status code is in the 2xx range. + /// is not specified, the command will be considered successful if the response status code is in the 2xx range. Set + /// to flow the HTTP response body back to the command caller. /// /// /// Adds a command to the project resource that when invoked sends an HTTP POST request to the path /clear-cache. @@ -2367,7 +2369,7 @@ public static IResourceBuilder WithCommand( /// /// This method is not available in polyglot app hosts. /// - [AspireExportIgnore(Reason = "Func is not ATS-compatible.")] + [AspireExportIgnore(Reason = "Use the ATS-specific withHttpCommand export.")] public static IResourceBuilder WithHttpCommand( this IResourceBuilder builder, string path, @@ -2426,7 +2428,8 @@ public static IResourceBuilder WithHttpCommand( /// /// /// The callback will be invoked after the response is received to determine the result of the command invocation. If this callback - /// is not specified, the command will be considered succesful if the response status code is in the 2xx range. + /// is not specified, the command will be considered successful if the response status code is in the 2xx range. Set + /// to flow the HTTP response body back to the command caller. /// /// /// Adds commands to a project resource that when invoked sends an HTTP POST request to an endpoint on a separate load generator resource, to generate load against the @@ -2442,7 +2445,7 @@ public static IResourceBuilder WithHttpCommand( /// /// This method is not available in polyglot app hosts. /// - [AspireExportIgnore(Reason = "Func delegate — not ATS-compatible.")] + [AspireExportIgnore(Reason = "Use the ATS-specific withHttpCommand export.")] public static IResourceBuilder WithHttpCommand( this IResourceBuilder builder, string path, @@ -2520,9 +2523,7 @@ public static IResourceBuilder WithHttpCommand( return await commandOptions.GetCommandResult(resultContext).ConfigureAwait(false); } - return response.IsSuccessStatusCode - ? CommandResults.Success() - : CommandResults.Failure($"Request failed with status code {response.StatusCode}"); + return await GetDefaultHttpCommandResultAsync(response, commandOptions, context.CancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -2534,6 +2535,132 @@ public static IResourceBuilder WithHttpCommand( return builder; } + [AspireExport("withHttpCommand", Description = "Adds an HTTP resource command")] + internal static IResourceBuilder WithHttpCommandExport( + this IResourceBuilder builder, + string path, + string displayName, + HttpCommandExportOptions? options = null) + where TResource : IResourceWithEndpoints + => builder.WithHttpCommand( + path, + displayName, + options?.EndpointName, + options?.CommandName, + CreateHttpCommandOptions(options)); + + private static HttpCommandOptions? CreateHttpCommandOptions(HttpCommandExportOptions? exportOptions) + { + if (exportOptions is null) + { + return null; + } + + return new HttpCommandOptions + { + Description = exportOptions.Description, + ConfirmationMessage = exportOptions.ConfirmationMessage, + IconName = exportOptions.IconName, + IconVariant = exportOptions.IconVariant, + IsHighlighted = exportOptions.IsHighlighted, + Method = !string.IsNullOrWhiteSpace(exportOptions.MethodName) ? new HttpMethod(exportOptions.MethodName) : null, + ResultMode = exportOptions.ResultMode + }; + } + + internal static async Task GetDefaultHttpCommandResultAsync(HttpResponseMessage response, HttpCommandOptions commandOptions, CancellationToken cancellationToken) + { + var errorMessage = response.IsSuccessStatusCode + ? null + : $"Request failed with status code {response.StatusCode}"; + + if (TryGetHttpCommandResultFormat(commandOptions.ResultMode, response.Content?.Headers.ContentType, out var resultFormat) && + response.Content is not null) + { + var result = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrEmpty(result)) + { + return errorMessage is null + ? CommandResults.Success(result, resultFormat) + : CommandResults.Failure(errorMessage, result, resultFormat); + } + } + + return errorMessage is null + ? CommandResults.Success() + : CommandResults.Failure(errorMessage); + } + + private static bool TryGetHttpCommandResultFormat(HttpCommandResultMode resultMode, MediaTypeHeaderValue? contentType, out CommandResultFormat resultFormat) + { + resultFormat = default; + + switch (resultMode) + { + case HttpCommandResultMode.None: + return false; + case HttpCommandResultMode.Json: + resultFormat = CommandResultFormat.Json; + return true; + case HttpCommandResultMode.Text: + resultFormat = CommandResultFormat.Text; + return true; + case HttpCommandResultMode.Auto: + return TryInferHttpCommandResultFormat(contentType, out resultFormat); + default: + throw new InvalidOperationException($"Unsupported {nameof(HttpCommandResultMode)} value '{resultMode}'."); + } + } + + internal static bool TryInferHttpCommandResultFormat(MediaTypeHeaderValue? contentType, out CommandResultFormat resultFormat) + { + switch (GetKnownHttpCommandResultContentType(contentType)) + { + case KnownHttpCommandResultContentType.Json: + resultFormat = CommandResultFormat.Json; + return true; + case KnownHttpCommandResultContentType.Text: + resultFormat = CommandResultFormat.Text; + return true; + default: + resultFormat = default; + return false; + } + } + + private static KnownHttpCommandResultContentType GetKnownHttpCommandResultContentType(MediaTypeHeaderValue? contentType) + { + var mediaType = contentType?.MediaType; + + if (string.IsNullOrEmpty(mediaType)) + { + return KnownHttpCommandResultContentType.None; + } + + if (mediaType.Equals("application/json", StringComparison.OrdinalIgnoreCase) || + mediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase)) + { + return KnownHttpCommandResultContentType.Json; + } + + if (mediaType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) || + mediaType.Equals("application/xml", StringComparison.OrdinalIgnoreCase) || + mediaType.EndsWith("+xml", StringComparison.OrdinalIgnoreCase) || + mediaType.Equals("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)) + { + return KnownHttpCommandResultContentType.Text; + } + + return KnownHttpCommandResultContentType.None; + } + + private enum KnownHttpCommandResultContentType + { + None, + Json, + Text + } + /// /// Adds a to the resource annotations to associate a certificate authority collection with the resource. /// This is used to configure additional trusted certificate authorities for the resource. diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go index dc09cf3410b..0c429507247 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -127,6 +127,16 @@ const ( EndpointPropertyTlsEnabled EndpointProperty = "TlsEnabled" ) +// HttpCommandResultMode represents HttpCommandResultMode. +type HttpCommandResultMode string + +const ( + HttpCommandResultModeNone HttpCommandResultMode = "None" + HttpCommandResultModeAuto HttpCommandResultMode = "Auto" + HttpCommandResultModeJson HttpCommandResultMode = "Json" + HttpCommandResultModeText HttpCommandResultMode = "Text" +) + // CommandResultFormat represents CommandResultFormat. type CommandResultFormat string @@ -238,6 +248,34 @@ func (d *CommandOptions) ToMap() map[string]any { } } +// HttpCommandExportOptions represents HttpCommandExportOptions. +type HttpCommandExportOptions struct { + Description string `json:"Description,omitempty"` + ConfirmationMessage string `json:"ConfirmationMessage,omitempty"` + IconName string `json:"IconName,omitempty"` + IconVariant IconVariant `json:"IconVariant,omitempty"` + IsHighlighted bool `json:"IsHighlighted,omitempty"` + CommandName string `json:"CommandName,omitempty"` + EndpointName string `json:"EndpointName,omitempty"` + MethodName string `json:"MethodName,omitempty"` + ResultMode HttpCommandResultMode `json:"ResultMode,omitempty"` +} + +// ToMap converts the DTO to a map for JSON serialization. +func (d *HttpCommandExportOptions) ToMap() map[string]any { + return map[string]any{ + "Description": SerializeValue(d.Description), + "ConfirmationMessage": SerializeValue(d.ConfirmationMessage), + "IconName": SerializeValue(d.IconName), + "IconVariant": SerializeValue(d.IconVariant), + "IsHighlighted": SerializeValue(d.IsHighlighted), + "CommandName": SerializeValue(d.CommandName), + "EndpointName": SerializeValue(d.EndpointName), + "MethodName": SerializeValue(d.MethodName), + "ResultMode": SerializeValue(d.ResultMode), + } +} + // ExecuteCommandResult represents ExecuteCommandResult. type ExecuteCommandResult struct { Success bool `json:"Success,omitempty"` @@ -1123,6 +1161,23 @@ func (s *CSharpAppResource) WithCommand(name string, displayName string, execute return result.(*IResource), nil } +// WithHttpCommand adds an HTTP resource command +func (s *CSharpAppResource) WithHttpCommand(path string, displayName string, options *HttpCommandExportOptions) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["path"] = SerializeValue(path) + reqArgs["displayName"] = SerializeValue(displayName) + if options != nil { + reqArgs["options"] = SerializeValue(options) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withHttpCommand", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + // WithDeveloperCertificateTrust configures developer certificate trust func (s *CSharpAppResource) WithDeveloperCertificateTrust(trust bool) (*IResourceWithEnvironment, error) { reqArgs := map[string]any{ @@ -4203,6 +4258,23 @@ func (s *ContainerResource) WithCommand(name string, displayName string, execute return result.(*IResource), nil } +// WithHttpCommand adds an HTTP resource command +func (s *ContainerResource) WithHttpCommand(path string, displayName string, options *HttpCommandExportOptions) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["path"] = SerializeValue(path) + reqArgs["displayName"] = SerializeValue(displayName) + if options != nil { + reqArgs["options"] = SerializeValue(options) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withHttpCommand", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + // WithDeveloperCertificateTrust configures developer certificate trust func (s *ContainerResource) WithDeveloperCertificateTrust(trust bool) (*IResourceWithEnvironment, error) { reqArgs := map[string]any{ @@ -5796,6 +5868,23 @@ func (s *DotnetToolResource) WithCommand(name string, displayName string, execut return result.(*IResource), nil } +// WithHttpCommand adds an HTTP resource command +func (s *DotnetToolResource) WithHttpCommand(path string, displayName string, options *HttpCommandExportOptions) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["path"] = SerializeValue(path) + reqArgs["displayName"] = SerializeValue(displayName) + if options != nil { + reqArgs["options"] = SerializeValue(options) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withHttpCommand", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + // WithDeveloperCertificateTrust configures developer certificate trust func (s *DotnetToolResource) WithDeveloperCertificateTrust(trust bool) (*IResourceWithEnvironment, error) { reqArgs := map[string]any{ @@ -7465,6 +7554,23 @@ func (s *ExecutableResource) WithCommand(name string, displayName string, execut return result.(*IResource), nil } +// WithHttpCommand adds an HTTP resource command +func (s *ExecutableResource) WithHttpCommand(path string, displayName string, options *HttpCommandExportOptions) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["path"] = SerializeValue(path) + reqArgs["displayName"] = SerializeValue(displayName) + if options != nil { + reqArgs["options"] = SerializeValue(options) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withHttpCommand", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + // WithDeveloperCertificateTrust configures developer certificate trust func (s *ExecutableResource) WithDeveloperCertificateTrust(trust bool) (*IResourceWithEnvironment, error) { reqArgs := map[string]any{ @@ -12118,6 +12224,23 @@ func (s *ProjectResource) WithCommand(name string, displayName string, executeCo return result.(*IResource), nil } +// WithHttpCommand adds an HTTP resource command +func (s *ProjectResource) WithHttpCommand(path string, displayName string, options *HttpCommandExportOptions) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["path"] = SerializeValue(path) + reqArgs["displayName"] = SerializeValue(displayName) + if options != nil { + reqArgs["options"] = SerializeValue(options) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withHttpCommand", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + // WithDeveloperCertificateTrust configures developer certificate trust func (s *ProjectResource) WithDeveloperCertificateTrust(trust bool) (*IResourceWithEnvironment, error) { reqArgs := map[string]any{ @@ -14207,6 +14330,23 @@ func (s *TestDatabaseResource) WithCommand(name string, displayName string, exec return result.(*IResource), nil } +// WithHttpCommand adds an HTTP resource command +func (s *TestDatabaseResource) WithHttpCommand(path string, displayName string, options *HttpCommandExportOptions) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["path"] = SerializeValue(path) + reqArgs["displayName"] = SerializeValue(displayName) + if options != nil { + reqArgs["options"] = SerializeValue(options) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withHttpCommand", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + // WithDeveloperCertificateTrust configures developer certificate trust func (s *TestDatabaseResource) WithDeveloperCertificateTrust(trust bool) (*IResourceWithEnvironment, error) { reqArgs := map[string]any{ @@ -15841,6 +15981,23 @@ func (s *TestRedisResource) WithCommand(name string, displayName string, execute return result.(*IResource), nil } +// WithHttpCommand adds an HTTP resource command +func (s *TestRedisResource) WithHttpCommand(path string, displayName string, options *HttpCommandExportOptions) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["path"] = SerializeValue(path) + reqArgs["displayName"] = SerializeValue(displayName) + if options != nil { + reqArgs["options"] = SerializeValue(options) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withHttpCommand", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + // WithDeveloperCertificateTrust configures developer certificate trust func (s *TestRedisResource) WithDeveloperCertificateTrust(trust bool) (*IResourceWithEnvironment, error) { reqArgs := map[string]any{ @@ -17631,6 +17788,23 @@ func (s *TestVaultResource) WithCommand(name string, displayName string, execute return result.(*IResource), nil } +// WithHttpCommand adds an HTTP resource command +func (s *TestVaultResource) WithHttpCommand(path string, displayName string, options *HttpCommandExportOptions) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["path"] = SerializeValue(path) + reqArgs["displayName"] = SerializeValue(displayName) + if options != nil { + reqArgs["options"] = SerializeValue(options) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withHttpCommand", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + // WithDeveloperCertificateTrust configures developer certificate trust func (s *TestVaultResource) WithDeveloperCertificateTrust(trust bool) (*IResourceWithEnvironment, error) { reqArgs := map[string]any{ diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index e1bb1aa0e9c..ac5dfae7f4b 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -1923,6 +1923,23 @@ public CSharpAppResource withCommand(String name, String displayName, AspireFunc return this; } + public CSharpAppResource withHttpCommand(String path, String displayName) { + return withHttpCommand(path, displayName, null); + } + + /** Adds an HTTP resource command */ + public CSharpAppResource withHttpCommand(String path, String displayName, HttpCommandExportOptions options) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("path", AspireClient.serializeValue(path)); + reqArgs.put("displayName", AspireClient.serializeValue(displayName)); + if (options != null) { + reqArgs.put("options", AspireClient.serializeValue(options)); + } + getClient().invokeCapability("Aspire.Hosting/withHttpCommand", reqArgs); + return this; + } + /** Configures developer certificate trust */ public CSharpAppResource withDeveloperCertificateTrust(boolean trust) { Map reqArgs = new HashMap<>(); @@ -5217,6 +5234,23 @@ public ContainerResource withCommand(String name, String displayName, AspireFunc return this; } + public ContainerResource withHttpCommand(String path, String displayName) { + return withHttpCommand(path, displayName, null); + } + + /** Adds an HTTP resource command */ + public ContainerResource withHttpCommand(String path, String displayName, HttpCommandExportOptions options) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("path", AspireClient.serializeValue(path)); + reqArgs.put("displayName", AspireClient.serializeValue(displayName)); + if (options != null) { + reqArgs.put("options", AspireClient.serializeValue(options)); + } + getClient().invokeCapability("Aspire.Hosting/withHttpCommand", reqArgs); + return this; + } + /** Configures developer certificate trust */ public ContainerResource withDeveloperCertificateTrust(boolean trust) { Map reqArgs = new HashMap<>(); @@ -6867,6 +6901,23 @@ public DotnetToolResource withCommand(String name, String displayName, AspireFun return this; } + public DotnetToolResource withHttpCommand(String path, String displayName) { + return withHttpCommand(path, displayName, null); + } + + /** Adds an HTTP resource command */ + public DotnetToolResource withHttpCommand(String path, String displayName, HttpCommandExportOptions options) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("path", AspireClient.serializeValue(path)); + reqArgs.put("displayName", AspireClient.serializeValue(displayName)); + if (options != null) { + reqArgs.put("options", AspireClient.serializeValue(options)); + } + getClient().invokeCapability("Aspire.Hosting/withHttpCommand", reqArgs); + return this; + } + /** Configures developer certificate trust */ public DotnetToolResource withDeveloperCertificateTrust(boolean trust) { Map reqArgs = new HashMap<>(); @@ -8456,6 +8507,23 @@ public ExecutableResource withCommand(String name, String displayName, AspireFun return this; } + public ExecutableResource withHttpCommand(String path, String displayName) { + return withHttpCommand(path, displayName, null); + } + + /** Adds an HTTP resource command */ + public ExecutableResource withHttpCommand(String path, String displayName, HttpCommandExportOptions options) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("path", AspireClient.serializeValue(path)); + reqArgs.put("displayName", AspireClient.serializeValue(displayName)); + if (options != null) { + reqArgs.put("options", AspireClient.serializeValue(options)); + } + getClient().invokeCapability("Aspire.Hosting/withHttpCommand", reqArgs); + return this; + } + /** Configures developer certificate trust */ public ExecutableResource withDeveloperCertificateTrust(boolean trust) { Map reqArgs = new HashMap<>(); @@ -9911,6 +9979,91 @@ AspireClient getClient() { } } +// ===== HttpCommandExportOptions.java ===== +// HttpCommandExportOptions.java - GENERATED CODE - DO NOT EDIT + +package aspire; + +import java.util.*; +import java.util.function.*; + +/** HttpCommandExportOptions DTO. */ +public class HttpCommandExportOptions { + private String description; + private String confirmationMessage; + private String iconName; + private IconVariant iconVariant; + private boolean isHighlighted; + private String commandName; + private String endpointName; + private String methodName; + private HttpCommandResultMode resultMode; + + public String getDescription() { return description; } + public void setDescription(String value) { this.description = value; } + public String getConfirmationMessage() { return confirmationMessage; } + public void setConfirmationMessage(String value) { this.confirmationMessage = value; } + public String getIconName() { return iconName; } + public void setIconName(String value) { this.iconName = value; } + public IconVariant getIconVariant() { return iconVariant; } + public void setIconVariant(IconVariant value) { this.iconVariant = value; } + public boolean getIsHighlighted() { return isHighlighted; } + public void setIsHighlighted(boolean value) { this.isHighlighted = value; } + public String getCommandName() { return commandName; } + public void setCommandName(String value) { this.commandName = value; } + public String getEndpointName() { return endpointName; } + public void setEndpointName(String value) { this.endpointName = value; } + public String getMethodName() { return methodName; } + public void setMethodName(String value) { this.methodName = value; } + public HttpCommandResultMode getResultMode() { return resultMode; } + public void setResultMode(HttpCommandResultMode value) { this.resultMode = value; } + + public Map toMap() { + Map map = new HashMap<>(); + map.put("Description", AspireClient.serializeValue(description)); + map.put("ConfirmationMessage", AspireClient.serializeValue(confirmationMessage)); + map.put("IconName", AspireClient.serializeValue(iconName)); + map.put("IconVariant", AspireClient.serializeValue(iconVariant)); + map.put("IsHighlighted", AspireClient.serializeValue(isHighlighted)); + map.put("CommandName", AspireClient.serializeValue(commandName)); + map.put("EndpointName", AspireClient.serializeValue(endpointName)); + map.put("MethodName", AspireClient.serializeValue(methodName)); + map.put("ResultMode", AspireClient.serializeValue(resultMode)); + return map; + } +} + +// ===== HttpCommandResultMode.java ===== +// HttpCommandResultMode.java - GENERATED CODE - DO NOT EDIT + +package aspire; + +import java.util.*; +import java.util.function.*; + +/** HttpCommandResultMode enum. */ +public enum HttpCommandResultMode implements WireValueEnum { + NONE("None"), + AUTO("Auto"), + JSON("Json"), + TEXT("Text"); + + private final String value; + + HttpCommandResultMode(String value) { + this.value = value; + } + + public String getValue() { return value; } + + public static HttpCommandResultMode fromValue(String value) { + for (HttpCommandResultMode e : values()) { + if (e.value.equals(value)) return e; + } + throw new IllegalArgumentException("Unknown value: " + value); + } +} + // ===== IComputeResource.java ===== // IComputeResource.java - GENERATED CODE - DO NOT EDIT @@ -13066,6 +13219,23 @@ public ProjectResource withCommand(String name, String displayName, AspireFunc1< return this; } + public ProjectResource withHttpCommand(String path, String displayName) { + return withHttpCommand(path, displayName, null); + } + + /** Adds an HTTP resource command */ + public ProjectResource withHttpCommand(String path, String displayName, HttpCommandExportOptions options) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("path", AspireClient.serializeValue(path)); + reqArgs.put("displayName", AspireClient.serializeValue(displayName)); + if (options != null) { + reqArgs.put("options", AspireClient.serializeValue(options)); + } + getClient().invokeCapability("Aspire.Hosting/withHttpCommand", reqArgs); + return this; + } + /** Configures developer certificate trust */ public ProjectResource withDeveloperCertificateTrust(boolean trust) { Map reqArgs = new HashMap<>(); @@ -15367,6 +15537,23 @@ public TestDatabaseResource withCommand(String name, String displayName, AspireF return this; } + public TestDatabaseResource withHttpCommand(String path, String displayName) { + return withHttpCommand(path, displayName, null); + } + + /** Adds an HTTP resource command */ + public TestDatabaseResource withHttpCommand(String path, String displayName, HttpCommandExportOptions options) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("path", AspireClient.serializeValue(path)); + reqArgs.put("displayName", AspireClient.serializeValue(displayName)); + if (options != null) { + reqArgs.put("options", AspireClient.serializeValue(options)); + } + getClient().invokeCapability("Aspire.Hosting/withHttpCommand", reqArgs); + return this; + } + /** Configures developer certificate trust */ public TestDatabaseResource withDeveloperCertificateTrust(boolean trust) { Map reqArgs = new HashMap<>(); @@ -17014,6 +17201,23 @@ public TestRedisResource withCommand(String name, String displayName, AspireFunc return this; } + public TestRedisResource withHttpCommand(String path, String displayName) { + return withHttpCommand(path, displayName, null); + } + + /** Adds an HTTP resource command */ + public TestRedisResource withHttpCommand(String path, String displayName, HttpCommandExportOptions options) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("path", AspireClient.serializeValue(path)); + reqArgs.put("displayName", AspireClient.serializeValue(displayName)); + if (options != null) { + reqArgs.put("options", AspireClient.serializeValue(options)); + } + getClient().invokeCapability("Aspire.Hosting/withHttpCommand", reqArgs); + return this; + } + /** Configures developer certificate trust */ public TestRedisResource withDeveloperCertificateTrust(boolean trust) { Map reqArgs = new HashMap<>(); @@ -18758,6 +18962,23 @@ public TestVaultResource withCommand(String name, String displayName, AspireFunc return this; } + public TestVaultResource withHttpCommand(String path, String displayName) { + return withHttpCommand(path, displayName, null); + } + + /** Adds an HTTP resource command */ + public TestVaultResource withHttpCommand(String path, String displayName, HttpCommandExportOptions options) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("path", AspireClient.serializeValue(path)); + reqArgs.put("displayName", AspireClient.serializeValue(displayName)); + if (options != null) { + reqArgs.put("options", AspireClient.serializeValue(options)); + } + getClient().invokeCapability("Aspire.Hosting/withHttpCommand", reqArgs); + return this; + } + /** Configures developer certificate trust */ public TestVaultResource withDeveloperCertificateTrust(boolean trust) { Map reqArgs = new HashMap<>(); @@ -20147,6 +20368,8 @@ public WithVolumeOptions isReadOnly(Boolean value) { .modules/ExternalServiceResource.java .modules/Handle.java .modules/HandleWrapperBase.java +.modules/HttpCommandExportOptions.java +.modules/HttpCommandResultMode.java .modules/IComputeResource.java .modules/IConfiguration.java .modules/IConfigurationSection.java diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index 8c2d50371dd..b2c33f6f62f 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -1499,6 +1499,8 @@ def _validate_dict_types(args: typing.Any, arg_types: typing.Any) -> bool: EndpointProperty = typing.Literal["Url", "Host", "IPV4Host", "Port", "Scheme", "TargetPort", "HostAndPort", "TlsEnabled"] +HttpCommandResultMode = typing.Literal["None", "Auto", "Json", "Text"] + IconVariant = typing.Literal["Regular", "Filled"] ImagePullPolicy = typing.Literal["Default", "Always", "Missing", "Never"] @@ -1627,6 +1629,12 @@ class HttpHealthCheckParameters(typing.TypedDict, total=False): endpoint_name: str +class HttpCommandParameters(typing.TypedDict, total=False): + path: typing.Required[str] + display_name: typing.Required[str] + options: HttpCommandExportOptions + + class HttpProbeParameters(typing.TypedDict, total=False): probe_type: typing.Required[ProbeType] path: str @@ -1683,6 +1691,17 @@ class ExecuteCommandResult(typing.TypedDict, total=False): Result: str ResultFormat: CommandResultFormat +class HttpCommandExportOptions(typing.TypedDict, total=False): + Description: str + ConfirmationMessage: str + IconName: str + IconVariant: IconVariant + IsHighlighted: bool + CommandName: str + EndpointName: str + MethodName: str + ResultMode: HttpCommandResultMode + class ResourceEventDto(typing.TypedDict, total=False): ResourceName: str ResourceId: str @@ -4832,6 +4851,10 @@ def with_url_for_endpoint_factory(self, endpoint_name: str, callback: typing.Cal def with_http_health_check(self, *, path: str | None = None, status_code: int | None = None, endpoint_name: str | None = None) -> typing.Self: """Adds an HTTP health check""" + @abc.abstractmethod + def with_http_command(self, path: str, display_name: str, *, options: HttpCommandExportOptions | None = None) -> typing.Self: + """Adds an HTTP resource command""" + @abc.abstractmethod def with_http_probe(self, probe_type: ProbeType, *, path: str | None = None, initial_delay_seconds: int | None = None, period_seconds: int | None = None, timeout_seconds: int | None = None, failure_threshold: int | None = None, success_threshold: int | None = None, endpoint_name: str | None = None) -> typing.Self: """Adds an HTTP health probe to the resource""" @@ -6075,6 +6098,7 @@ class ContainerResourceKwargs(_BaseResourceKwargs, total=False): wait_for_start: AbstractResource | tuple[AbstractResource, WaitBehavior] wait_for_completion: AbstractResource | tuple[AbstractResource, int] http_health_check: HttpHealthCheckParameters | typing.Literal[True] + http_command: tuple[str, str] | HttpCommandParameters developer_certificate_trust: bool certificate_trust_scope: CertificateTrustScope https_developer_certificate: ParameterResource | typing.Literal[True] @@ -6625,6 +6649,20 @@ def with_http_health_check(self, *, path: str | None = None, status_code: int | self._handle = self._wrap_builder(result) return self + def with_http_command(self, path: str, display_name: str, *, options: HttpCommandExportOptions | None = None) -> typing.Self: + """Adds an HTTP resource command""" + rpc_args: dict[str, typing.Any] = {'builder': self._handle} + rpc_args['path'] = path + rpc_args['displayName'] = display_name + if options is not None: + rpc_args['options'] = options + result = self._client.invoke_capability( + 'Aspire.Hosting/withHttpCommand', + rpc_args, + ) + self._handle = self._wrap_builder(result) + return self + def with_developer_certificate_trust(self, trust: bool) -> typing.Self: """Configures developer certificate trust""" rpc_args: dict[str, typing.Any] = {'builder': self._handle} @@ -7133,6 +7171,20 @@ def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withHttpHealthCheck', rpc_args)) else: raise TypeError("Invalid type for option 'http_health_check'. Expected: HttpHealthCheckParameters or Literal[True]") + if _http_command := kwargs.pop("http_command", None): + if _validate_tuple_types(_http_command, (str, str)): + rpc_args: dict[str, typing.Any] = {"builder": handle} + rpc_args["path"] = typing.cast(tuple[str, str], _http_command)[0] + rpc_args["displayName"] = typing.cast(tuple[str, str], _http_command)[1] + handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withHttpCommand', rpc_args)) + elif _validate_dict_types(_http_command, HttpCommandParameters): + rpc_args: dict[str, typing.Any] = {"builder": handle} + rpc_args["path"] = typing.cast(HttpCommandParameters, _http_command)["path"] + rpc_args["displayName"] = typing.cast(HttpCommandParameters, _http_command)["display_name"] + rpc_args["options"] = typing.cast(HttpCommandParameters, _http_command).get("options") + handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withHttpCommand', rpc_args)) + else: + raise TypeError("Invalid type for option 'http_command'. Expected: (str, str) or HttpCommandParameters") if _developer_certificate_trust := kwargs.pop("developer_certificate_trust", None): if _validate_type(_developer_certificate_trust, bool): rpc_args: dict[str, typing.Any] = {"builder": handle} @@ -7263,6 +7315,7 @@ class ProjectResourceKwargs(_BaseResourceKwargs, total=False): wait_for_start: AbstractResource | tuple[AbstractResource, WaitBehavior] wait_for_completion: AbstractResource | tuple[AbstractResource, int] http_health_check: HttpHealthCheckParameters | typing.Literal[True] + http_command: tuple[str, str] | HttpCommandParameters developer_certificate_trust: bool certificate_trust_scope: CertificateTrustScope https_developer_certificate: ParameterResource | typing.Literal[True] @@ -7661,6 +7714,20 @@ def with_http_health_check(self, *, path: str | None = None, status_code: int | self._handle = self._wrap_builder(result) return self + def with_http_command(self, path: str, display_name: str, *, options: HttpCommandExportOptions | None = None) -> typing.Self: + """Adds an HTTP resource command""" + rpc_args: dict[str, typing.Any] = {'builder': self._handle} + rpc_args['path'] = path + rpc_args['displayName'] = display_name + if options is not None: + rpc_args['options'] = options + result = self._client.invoke_capability( + 'Aspire.Hosting/withHttpCommand', + rpc_args, + ) + self._handle = self._wrap_builder(result) + return self + def with_developer_certificate_trust(self, trust: bool) -> typing.Self: """Configures developer certificate trust""" rpc_args: dict[str, typing.Any] = {'builder': self._handle} @@ -8048,6 +8115,20 @@ def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withHttpHealthCheck', rpc_args)) else: raise TypeError("Invalid type for option 'http_health_check'. Expected: HttpHealthCheckParameters or Literal[True]") + if _http_command := kwargs.pop("http_command", None): + if _validate_tuple_types(_http_command, (str, str)): + rpc_args: dict[str, typing.Any] = {"builder": handle} + rpc_args["path"] = typing.cast(tuple[str, str], _http_command)[0] + rpc_args["displayName"] = typing.cast(tuple[str, str], _http_command)[1] + handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withHttpCommand', rpc_args)) + elif _validate_dict_types(_http_command, HttpCommandParameters): + rpc_args: dict[str, typing.Any] = {"builder": handle} + rpc_args["path"] = typing.cast(HttpCommandParameters, _http_command)["path"] + rpc_args["displayName"] = typing.cast(HttpCommandParameters, _http_command)["display_name"] + rpc_args["options"] = typing.cast(HttpCommandParameters, _http_command).get("options") + handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withHttpCommand', rpc_args)) + else: + raise TypeError("Invalid type for option 'http_command'. Expected: (str, str) or HttpCommandParameters") if _developer_certificate_trust := kwargs.pop("developer_certificate_trust", None): if _validate_type(_developer_certificate_trust, bool): rpc_args: dict[str, typing.Any] = {"builder": handle} @@ -8178,6 +8259,7 @@ class ExecutableResourceKwargs(_BaseResourceKwargs, total=False): wait_for_start: AbstractResource | tuple[AbstractResource, WaitBehavior] wait_for_completion: AbstractResource | tuple[AbstractResource, int] http_health_check: HttpHealthCheckParameters | typing.Literal[True] + http_command: tuple[str, str] | HttpCommandParameters developer_certificate_trust: bool certificate_trust_scope: CertificateTrustScope https_developer_certificate: ParameterResource | typing.Literal[True] @@ -8566,6 +8648,20 @@ def with_http_health_check(self, *, path: str | None = None, status_code: int | self._handle = self._wrap_builder(result) return self + def with_http_command(self, path: str, display_name: str, *, options: HttpCommandExportOptions | None = None) -> typing.Self: + """Adds an HTTP resource command""" + rpc_args: dict[str, typing.Any] = {'builder': self._handle} + rpc_args['path'] = path + rpc_args['displayName'] = display_name + if options is not None: + rpc_args['options'] = options + result = self._client.invoke_capability( + 'Aspire.Hosting/withHttpCommand', + rpc_args, + ) + self._handle = self._wrap_builder(result) + return self + def with_developer_certificate_trust(self, trust: bool) -> typing.Self: """Configures developer certificate trust""" rpc_args: dict[str, typing.Any] = {'builder': self._handle} @@ -8946,6 +9042,20 @@ def __init__(self, handle: Handle, client: AspireClient, **kwargs: typing.Unpack handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withHttpHealthCheck', rpc_args)) else: raise TypeError("Invalid type for option 'http_health_check'. Expected: HttpHealthCheckParameters or Literal[True]") + if _http_command := kwargs.pop("http_command", None): + if _validate_tuple_types(_http_command, (str, str)): + rpc_args: dict[str, typing.Any] = {"builder": handle} + rpc_args["path"] = typing.cast(tuple[str, str], _http_command)[0] + rpc_args["displayName"] = typing.cast(tuple[str, str], _http_command)[1] + handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withHttpCommand', rpc_args)) + elif _validate_dict_types(_http_command, HttpCommandParameters): + rpc_args: dict[str, typing.Any] = {"builder": handle} + rpc_args["path"] = typing.cast(HttpCommandParameters, _http_command)["path"] + rpc_args["displayName"] = typing.cast(HttpCommandParameters, _http_command)["display_name"] + rpc_args["options"] = typing.cast(HttpCommandParameters, _http_command).get("options") + handle = self._wrap_builder(client.invoke_capability('Aspire.Hosting/withHttpCommand', rpc_args)) + else: + raise TypeError("Invalid type for option 'http_command'. Expected: (str, str) or HttpCommandParameters") if _developer_certificate_trust := kwargs.pop("developer_certificate_trust", None): if _validate_type(_developer_certificate_trust, bool): rpc_args: dict[str, typing.Any] = {"builder": handle} diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs index 164d20528a9..37f7eb39725 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -315,6 +315,31 @@ impl std::fmt::Display for EndpointProperty { } } +/// HttpCommandResultMode +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum HttpCommandResultMode { + #[default] + #[serde(rename = "None")] + None, + #[serde(rename = "Auto")] + Auto, + #[serde(rename = "Json")] + Json, + #[serde(rename = "Text")] + Text, +} + +impl std::fmt::Display for HttpCommandResultMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::None => write!(f, "None"), + Self::Auto => write!(f, "Auto"), + Self::Json => write!(f, "Json"), + Self::Text => write!(f, "Text"), + } + } +} + /// CommandResultFormat #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum CommandResultFormat { @@ -503,6 +528,45 @@ impl CommandOptions { } } +/// HttpCommandExportOptions +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct HttpCommandExportOptions { + #[serde(rename = "Description")] + pub description: String, + #[serde(rename = "ConfirmationMessage")] + pub confirmation_message: String, + #[serde(rename = "IconName")] + pub icon_name: String, + #[serde(rename = "IconVariant")] + pub icon_variant: IconVariant, + #[serde(rename = "IsHighlighted")] + pub is_highlighted: bool, + #[serde(rename = "CommandName")] + pub command_name: String, + #[serde(rename = "EndpointName")] + pub endpoint_name: String, + #[serde(rename = "MethodName")] + pub method_name: String, + #[serde(rename = "ResultMode")] + pub result_mode: HttpCommandResultMode, +} + +impl HttpCommandExportOptions { + pub fn to_map(&self) -> HashMap { + let mut map = HashMap::new(); + map.insert("Description".to_string(), serde_json::to_value(&self.description).unwrap_or(Value::Null)); + map.insert("ConfirmationMessage".to_string(), serde_json::to_value(&self.confirmation_message).unwrap_or(Value::Null)); + map.insert("IconName".to_string(), serde_json::to_value(&self.icon_name).unwrap_or(Value::Null)); + map.insert("IconVariant".to_string(), serde_json::to_value(&self.icon_variant).unwrap_or(Value::Null)); + map.insert("IsHighlighted".to_string(), serde_json::to_value(&self.is_highlighted).unwrap_or(Value::Null)); + map.insert("CommandName".to_string(), serde_json::to_value(&self.command_name).unwrap_or(Value::Null)); + map.insert("EndpointName".to_string(), serde_json::to_value(&self.endpoint_name).unwrap_or(Value::Null)); + map.insert("MethodName".to_string(), serde_json::to_value(&self.method_name).unwrap_or(Value::Null)); + map.insert("ResultMode".to_string(), serde_json::to_value(&self.result_mode).unwrap_or(Value::Null)); + map + } +} + /// ExecuteCommandResult #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ExecuteCommandResult { @@ -1318,6 +1382,20 @@ impl CSharpAppResource { Ok(IResource::new(handle, self.client.clone())) } + /// Adds an HTTP resource command + pub fn with_http_command(&self, path: &str, display_name: &str, options: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("path".to_string(), serde_json::to_value(&path).unwrap_or(Value::Null)); + args.insert("displayName".to_string(), serde_json::to_value(&display_name).unwrap_or(Value::Null)); + if let Some(ref v) = options { + args.insert("options".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withHttpCommand", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + /// Configures developer certificate trust pub fn with_developer_certificate_trust(&self, trust: bool) -> Result> { let mut args: HashMap = HashMap::new(); @@ -3813,6 +3891,20 @@ impl ContainerResource { Ok(IResource::new(handle, self.client.clone())) } + /// Adds an HTTP resource command + pub fn with_http_command(&self, path: &str, display_name: &str, options: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("path".to_string(), serde_json::to_value(&path).unwrap_or(Value::Null)); + args.insert("displayName".to_string(), serde_json::to_value(&display_name).unwrap_or(Value::Null)); + if let Some(ref v) = options { + args.insert("options".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withHttpCommand", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + /// Configures developer certificate trust pub fn with_developer_certificate_trust(&self, trust: bool) -> Result> { let mut args: HashMap = HashMap::new(); @@ -5181,6 +5273,20 @@ impl DotnetToolResource { Ok(IResource::new(handle, self.client.clone())) } + /// Adds an HTTP resource command + pub fn with_http_command(&self, path: &str, display_name: &str, options: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("path".to_string(), serde_json::to_value(&path).unwrap_or(Value::Null)); + args.insert("displayName".to_string(), serde_json::to_value(&display_name).unwrap_or(Value::Null)); + if let Some(ref v) = options { + args.insert("options".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withHttpCommand", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + /// Configures developer certificate trust pub fn with_developer_certificate_trust(&self, trust: bool) -> Result> { let mut args: HashMap = HashMap::new(); @@ -6537,6 +6643,20 @@ impl ExecutableResource { Ok(IResource::new(handle, self.client.clone())) } + /// Adds an HTTP resource command + pub fn with_http_command(&self, path: &str, display_name: &str, options: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("path".to_string(), serde_json::to_value(&path).unwrap_or(Value::Null)); + args.insert("displayName".to_string(), serde_json::to_value(&display_name).unwrap_or(Value::Null)); + if let Some(ref v) = options { + args.insert("options".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withHttpCommand", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + /// Configures developer certificate trust pub fn with_developer_certificate_trust(&self, trust: bool) -> Result> { let mut args: HashMap = HashMap::new(); @@ -10774,6 +10894,20 @@ impl ProjectResource { Ok(IResource::new(handle, self.client.clone())) } + /// Adds an HTTP resource command + pub fn with_http_command(&self, path: &str, display_name: &str, options: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("path".to_string(), serde_json::to_value(&path).unwrap_or(Value::Null)); + args.insert("displayName".to_string(), serde_json::to_value(&display_name).unwrap_or(Value::Null)); + if let Some(ref v) = options { + args.insert("options".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withHttpCommand", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + /// Configures developer certificate trust pub fn with_developer_certificate_trust(&self, trust: bool) -> Result> { let mut args: HashMap = HashMap::new(); @@ -12592,6 +12726,20 @@ impl TestDatabaseResource { Ok(IResource::new(handle, self.client.clone())) } + /// Adds an HTTP resource command + pub fn with_http_command(&self, path: &str, display_name: &str, options: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("path".to_string(), serde_json::to_value(&path).unwrap_or(Value::Null)); + args.insert("displayName".to_string(), serde_json::to_value(&display_name).unwrap_or(Value::Null)); + if let Some(ref v) = options { + args.insert("options".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withHttpCommand", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + /// Configures developer certificate trust pub fn with_developer_certificate_trust(&self, trust: bool) -> Result> { let mut args: HashMap = HashMap::new(); @@ -13910,6 +14058,20 @@ impl TestRedisResource { Ok(IResource::new(handle, self.client.clone())) } + /// Adds an HTTP resource command + pub fn with_http_command(&self, path: &str, display_name: &str, options: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("path".to_string(), serde_json::to_value(&path).unwrap_or(Value::Null)); + args.insert("displayName".to_string(), serde_json::to_value(&display_name).unwrap_or(Value::Null)); + if let Some(ref v) = options { + args.insert("options".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withHttpCommand", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + /// Configures developer certificate trust pub fn with_developer_certificate_trust(&self, trust: bool) -> Result> { let mut args: HashMap = HashMap::new(); @@ -15348,6 +15510,20 @@ impl TestVaultResource { Ok(IResource::new(handle, self.client.clone())) } + /// Adds an HTTP resource command + pub fn with_http_command(&self, path: &str, display_name: &str, options: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("path".to_string(), serde_json::to_value(&path).unwrap_or(Value::Null)); + args.insert("displayName".to_string(), serde_json::to_value(&display_name).unwrap_or(Value::Null)); + if let Some(ref v) = options { + args.insert("options".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withHttpCommand", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + /// Configures developer certificate trust pub fn with_developer_certificate_trust(&self, trust: bool) -> Result> { let mut args: HashMap = HashMap::new(); diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/HostingContainerResourceCapabilities.verified.txt b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/HostingContainerResourceCapabilities.verified.txt index 6ec567962be..095d6135d72 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/HostingContainerResourceCapabilities.verified.txt +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/HostingContainerResourceCapabilities.verified.txt @@ -615,6 +615,20 @@ } ] }, + { + CapabilityId: Aspire.Hosting/withHttpCommand, + MethodName: withHttpCommand, + TargetType: { + TypeId: Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints, + IsInterface: true + }, + ExpandedTargetTypes: [ + { + TypeId: Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource, + IsInterface: false + } + ] + }, { CapabilityId: Aspire.Hosting/withHttpEndpoint, MethodName: withHttpEndpoint, diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts index c155ffe8f83..4850f549192 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts @@ -280,6 +280,14 @@ export enum EndpointProperty { TlsEnabled = "TlsEnabled", } +/** Enum type for HttpCommandResultMode */ +export enum HttpCommandResultMode { + None = "None", + Auto = "Auto", + Json = "Json", + Text = "Text", +} + /** Enum type for IconVariant */ export enum IconVariant { Regular = "Regular", @@ -400,6 +408,19 @@ export interface ExecuteCommandResult { resultFormat?: CommandResultFormat; } +/** DTO interface for HttpCommandExportOptions */ +export interface HttpCommandExportOptions { + description?: string; + confirmationMessage?: string; + iconName?: string; + iconVariant?: IconVariant; + isHighlighted?: boolean; + commandName?: string; + endpointName?: string; + methodName?: string; + resultMode?: HttpCommandResultMode; +} + /** DTO interface for ResourceEventDto */ export interface ResourceEventDto { resourceName?: string; @@ -7666,6 +7687,22 @@ export class ContainerResource extends ResourceBuilderBase { + const rpcArgs: Record = { builder: this._handle, path, displayName }; + if (options !== undefined) rpcArgs.options = options; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpCommand', + rpcArgs + ); + return new ContainerResource(result, this._client); + } + + /** Adds an HTTP resource command */ + withHttpCommand(path: string, displayName: string, options?: HttpCommandExportOptions): ContainerResourcePromise { + return new ContainerResourcePromise(this._withHttpCommandInternal(path, displayName, options)); + } + /** @internal */ private async _withDeveloperCertificateTrustInternal(trust: boolean): Promise { const rpcArgs: Record = { builder: this._handle, trust }; @@ -8700,6 +8737,11 @@ export class ContainerResourcePromise implements PromiseLike return new ContainerResourcePromise(this._promise.then(obj => obj.withCommand(name, displayName, executeCommand, options))); } + /** Adds an HTTP resource command */ + withHttpCommand(path: string, displayName: string, options?: HttpCommandExportOptions): ContainerResourcePromise { + return new ContainerResourcePromise(this._promise.then(obj => obj.withHttpCommand(path, displayName, options))); + } + /** Configures developer certificate trust */ withDeveloperCertificateTrust(trust: boolean): ContainerResourcePromise { return new ContainerResourcePromise(this._promise.then(obj => obj.withDeveloperCertificateTrust(trust))); @@ -9667,6 +9709,22 @@ export class CSharpAppResource extends ResourceBuilderBase { + const rpcArgs: Record = { builder: this._handle, path, displayName }; + if (options !== undefined) rpcArgs.options = options; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpCommand', + rpcArgs + ); + return new CSharpAppResource(result, this._client); + } + + /** Adds an HTTP resource command */ + withHttpCommand(path: string, displayName: string, options?: HttpCommandExportOptions): CSharpAppResourcePromise { + return new CSharpAppResourcePromise(this._withHttpCommandInternal(path, displayName, options)); + } + /** @internal */ private async _withDeveloperCertificateTrustInternal(trust: boolean): Promise { const rpcArgs: Record = { builder: this._handle, trust }; @@ -10617,6 +10675,11 @@ export class CSharpAppResourcePromise implements PromiseLike return new CSharpAppResourcePromise(this._promise.then(obj => obj.withCommand(name, displayName, executeCommand, options))); } + /** Adds an HTTP resource command */ + withHttpCommand(path: string, displayName: string, options?: HttpCommandExportOptions): CSharpAppResourcePromise { + return new CSharpAppResourcePromise(this._promise.then(obj => obj.withHttpCommand(path, displayName, options))); + } + /** Configures developer certificate trust */ withDeveloperCertificateTrust(trust: boolean): CSharpAppResourcePromise { return new CSharpAppResourcePromise(this._promise.then(obj => obj.withDeveloperCertificateTrust(trust))); @@ -11667,6 +11730,22 @@ export class DotnetToolResource extends ResourceBuilderBase { + const rpcArgs: Record = { builder: this._handle, path, displayName }; + if (options !== undefined) rpcArgs.options = options; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpCommand', + rpcArgs + ); + return new DotnetToolResource(result, this._client); + } + + /** Adds an HTTP resource command */ + withHttpCommand(path: string, displayName: string, options?: HttpCommandExportOptions): DotnetToolResourcePromise { + return new DotnetToolResourcePromise(this._withHttpCommandInternal(path, displayName, options)); + } + /** @internal */ private async _withDeveloperCertificateTrustInternal(trust: boolean): Promise { const rpcArgs: Record = { builder: this._handle, trust }; @@ -12647,6 +12726,11 @@ export class DotnetToolResourcePromise implements PromiseLike obj.withCommand(name, displayName, executeCommand, options))); } + /** Adds an HTTP resource command */ + withHttpCommand(path: string, displayName: string, options?: HttpCommandExportOptions): DotnetToolResourcePromise { + return new DotnetToolResourcePromise(this._promise.then(obj => obj.withHttpCommand(path, displayName, options))); + } + /** Configures developer certificate trust */ withDeveloperCertificateTrust(trust: boolean): DotnetToolResourcePromise { return new DotnetToolResourcePromise(this._promise.then(obj => obj.withDeveloperCertificateTrust(trust))); @@ -13607,6 +13691,22 @@ export class ExecutableResource extends ResourceBuilderBase { + const rpcArgs: Record = { builder: this._handle, path, displayName }; + if (options !== undefined) rpcArgs.options = options; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpCommand', + rpcArgs + ); + return new ExecutableResource(result, this._client); + } + + /** Adds an HTTP resource command */ + withHttpCommand(path: string, displayName: string, options?: HttpCommandExportOptions): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._withHttpCommandInternal(path, displayName, options)); + } + /** @internal */ private async _withDeveloperCertificateTrustInternal(trust: boolean): Promise { const rpcArgs: Record = { builder: this._handle, trust }; @@ -14557,6 +14657,11 @@ export class ExecutableResourcePromise implements PromiseLike obj.withCommand(name, displayName, executeCommand, options))); } + /** Adds an HTTP resource command */ + withHttpCommand(path: string, displayName: string, options?: HttpCommandExportOptions): ExecutableResourcePromise { + return new ExecutableResourcePromise(this._promise.then(obj => obj.withHttpCommand(path, displayName, options))); + } + /** Configures developer certificate trust */ withDeveloperCertificateTrust(trust: boolean): ExecutableResourcePromise { return new ExecutableResourcePromise(this._promise.then(obj => obj.withDeveloperCertificateTrust(trust))); @@ -17517,6 +17622,22 @@ export class ProjectResource extends ResourceBuilderBase return new ProjectResourcePromise(this._withCommandInternal(name, displayName, executeCommand, commandOptions)); } + /** @internal */ + private async _withHttpCommandInternal(path: string, displayName: string, options?: HttpCommandExportOptions): Promise { + const rpcArgs: Record = { builder: this._handle, path, displayName }; + if (options !== undefined) rpcArgs.options = options; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpCommand', + rpcArgs + ); + return new ProjectResource(result, this._client); + } + + /** Adds an HTTP resource command */ + withHttpCommand(path: string, displayName: string, options?: HttpCommandExportOptions): ProjectResourcePromise { + return new ProjectResourcePromise(this._withHttpCommandInternal(path, displayName, options)); + } + /** @internal */ private async _withDeveloperCertificateTrustInternal(trust: boolean): Promise { const rpcArgs: Record = { builder: this._handle, trust }; @@ -18467,6 +18588,11 @@ export class ProjectResourcePromise implements PromiseLike { return new ProjectResourcePromise(this._promise.then(obj => obj.withCommand(name, displayName, executeCommand, options))); } + /** Adds an HTTP resource command */ + withHttpCommand(path: string, displayName: string, options?: HttpCommandExportOptions): ProjectResourcePromise { + return new ProjectResourcePromise(this._promise.then(obj => obj.withHttpCommand(path, displayName, options))); + } + /** Configures developer certificate trust */ withDeveloperCertificateTrust(trust: boolean): ProjectResourcePromise { return new ProjectResourcePromise(this._promise.then(obj => obj.withDeveloperCertificateTrust(trust))); @@ -19625,6 +19751,22 @@ export class TestDatabaseResource extends ResourceBuilderBase { + const rpcArgs: Record = { builder: this._handle, path, displayName }; + if (options !== undefined) rpcArgs.options = options; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpCommand', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Adds an HTTP resource command */ + withHttpCommand(path: string, displayName: string, options?: HttpCommandExportOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withHttpCommandInternal(path, displayName, options)); + } + /** @internal */ private async _withDeveloperCertificateTrustInternal(trust: boolean): Promise { const rpcArgs: Record = { builder: this._handle, trust }; @@ -20659,6 +20801,11 @@ export class TestDatabaseResourcePromise implements PromiseLike obj.withCommand(name, displayName, executeCommand, options))); } + /** Adds an HTTP resource command */ + withHttpCommand(path: string, displayName: string, options?: HttpCommandExportOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withHttpCommand(path, displayName, options))); + } + /** Configures developer certificate trust */ withDeveloperCertificateTrust(trust: boolean): TestDatabaseResourcePromise { return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withDeveloperCertificateTrust(trust))); @@ -21852,6 +21999,22 @@ export class TestRedisResource extends ResourceBuilderBase { + const rpcArgs: Record = { builder: this._handle, path, displayName }; + if (options !== undefined) rpcArgs.options = options; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpCommand', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Adds an HTTP resource command */ + withHttpCommand(path: string, displayName: string, options?: HttpCommandExportOptions): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withHttpCommandInternal(path, displayName, options)); + } + /** @internal */ private async _withDeveloperCertificateTrustInternal(trust: boolean): Promise { const rpcArgs: Record = { builder: this._handle, trust }; @@ -23085,6 +23248,11 @@ export class TestRedisResourcePromise implements PromiseLike return new TestRedisResourcePromise(this._promise.then(obj => obj.withCommand(name, displayName, executeCommand, options))); } + /** Adds an HTTP resource command */ + withHttpCommand(path: string, displayName: string, options?: HttpCommandExportOptions): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._promise.then(obj => obj.withHttpCommand(path, displayName, options))); + } + /** Configures developer certificate trust */ withDeveloperCertificateTrust(trust: boolean): TestRedisResourcePromise { return new TestRedisResourcePromise(this._promise.then(obj => obj.withDeveloperCertificateTrust(trust))); @@ -24313,6 +24481,22 @@ export class TestVaultResource extends ResourceBuilderBase { + const rpcArgs: Record = { builder: this._handle, path, displayName }; + if (options !== undefined) rpcArgs.options = options; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpCommand', + rpcArgs + ); + return new TestVaultResource(result, this._client); + } + + /** Adds an HTTP resource command */ + withHttpCommand(path: string, displayName: string, options?: HttpCommandExportOptions): TestVaultResourcePromise { + return new TestVaultResourcePromise(this._withHttpCommandInternal(path, displayName, options)); + } + /** @internal */ private async _withDeveloperCertificateTrustInternal(trust: boolean): Promise { const rpcArgs: Record = { builder: this._handle, trust }; @@ -25362,6 +25546,11 @@ export class TestVaultResourcePromise implements PromiseLike return new TestVaultResourcePromise(this._promise.then(obj => obj.withCommand(name, displayName, executeCommand, options))); } + /** Adds an HTTP resource command */ + withHttpCommand(path: string, displayName: string, options?: HttpCommandExportOptions): TestVaultResourcePromise { + return new TestVaultResourcePromise(this._promise.then(obj => obj.withHttpCommand(path, displayName, options))); + } + /** Configures developer certificate trust */ withDeveloperCertificateTrust(trust: boolean): TestVaultResourcePromise { return new TestVaultResourcePromise(this._promise.then(obj => obj.withDeveloperCertificateTrust(trust))); @@ -27139,6 +27328,22 @@ export class ResourceWithEndpoints extends ResourceBuilderBase { + const rpcArgs: Record = { builder: this._handle, path, displayName }; + if (options !== undefined) rpcArgs.options = options; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpCommand', + rpcArgs + ); + return new ResourceWithEndpoints(result, this._client); + } + + /** Adds an HTTP resource command */ + withHttpCommand(path: string, displayName: string, options?: HttpCommandExportOptions): ResourceWithEndpointsPromise { + return new ResourceWithEndpointsPromise(this._withHttpCommandInternal(path, displayName, options)); + } + /** @internal */ private async _withHttpProbeInternal(probeType: ProbeType, path?: string, initialDelaySeconds?: number, periodSeconds?: number, timeoutSeconds?: number, failureThreshold?: number, successThreshold?: number, endpointName?: string): Promise { const rpcArgs: Record = { builder: this._handle, probeType }; @@ -27250,6 +27455,11 @@ export class ResourceWithEndpointsPromise implements PromiseLike obj.withHttpHealthCheck(options))); } + /** Adds an HTTP resource command */ + withHttpCommand(path: string, displayName: string, options?: HttpCommandExportOptions): ResourceWithEndpointsPromise { + return new ResourceWithEndpointsPromise(this._promise.then(obj => obj.withHttpCommand(path, displayName, options))); + } + /** Adds an HTTP health probe to the resource */ withHttpProbe(probeType: ProbeType, options?: WithHttpProbeOptions): ResourceWithEndpointsPromise { return new ResourceWithEndpointsPromise(this._promise.then(obj => obj.withHttpProbe(probeType, options))); diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs index b0491320bbb..fd64d4ce981 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs @@ -296,6 +296,40 @@ public void ScanAssembly_HostingAssembly_ExportsExpectedHandleTypesAndInstanceMe Assert.Contains(result.Capabilities, capability => capability.CapabilityId.EndsWith("/PipelineSummary.add", StringComparison.Ordinal)); } + [Fact] + public void ScanAssembly_HostingAssembly_ExportsWithHttpCommandCapabilityWithAtsFriendlyOptions() + { + var hostingAssembly = typeof(DistributedApplication).Assembly; + var result = AtsCapabilityScanner.ScanAssembly(hostingAssembly); + + var capability = Assert.Single(result.Capabilities, + c => c.CapabilityId == "Aspire.Hosting/withHttpCommand"); + + Assert.Equal("withHttpCommand", capability.MethodName); + Assert.Equal(3, capability.Parameters.Count); + Assert.DoesNotContain(capability.Parameters, p => p.Name == "endpointName"); + Assert.DoesNotContain(capability.Parameters, p => p.Name == "commandName"); + + var optionsParameter = Assert.Single(capability.Parameters, p => p.Name == "options"); + Assert.Equal("options", optionsParameter.Name); + Assert.NotNull(optionsParameter.Type); + Assert.Equal(AtsTypeCategory.Dto, optionsParameter.Type.Category); + Assert.Equal(AtsTypeMapping.DeriveTypeId(typeof(HttpCommandExportOptions)), optionsParameter.Type.TypeId); + + var dto = Assert.Single(result.DtoTypes, d => d.TypeId == AtsTypeMapping.DeriveTypeId(typeof(HttpCommandExportOptions))); + Assert.Equal(nameof(HttpCommandExportOptions), dto.Name); + Assert.Contains(dto.Properties, p => p.Name == nameof(HttpCommandExportOptions.CommandName)); + Assert.Contains(dto.Properties, p => p.Name == nameof(HttpCommandExportOptions.EndpointName)); + Assert.Contains(dto.Properties, p => p.Name == nameof(HttpCommandExportOptions.MethodName)); + Assert.Contains(dto.Properties, p => p.Name == nameof(HttpCommandExportOptions.ResultMode)); + Assert.DoesNotContain(dto.Properties, p => p.Name == nameof(CommandOptions.Parameter)); + Assert.DoesNotContain(dto.Properties, p => p.Name == nameof(HttpCommandOptions.HttpClientName)); + Assert.DoesNotContain(dto.Properties, p => p.Name == nameof(HttpCommandOptions.PrepareRequest)); + Assert.DoesNotContain(dto.Properties, p => p.Name == nameof(HttpCommandOptions.Method)); + Assert.DoesNotContain(dto.Properties, p => p.Name == nameof(HttpCommandOptions.EndpointSelector)); + Assert.DoesNotContain(dto.Properties, p => p.Name == nameof(HttpCommandOptions.GetCommandResult)); + } + #endregion #region Callback Parameter Type Resolution Tests diff --git a/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs b/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs index 7e56739ec96..787e80947f2 100644 --- a/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs +++ b/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net; +using System.Net.Http.Headers; +using System.Text; using Aspire.Hosting.Testing; using Aspire.Hosting.Utils; using Microsoft.AspNetCore.InternalTesting; @@ -13,6 +15,88 @@ namespace Aspire.Hosting.Tests; [Trait("Partition", "6")] public class WithHttpCommandTests(ITestOutputHelper testOutputHelper) { + [Fact] + public void HttpCommandResultMode_HasExpectedOrdering() + { + Assert.Equal(0, (int)HttpCommandResultMode.None); + Assert.Equal(1, (int)HttpCommandResultMode.Auto); + Assert.Equal(2, (int)HttpCommandResultMode.Json); + Assert.Equal(3, (int)HttpCommandResultMode.Text); + } + + [Theory] + [InlineData(null, false, null)] + [InlineData("application/octet-stream", false, null)] + [InlineData("application/json", true, CommandResultFormat.Json)] + [InlineData("application/problem+json", true, CommandResultFormat.Json)] + [InlineData("text/plain", true, CommandResultFormat.Text)] + [InlineData("application/xml", true, CommandResultFormat.Text)] + [InlineData("application/problem+xml", true, CommandResultFormat.Text)] + [InlineData("application/x-www-form-urlencoded", true, CommandResultFormat.Text)] + public void TryInferHttpCommandResultFormat_ReturnsExpectedResult(string? mediaType, bool expectedSuccess, CommandResultFormat? expectedFormat) + { + var contentType = mediaType is null ? null : MediaTypeHeaderValue.Parse(mediaType); + + var success = ResourceBuilderExtensions.TryInferHttpCommandResultFormat(contentType, out var resultFormat); + + Assert.Equal(expectedSuccess, success); + Assert.Equal(expectedFormat, success ? resultFormat : null); + } + + [Fact] + public async Task GetDefaultHttpCommandResultAsync_WithoutOptIn_DoesNotReturnResponseBody() + { + using var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"token":"abc123"}""", Encoding.UTF8, "application/json") + }; + + var result = await ResourceBuilderExtensions.GetDefaultHttpCommandResultAsync(response, new HttpCommandOptions(), CancellationToken.None); + + Assert.True(result.Success); + Assert.Null(result.ErrorMessage); + Assert.Null(result.Result); + Assert.Null(result.ResultFormat); + } + + [Fact] + public async Task GetDefaultHttpCommandResultAsync_WithResultModeJson_ReturnsResponseBody() + { + using var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"token":"abc123"}""", Encoding.UTF8, "application/json") + }; + + var result = await ResourceBuilderExtensions.GetDefaultHttpCommandResultAsync(response, new HttpCommandOptions + { + ResultMode = HttpCommandResultMode.Json + }, CancellationToken.None); + + Assert.True(result.Success); + Assert.Null(result.ErrorMessage); + Assert.Equal("""{"token":"abc123"}""", result.Result); + Assert.Equal(CommandResultFormat.Json, result.ResultFormat); + } + + [Fact] + public async Task GetDefaultHttpCommandResultAsync_WithResultModeAuto_ReturnsErrorBodyUsingInferredFormat() + { + using var response = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("invalid request", Encoding.UTF8, "text/plain") + }; + + var result = await ResourceBuilderExtensions.GetDefaultHttpCommandResultAsync(response, new HttpCommandOptions + { + ResultMode = HttpCommandResultMode.Auto + }, CancellationToken.None); + + Assert.False(result.Success); + Assert.Equal("Request failed with status code BadRequest", result.ErrorMessage); + Assert.Equal("invalid request", result.Result); + Assert.Equal(CommandResultFormat.Text, result.ResultFormat); + } + [Fact] public void WithHttpCommand_AddsHttpClientFactory() { @@ -257,14 +341,23 @@ public async Task WithHttpCommand_UsesNamedHttpClient() Assert.True(fakeHandler.Called); } - private sealed class FakeHttpMessageHandler(HttpStatusCode statusCode) : HttpMessageHandler + private sealed class FakeHttpMessageHandler(HttpStatusCode statusCode, string? responseBody = null, string? mediaType = null) : HttpMessageHandler { public bool Called { get; private set; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { Called = true; - return Task.FromResult(new HttpResponseMessage(statusCode)); + + var response = new HttpResponseMessage(statusCode); + if (responseBody is not null) + { + response.Content = mediaType is not null + ? new StringContent(responseBody, Encoding.UTF8, mediaType) + : new StringContent(responseBody); + } + + return Task.FromResult(response); } } @@ -400,6 +493,143 @@ public async Task WithHttpCommand_CallsGetResponseCallback_AfterSendingRequest() Assert.Equal("A test error message", result.ErrorMessage); } + [Fact] + public async Task WithHttpCommand_WithoutOptIn_DoesNotReturnJsonResponseBody() + { + // Arrange + using var builder = CreateTestDistributedApplicationBuilder(); + + var fakeHandler = new FakeHttpMessageHandler(HttpStatusCode.OK, """{"token":"abc123"}""", "application/json"); + builder.Services.AddHttpClient("commandclient") + .ConfigurePrimaryHttpMessageHandler(() => fakeHandler); + + var service = CreateResourceWithAllocatedEndpoint(builder, "service"); + service.WithHttpCommand("/token", "Generate Token", commandName: "mycommand", commandOptions: new() { HttpClientName = "commandclient" }); + + // Act + using var app = builder.Build(); + await app.StartAsync().DefaultTimeout(); + + await MoveResourceToRunningStateAsync(app, service.Resource); + + var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand").DefaultTimeout(); + + // Assert + Assert.True(result.Success); + Assert.Null(result.Result); + Assert.Null(result.ResultFormat); + } + + [Fact] + public async Task WithHttpCommand_WithResultModeJson_ReturnsJsonResponseBody() + { + // Arrange + using var builder = CreateTestDistributedApplicationBuilder(); + + var fakeHandler = new FakeHttpMessageHandler(HttpStatusCode.OK, """{"token":"abc123"}""", "application/json"); + builder.Services.AddHttpClient("commandclient") + .ConfigurePrimaryHttpMessageHandler(() => fakeHandler); + + var service = CreateResourceWithAllocatedEndpoint(builder, "service"); + service.WithHttpCommand("/token", "Generate Token", commandName: "mycommand", commandOptions: new() { HttpClientName = "commandclient", ResultMode = HttpCommandResultMode.Json }); + + // Act + using var app = builder.Build(); + await app.StartAsync().DefaultTimeout(); + + await MoveResourceToRunningStateAsync(app, service.Resource); + + var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand").DefaultTimeout(); + + // Assert + Assert.True(result.Success); + Assert.Equal("""{"token":"abc123"}""", result.Result); + Assert.Equal(CommandResultFormat.Json, result.ResultFormat); + } + + [Fact] + public async Task WithHttpCommand_WithResultModeText_ReturnsTextResponseBody() + { + // Arrange + using var builder = CreateTestDistributedApplicationBuilder(); + + var fakeHandler = new FakeHttpMessageHandler(HttpStatusCode.BadRequest, "invalid request", "text/plain"); + builder.Services.AddHttpClient("commandclient") + .ConfigurePrimaryHttpMessageHandler(() => fakeHandler); + + var service = CreateResourceWithAllocatedEndpoint(builder, "service"); + service.WithHttpCommand("/text", "Get Text", commandName: "mycommand", commandOptions: new() { HttpClientName = "commandclient", ResultMode = HttpCommandResultMode.Text }); + + // Act + using var app = builder.Build(); + await app.StartAsync().DefaultTimeout(); + + await MoveResourceToRunningStateAsync(app, service.Resource); + + var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand").DefaultTimeout(); + + // Assert + Assert.False(result.Success); + Assert.Equal("Request failed with status code BadRequest", result.ErrorMessage); + Assert.Equal("invalid request", result.Result); + Assert.Equal(CommandResultFormat.Text, result.ResultFormat); + } + + [Fact] + public async Task WithHttpCommand_WithResultModeAuto_ReturnsJsonResponseBody() + { + // Arrange + using var builder = CreateTestDistributedApplicationBuilder(); + + var fakeHandler = new FakeHttpMessageHandler(HttpStatusCode.OK, """{"token":"abc123"}""", "application/json"); + builder.Services.AddHttpClient("commandclient") + .ConfigurePrimaryHttpMessageHandler(() => fakeHandler); + + var service = CreateResourceWithAllocatedEndpoint(builder, "service"); + service.WithHttpCommand("/token", "Generate Token", commandName: "mycommand", commandOptions: new() { HttpClientName = "commandclient", ResultMode = HttpCommandResultMode.Auto }); + + // Act + using var app = builder.Build(); + await app.StartAsync().DefaultTimeout(); + + await MoveResourceToRunningStateAsync(app, service.Resource); + + var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand").DefaultTimeout(); + + // Assert + Assert.True(result.Success); + Assert.Equal("""{"token":"abc123"}""", result.Result); + Assert.Equal(CommandResultFormat.Json, result.ResultFormat); + } + + [Fact] + public async Task WithHttpCommand_WithResultModeAuto_ReturnsTextResponseBody() + { + // Arrange + using var builder = CreateTestDistributedApplicationBuilder(); + + var fakeHandler = new FakeHttpMessageHandler(HttpStatusCode.BadRequest, "invalid request", "text/plain"); + builder.Services.AddHttpClient("commandclient") + .ConfigurePrimaryHttpMessageHandler(() => fakeHandler); + + var service = CreateResourceWithAllocatedEndpoint(builder, "service"); + service.WithHttpCommand("/text", "Get Text", commandName: "mycommand", commandOptions: new() { HttpClientName = "commandclient", ResultMode = HttpCommandResultMode.Auto }); + + // Act + using var app = builder.Build(); + await app.StartAsync().DefaultTimeout(); + + await MoveResourceToRunningStateAsync(app, service.Resource); + + var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand").DefaultTimeout(); + + // Assert + Assert.False(result.Success); + Assert.Equal("Request failed with status code BadRequest", result.ErrorMessage); + Assert.Equal("invalid request", result.Result); + Assert.Equal(CommandResultFormat.Text, result.ResultFormat); + } + [Fact] public async Task WithHttpCommand_EnablesCommandOnceResourceIsRunning() {