Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
00cf76c
Initial plan for issue
Copilot Jun 20, 2025
a02a4a1
Implement basic ExternalServiceResource with service discovery support
Copilot Jun 20, 2025
9d4b64d
Complete ExternalServiceResource implementation with working endpoint…
Copilot Jun 20, 2025
ca43b32
Update implementation to follow DamianEdwards' proposal - WIP endpoin…
Copilot Jun 20, 2025
b6d971b
Fix port determination for external service endpoints to consider scheme
Copilot Jun 20, 2025
b28113b
Address code review feedback - add health checks, fix endpoint naming…
Copilot Jun 20, 2025
8696040
Refactor ExternalServiceResource to follow DamianEdwards feedback - u…
Copilot Jun 20, 2025
0f3918e
WIP
DamianEdwards Jun 26, 2025
38822e4
Merge branch 'main' into copilot/fix-2311
DamianEdwards Jun 26, 2025
fdd7e8c
ExternalServiceResource working now
DamianEdwards Jun 26, 2025
8c3edca
Add support for Yarp and ExternalServiceResource
DamianEdwards Jun 27, 2025
4e98018
Fix things
DamianEdwards Jun 27, 2025
facb128
Yarp fixes
DamianEdwards Jun 27, 2025
bd0f2fc
Use playground ServiceDefaults
DamianEdwards Jun 27, 2025
7a1ce28
Address code review feedback - use "http" endpoint name and remove un…
Copilot Jun 27, 2025
cc6f735
More changes
DamianEdwards Jun 27, 2025
a107a76
Merge branch 'copilot/fix-2311' of https://github.com/dotnet/aspire i…
DamianEdwards Jun 27, 2025
b08db43
Use the parameter to set env var values directly
DamianEdwards Jun 27, 2025
d5c44de
Don't read parameter value in publish mode
DamianEdwards Jun 27, 2025
f187960
Throw at publish time if URLs come from parameters
DamianEdwards Jun 28, 2025
490717d
Make publishing URL from parameter a warning & dashboard updates for …
DamianEdwards Jun 28, 2025
6e1268f
Fix uninstrumented peers name resolution for hypens & show non-endpoi…
DamianEdwards Jun 30, 2025
83fe7a5
Merge branch 'main' into copilot/fix-2311
DamianEdwards Jun 30, 2025
baaaeb8
Post merge fix
DamianEdwards Jun 30, 2025
b52d120
Make Yarp usable with service discovery
DamianEdwards Jun 30, 2025
2275f4e
Update Aspire.slnx
DamianEdwards Jun 30, 2025
35f6b8d
PR review feedback
DamianEdwards Jun 30, 2025
7580749
Update ApplicationKeyTests.cs
DamianEdwards Jun 30, 2025
973652e
Update StringComparers.cs
DamianEdwards Jun 30, 2025
e579d05
Rewrite ExternalServiceTests with comprehensive test coverage for cur…
Copilot Jun 30, 2025
56848cb
Reimplement ExternalServiceResource to follow @DamianEdwards' proposal
Copilot Jul 1, 2025
71175bd
Remove Bootstrap vendor files and use CDN instead to reduce diff size
Copilot Jul 1, 2025
6392b16
Revert changes from commit 56848cb and update tests appropriately
Copilot Jul 1, 2025
1813278
Fix build
DamianEdwards Jul 1, 2025
207cf72
Fix tests
DamianEdwards Jul 1, 2025
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
72 changes: 72 additions & 0 deletions src/Aspire.Hosting/ExternalServiceAllocatedEndpoint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting;

/// <summary>
/// A special allocated endpoint for external services that provides the URL expression as the URI.
/// </summary>
internal class ExternalServiceAllocatedEndpoint : AllocatedEndpoint
{
private readonly ReferenceExpression _urlExpression;
private readonly string _literalUrl;

public ExternalServiceAllocatedEndpoint(EndpointAnnotation endpointAnnotation, ReferenceExpression urlExpression)
: base(endpointAnnotation, GetHostFromUrl(urlExpression), GetPortFromUrl(urlExpression))
{
_urlExpression = urlExpression;
_literalUrl = IsLiteralUrl(urlExpression, out var url) ? url : "";
}

/// <summary>
/// Gets the URL expression that represents this external service endpoint.
/// </summary>
public ReferenceExpression UrlExpression => _urlExpression;

private static string GetHostFromUrl(ReferenceExpression urlExpression)
{
// For literal URLs, extract the host; for expressions, use a placeholder
if (IsLiteralUrl(urlExpression, out var literalUrl) && Uri.TryCreate(literalUrl, UriKind.Absolute, out var uri))
{
return uri.Host;
}
return "external.service";
}

private static int GetPortFromUrl(ReferenceExpression urlExpression)
{
// For literal URLs, extract the port; for expressions, use default port
if (IsLiteralUrl(urlExpression, out var literalUrl) && Uri.TryCreate(literalUrl, UriKind.Absolute, out var uri))
{
return uri.Port;
}
return 80;
}

private static bool IsLiteralUrl(ReferenceExpression expression, out string url)
{
if (expression.ValueProviders.Count == 0)
{
url = expression.Format;
return true;
}

url = string.Empty;
return false;
}

/// <summary>
/// Returns the original URL for literal URLs, or the expression for parameterized URLs.
/// </summary>
/// <returns>The URL or expression value.</returns>
public override string ToString()
{
if (!string.IsNullOrEmpty(_literalUrl))
{
return _literalUrl;
}
return _urlExpression.ValueExpression;
}
}
154 changes: 154 additions & 0 deletions src/Aspire.Hosting/ExternalServiceBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Net.Sockets;
using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting;

/// <summary>
/// Provides extension methods for adding external services to an application.
/// </summary>
public static class ExternalServiceBuilderExtensions
{
/// <summary>
/// Adds an external service resource to the distributed application with the specified URL.
/// </summary>
/// <param name="builder">The distributed application builder.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="url">The URL of the external service.</param>
/// <returns>An <see cref="IResourceBuilder{ExternalServiceResource}"/> instance.</returns>
/// <exception cref="ArgumentException">Thrown when the URL is not a valid, absolute URI.</exception>
public static IResourceBuilder<ExternalServiceResource> AddExternalService(this IDistributedApplicationBuilder builder, [ResourceName] string name, string url)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(url);

if (!Uri.IsWellFormedUriString(url, UriKind.Absolute))
{
throw new ArgumentException($"The URL '{url}' is not a valid absolute URI.", nameof(url));
}

var uri = new Uri(url);
return builder.AddExternalService(name, uri);
}

/// <summary>
/// Adds an external service resource to the distributed application with the specified URI.
/// </summary>
/// <param name="builder">The distributed application builder.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="uri">The URI of the external service.</param>
/// <returns>An <see cref="IResourceBuilder{ExternalServiceResource}"/> instance.</returns>
/// <exception cref="ArgumentException">Thrown when the URI is not absolute.</exception>
public static IResourceBuilder<ExternalServiceResource> AddExternalService(this IDistributedApplicationBuilder builder, [ResourceName] string name, Uri uri)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(uri);

if (!uri.IsAbsoluteUri)
{
throw new ArgumentException("The URI for the external service must be absolute.", nameof(uri));
}

var rb = new ReferenceExpressionBuilder();
rb.AppendLiteral(uri.ToString());
var urlExpression = rb.Build();
return builder.AddExternalService(name, urlExpression);
}

/// <summary>
/// Adds an external service resource to the distributed application with the specified URL expression.
/// </summary>
/// <param name="builder">The distributed application builder.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="urlExpression">The URL expression for the external service.</param>
/// <returns>An <see cref="IResourceBuilder{ExternalServiceResource}"/> instance.</returns>
public static IResourceBuilder<ExternalServiceResource> AddExternalService(this IDistributedApplicationBuilder builder, [ResourceName] string name, ReferenceExpression urlExpression)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(urlExpression);

var resource = new ExternalServiceResource(name, urlExpression);

return builder.AddResource(resource)
.WithReferenceRelationship(urlExpression)
.WithInitialState(new CustomResourceSnapshot
{
ResourceType = "ExternalService",
Properties = [],
State = "Running"
})
.WithExternalServiceEndpoints();
}

/// <summary>
/// Adds an external service resource to the distributed application with a parameterized URL.
/// </summary>
/// <param name="builder">The distributed application builder.</param>
/// <param name="name">The name of the resource.</param>
/// <returns>An <see cref="IResourceBuilder{IResourceWithServiceDiscovery}"/> instance.</returns>
public static IResourceBuilder<IResourceWithServiceDiscovery> AddExternalService(this IDistributedApplicationBuilder builder, [ResourceName] string name)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(name);

return builder.AddParameter(
new ExternalServiceParameterResource(
name,
parameterDefault => GetParameterValue(builder.Configuration, name, parameterDefault)));
}

private static string GetParameterValue(Microsoft.Extensions.Configuration.ConfigurationManager configuration, string name, ParameterDefault? parameterDefault)
{
var configurationKey = $"Parameters:{name}";
return configuration[configurationKey]
?? parameterDefault?.GetDefaultValue()
?? throw new DistributedApplicationException($"External service parameter resource could not be used because parameter '{name}' is missing.");
}

/// <summary>
/// Configures the external service to provide endpoints for service discovery and endpoint references.
/// </summary>
private static IResourceBuilder<ExternalServiceResource> WithExternalServiceEndpoints(this IResourceBuilder<ExternalServiceResource> builder)
{
var urlExpression = builder.Resource.UrlExpression;

// Determine the scheme from the URL if it's a literal URL
var scheme = "http"; // default
if (IsLiteralUrl(urlExpression, out var literalUrl) && Uri.TryCreate(literalUrl, UriKind.Absolute, out var uri))
{
scheme = uri.Scheme;
}

// Add a default endpoint annotation that represents the external service
// This allows GetEndpoint() to work and enables endpoint references
var endpointAnnotation = new EndpointAnnotation(ProtocolType.Tcp, scheme, name: "default");

// Create a special allocated endpoint that will resolve the URL expression at runtime
endpointAnnotation.AllocatedEndpoint = new ExternalServiceAllocatedEndpoint(endpointAnnotation, urlExpression);

builder.WithAnnotation(endpointAnnotation);

return builder;
}

/// <summary>
/// Checks if a ReferenceExpression represents a literal URL string.
/// </summary>
private static bool IsLiteralUrl(ReferenceExpression expression, out string url)
{
// If the expression has no value providers, it's a literal string
if (expression.ValueProviders.Count == 0)
{
url = expression.Format;
return true;
}

url = string.Empty;
return false;
}
}
20 changes: 20 additions & 0 deletions src/Aspire.Hosting/ExternalServiceParameterResource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting;

internal sealed class ExternalServiceParameterResource : ParameterResource, IResourceWithServiceDiscovery
{
public ExternalServiceParameterResource(string name, Func<ParameterDefault?, string> callback) : base(name, callback, secret: false)
{
// Add endpoint annotation for service discovery
var endpointAnnotation = new EndpointAnnotation(System.Net.Sockets.ProtocolType.Tcp, "http", name: "default");
endpointAnnotation.AllocatedEndpoint = new ExternalServiceAllocatedEndpoint(endpointAnnotation, UrlExpression);
Annotations.Add(endpointAnnotation);
}

public ReferenceExpression UrlExpression =>
ReferenceExpression.Create($"{this}");
}
19 changes: 19 additions & 0 deletions src/Aspire.Hosting/ExternalServiceResource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting;

/// <summary>
/// Represents an external service resource with service discovery capabilities.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="urlExpression">The URL expression for the external service.</param>
public sealed class ExternalServiceResource(string name, ReferenceExpression urlExpression) : Resource(name), IResourceWithServiceDiscovery, IResourceWithoutLifetime
{
/// <summary>
/// Gets the URL expression for the external service.
/// </summary>
public ReferenceExpression UrlExpression => urlExpression;
}
Loading
Loading