Skip to content
Draft
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
11 changes: 11 additions & 0 deletions src/Aspire.Hosting.RemoteHost/Ats/AtsMarshaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,12 @@ private AtsTypeRef CreateTypeRef(Type type)
capabilityId, paramName,
$"Parameter type '{targetType.Name}' must have [AspireDto] attribute to be deserialized from JSON");
}

if (IsIAtsConvertable(targetType))
{
return targetType.GetMethod("Deserialize")?.Invoke(null, [jsonObj]);
}
Comment on lines +659 to +662

return DeserializeDto(jsonObj, targetType, context);
}

Expand All @@ -676,6 +682,11 @@ private AtsTypeRef CreateTypeRef(Type type)
}
}

private static bool IsIAtsConvertable(Type type)
{
return type.GetInterfaces().Any(x => x.FullName == "Aspire.Hosting.Ats.IAtsConvertable");
}
Comment on lines +685 to +688

/// <summary>
/// Checks if an exception indicates a type mismatch.
/// </summary>
Expand Down
125 changes: 125 additions & 0 deletions src/Aspire.Hosting/Ats/AtsConvertable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json;
using System.Text.Json.Nodes;

namespace Aspire.Hosting.Ats;

/// <summary>
/// Represents an object that has custom conversion logic for crossing ATS boundaries.
/// </summary>
public interface IAtsConvertable
{
Comment on lines +12 to +13
/// <summary>
/// Deserializes the given JSON object into the implementing class's type.
/// </summary>
/// <param name="jsonObj">The JSON document to convert.</param>
/// <returns>The deserialized object.</returns>
static abstract object? Deserialize(JsonObject jsonObj);

/// <summary>
/// Serializes the given object to JSON.
/// </summary>
/// <param name="value">The value to serialize.</param>
/// <returns>A JSON object matching the given value.</returns>
static abstract JsonNode? Serialize(object value);
}

/// <summary>
/// Represents an object that is convertable across language boundaries through the ATS system.
/// </summary>
/// <remarks>
/// This implementation of <see cref="IAtsConvertable"/> allows for completely generic objects to be sent
/// across language boundaries.
/// <example>
///
/// <code>
/// // User-defined custom TypeScript object
/// {
/// route: "aspire.dev",
/// match: "http",
/// users: ["chris", "dave", "maddy"]
/// }
/// </code>
/// </example>
/// The above object will get serialized into the <see cref="Object"/> property as a <see cref="Dictionary{TKey, TValue}"/>.
/// </remarks>
/// <ats-summary>An object that supports serialization of custom properties.</ats-summary>
[AspireDto]
[AspireExport]
public class CustomAtsObjectDto : IAtsConvertable
{
/// <summary>
/// Contains the result of deserialization.
/// </summary>
[AspireExportIgnore]
internal Dictionary<string, object?>? Object { get; set; }

/// <summary>
/// Deserializes a <see cref="JsonObject"/> into a Dictionary. A new <see cref="CustomAtsObjectDto"/> will be returned
/// with the results of the deserialization set to the object's <see cref="Object"/> property.
/// </summary>
/// <param name="jsonObj">The JSON value to deserialize.</param>
/// <returns>A new <see cref="CustomAtsObjectDto"/> containing the deserialized <paramref name="jsonObj"/>.</returns>
/// <exception cref="NotSupportedException">Thrown if the <paramref name="jsonObj"/> contains an unsupported type.</exception>
public static object? Deserialize(JsonObject jsonObj)
{
if (jsonObj is null)
{
return null;
}

return new CustomAtsObjectDto()
{
Object = jsonObj.ToDictionary(kvp => kvp.Key, kvp => ConvertJsonNode(kvp.Value))
};

static object? ConvertJsonNode(JsonNode? node)
{
return node switch
{
null => null,

JsonValue value => ConvertJsonValue(value),

JsonObject obj => obj.ToDictionary(
kvp => kvp.Key,
kvp => ConvertJsonNode(kvp.Value)),

JsonArray array => array
.Select(ConvertJsonNode)
.ToList(),

_ => throw new NotSupportedException(
$"Unsupported JsonNode type: {node.GetType().FullName}")
};
}

static object? ConvertJsonValue(JsonValue value)
{
var element = value.GetValue<JsonElement>();

return value.GetValueKind() switch
{
JsonValueKind.Null => null,
JsonValueKind.String => element.GetString(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Number when element.TryGetInt64(out var longValue) => longValue,
JsonValueKind.Number => element.GetDouble(),
_ => throw new NotSupportedException($"Unsupported JSON value kind '{value.GetValueKind()}'.")
};
}
}

/// <summary>
/// Forwards serialization to the application's default <see cref="JsonSerializer"/>.
/// </summary>
/// <param name="value">The value to serialize.</param>
/// <returns>The serialized JSON.</returns>
public static JsonNode? Serialize(object value)
{
return JsonSerializer.Serialize(value);
}
Comment on lines +121 to +124
}
41 changes: 19 additions & 22 deletions tests/Aspire.Hosting.RemoteHost.Tests/AtsMarshallerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Aspire.TypeSystem;
using Aspire.Hosting.RemoteHost.Ats;
using Xunit;
using Aspire.Hosting.Ats;

namespace Aspire.Hosting.RemoteHost.Tests;

Expand Down Expand Up @@ -713,33 +714,29 @@ public void UnmarshalFromJson_UnmarshalsDto()
Assert.Equal(5, dto.Count);
}

[Fact]
public async Task UnmarshalFromJson_UnmarshalsDtoInitListProperties()
[Fact]
public void UnmarshalFromJson_UnmarshalsCustomAtsObjectDto()
Comment on lines +717 to +718
{
var (marshaller, context) = CreateMarshallerWithContext();
var json = new JsonObject
var jsonContent = """
{
["name"] = "test",
["addressPrefixes"] = new JsonArray("203.0.113.0/24", "198.51.100.0/24"),
["addressPrefixReferences"] = new JsonArray
{
new JsonObject
{
["$expr"] = new JsonObject
{
["format"] = "10.0.0.0/24"
}
}
"name": "test",
"count": 5,
"complex": {
"nestedProperty": true
}
};
}
""";

var result = marshaller.UnmarshalFromJson(json, typeof(DtoWithInitListProperties), context);
var json = JsonNode.Parse(jsonContent);

var dto = Assert.IsType<DtoWithInitListProperties>(result);
Assert.Equal("test", dto.Name);
Assert.Equal(["203.0.113.0/24", "198.51.100.0/24"], dto.AddressPrefixes);
var reference = Assert.Single(dto.AddressPrefixReferences);
Assert.Equal("10.0.0.0/24", await reference.GetValueAsync(default));
var result = marshaller.UnmarshalFromJson(json, typeof(CustomAtsObjectDto), context);

Assert.NotNull(result);
var dto = (CustomAtsObjectDto)result;
Assert.Equal("test", dto.Object!["name"]);
Assert.Equal(5L, dto.Object!["count"]);
Assert.True((bool)(dto.Object!["complex"] as Dictionary<string, object?>)!["nestedProperty"]!);
}

[Fact]
Expand Down Expand Up @@ -1367,4 +1364,4 @@ public void UnmarshalFromJson_CondExpr_ThrowsWhenConditionHandleMissing()
Assert.Throws<CapabilityException>(() =>
marshaller.UnmarshalFromJson(json, typeof(ReferenceExpression), context));
}
}
}
Loading