< Summary

Information
Class: TeleFlow.Telegram.Internal.CallbackDataMetadata
Assembly: TeleFlow.Framework
File(s): /_/src/TeleFlow.Framework/Internal/CallbackDataMetadata.cs
Line coverage
72%
Covered lines: 108
Uncovered lines: 40
Coverable lines: 148
Total lines: 351
Line coverage: 72.9%
Branch coverage
56%
Covered branches: 45
Total branches: 80
Branch coverage: 56.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
TryCreate(...)100%44100%
CreateLookup(...)100%22100%
Create(...)50%22100%
CreateConstructorFields(...)50%4488.88%
CreatePropertyFields(...)0%2040%
ValidatePrefix(...)50%231050%
ValidateFieldType(...)71.42%161476.92%
FormatField(...)40%111080%
Pack(...)75%4481.81%
ParseField(...)28.57%671435.29%
MatchesSerializedPayload(...)83.33%121292.3%
Escape(...)100%11100%
Unescape(...)100%11100%
.cctor()100%11100%
.ctor(...)100%11100%
Valid(...)100%11100%
Invalid(...)100%11100%

File(s)

/_/src/TeleFlow.Framework/Internal/CallbackDataMetadata.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using System.Globalization;
 3using System.Reflection;
 4using System.Text;
 5using System.Text.Json;
 6using TeleFlow.Annotations;
 7
 8namespace TeleFlow.Telegram.Internal;
 9
 10/// <summary>
 11/// Caches compact callback payload metadata discovered from <see cref="CallbackDataAttribute"/>
 12/// so typed callback routing and keyboard packing share one prefix-plus-fields contract.
 13/// </summary>
 14internal sealed class CallbackDataMetadata
 15{
 116    private static readonly ConcurrentDictionary<Type, Lazy<MetadataLookup>> MetadataCache = new();
 17
 18    private CallbackDataMetadata(
 19        Type payloadType,
 20        string prefix,
 21        IReadOnlyList<CallbackDataField> fields,
 22        ConstructorInfo? constructor)
 23    {
 24        PayloadType = payloadType;
 25        Prefix = prefix;
 26        Fields = fields;
 27        Constructor = constructor;
 528    }
 29
 30    public Type PayloadType { get; }
 31
 32    public string Prefix { get; }
 33
 34    public IReadOnlyList<CallbackDataField> Fields { get; }
 35
 36    public ConstructorInfo? Constructor { get; }
 37
 38    public static bool TryCreate(Type payloadType, out CallbackDataMetadata metadata)
 39    {
 9240        ArgumentNullException.ThrowIfNull(payloadType);
 41
 9242        var lookup = MetadataCache.GetOrAdd(
 9243            payloadType,
 10444            static type => new Lazy<MetadataLookup>(
 1245                () => CreateLookup(type),
 10446                LazyThreadSafetyMode.ExecutionAndPublication)).Value;
 47
 9248        if (lookup.ErrorMessage is not null)
 49        {
 250            throw new InvalidOperationException(lookup.ErrorMessage);
 51        }
 52
 9053        if (lookup.Metadata is null)
 54        {
 5955            metadata = null!;
 5956            return false;
 57        }
 58
 3159        metadata = lookup.Metadata;
 3160        return true;
 61    }
 62
 63    private static MetadataLookup CreateLookup(Type payloadType)
 64    {
 1265        var attribute = payloadType.GetCustomAttribute<CallbackDataAttribute>(inherit: false);
 1266        if (attribute is null)
 67        {
 668            return MetadataLookup.Missing;
 69        }
 70
 71        try
 72        {
 673            return MetadataLookup.Valid(Create(payloadType, attribute.Prefix));
 74        }
 75        catch (InvalidOperationException exception)
 76        {
 177            return MetadataLookup.Invalid(exception.Message);
 78        }
 679    }
 80
 81    private static CallbackDataMetadata Create(Type payloadType, string prefix)
 82    {
 683        ValidatePrefix(prefix, payloadType);
 84
 685        var constructor = payloadType
 686            .GetConstructors(BindingFlags.Instance | BindingFlags.Public)
 687            .Where(static candidate => candidate.GetParameters().Length > 0)
 688            .OrderByDescending(static candidate => candidate.GetParameters().Length)
 689            .FirstOrDefault();
 90
 691        var fields = constructor is not null
 692            ? CreateConstructorFields(payloadType, constructor)
 693            : CreatePropertyFields(payloadType);
 94
 595        return new CallbackDataMetadata(payloadType, prefix, fields, constructor);
 96    }
 97
 98    private static CallbackDataField[] CreateConstructorFields(
 99        Type payloadType,
 100        ConstructorInfo constructor)
 101    {
 6102        var properties = payloadType
 6103            .GetProperties(BindingFlags.Instance | BindingFlags.Public)
 7104            .Where(static property => property.GetMethod is not null)
 13105            .ToDictionary(static property => property.Name, StringComparer.OrdinalIgnoreCase);
 106
 6107        return constructor
 6108            .GetParameters()
 6109            .Select(parameter =>
 6110            {
 7111                if (!properties.TryGetValue(parameter.Name ?? string.Empty, out var property))
 6112                {
 0113                    throw new InvalidOperationException(
 0114                        $"Callback data payload type '{payloadType.FullName}' constructor parameter '{parameter.Name}' d
 6115                }
 6116
 7117                ValidateFieldType(payloadType, property.PropertyType, property.Name);
 6118                return new CallbackDataField(property, parameter);
 6119            })
 6120            .ToArray();
 121    }
 122
 123    private static CallbackDataField[] CreatePropertyFields(Type payloadType)
 124    {
 0125        var properties = payloadType
 0126            .GetProperties(BindingFlags.Instance | BindingFlags.Public)
 0127            .Where(static property => property.GetMethod is not null)
 0128            .OrderBy(static property => property.MetadataToken)
 0129            .ToArray();
 130
 0131        foreach (var property in properties)
 132        {
 0133            if (property.SetMethod is null)
 134            {
 0135                throw new InvalidOperationException(
 0136                    $"Callback data payload type '{payloadType.FullName}' property '{property.Name}' must be settable wh
 137            }
 138
 0139            ValidateFieldType(payloadType, property.PropertyType, property.Name);
 140        }
 141
 0142        return properties
 0143            .Select(static property => new CallbackDataField(property, Parameter: null))
 0144            .ToArray();
 145    }
 146
 147    private static void ValidatePrefix(string prefix, Type payloadType)
 148    {
 6149        if (string.IsNullOrWhiteSpace(prefix))
 150        {
 0151            throw new InvalidOperationException(
 0152                $"Callback data payload type '{payloadType.FullName}' must declare a non-empty callback data prefix.");
 153        }
 154
 6155        if (prefix.Contains(':', StringComparison.Ordinal) ||
 6156            prefix.Contains('%', StringComparison.Ordinal) ||
 6157            prefix.Any(char.IsWhiteSpace))
 158        {
 0159            throw new InvalidOperationException(
 0160                $"Callback data payload type '{payloadType.FullName}' prefix must not contain ':', '%', or whitespace.")
 161        }
 162
 6163        if (Encoding.UTF8.GetByteCount(prefix) > CallbackDataCodec.MaxTelegramCallbackDataBytes)
 164        {
 0165            throw new InvalidOperationException(
 0166                $"Callback data payload type '{payloadType.FullName}' prefix must be at most {CallbackDataCodec.MaxTeleg
 167        }
 6168    }
 169
 170    private static void ValidateFieldType(Type payloadType, Type fieldType, string fieldName)
 171    {
 7172        var type = Nullable.GetUnderlyingType(fieldType) ?? fieldType;
 173
 7174        if (Nullable.GetUnderlyingType(fieldType) is not null)
 175        {
 1176            throw new InvalidOperationException(
 1177                $"Callback data payload type '{payloadType.FullName}' field '{fieldName}' must not be nullable.");
 178        }
 179
 6180        if (type == typeof(string) ||
 6181            type == typeof(int) ||
 6182            type == typeof(long) ||
 6183            type == typeof(bool) ||
 6184            type.IsEnum)
 185        {
 6186            return;
 187        }
 188
 0189        throw new InvalidOperationException(
 0190            $"Callback data payload type '{payloadType.FullName}' field '{fieldName}' has unsupported type '{fieldType.F
 0191            "Supported compact callback data types are string, int, long, bool, and enums.");
 192    }
 193
 194    public string FormatField(object? value, Type fieldType)
 195    {
 13196        if (value is null)
 197        {
 0198            throw new InvalidOperationException(
 0199                $"Callback data payload type '{PayloadType.FullName}' contains a null field value. Compact callback data
 200        }
 201
 13202        var type = Nullable.GetUnderlyingType(fieldType) ?? fieldType;
 203
 13204        string text = type == typeof(bool)
 13205            ? ((bool)value ? "true" : "false")
 13206            : type.IsEnum
 13207                ? value.ToString()!
 13208                : Convert.ToString(value, CultureInfo.InvariantCulture)!;
 209
 13210        return Escape(text);
 211    }
 212
 213    public string Pack(object payload)
 214    {
 10215        ArgumentNullException.ThrowIfNull(payload);
 216
 10217        if (!PayloadType.IsInstanceOfType(payload))
 218        {
 0219            throw new InvalidOperationException(
 0220                $"Callback data payload type '{payload.GetType().FullName}' is not compatible with metadata for '{Payloa
 221        }
 222
 10223        var builder = new StringBuilder(Prefix);
 224
 46225        for (var index = 0; index < Fields.Count; index++)
 226        {
 13227            var field = Fields[index];
 13228            builder
 13229                .Append(':')
 13230                .Append(FormatField(field.Property.GetValue(payload), field.Property.PropertyType));
 231        }
 232
 10233        return builder.ToString();
 234    }
 235
 236    public object ParseField(string value, Type fieldType)
 237    {
 10238        var text = Unescape(value);
 10239        var type = Nullable.GetUnderlyingType(fieldType) ?? fieldType;
 240
 10241        if (type == typeof(string))
 242        {
 5243            return text;
 244        }
 245
 5246        if (type == typeof(int))
 247        {
 5248            return int.Parse(text, CultureInfo.InvariantCulture);
 249        }
 250
 0251        if (type == typeof(long))
 252        {
 0253            return long.Parse(text, CultureInfo.InvariantCulture);
 254        }
 255
 0256        if (type == typeof(bool))
 257        {
 0258            return bool.Parse(text);
 259        }
 260
 0261        if (type.IsEnum)
 262        {
 0263            if (Enum.TryParse(type, text, ignoreCase: false, out var enumValue))
 264            {
 0265                return enumValue;
 266            }
 267
 0268            throw new JsonException(
 0269                $"Telegram callback data field for payload type '{PayloadType.FullName}' is not a valid enum value for '
 270        }
 271
 0272        throw new InvalidOperationException(
 0273            $"Callback data payload type '{PayloadType.FullName}' has unsupported field type '{fieldType.FullName}'.");
 274    }
 275
 276    public bool MatchesSerializedPayload(string serializedPayload)
 277    {
 10278        ArgumentNullException.ThrowIfNull(serializedPayload);
 279
 10280        if (!serializedPayload.StartsWith(Prefix, StringComparison.Ordinal))
 281        {
 2282            return false;
 283        }
 284
 8285        if (Fields.Count == 0)
 286        {
 0287            return serializedPayload.Length == Prefix.Length;
 288        }
 289
 8290        if (serializedPayload.Length <= Prefix.Length ||
 8291            serializedPayload[Prefix.Length] != ':')
 292        {
 1293            return false;
 294        }
 295
 7296        var separatorCount = 0;
 297
 114298        for (var index = Prefix.Length; index < serializedPayload.Length; index++)
 299        {
 50300            if (serializedPayload[index] == ':')
 301            {
 14302                separatorCount++;
 303            }
 304        }
 305
 7306        return separatorCount == Fields.Count;
 307    }
 308
 309    private static string Escape(string value)
 310    {
 13311        return value
 13312            .Replace("%", "%25", StringComparison.Ordinal)
 13313            .Replace(":", "%3A", StringComparison.Ordinal);
 314    }
 315
 316    private static string Unescape(string value)
 317    {
 10318        return value
 10319            .Replace("%3A", ":", StringComparison.Ordinal)
 10320            .Replace("%25", "%", StringComparison.Ordinal);
 321    }
 322
 323    private sealed class MetadataLookup
 324    {
 1325        public static readonly MetadataLookup Missing = new(metadata: null, errorMessage: null);
 326
 327        private MetadataLookup(
 328            CallbackDataMetadata? metadata,
 329            string? errorMessage)
 330        {
 331            Metadata = metadata;
 332            ErrorMessage = errorMessage;
 7333        }
 334
 335        public CallbackDataMetadata? Metadata { get; }
 336
 337        public string? ErrorMessage { get; }
 338
 339        public static MetadataLookup Valid(CallbackDataMetadata metadata)
 340        {
 5341            return new MetadataLookup(metadata, errorMessage: null);
 342        }
 343
 344        public static MetadataLookup Invalid(string errorMessage)
 345        {
 1346            return new MetadataLookup(metadata: null, errorMessage);
 347        }
 348    }
 349}
 350
 351internal sealed record CallbackDataField(PropertyInfo Property, ParameterInfo? Parameter);