File: Backchannel\BackchannelContractTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Reflection;
using System.Text;
 
namespace Aspire.Hosting.Backchannel;
 
/// <summary>
/// Validates that backchannel request/response types follow the contract rules.
/// </summary>
public class BackchannelContractTests
{
    // V2 request/response types that must follow the contract
    private static readonly Type[] s_contractTypes =
    [
        typeof(GetCapabilitiesRequest),
        typeof(GetCapabilitiesResponse),
        typeof(GetAppHostInfoRequest),
        typeof(GetAppHostInfoResponse),
        typeof(GetDashboardInfoRequest),
        typeof(GetDashboardInfoResponse),
        typeof(GetResourcesRequest),
        typeof(GetResourcesResponse),
        typeof(WatchResourcesRequest),
        typeof(GetConsoleLogsRequest),
        typeof(CallMcpToolRequest),
        typeof(CallMcpToolResponse),
        typeof(McpToolContentItem),
        typeof(StopAppHostRequest),
        typeof(StopAppHostResponse),
        typeof(ResourceSnapshot),
        typeof(ResourceSnapshotEndpoint),
        typeof(ResourceSnapshotRelationship),
        typeof(ResourceSnapshotHealthReport),
        typeof(ResourceSnapshotVolume),
        typeof(ResourceSnapshotMcpServer),
        typeof(ResourceLogLine),
    ];
 
    /// <summary>
    /// Validates all backchannel contract rules:
    /// 1. All types are sealed classes
    /// 2. Properties use { get; init; } pattern (not { get; set; })
    /// 3. Required properties have 'required' modifier and are not nullable
    /// 4. Optional properties are nullable (T?) or have default values
    /// 5. No public fields allowed
    /// 6. Request/Response types follow naming convention
    /// </summary>
    [Fact]
    public void BackchannelTypes_FollowContractRules()
    {
        var errors = new StringBuilder();
 
        foreach (var type in s_contractTypes)
        {
            // Rule 1: Must be sealed class
            if (!type.IsClass)
            {
                errors.AppendLine($"{type.Name}: Must be a class (not struct or interface)");
            }
            else if (!type.IsSealed)
            {
                errors.AppendLine($"{type.Name}: Must be sealed");
            }
 
            // Rule 5: No public fields
            foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Instance))
            {
                errors.AppendLine($"{type.Name}.{field.Name}: Public fields not allowed, use properties");
            }
 
            // Rule 6: Naming convention (skip helper types)
            if (!type.Name.StartsWith("ResourceSnapshot") &&
                type.Name != "McpToolContentItem" &&
                type.Name != "ResourceLogLine")
            {
                if (!type.Name.EndsWith("Request") && !type.Name.EndsWith("Response"))
                {
                    errors.AppendLine($"{type.Name}: Name should end with 'Request' or 'Response'");
                }
            }
 
            foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
            {
                var setMethod = prop.GetSetMethod();
 
                // Skip computed properties (no setter)
                if (setMethod is null)
                {
                    continue;
                }
 
                // Rule 2: Must use { get; init; } not { get; set; }
                var isInitOnly = setMethod.ReturnParameter
                    .GetRequiredCustomModifiers()
                    .Any(m => m.FullName == "System.Runtime.CompilerServices.IsExternalInit");
 
                if (!isInitOnly)
                {
                    errors.AppendLine($"{type.Name}.{prop.Name}: Must use {{ get; init; }} not {{ get; set; }}");
                }
 
                var isRequired = prop.GetCustomAttribute<System.Runtime.CompilerServices.RequiredMemberAttribute>() is not null;
                var nullabilityContext = new NullabilityInfoContext();
                var nullabilityInfo = nullabilityContext.Create(prop);
 
                if (isRequired)
                {
                    // Rule 3: Required properties should not be nullable
                    bool isNullable = prop.PropertyType.IsValueType
                        ? Nullable.GetUnderlyingType(prop.PropertyType) is not null
                        : nullabilityInfo.WriteState == NullabilityState.Nullable;
 
                    if (isNullable)
                    {
                        errors.AppendLine($"{type.Name}.{prop.Name}: Required properties should not be nullable");
                    }
                }
                else
                {
                    // Rule 4: Optional reference types should be nullable or have defaults
                    if (!prop.PropertyType.IsValueType)
                    {
                        var isNullable = nullabilityInfo.WriteState == NullabilityState.Nullable;
                        var isCollectionWithDefault = prop.PropertyType.IsArray ||
                            (prop.PropertyType.IsGenericType && IsAllowedCollectionType(prop.PropertyType));
 
                        if (!isNullable && !isCollectionWithDefault)
                        {
                            errors.AppendLine($"{type.Name}.{prop.Name}: Optional properties should be nullable (T?) or have a default");
                        }
                    }
                }
            }
        }
 
        Assert.True(errors.Length == 0, $"Contract violations found:\n{errors}");
    }
 
    private static bool IsAllowedCollectionType(Type type)
    {
        var genericDef = type.GetGenericTypeDefinition();
        return genericDef == typeof(Dictionary<,>) ||
               genericDef == typeof(List<>) ||
               genericDef == typeof(IReadOnlyList<>) ||
               genericDef == typeof(IReadOnlyDictionary<,>);
    }
}