< Summary

Information
Class: TeleFlow.Telegram.Internal.CallbackDataField
Assembly: TeleFlow.Framework
File(s): /_/src/TeleFlow.Framework/Internal/CallbackDataMetadata.cs
Line coverage
100%
Covered lines: 1
Uncovered lines: 0
Coverable lines: 1
Total lines: 351
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)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{
 16    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;
 28    }
 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    {
 40        ArgumentNullException.ThrowIfNull(payloadType);
 41
 42        var lookup = MetadataCache.GetOrAdd(
 43            payloadType,
 44            static type => new Lazy<MetadataLookup>(
 45                () => CreateLookup(type),
 46                LazyThreadSafetyMode.ExecutionAndPublication)).Value;
 47
 48        if (lookup.ErrorMessage is not null)
 49        {
 50            throw new InvalidOperationException(lookup.ErrorMessage);
 51        }
 52
 53        if (lookup.Metadata is null)
 54        {
 55            metadata = null!;
 56            return false;
 57        }
 58
 59        metadata = lookup.Metadata;
 60        return true;
 61    }
 62
 63    private static MetadataLookup CreateLookup(Type payloadType)
 64    {
 65        var attribute = payloadType.GetCustomAttribute<CallbackDataAttribute>(inherit: false);
 66        if (attribute is null)
 67        {
 68            return MetadataLookup.Missing;
 69        }
 70
 71        try
 72        {
 73            return MetadataLookup.Valid(Create(payloadType, attribute.Prefix));
 74        }
 75        catch (InvalidOperationException exception)
 76        {
 77            return MetadataLookup.Invalid(exception.Message);
 78        }
 79    }
 80
 81    private static CallbackDataMetadata Create(Type payloadType, string prefix)
 82    {
 83        ValidatePrefix(prefix, payloadType);
 84
 85        var constructor = payloadType
 86            .GetConstructors(BindingFlags.Instance | BindingFlags.Public)
 87            .Where(static candidate => candidate.GetParameters().Length > 0)
 88            .OrderByDescending(static candidate => candidate.GetParameters().Length)
 89            .FirstOrDefault();
 90
 91        var fields = constructor is not null
 92            ? CreateConstructorFields(payloadType, constructor)
 93            : CreatePropertyFields(payloadType);
 94
 95        return new CallbackDataMetadata(payloadType, prefix, fields, constructor);
 96    }
 97
 98    private static CallbackDataField[] CreateConstructorFields(
 99        Type payloadType,
 100        ConstructorInfo constructor)
 101    {
 102        var properties = payloadType
 103            .GetProperties(BindingFlags.Instance | BindingFlags.Public)
 104            .Where(static property => property.GetMethod is not null)
 105            .ToDictionary(static property => property.Name, StringComparer.OrdinalIgnoreCase);
 106
 107        return constructor
 108            .GetParameters()
 109            .Select(parameter =>
 110            {
 111                if (!properties.TryGetValue(parameter.Name ?? string.Empty, out var property))
 112                {
 113                    throw new InvalidOperationException(
 114                        $"Callback data payload type '{payloadType.FullName}' constructor parameter '{parameter.Name}' d
 115                }
 116
 117                ValidateFieldType(payloadType, property.PropertyType, property.Name);
 118                return new CallbackDataField(property, parameter);
 119            })
 120            .ToArray();
 121    }
 122
 123    private static CallbackDataField[] CreatePropertyFields(Type payloadType)
 124    {
 125        var properties = payloadType
 126            .GetProperties(BindingFlags.Instance | BindingFlags.Public)
 127            .Where(static property => property.GetMethod is not null)
 128            .OrderBy(static property => property.MetadataToken)
 129            .ToArray();
 130
 131        foreach (var property in properties)
 132        {
 133            if (property.SetMethod is null)
 134            {
 135                throw new InvalidOperationException(
 136                    $"Callback data payload type '{payloadType.FullName}' property '{property.Name}' must be settable wh
 137            }
 138
 139            ValidateFieldType(payloadType, property.PropertyType, property.Name);
 140        }
 141
 142        return properties
 143            .Select(static property => new CallbackDataField(property, Parameter: null))
 144            .ToArray();
 145    }
 146
 147    private static void ValidatePrefix(string prefix, Type payloadType)
 148    {
 149        if (string.IsNullOrWhiteSpace(prefix))
 150        {
 151            throw new InvalidOperationException(
 152                $"Callback data payload type '{payloadType.FullName}' must declare a non-empty callback data prefix.");
 153        }
 154
 155        if (prefix.Contains(':', StringComparison.Ordinal) ||
 156            prefix.Contains('%', StringComparison.Ordinal) ||
 157            prefix.Any(char.IsWhiteSpace))
 158        {
 159            throw new InvalidOperationException(
 160                $"Callback data payload type '{payloadType.FullName}' prefix must not contain ':', '%', or whitespace.")
 161        }
 162
 163        if (Encoding.UTF8.GetByteCount(prefix) > CallbackDataCodec.MaxTelegramCallbackDataBytes)
 164        {
 165            throw new InvalidOperationException(
 166                $"Callback data payload type '{payloadType.FullName}' prefix must be at most {CallbackDataCodec.MaxTeleg
 167        }
 168    }
 169
 170    private static void ValidateFieldType(Type payloadType, Type fieldType, string fieldName)
 171    {
 172        var type = Nullable.GetUnderlyingType(fieldType) ?? fieldType;
 173
 174        if (Nullable.GetUnderlyingType(fieldType) is not null)
 175        {
 176            throw new InvalidOperationException(
 177                $"Callback data payload type '{payloadType.FullName}' field '{fieldName}' must not be nullable.");
 178        }
 179
 180        if (type == typeof(string) ||
 181            type == typeof(int) ||
 182            type == typeof(long) ||
 183            type == typeof(bool) ||
 184            type.IsEnum)
 185        {
 186            return;
 187        }
 188
 189        throw new InvalidOperationException(
 190            $"Callback data payload type '{payloadType.FullName}' field '{fieldName}' has unsupported type '{fieldType.F
 191            "Supported compact callback data types are string, int, long, bool, and enums.");
 192    }
 193
 194    public string FormatField(object? value, Type fieldType)
 195    {
 196        if (value is null)
 197        {
 198            throw new InvalidOperationException(
 199                $"Callback data payload type '{PayloadType.FullName}' contains a null field value. Compact callback data
 200        }
 201
 202        var type = Nullable.GetUnderlyingType(fieldType) ?? fieldType;
 203
 204        string text = type == typeof(bool)
 205            ? ((bool)value ? "true" : "false")
 206            : type.IsEnum
 207                ? value.ToString()!
 208                : Convert.ToString(value, CultureInfo.InvariantCulture)!;
 209
 210        return Escape(text);
 211    }
 212
 213    public string Pack(object payload)
 214    {
 215        ArgumentNullException.ThrowIfNull(payload);
 216
 217        if (!PayloadType.IsInstanceOfType(payload))
 218        {
 219            throw new InvalidOperationException(
 220                $"Callback data payload type '{payload.GetType().FullName}' is not compatible with metadata for '{Payloa
 221        }
 222
 223        var builder = new StringBuilder(Prefix);
 224
 225        for (var index = 0; index < Fields.Count; index++)
 226        {
 227            var field = Fields[index];
 228            builder
 229                .Append(':')
 230                .Append(FormatField(field.Property.GetValue(payload), field.Property.PropertyType));
 231        }
 232
 233        return builder.ToString();
 234    }
 235
 236    public object ParseField(string value, Type fieldType)
 237    {
 238        var text = Unescape(value);
 239        var type = Nullable.GetUnderlyingType(fieldType) ?? fieldType;
 240
 241        if (type == typeof(string))
 242        {
 243            return text;
 244        }
 245
 246        if (type == typeof(int))
 247        {
 248            return int.Parse(text, CultureInfo.InvariantCulture);
 249        }
 250
 251        if (type == typeof(long))
 252        {
 253            return long.Parse(text, CultureInfo.InvariantCulture);
 254        }
 255
 256        if (type == typeof(bool))
 257        {
 258            return bool.Parse(text);
 259        }
 260
 261        if (type.IsEnum)
 262        {
 263            if (Enum.TryParse(type, text, ignoreCase: false, out var enumValue))
 264            {
 265                return enumValue;
 266            }
 267
 268            throw new JsonException(
 269                $"Telegram callback data field for payload type '{PayloadType.FullName}' is not a valid enum value for '
 270        }
 271
 272        throw new InvalidOperationException(
 273            $"Callback data payload type '{PayloadType.FullName}' has unsupported field type '{fieldType.FullName}'.");
 274    }
 275
 276    public bool MatchesSerializedPayload(string serializedPayload)
 277    {
 278        ArgumentNullException.ThrowIfNull(serializedPayload);
 279
 280        if (!serializedPayload.StartsWith(Prefix, StringComparison.Ordinal))
 281        {
 282            return false;
 283        }
 284
 285        if (Fields.Count == 0)
 286        {
 287            return serializedPayload.Length == Prefix.Length;
 288        }
 289
 290        if (serializedPayload.Length <= Prefix.Length ||
 291            serializedPayload[Prefix.Length] != ':')
 292        {
 293            return false;
 294        }
 295
 296        var separatorCount = 0;
 297
 298        for (var index = Prefix.Length; index < serializedPayload.Length; index++)
 299        {
 300            if (serializedPayload[index] == ':')
 301            {
 302                separatorCount++;
 303            }
 304        }
 305
 306        return separatorCount == Fields.Count;
 307    }
 308
 309    private static string Escape(string value)
 310    {
 311        return value
 312            .Replace("%", "%25", StringComparison.Ordinal)
 313            .Replace(":", "%3A", StringComparison.Ordinal);
 314    }
 315
 316    private static string Unescape(string value)
 317    {
 318        return value
 319            .Replace("%3A", ":", StringComparison.Ordinal)
 320            .Replace("%25", "%", StringComparison.Ordinal);
 321    }
 322
 323    private sealed class MetadataLookup
 324    {
 325        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;
 333        }
 334
 335        public CallbackDataMetadata? Metadata { get; }
 336
 337        public string? ErrorMessage { get; }
 338
 339        public static MetadataLookup Valid(CallbackDataMetadata metadata)
 340        {
 341            return new MetadataLookup(metadata, errorMessage: null);
 342        }
 343
 344        public static MetadataLookup Invalid(string errorMessage)
 345        {
 346            return new MetadataLookup(metadata: null, errorMessage);
 347        }
 348    }
 349}
 350
 6351internal sealed record CallbackDataField(PropertyInfo Property, ParameterInfo? Parameter);