File: NativeCSharpProjection.cs
Web Access
Project: src\src\Aspire.Hosting.Native.CodeGeneration\Aspire.Hosting.Native.CodeGeneration.csproj (Aspire.Hosting.Native.CodeGeneration)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Text;
using System.Text.Json;
using Aspire.TypeSystem;
 
namespace Aspire.Hosting.Native.CodeGeneration;
 
/// <summary>Projects the same scanned ATS declarations as native dispatch into a standalone .NET client.</summary>
internal static class NativeCSharpProjection
{
    public static string Generate(AtsContext context)
    {
        if (context.Properties.Count != 0 || context.EnumTypes.Count != 0 || context.ExportedValues.Count != 0)
        {
            throw new NotSupportedException("The native C# projection supports methods, DTOs and handles only.");
        }
        var handles = context.HandleTypes.ToDictionary(type => type.AtsTypeId, type => type.ClrType!.Name);
        var dtos = context.DtoTypes.ToDictionary(type => type.TypeId, type => type.Name);
        if (handles.Values.Concat(dtos.Values).Distinct(StringComparer.Ordinal).Count() != handles.Count + dtos.Count)
        {
            throw new NotSupportedException("The native C# projection requires unique simple type names.");
        }
        var code = new StringBuilder("""
            // Generated from the native ATS contract. Do not edit.
            #nullable enable
            using System.Text.Json;
            using System.Text.Json.Serialization;
            namespace Aspire.Hosting.Native.Client;
 
            """);
        var scalarTypes = new HashSet<string>(StringComparer.Ordinal) { "string", "bool", "int", "long" };
        foreach (var dto in context.DtoTypes.OrderBy(type => type.Name, StringComparer.Ordinal))
        {
            code.AppendLine(CultureInfo.InvariantCulture, $"/// <summary>Copied ATS data for {dto.Name}; contains no execution authority.</summary>");
            code.AppendLine(CultureInfo.InvariantCulture, $"public sealed class {dto.Name}");
            code.AppendLine("{");
            foreach (var property in dto.Properties)
            {
                if (property.IsCallback || ContainsHandle(property.Type))
                {
                    throw new NotSupportedException("Native DTOs cannot contain callbacks or live capability handles.");
                }
                code.AppendLine(CultureInfo.InvariantCulture, $"    /// <summary>Gets the {property.Name} contract field.</summary>");
                code.AppendLine(CultureInfo.InvariantCulture,
                    $"    public required {TypeName(property.Type)} {property.Name} {{ get; init; }}");
            }
            code.AppendLine("}");
        }
        foreach (var handle in context.HandleTypes.OrderBy(type => type.AtsTypeId, StringComparer.Ordinal))
        {
            var name = handles[handle.AtsTypeId];
            code.AppendLine(CultureInfo.InvariantCulture, $"/// <summary>Connection-scoped ATS capability for {name}.</summary>");
            code.AppendLine(CultureInfo.InvariantCulture, $"public sealed class {name} : NativeRemoteObject");
            code.AppendLine("{");
            code.AppendLine("    /// <summary>Wraps an authoritative wire identity for generated provider projections.</summary>");
            code.AppendLine(CultureInfo.InvariantCulture, $"    public {name}(NativeClient client, JsonElement handle) : base(client, handle, {Literal(handle.AtsTypeId)}) {{ }}");
            foreach (var capability in context.Capabilities.Where(capability => capability.TargetTypeId == handle.AtsTypeId)
                .OrderBy(capability => capability.CapabilityId, StringComparer.Ordinal))
            {
                AppendMethod(capability, isStatic: false);
            }
            code.AppendLine("}");
        }
        var staticCapabilities = context.Capabilities.Where(capability => context.Methods[capability.CapabilityId].IsStatic).ToArray();
        if (staticCapabilities.Length != 0)
        {
            code.AppendLine("""
                /// <summary>Static factories exported by the native ATS contract.</summary>
                public static class NativeFactories
                {
                """);
            foreach (var capability in staticCapabilities.OrderBy(capability => capability.CapabilityId, StringComparer.Ordinal))
            {
                AppendMethod(capability, isStatic: true);
            }
            code.AppendLine("}");
        }
        foreach (var type in dtos.Values.Concat(scalarTypes).Order(StringComparer.Ordinal))
        {
            code.AppendLine(CultureInfo.InvariantCulture, $"[JsonSerializable(typeof({type}))]");
        }
        code.AppendLine("""
            [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
                UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, RespectNullableAnnotations = true)]
            internal sealed partial class NativeClientJsonContext : JsonSerializerContext;
            """);
 
        return code.ToString();
 
        void AppendMethod(AtsCapabilityInfo capability, bool isStatic)
        {
            var method = context.Methods[capability.CapabilityId];
            // ATS includes the implicit instance receiver as a "context" parameter.
            // The C# projection binds it to this wrapper, never a caller argument.
            var inputs = capability.Parameters.Where(parameter => isStatic || parameter.Name != capability.TargetParameterName).ToArray();
            if (method.IsGenericMethod || method.GetParameters().Any(parameter => parameter.IsOptional) ||
                inputs.Any(parameter => parameter.Type is null || parameter.Type.IsNullable == true))
            {
                throw new NotSupportedException($"Unsupported native C# signature: {capability.CapabilityId}.");
            }
            var resultType = TypeName(capability.ReturnType);
            var task = resultType == "void" ? "Task" : $"Task<{resultType}>";
            var reservedNames = inputs.Select(parameter => parameter.Name).ToHashSet(StringComparer.Ordinal);
            var connectionName = ReserveName("clientConnection");
            var argumentsName = ReserveName("arguments");
            var resultName = ReserveName("result");
            var clientName = ReserveName("client");
            var cancellationName = ReserveName("cancellationToken");
            var callbacks = inputs.Where(parameter => parameter.IsCallback).ToArray();
            foreach (var callback in callbacks)
            {
                if (callback.CallbackParameters is not [{ Type.Category: AtsTypeCategory.Handle }] ||
                    callback.CallbackReturnType?.TypeId != "void")
                {
                    throw new NotSupportedException($"Unsupported native callback: {capability.CapabilityId}.");
                }
            }
            var callbackNames = callbacks.ToDictionary(parameter => parameter.Name,
                parameter => ReserveName(parameter.Name + "Id"), StringComparer.Ordinal);
            var parameters = inputs.Select(parameter => $"{(parameter.IsCallback ? $"Func<{TypeName(parameter.CallbackParameters![0].Type)}, Task>" : TypeName(parameter.Type!))} @{parameter.Name}");
            if (isStatic)
            {
                parameters = new[] { $"NativeClient {clientName}" }.Concat(parameters);
            }
            parameters = parameters.Append($"CancellationToken {cancellationName} = default");
            code.AppendLine(CultureInfo.InvariantCulture, $"    /// <summary>Invokes the native {method.Name} capability.</summary>");
            foreach (var parameter in inputs)
            {
                code.AppendLine(CultureInfo.InvariantCulture, $"    /// <param name=\"{parameter.Name}\">The {parameter.Name} contract argument.</param>");
            }
            if (isStatic)
            {
                code.AppendLine(CultureInfo.InvariantCulture, $"    /// <param name=\"{clientName}\">The authenticated connection owning the result.</param>");
            }
            code.AppendLine(CultureInfo.InvariantCulture, $"    /// <param name=\"{cancellationName}\">Cancels the caller's wait; it does not roll back server-side execution.</param>");
            code.AppendLine("    /// <returns>The copied result or connection-scoped capability.</returns>");
            code.AppendLine(CultureInfo.InvariantCulture,
                $"    public {(isStatic ? "static " : "")}async {task} {method.Name}Async({string.Join(", ", parameters)})");
            code.AppendLine("    {");
            code.AppendLine(CultureInfo.InvariantCulture, $"        var {connectionName} = {(isStatic ? clientName : "this.Client")};");
            code.AppendLine(CultureInfo.InvariantCulture, $"        var {argumentsName} = new Dictionary<string, JsonElement>(StringComparer.Ordinal);");
            if (!isStatic)
            {
                code.AppendLine(CultureInfo.InvariantCulture, $"        {argumentsName}.Add({Literal(capability.TargetParameterName!)}, {connectionName}.GetHandle(this));");
            }
            foreach (var parameter in inputs)
            {
                if (parameter.IsCallback)
                {
                    continue;
                }
                var parameterType = TypeName(parameter.Type!);
                if (parameterType == "string[]")
                {
                    scalarTypes.Add(parameterType);
                }
                var value = parameter.Type!.Category == AtsTypeCategory.Union
                    ? $"@{parameter.Name} switch {{ {string.Join(", ", parameter.Type.UnionTypes!.Select(member =>
                        member.TypeId == "string"
                            ? $"string text => JsonSerializer.SerializeToElement(text, NativeClientJsonContext.Default.String)"
                            : $"{TypeName(member)} remote => {connectionName}.GetHandle(remote)"))}, _ => throw new ArgumentException(\"Unsupported symbolic value.\", nameof(@{parameter.Name})) }}"
                    : parameter.Type.Category == AtsTypeCategory.Handle
                    ? $"{connectionName}.GetHandle(@{parameter.Name})"
                    : $"JsonSerializer.SerializeToElement(@{parameter.Name}, NativeClientJsonContext.Default.{JsonContextName(parameterType)})";
                code.AppendLine(CultureInfo.InvariantCulture, $"        {argumentsName}.Add({Literal(parameter.Name)}, {value});");
            }
            foreach (var callback in callbacks)
            {
                var callbackType = TypeName(callback.CallbackParameters![0].Type);
                var callbackName = callbackNames[callback.Name];
                code.AppendLine(CultureInfo.InvariantCulture, $"        ArgumentNullException.ThrowIfNull(@{callback.Name});");
                code.AppendLine(CultureInfo.InvariantCulture, $"        var {callbackName} = Guid.NewGuid().ToString(\"N\");");
                code.AppendLine(CultureInfo.InvariantCulture, $"        {connectionName}.RegisterCallback({callbackName}, async callbackArguments =>");
                code.AppendLine("        {");
                code.AppendLine(CultureInfo.InvariantCulture,
                    $"            await @{callback.Name}(new {callbackType}({connectionName}, callbackArguments.GetProperty(\"p0\"))).ConfigureAwait(false);");
                code.AppendLine("            return JsonSerializer.SerializeToElement((string?)null, NativeClientJsonContext.Default.String);");
                code.AppendLine("        });");
                code.AppendLine(CultureInfo.InvariantCulture,
                    $"        {argumentsName}.Add({Literal(callback.Name)}, JsonSerializer.SerializeToElement({callbackName}, NativeClientJsonContext.Default.String));");
            }
            if (callbacks.Length != 0)
            {
                code.AppendLine("        try");
                code.AppendLine("        {");
            }
            var invocationIndent = callbacks.Length != 0 ? "            " : "        ";
            code.AppendLine(CultureInfo.InvariantCulture,
                $"{invocationIndent}var {resultName} = await {connectionName}.InvokeAsync({Literal(capability.CapabilityId)}, {argumentsName}, {cancellationName}).ConfigureAwait(false);");
            if (capability.ReturnType.Category == AtsTypeCategory.Handle)
            {
                code.AppendLine(CultureInfo.InvariantCulture, $"{invocationIndent}return new {resultType}({connectionName}, {resultName});");
            }
            else if (resultType == "void")
            {
                code.AppendLine(CultureInfo.InvariantCulture, $"{invocationIndent}if ({resultName}.ValueKind != JsonValueKind.Null) throw new InvalidDataException(\"The capability returned an unexpected result.\");");
            }
            else
            {
                var missingResult = resultType is "bool" or "int" or "long" || capability.ReturnType.IsNullable == true ? "" :
                    " ?? throw new InvalidDataException(\"The capability returned no result.\")";
                code.AppendLine(CultureInfo.InvariantCulture,
                    $"{invocationIndent}return JsonSerializer.Deserialize({resultName}, NativeClientJsonContext.Default.{JsonContextName(resultType)}){missingResult};");
            }
            if (callbacks.Length != 0)
            {
                code.AppendLine("        }");
                code.AppendLine("        finally");
                code.AppendLine("        {");
                foreach (var callbackName in callbackNames.Values)
                {
                    code.AppendLine(CultureInfo.InvariantCulture, $"            {connectionName}.UnregisterCallback({callbackName});");
                }
                code.AppendLine("        }");
            }
            code.AppendLine("    }");
 
            string ReserveName(string preferred)
            {
                var name = preferred;
                while (!reservedNames.Add(name))
                {
                    name += "_";
                }
 
                return name;
            }
        }
 
        string TypeName(AtsTypeRef type)
        {
            var name = type.Category switch
            {
                AtsTypeCategory.Handle when handles.TryGetValue(type.TypeId, out var handle) => handle,
                AtsTypeCategory.Dto when dtos.TryGetValue(type.TypeId, out var dto) => dto,
                AtsTypeCategory.Array => TypeName(type.ElementType!) + "[]",
                AtsTypeCategory.Union => "object",
                AtsTypeCategory.Callback => "Delegate",
                AtsTypeCategory.Primitive => type.TypeId switch
                {
                    "string" => "string",
                    "bool" or "boolean" => "bool",
                    "int" or "integer" or "number" when type.ClrType == typeof(int) => "int",
                    "long" or "number" when type.ClrType == typeof(long) => "long",
                    "void" => "void",
                    _ => throw new NotSupportedException($"Unsupported native primitive: {type.TypeId} ({type.ClrType}).")
                },
                _ => throw new NotSupportedException($"Unsupported native C# type: {type.TypeId} ({type.Category}).")
            };
 
            return type.IsNullable == true ? name + "?" : name;
        }
    }
 
    private static bool ContainsHandle(AtsTypeRef type) => type.Category == AtsTypeCategory.Handle ||
        type.ElementType is { } element && ContainsHandle(element) ||
        type.ValueType is { } value && ContainsHandle(value);
 
    private static string JsonContextName(string type) => type.TrimEnd('?') switch
    {
        "string" => "String",
        "bool" => "Boolean",
        "int" => "Int32",
        "long" => "Int64",
        "string[]" => "StringArray",
        _ => type.TrimEnd('?')
    };
 
    private static string Literal(string value) => JsonSerializer.Serialize(value);
}