Extend the External Service APIs
- Dominant language
- C#
- Stars
- 6.3k
- Forks
- 991
- Avg merge
- 2d 15h
- Merged PRs (30d)
- 196
Description
### Is there an existing issue for this?
- [x] I have searched the existing issues
### Is your feature request related to a problem? Please describe the problem.
The current implementation around External Services is extremely limited, albeit for decent reasons, given expanding it for what I'm about to suggest in a way that is flexible enough for a variety of frameworks and various identity providers is understandably daunting. That said, I wanted to at least provide our specific scenario and some extension methods and conventions I have created to serve our use case, in hopes that it can help shape an agnostic implementation that supports additional frameworks and identity providers beyond .NET and Microsoft Entra (our use case).
The main limitation for us was that the current API is limited to a name and a url. In most, if not all of our uses of external APIs, we, at minimum, need to define an Audience or Scope where we eventually end up using a TokenCredential to generate a token for that audience to communicate with the external service. What this typically resulted in was defining an entity in our appsettings that would look something like:
```json
{
"OurApi": {
"Url": "https://our-api.com",
"Audience: "some-guid/.default"
}
}
```
We'd then setup an HttpClient and a message handler that would pull out the DI-registered TokenCredential and generate a token for the client at request time.
So the challenge became, _how do we pass erroneous properties to the client from the app host beyond just the url?_
### Describe the solution you'd like
# The Solution
## Part 1: The Host end
This covers a few parts to make a clean implementation on the App Host end.
### An extension method that supports just a name (so we can add the url later via a convention in the appsettings.
```csharp
internal static IResourceBuilder AddExternalService(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
Action>? configure = null)
{
var options = builder.Configuration.GetSection($"{ConfigSection}:{name}").Get();
if (options is null || string.IsNullOrWhiteSpace(options.Url))
{
throw new InvalidOperationException($"External service '{name}' requires a Url. Add an '{ConfigSection}:{name}:Url' entry to the AppHost's appsettings.");
}
var resource = builder.AddExternalService(name, options.Url);
if (!string.IsNullOrWhiteSpace(options.Audience))
{
resource.WithAudience(options.Audience);
}
foreach (var (headerName, headerValue) in options.Headers)
{
resource.WithHeader(headerName, headerValue);
}
foreach (var (key, value) in options.Properties)
{
resource.WithProperty(key, value);
}
configure?.Invoke(resource);
return resource;
}
```
### ExternalServiceOptions
This can be modified/expanded for more scenarios
```csharp
internal sealed class ExternalServiceOptions
{
///
/// Gets or sets the base URL of the external service. Required.
///
public string Url { get; set; } = default!;
///
/// Gets or sets the token audience. When present, the client-side helper will automatically
/// configure an authenticated message handler that acquires tokens for this audience.
///
public string? Audience { get; set; }
///
/// Gets or sets default request headers to include on every request to this service.
///
public Dictionary Headers { get; set; } = [];
///
/// Gets or sets arbitrary properties that will be injected as environment variables
/// but do not trigger any automatic behavior.
///
public Dictionary Properties { get; set; } = [];
}
```
### Injecting the extra values
Ideally this would be handled better once implemented inside the SDK itself, but for the time being we're using Aspire's annotation system.
#### Extension method that wraps the in-built .WithReference and adds the annotations.
```csharp
internal static IResourceBuilder WithExternalServiceReference(this IResourceBuilder builder, IResourceBuilder externalService)
where T : IResourceWithEnvironment
{
// Standard Aspire reference — injects services____https__0 etc.
builder.WithReference(externalService);
if (!externalService.Resource.TryGetLastAnnotation(out var annotation))
{
return builder;
}
var name = externalService.Resource.Name;
InjectMetadataEnvironment(builder, name, annotation);
return builder;
}
private static void InjectMetadataEnvironment(IResourceBuilder builder, string name, ExternalServiceAnnotation annotation)
where T : IResourceWithEnvironment
{
if (annotation.Audience is not null)
{
// We try to follow the existing patterns for service discovery
builder.WithEnvironment($"{Prefix}__{name}__Audience", annotation.Audience);
}
foreach (var (headerName, headerValue) in annotation.Headers)
{
builder.WithEnvironment($"{Prefix}__{name}__Headers__{headerName}", headerValue);
}
foreach (var (key, value) in annotation.Properties)
{
builder.WithEnvironment($"{Prefix}__{name}__Properties__{key}", value);
}
}
```
#### Usage
```csharp
var externalApi = builder.AddExternalService("external-api");
var myApi = builder.AddProject("api")
.WithExternalServiceReference(externalApi);
```
#### appsettings format
```json
{
"ExternalServices": {
"external-api": {
"Url": "https://external-api.com",
"Audience": "https://external-api.com/.default"
}
}
}
```
## The Client side
## The core extension method
```csharp
public static IHttpClientBuilder AddExternalServiceClient(
this IHostApplicationBuilder builder,
string name,
Action? configureClient = null)
where TClient : class
{
ArgumentException.ThrowIfNullOrEmpty(name);
var section = $"{ConfigSection}:{name}";
// Similar class to the App Host one for extracting the config values
builder.Services.Configure(name, builder.Configuration.GetSection(section));
var httpClientBuilder = builder.Services.AddHttpClient((sp, httpClient) =>
{
// Use service discovery's url format
httpClient.BaseAddress = new Uri($"https+http://{name}");
var optionsMonitor = sp.GetRequiredService>();
var options = optionsMonitor.Get(name);
foreach (var (headerName, headerValue) in options.Headers)
{
httpClient.DefaultRequestHeaders.TryAddWithoutValidation(headerName, headerValue);
}
configureClient?.Invoke(httpClient);
});
var audience = builder.Configuration[$"{section}:Audience"];
if (!string.IsNullOrWhiteSpace(audience))
{
// I'm omitting the message handler for brevity, but it just has override that generates a token using a TokenCredential when the client calls SendAsync and adds it as an Authentication header.
httpClientBuilder.AddHttpMessageHandler(sp => new AuthenticatedMessageHandler(sp, audience));
}
return httpClientBuilder;
}
```
## Usage
A nice aspirey feeling method to easily setup your client. Now you've got a client that will generate auth tokens for requests and can apply arbitrary headers for each request using a little convention in your app host's appsettings.
```csharp
builder.AddExternalServiceClient("external-api");
```
### Additional context
Now, it's worth reiterating, this takes advantage of .NET semantics and relies on Azure.Identity/Microsoft Entra. Maybe this might just mean there could be more identity-specific methods that handle/assert the usage of a specific provider? I don't have the best idea on making this agnostic, but at least for .NET and Entra, this works pretty well.
Contributor guide
Assessment
This issue has not been assessed yet.