Skip to content

Commit 3622e08

Browse files
davidfowlCopilot
andcommitted
Add ATS support for HTTP command results
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 51695cd commit 3622e08

16 files changed

Lines changed: 1427 additions & 24 deletions

File tree

playground/Stress/Stress.ApiService/Program.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,21 @@
161161
return $"Sent requests to {string.Join(';', urls)}";
162162
});
163163

164+
app.MapGet("/http-command-json-result", () =>
165+
{
166+
return Results.Json(new
167+
{
168+
token = Convert.ToBase64String(Guid.NewGuid().ToByteArray()),
169+
issuedAt = DateTimeOffset.UtcNow,
170+
expiresIn = 3600
171+
});
172+
});
173+
174+
app.MapGet("/http-command-text-result", () =>
175+
{
176+
return Results.Text($"Generated text token: {Convert.ToBase64String(Guid.NewGuid().ToByteArray())}");
177+
});
178+
164179
app.MapGet("/log-message-limit", async ([FromServices] ILogger<Program> logger) =>
165180
{
166181
const int LogCount = 10_000;

playground/Stress/Stress.AppHost/Program.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@
8484
serviceBuilder.WithHttpCommand("/log-message", "Log message", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
8585
serviceBuilder.WithHttpCommand("/log-message-limit", "Log message limit", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
8686
serviceBuilder.WithHttpCommand("/log-message-limit-large", "Log message limit large", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
87+
serviceBuilder.WithHttpCommand("/http-command-json-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" });
88+
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" });
89+
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" });
8790
serviceBuilder.WithHttpCommand("/multiple-traces-linked", "Multiple traces linked", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
8891
serviceBuilder.WithHttpCommand("/overflow-counter", "Overflow counter", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
8992
serviceBuilder.WithHttpCommand("/nested-trace-spans", "Out of order nested spans", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });

src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -514,9 +514,7 @@ private string GetWrapperOrHandleName(string typeId)
514514
/// </summary>
515515
private static string GetDtoClassName(string typeId)
516516
{
517-
// Extract simple type name and use as class name
518-
var simpleTypeName = ExtractSimpleTypeName(typeId);
519-
return simpleTypeName;
517+
return SanitizePythonIdentifier(ExtractSimpleTypeName(typeId));
520518
}
521519

522520
/// <summary>

src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -237,9 +237,7 @@ private string GetWrapperOrHandleName(string typeId)
237237
/// </summary>
238238
private static string GetDtoInterfaceName(string typeId)
239239
{
240-
// Extract simple type name and use as interface name
241-
var simpleTypeName = ExtractSimpleTypeName(typeId);
242-
return simpleTypeName;
240+
return ExtractSimpleTypeName(typeId);
243241
}
244242

245243
/// <summary>
@@ -517,7 +515,7 @@ private string GenerateAspireSdk(AtsContext context)
517515
foreach (var cap in builder.Capabilities)
518516
{
519517
var (_, optionalParams) = SeparateParameters(cap.Parameters);
520-
if (optionalParams.Count > 0)
518+
if (optionalParams.Count > 0 && !TryGetDirectOptionsParameter(optionalParams, out _))
521519
{
522520
RegisterOptionsInterface(cap.CapabilityId, cap.MethodName, optionalParams);
523521
}
@@ -742,6 +740,28 @@ private static (List<AtsParameterInfo> Required, List<AtsParameterInfo> Optional
742740
return (required, optional);
743741
}
744742

743+
private static bool TryGetDirectOptionsParameter(List<AtsParameterInfo> optionalParams, out AtsParameterInfo? directOptionsParam)
744+
{
745+
directOptionsParam = null;
746+
747+
// When ATS already exposes a single DTO parameter named "options", reuse that DTO type
748+
// directly so the generated TypeScript API stays flat instead of wrapping it in another
749+
// generated options object.
750+
if (optionalParams.Count != 1)
751+
{
752+
return false;
753+
}
754+
755+
var candidate = optionalParams[0];
756+
if (!string.Equals(candidate.Name, "options", StringComparison.Ordinal) || candidate.Type?.Category != AtsTypeCategory.Dto)
757+
{
758+
return false;
759+
}
760+
761+
directOptionsParam = candidate;
762+
return true;
763+
}
764+
745765
/// <summary>
746766
/// Registers an options interface to be generated later.
747767
/// 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
974994
// Separate required and optional parameters
975995
var (requiredParams, optionalParams) = SeparateParameters(capability.Parameters);
976996
var hasOptionals = optionalParams.Count > 0;
977-
var optionsInterfaceName = ResolveOptionsInterfaceName(capability);
997+
var hasDirectOptionsParameter = TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam);
998+
var optionsTypeName = hasDirectOptionsParameter ? MapParameterToTypeScript(directOptionsParam!) : ResolveOptionsInterfaceName(capability);
978999

9791000
// Build parameter list for public method
9801001
var publicParamDefs = new List<string>();
@@ -985,7 +1006,7 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab
9851006
}
9861007
if (hasOptionals)
9871008
{
988-
publicParamDefs.Add($"options?: {optionsInterfaceName}");
1009+
publicParamDefs.Add($"options?: {optionsTypeName}");
9891010
}
9901011
var publicParamsString = string.Join(", ", publicParamDefs);
9911012

@@ -1039,7 +1060,7 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab
10391060
WriteLine($"): Promise<{returnType}> {{");
10401061

10411062
// Extract optional params from options object
1042-
foreach (var param in optionalParams)
1063+
foreach (var param in hasDirectOptionsParameter ? [] : optionalParams)
10431064
{
10441065
WriteLine($" const {param.Name} = options?.{param.Name};");
10451066
}
@@ -1122,7 +1143,7 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab
11221143
WriteLine();
11231144

11241145
// Extract optional params from options object and forward to internal method
1125-
foreach (var param in optionalParams)
1146+
foreach (var param in hasDirectOptionsParameter ? [] : optionalParams)
11261147
{
11271148
WriteLine($" const {param.Name} = options?.{param.Name};");
11281149
}
@@ -1195,7 +1216,8 @@ private void GenerateThenableClass(BuilderModel builder)
11951216
// Separate required and optional parameters
11961217
var (requiredParams, optionalParams) = SeparateParameters(capability.Parameters);
11971218
var hasOptionals = optionalParams.Count > 0;
1198-
var optionsInterfaceName = ResolveOptionsInterfaceName(capability);
1219+
var hasDirectOptionsParameter = TryGetDirectOptionsParameter(optionalParams, out var directOptionsParam);
1220+
var optionsTypeName = hasDirectOptionsParameter ? MapParameterToTypeScript(directOptionsParam!) : ResolveOptionsInterfaceName(capability);
11991221

12001222
// Build parameter list using options pattern
12011223
var publicParamDefs = new List<string>();
@@ -1206,7 +1228,7 @@ private void GenerateThenableClass(BuilderModel builder)
12061228
}
12071229
if (hasOptionals)
12081230
{
1209-
publicParamDefs.Add($"options?: {optionsInterfaceName}");
1231+
publicParamDefs.Add($"options?: {optionsTypeName}");
12101232
}
12111233
var paramsString = string.Join(", ", publicParamDefs);
12121234

src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -953,7 +953,6 @@ public static List<AtsCapabilityInfo> ScanCapabilities(
953953
AssemblyExportedTypeCache assemblyExportedTypeCache)
954954
{
955955
var typeId = AtsTypeMapping.DeriveTypeId(type);
956-
var typeName = type.Name;
957956

958957
// Load XML documentation for descriptions
959958
var xmlDoc = LoadXmlDocumentation(type.Assembly);
@@ -990,7 +989,7 @@ public static List<AtsCapabilityInfo> ScanCapabilities(
990989
return new AtsDtoTypeInfo
991990
{
992991
TypeId = typeId,
993-
Name = typeName,
992+
Name = type.Name,
994993
ClrType = type,
995994
Description = typeDescription,
996995
Properties = properties

src/Aspire.Hosting/ApplicationModel/HttpCommandOptions.cs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,32 @@
33

44
namespace Aspire.Hosting.ApplicationModel;
55

6+
/// <summary>
7+
/// Specifies how an HTTP command should surface the HTTP response body as command result data.
8+
/// </summary>
9+
public enum HttpCommandResultMode
10+
{
11+
/// <summary>
12+
/// Do not capture the HTTP response body as command result data.
13+
/// </summary>
14+
None,
15+
16+
/// <summary>
17+
/// Infer the command result format from the HTTP response content type.
18+
/// </summary>
19+
Auto,
20+
21+
/// <summary>
22+
/// Return the HTTP response body as JSON command result data.
23+
/// </summary>
24+
Json,
25+
26+
/// <summary>
27+
/// Return the HTTP response body as plain text command result data.
28+
/// </summary>
29+
Text
30+
}
31+
632
/// <summary>
733
/// Optional configuration for resource HTTP commands added with <see cref="ResourceBuilderExtensions.WithHttpCommand{TResource}(Aspire.Hosting.ApplicationModel.IResourceBuilder{TResource}, string, string, string?, string?, Aspire.Hosting.ApplicationModel.HttpCommandOptions?)"/>."/>
834
/// </summary>
@@ -34,4 +60,62 @@ public class HttpCommandOptions : CommandOptions
3460
/// Gets or sets a callback to be invoked after the response is received to determine the result of the command invocation.
3561
/// </summary>
3662
public Func<HttpCommandResultContext, Task<ExecuteCommandResult>>? GetCommandResult { get; set; }
63+
64+
/// <summary>
65+
/// Gets or sets how the HTTP response content should be returned as command result data
66+
/// when <see cref="GetCommandResult"/> is not specified. The default is <see cref="HttpCommandResultMode.None"/>.
67+
/// </summary>
68+
public HttpCommandResultMode ResultMode { get; set; }
69+
}
70+
71+
/// <summary>
72+
/// ATS-friendly configuration for resource HTTP commands.
73+
/// </summary>
74+
[AspireDto]
75+
internal sealed class HttpCommandExportOptions
76+
{
77+
/// <summary>
78+
/// Optional description of the command, to be shown in the UI.
79+
/// </summary>
80+
public string? Description { get; set; }
81+
82+
/// <summary>
83+
/// When a confirmation message is specified, the UI will prompt with an OK/Cancel dialog before starting the command.
84+
/// </summary>
85+
public string? ConfirmationMessage { get; set; }
86+
87+
/// <summary>
88+
/// The icon name for the command.
89+
/// </summary>
90+
public string? IconName { get; set; }
91+
92+
/// <summary>
93+
/// The icon variant.
94+
/// </summary>
95+
public IconVariant? IconVariant { get; set; }
96+
97+
/// <summary>
98+
/// A flag indicating whether the command is highlighted in the UI.
99+
/// </summary>
100+
public bool IsHighlighted { get; set; }
101+
102+
/// <summary>
103+
/// Gets or sets the command name.
104+
/// </summary>
105+
public string? CommandName { get; set; }
106+
107+
/// <summary>
108+
/// Gets or sets the HTTP endpoint name to send the request to when the command is invoked.
109+
/// </summary>
110+
public string? EndpointName { get; set; }
111+
112+
/// <summary>
113+
/// Gets or sets the HTTP method name to use when sending the request.
114+
/// </summary>
115+
public string? MethodName { get; set; }
116+
117+
/// <summary>
118+
/// Gets or sets how the HTTP response content should be returned as command result data.
119+
/// </summary>
120+
public HttpCommandResultMode ResultMode { get; set; }
37121
}

0 commit comments

Comments
 (0)