Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding content for WithHttpCommand #2875

Merged
merged 9 commits into from
Mar 26, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/fundamentals/custom-resource-commands.md
Original file line number Diff line number Diff line change
@@ -124,5 +124,6 @@ Select the **Clear cache** command to clear the cache of the Redis resource. The

## See also

- [.NET Aspire orchestration overview](app-host-overview.md)
- [Custom HTTP commands in .NET Aspire](http-commands.md)
- [.NET Aspire dashboard: Resource submenu actions](dashboard/explore.md#resource-submenu-actions)
- [.NET Aspire orchestration overview](app-host-overview.md)
103 changes: 103 additions & 0 deletions docs/fundamentals/http-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
---
title: Custom HTTP commands in .NET Aspire
description: Learn how to create custom HTTP commands in .NET Aspire.
ms.date: 03/25/2025
ms.topic: how-to
---

# Custom HTTP commands in .NET Aspire

In .NET Aspire, you can add custom HTTP commands to resources using the `WithHttpCommand` API. This API extends existing functionality, where you provide [custom commands on resources](custom-resource-commands.md). This feature enables a command on a resource that sends an HTTP request to a specified endpoint and path. This is useful for scenarios such as triggering database migrations, clearing caches, or performing custom actions on resources through HTTP requests.

To implement custom HTTP commands, you define a command on a resource and a corresponding HTTP endpoint that handles the request. This article provides an overview of how to create and configure custom HTTP commands in .NET Aspire.

## HTTP command APIs

The available APIs provide extensive capabilities with numerous parameters to customize the HTTP command. To add an HTTP command to a resource, use the `WithHttpCommand` extension method on the resource builder. There are two overloads available:

<!-- TODO: Replace with xref when available... -->

The `WithHttpCommand` API provides two overloads to add custom HTTP commands to resources in .NET Aspire. These APIs are designed to offer flexibility and cater to different use cases when defining HTTP commands.

1. **Overload with `endpointName`:**

This version is ideal when you have a predefined endpoint name that the HTTP command should target. It simplifies the configuration by directly associating the command with a specific endpoint. This is useful in scenarios where the endpoint is static and well-known during development.

1. **Overload with `endpointSelector`:**

This version provides more dynamic behavior by allowing you to specify a callback (`endpointSelector`) to determine the endpoint at runtime. This is useful when the endpoint might vary based on the resource's state or other contextual factors. It offers greater flexibility for advanced scenarios where the endpoint can't be hardcoded.

Both overloads allow you to customize the HTTP command extensively, providing an `HttpCommandOptions` subclass of the `CommandOptions` type, including specifying the HTTP method, configure the request, handling the response, and define UI-related properties like display name, description, and icons. The choice between the two depends on whether the endpoint is static or dynamic in your use case.

These APIs are designed to integrate seamlessly with the .NET Aspire ecosystem, enabling developers to extend resource functionality with minimal effort while maintaining control over the behavior and presentation of the commands.

## Considerations when registering HTTP commands

Since HTTP commands are exposed via HTTP endpoints, consider potential security risks. Limit these endpoints to development or staging environments when possible. Always validate incoming requests to ensure they originate from trusted sources. For more information, see [ASP.NET Core security](/aspnet/core/security).

Use the `HttpCommandOptions.PrepareRequest` callback to enhance security by adding authentication headers or other measures. A common approach is to use a shared secret, [external parameter](external-parameters.md), or token known only to the app host and resource. This shared value can be used to validate requests and prevent unauthorized access.

## Add a custom HTTP command

In your app host _Program.cs_ file, you add a custom HTTP command using the `WithHttpCommand` API on an <xref:Aspire.Hosting.ApplicationModel.IResourceBuilder`1> where `T` is an <xref:Aspire.Hosting.ApplicationModel.IResourceWithEndpoints>. Here's an example of how to do this:

:::code source="snippets/http-commands/AspireApp/AspireApp.AppHost/Program.cs":::

The preceding code:

- Creates a new distributed application builder.
- Adds a [Redis cache](../caching/stackexchange-redis-integration.md) named `cache` to the application.
- Adds a parameter named `ApiCacheInvalidationKey` to the application. This parameter is marked as a secret, meaning its value is treated securely.
- Adds a project named `AspireApp_Api` to the application.
- Adds a reference to the Redis cache and [waits for it to be ready before proceeding](app-host-overview.md#waiting-for-resources).
- Configures an HTTP command for the project with the following:
- `path`: Specifies the URL path for the HTTP command (`/cache/invalidate`).
- `displayName`: Sets the name of the command as it appears in the UI (`Invalidate cache`).
- `commandOptions`: An optional instance of `HttpCommandOptions` that configures the command's behavior and appearance in the UI:
- `Description`: Provides a description of the command that's shown in the UI.
- `PrepareRequest`: A callback function that configures the HTTP request before sending it. In this case, it adds a custom (`X-CacheInvalidation-Key`) header with the value of the `ApiCacheInvalidationKey` parameter.
- `IconName`: Specifies the icon to be used for the command in the UI (`DocumentLightningFilled`).
- `IsHighlighted`: Indicates whether the command should be highlighted in the UI.
- Finally, the application is built and run.

The HTTP endpoint is responsible for invalidating the cache. When the command is executed, it sends an HTTP request to the specified path (`/cache/invalidate`) with the configured parameters. Since there's an added security measure, the request includes the `X-CacheInvalidation-Key` header with the value of the `ApiCacheInvalidationKey` parameter. This ensures that only authorized requests can trigger the cache invalidation process.

### Example HTTP endpoint

The preceding app host code snippet defined a custom HTTP command that sends a request to the `/cache/invalidate` endpoint. The ASP.NET Core minimal API project defines an HTTP endpoint that handles the cache invalidation request. Consider the following code snippet from the project's _Program.cs_ file:

:::code source="snippets/http-commands/AspireApp/AspireApp.Api/Program.cs" id="post":::

The preceding code:

- Assumes that the `app` variable is an instance of <xref:Microsoft.AspNetCore.Builder.IApplicationBuilder> and is set up to handle HTTP requests.
- Maps an HTTP POST endpoint at the path `/cache/invalidate`.
- The endpoint expects a header named `X-CacheInvalidation-Key` to be present in the request.
- It retrieves the value of the `ApiCacheInvalidationKey` parameter from the configuration.
- If the header value doesn't match the expected key, it returns an unauthorized response.
- If the header is valid, it calls the `ClearAllAsync` method on the `ICacheService` to clear all cached items.
- Finally, it returns an HTTP OK response.

### Example dashboard experiences

The sample app host and corresponding ASP.NET Core minimal API projects demonstrate both sides of the HTTP command implementation. When you run the app host, the dashboard's **Resources** page displays the custom HTTP command as a button. When you specify that the command should be highlighted (`isHighlighted: true`), the button appears on the **Actions** column of the **Resources** page. This allows users to easily trigger the command from the dashboard, as shown in the following screenshot:

:::image type="content" source="media/custom-http-command-highlighted.png" lightbox="media/custom-http-command-highlighted.png" alt-text=".NET Aspire dashboard: Resources page showing a highlighted custom HTTP command.":::

If you're to omit the `isHighlighted` parameter, or set it to `false`, the command appears nested under the horizontal ellipsis menu (three dots) in the **Actions** column of the **Resources** page. This allows users to access the command without cluttering the UI with too many buttons. The following screenshot shows the same command appearing in the ellipsis menu:

:::image type="content" source="media/custom-http-command.png" lightbox="media/custom-http-command.png" alt-text=".NET Aspire dashboard: Resources page showing a custom HTTP command in the ellipsis menu.":::

When the user selects the button, the command is executed, and the HTTP request is sent to the specified endpoint. The dashboard provides feedback on the command's execution status, allowing users to monitor the results. When it's starting, a toast notification appears:

:::image type="content" source="media/custom-http-command-starting.png" lightbox="media/custom-http-command-starting.png" alt-text=".NET Aspire dashboard: Toast notification showing the custom HTTP command is starting.":::

When the command completes, the dashboard updates the status and provides feedback on whether it was successful or failed. The following screenshot shows a successful execution of the command:

:::image type="content" source="media/custom-http-command-succeeded.png" lightbox="media/custom-http-command-succeeded.png" alt-text=".NET Aspire dashboard: Toast notification showing the custom HTTP command succeeded.":::

## See also

- [.NET Aspire orchestration overview](app-host-overview.md)
- [Custom resource commands in .NET Aspire](custom-resource-commands.md)
- [.NET Aspire GitHub repository: Playground sample](https://github.com/dotnet/aspire/tree/4fdfdbf57d35265913a3bbac38b92d98ed255a5d/playground/TestShop)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/fundamentals/media/custom-http-command.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System.Text.Json.Serialization;

namespace AspireApp.Api;

[JsonSerializable(typeof(Product))]
[JsonSerializable(typeof(Product[]))]
public partial class AppJsonContext : JsonSerializerContext;

Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnableSdkContainerDebugging>True</EnableSdkContainerDebugging>
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:9.0</ContainerBaseImage>
<UserSecretsId>e0e06cf4-582d-4892-a64f-05ee5544080c</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.StackExchange.Redis.DistributedCaching" Version="9.2.0-preview.1.25175.3" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" />
<PackageReference Include="Scalar.AspNetCore" Version="2.1.1" />
</ItemGroup>

<ItemGroup>
<ContainerPort Include="8081" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@AspireApp.Api_HostAddress = http://localhost:5290

GET {{AspireApp.Api_HostAddress}}/weatherforecast/
Accept: application/json

###
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System.Text.Json.Serialization.Metadata;

namespace AspireApp.Api;

public interface ICacheService
{
Task<T?> GetAsync<T>(string key, JsonTypeInfo<T> jsonTypeInfo);
Task SetAsync<T>(string key, T value, JsonTypeInfo<T> jsonTypeInfo, TimeSpan? absoluteExpiration = null);
Task RemoveAsync(string key);
IAsyncEnumerable<string> GetRegisteredKeysAsync();
Task ClearAllAsync();
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace AspireApp.Api;

public sealed record Product(int Id, string Name, decimal Price);
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace AspireApp.Api;

internal static class Products
{
public static readonly Product[] DefaultProducts =
[
new (1, "Laptop", 999.99m),
new (2, "Keyboard", 49.99m),
new (3, "Mouse", 24.99m),
];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using AspireApp.Api;
using Microsoft.AspNetCore.Mvc;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.AddRedisDistributedCache(connectionName: "cache");

builder.Services.AddScoped<ICacheService, RedisCacheService>();

if (builder.Environment.IsDevelopment())
{
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll", policy =>
{
policy.AllowAnyOrigin() // Allow requests from any origin
.AllowAnyHeader() // Allow any headers in requests
.AllowAnyMethod(); // Allow any HTTP methods (GET, POST, etc.)
});
});
}

// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseCors("AllowAll");

app.MapOpenApi();
app.MapScalarApiReference();

// <post>
app.MapPost("/cache/invalidate", static async (
[FromHeader(Name = "X-CacheInvalidation-Key")] string? header,
ICacheService registry,
IConfiguration config) =>
{
var hasValidHeader = config.GetValue<string>("ApiCacheInvalidationKey") is { } key
&& header == $"Key: {key}";

if (hasValidHeader is false)
{
return Results.Unauthorized();
}

await registry.ClearAllAsync();

return Results.Ok();
});
// </post>
}

app.UseHttpsRedirection();

app.MapGet("/api/products/{id:int}", async (string id, ICacheService cache) =>
{
var product = await cache.GetAsync($"product:{id}", AppJsonContext.Default.Product);

IResult result = product is null ? Results.NotFound() : Results.Ok(product);

return result;
});

app.Lifetime.ApplicationStarted.Register(async () =>
{
using var scope = app.Services.CreateScope();
var cache = scope.ServiceProvider.GetRequiredService<ICacheService>();

foreach (var product in Products.DefaultProducts)
{
await cache.SetAsync($"product:{product.Id}", product, AppJsonContext.Default.Product);
}
});

app.Run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"profiles": {
"http": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5290"
},
"https": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7056;http://localhost:5290"
},
"Container (.NET SDK)": {
"commandName": "SdkContainer",
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
"environmentVariables": {
"ASPNETCORE_HTTPS_PORTS": "8081",
"ASPNETCORE_HTTP_PORTS": "8080"
},
"publishAllPorts": true,
"useSSL": true
}
},
"$schema": "https://json.schemastore.org/launchsettings.json"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using StackExchange.Redis;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;

namespace AspireApp.Api;

public sealed class RedisCacheService(IConnectionMultiplexer connection) : ICacheService
{
private readonly IDatabase _db = connection.GetDatabase();
private const string RegistryKey = "cache:registry";

public async Task<T?> GetAsync<T>(string key, JsonTypeInfo<T> jsonTypeInfo)
{
var value = await _db.StringGetAsync(key);

return value.IsNullOrEmpty ? default : JsonSerializer.Deserialize(value!, jsonTypeInfo);
}

public async Task SetAsync<T>(string key, T value, JsonTypeInfo<T> jsonTypeInfo, TimeSpan? absoluteExpiration = null)
{
var json = JsonSerializer.Serialize(value, jsonTypeInfo);

await _db.StringSetAsync(key, json, absoluteExpiration ?? TimeSpan.FromMinutes(5));
await _db.SetAddAsync(RegistryKey, key);
}

public async Task RemoveAsync(string key)
{
await _db.KeyDeleteAsync(key);
await _db.SetRemoveAsync(RegistryKey, key);
}

public async IAsyncEnumerable<string> GetRegisteredKeysAsync()
{
var keys = await _db.SetMembersAsync(RegistryKey);

foreach (var key in keys)
{
yield return key.ToString();
}
}

public async Task ClearAllAsync()
{
await foreach (var key in GetRegisteredKeysAsync())
{
await _db.KeyDeleteAsync(key);
}

await _db.KeyDeleteAsync(RegistryKey);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">

<Sdk Name="Aspire.AppHost.Sdk" Version="9.1.0" />

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
<UserSecretsId>da41f190-8f5d-494c-a2e8-05e2d09bba4b</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.2.0-preview.1.25175.3" />
<PackageReference Include="Aspire.Hosting.Redis" Version="9.2.0-preview.1.25175.3" />
<PackageReference Include="Aspire.StackExchange.Redis.DistributedCaching" Version="9.2.0-preview.1.25175.3" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\AspireApp.Api\AspireApp.Api.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
var builder = DistributedApplication.CreateBuilder(args);

var cache = builder.AddRedis("cache");

var apiCacheInvalidationKey = builder.AddParameter("ApiCacheInvalidationKey", secret: true);

var api = builder.AddProject<Projects.AspireApp_Api>("api")
.WithReference(cache)
.WaitFor(cache)
.WithEnvironment("ApiCacheInvalidationKey", apiCacheInvalidationKey)
.WithHttpCommand(
path: "/cache/invalidate",
displayName: "Invalidate cache",
commandOptions: new HttpCommandOptions()
{
Description = """
Invalidates the API cache. All cached values are cleared!
""",
PrepareRequest = (context) =>
{
var key = apiCacheInvalidationKey.Resource.Value;

context.Request.Headers.Add("X-CacheInvalidation-Key", $"Key: {key}");

return Task.CompletedTask;
},
IconName = "DocumentLightning",
IsHighlighted = true
});

builder.Build().Run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17028;http://localhost:15219",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21270",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22132"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15219",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19122",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20186"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.35806.103
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireApp.AppHost", "AspireApp.AppHost\AspireApp.AppHost.csproj", "{3E85BCA2-E8A4-4545-B335-45D4FB37A9D4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireApp.Api", "AspireApp.Api\AspireApp.Api.csproj", "{C90FC13B-ECF7-2893-B9BD-49165768EE28}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{3E85BCA2-E8A4-4545-B335-45D4FB37A9D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3E85BCA2-E8A4-4545-B335-45D4FB37A9D4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3E85BCA2-E8A4-4545-B335-45D4FB37A9D4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3E85BCA2-E8A4-4545-B335-45D4FB37A9D4}.Release|Any CPU.Build.0 = Release|Any CPU
{C90FC13B-ECF7-2893-B9BD-49165768EE28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C90FC13B-ECF7-2893-B9BD-49165768EE28}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C90FC13B-ECF7-2893-B9BD-49165768EE28}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C90FC13B-ECF7-2893-B9BD-49165768EE28}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {71BDF2AB-F354-4B61-AF34-E68BB6B32F1C}
EndGlobalSection
EndGlobal
8 changes: 6 additions & 2 deletions docs/toc.yml
Original file line number Diff line number Diff line change
@@ -44,8 +44,12 @@ items:
href: get-started/build-aspire-apps-with-python.md
- name: Configuration
href: app-host/configuration.md
- name: Custom resource commands
href: fundamentals/custom-resource-commands.md
- name: Custom commands
items:
- name: Custom resource commands
href: fundamentals/custom-resource-commands.md
- name: Custom HTTP commands
href: fundamentals/http-commands.md
- name: Add Dockerfiles to the app model
href: app-host/withdockerfile.md
displayName: dockerfile,docker