Skip to content
Merged
Show file tree
Hide file tree
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
45 changes: 42 additions & 3 deletions src/Aspire.Hosting.RemoteHost/Ats/CapabilityDispatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Ats;
using Microsoft.Extensions.Logging;

Expand Down Expand Up @@ -169,7 +170,12 @@ private void RegisterContextTypeProperty(AtsCapabilityInfo capability, PropertyI
throw CapabilityException.HandleNotFound(handleRef.HandleId, capabilityId);
}

var value = prop.GetValue(contextObj);
// Bridge builder -> resource: if the handle contains an IResourceBuilder<T>
// but the property is declared on the resource type T, unwrap to the
// correct target object. See AtsCapabilityScanner.MapToAtsTypeId.
var target = ResolveContextTarget(contextObj!, prop.DeclaringType!);

var value = prop.GetValue(target);
return Task.FromResult(_marshaller.MarshalToJson(value, capability.ReturnType));
};

Expand Down Expand Up @@ -212,7 +218,10 @@ private void RegisterContextTypeProperty(AtsCapabilityInfo capability, PropertyI
ParameterName = "value"
};
var value = _marshaller.UnmarshalFromJson(valueNode, prop.PropertyType, unmarshalContext);
prop.SetValue(contextObj, value);

// Bridge builder -> resource for setter as well.
var setTarget = ResolveContextTarget(contextObj!, prop.DeclaringType!);
prop.SetValue(setTarget, value);

// Return the context handle for fluent chaining
return Task.FromResult<JsonNode?>(new JsonObject
Expand Down Expand Up @@ -292,11 +301,16 @@ private void RegisterContextTypeMethod(AtsCapabilityInfo capability, MethodInfo
methodToInvoke = GenericMethodResolver.MakeGenericMethodFromArgs(method, methodArgs);
}

// Bridge builder -> resource: if the handle contains an IResourceBuilder<T>
// but the method is declared on the resource type T, unwrap to the
// correct target object. See AtsCapabilityScanner.MapToAtsTypeId.
var invokeTarget = ResolveContextTarget(contextObj!, methodToInvoke.DeclaringType!);

object? result;
try
{
// Invoke instance method on the context object
result = methodToInvoke.Invoke(contextObj, methodArgs);
result = methodToInvoke.Invoke(invokeTarget, methodArgs);
}
catch (TargetInvocationException tie) when (tie.InnerException is not null)
{
Expand Down Expand Up @@ -517,6 +531,31 @@ private static bool IsTypeMismatchException(ArgumentException ex)
message.Contains("type mismatch", StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Resolves the correct target object for a member invocation.
/// Handles the case where the handle contains an <see cref="IResourceBuilder{T}"/> but the
/// member is declared on the resource type <c>T</c>, since
/// <c>AtsCapabilityScanner.MapToAtsTypeId</c> maps both to the same type ID.
/// </summary>
private static object ResolveContextTarget(object contextObj, Type declaringType)
{
if (declaringType.IsInstanceOfType(contextObj))
{
return contextObj;
}

// Handle is a builder, but the member is on the resource type - extract .Resource
if (contextObj is IResourceBuilder<IResource> builder &&
declaringType.IsInstanceOfType(builder.Resource))
{
return builder.Resource;
}

// No bridge matched — return as-is; the CLR will throw TargetException if the
// object truly doesn't match the declaring type.
return contextObj;
}

/// <summary>
/// Gets all registered capability IDs.
/// </summary>
Expand Down
157 changes: 157 additions & 0 deletions tests/Aspire.Hosting.RemoteHost.Tests/CapabilityDispatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json.Nodes;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Ats;
using Aspire.Hosting.RemoteHost.Ats;
using Xunit;
Expand Down Expand Up @@ -1153,6 +1154,120 @@ public void Invoke_AcceptOptionalEnum_WithoutValue()
Assert.Equal("No value", result.GetValue<string>());
}

// Builder-to-resource unwrapping tests
// These test the scenario where a handle contains an IResourceBuilder<T> but the
// property/method being invoked is declared on the resource type T.
// This happens in TypeScript polyglot apps when ATS maps both IResourceBuilder<T>
// and T to the same type ID.
[Fact]
public void Invoke_PropertyGetter_ResolvesBuilderToResource()
{
var handles = new HandleRegistry();
var dispatcher = new CapabilityDispatcher(handles, CreateTestMarshaller(handles), [typeof(TestResourceWithProperties).Assembly]);

// Create a resource and wrap it in a builder, but register the BUILDER in the handle registry
var resource = new TestResourceWithProperties("test-resource") { Color = "blue" };
var builder = new TestResourceBuilder<TestResourceWithProperties>(resource);
var handleId = handles.Register(builder, "Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.TestResourceWithProperties");
var args = new JsonObject { ["context"] = new JsonObject { ["$handle"] = handleId } };

// Invoke the property getter - the property is on TestResourceWithProperties, but handle contains the builder
var result = dispatcher.Invoke("Aspire.Hosting.RemoteHost.Tests/TestResourceWithProperties.color", args);

Assert.NotNull(result);
Assert.Equal("blue", result.GetValue<string>());
}

[Fact]
public void Invoke_PropertySetter_ResolvesBuilderToResource()
{
var handles = new HandleRegistry();
var dispatcher = new CapabilityDispatcher(handles, CreateTestMarshaller(handles), [typeof(TestResourceWithProperties).Assembly]);

var resource = new TestResourceWithProperties("test-resource") { Color = "red" };
var builder = new TestResourceBuilder<TestResourceWithProperties>(resource);
var handleId = handles.Register(builder, "Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.TestResourceWithProperties");
var args = new JsonObject
{
["context"] = new JsonObject { ["$handle"] = handleId },
["value"] = "green"
};

dispatcher.Invoke("Aspire.Hosting.RemoteHost.Tests/TestResourceWithProperties.setColor", args);

Assert.Equal("green", resource.Color);
}

[Fact]
public void Invoke_InstanceMethod_ResolvesBuilderToResource()
{
var handles = new HandleRegistry();
var dispatcher = new CapabilityDispatcher(handles, CreateTestMarshaller(handles), [typeof(TestResourceWithMethods).Assembly]);

var resource = new TestResourceWithMethods("test-resource");
var builder = new TestResourceBuilder<TestResourceWithMethods>(resource);
var handleId = handles.Register(builder, "Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.TestResourceWithMethods");
var args = new JsonObject
{
["context"] = new JsonObject { ["$handle"] = handleId },
["prefix"] = "hello"
};

var result = dispatcher.Invoke("Aspire.Hosting.RemoteHost.Tests/TestResourceWithMethods.greet", args);

Assert.NotNull(result);
Assert.Equal("hello, test-resource!", result.GetValue<string>());
}

[Fact]
public void Invoke_PropertyGetter_WorksDirectlyOnResource()
{
var handles = new HandleRegistry();
var dispatcher = new CapabilityDispatcher(handles, CreateTestMarshaller(handles), [typeof(TestResourceWithProperties).Assembly]);

// When the handle contains the resource directly (not wrapped in a builder), it should still work
var resource = new TestResourceWithProperties("test-resource") { Color = "yellow" };
var handleId = handles.Register(resource, "Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.TestResourceWithProperties");
var args = new JsonObject { ["context"] = new JsonObject { ["$handle"] = handleId } };

var result = dispatcher.Invoke("Aspire.Hosting.RemoteHost.Tests/TestResourceWithProperties.color", args);

Assert.NotNull(result);
Assert.Equal("yellow", result.GetValue<string>());
}

[Fact]
public void Invoke_PropertyGetter_ResolvesInheritedPropertyViaBuilder()
{
var handles = new HandleRegistry();
var dispatcher = new CapabilityDispatcher(handles, CreateTestMarshaller(handles), [typeof(TestResourceWithProperties).Assembly]);

var resource = new TestResourceWithProperties("my-resource") { Color = "red" };
var builder = new TestResourceBuilder<TestResourceWithProperties>(resource);
var handleId = handles.Register(builder, "Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.TestResourceWithProperties");
var args = new JsonObject { ["context"] = new JsonObject { ["$handle"] = handleId } };

var result = dispatcher.Invoke("Aspire.Hosting.RemoteHost.Tests/TestResourceWithProperties.name", args);

Assert.NotNull(result);
Assert.Equal("my-resource", result.GetValue<string>());
}

[Fact]
public void Invoke_PropertyGetter_ThrowsWhenHandleIsUnrelatedType()
{
var handles = new HandleRegistry();
var dispatcher = new CapabilityDispatcher(handles, CreateTestMarshaller(handles), [typeof(TestResourceWithProperties).Assembly]);

var handleId = handles.Register("not-a-resource", "Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.TestResourceWithProperties");
var args = new JsonObject { ["context"] = new JsonObject { ["$handle"] = handleId } };

var ex = Assert.Throws<CapabilityException>(() =>
dispatcher.Invoke("Aspire.Hosting.RemoteHost.Tests/TestResourceWithProperties.color", args));

Assert.Equal(AtsErrorCodes.InternalError, ex.Error.Code);
}

private static CapabilityDispatcher CreateDispatcher(params System.Reflection.Assembly[] assemblies)
{
var handles = new HandleRegistry();
Expand Down Expand Up @@ -1431,3 +1546,45 @@ public static string AcceptOptionalEnum(TestDispatchEnum? value = null)
return value.HasValue ? $"Received: {value.Value}" : "No value";
}
}

/// <summary>
/// A minimal IResourceBuilder implementation for testing builder-to-resource unwrapping.
/// </summary>
internal sealed class TestResourceBuilder<T> : IResourceBuilder<T> where T : IResource
{
public TestResourceBuilder(T resource)
{
Resource = resource;
}

public T Resource { get; }
public IDistributedApplicationBuilder ApplicationBuilder => throw new NotImplementedException();

public IResourceBuilder<T> WithAnnotation<TAnnotation>(TAnnotation annotation, ResourceAnnotationMutationBehavior behavior = ResourceAnnotationMutationBehavior.Append) where TAnnotation : IResourceAnnotation
=> throw new NotImplementedException();
}

/// <summary>
/// Test resource type with properties that should be accessible via builder handles.
/// </summary>
[AspireExport(ExposeProperties = true)]
internal sealed class TestResourceWithProperties : Resource
{
public TestResourceWithProperties(string name) : base(name) { }

public string Color { get; set; } = "default";
}

/// <summary>
/// Test resource type with instance methods that should be accessible via builder handles.
/// </summary>
[AspireExport(ExposeMethods = true)]
internal sealed class TestResourceWithMethods : Resource
{
public TestResourceWithMethods(string name) : base(name) { }

public string Greet(string prefix)
{
return $"{prefix}, {Name}!";
}
}
Loading