From 32889cbf6ab71434523a77ca2bb869e4792e844f Mon Sep 17 00:00:00 2001 From: omnifaced Date: Wed, 28 Oct 2026 03:34:52 +0300 Subject: [PATCH] feat: describe bot API enums with their wire values The docs manifest gave enums as bare names under `type: string`, and the OpenAPI document gave number enums as a plain `integer`. Both serializers write those enums as numbers, so a client built from either description sent `"bold"` where the server reads 10, and read user flags and entitlements as names where bitmasks arrive. - The manifest has a top-level `enums` list: wire format (`number` or `string`), underlying type, whether it is a flags enum, and each member's number. Enum fields reference it through `enumType`, and their `type` is the JSON type that travels. `enumValues` keeps its camelCase names, so the docs site reads it as before. Union variants also carry their discriminator's number. - OpenAPI lists number enum values in `enum` with `x-enum-varnames`, and flags enums map names to bits in `x-argon-flags`. Ion enums are unsigned, and the writer silently drops ulong nodes, so values are written as long. - The IEvents/v1/Stream body is a server-sent event stream whose `data:` is the bare payload, not the BotSseEvent envelope the route declares. Bumps docs/bot-api-docs to the regenerated description. --- docs/bot-api-docs | 2 +- .../Features/BotApi/BotContractVerifier.cs | 137 +++++++++++++++--- src/Argon.Core/Features/BotApi/BotOpenApi.cs | 73 ++++++++++ .../BotContractVerifierTests.cs | 44 ++++++ tests/ArgonSharedLogicTest/BotOpenApiTests.cs | 35 +++++ 5 files changed, 273 insertions(+), 18 deletions(-) diff --git a/docs/bot-api-docs b/docs/bot-api-docs index fb9c511c..641c4138 160000 --- a/docs/bot-api-docs +++ b/docs/bot-api-docs @@ -1 +1 @@ -Subproject commit fb9c511cf50f77f1591bb97f551ac9e8d1b294c7 +Subproject commit 641c41385a937a152405a353632dfd29e35574b8 diff --git a/src/Argon.Core/Features/BotApi/BotContractVerifier.cs b/src/Argon.Core/Features/BotApi/BotContractVerifier.cs index f92c022c..87231bae 100644 --- a/src/Argon.Core/Features/BotApi/BotContractVerifier.cs +++ b/src/Argon.Core/Features/BotApi/BotContractVerifier.cs @@ -48,6 +48,9 @@ public static DocsManifest GenerateDocsManifest() // Build events from discovered [BotEventDefinition]-attributed types var eventDefs = DiscoverEventDefinitions(); + // Every enum a payload or DTO uses, described once below with the values it has on the wire + var enums = new HashSet(); + // Build intent → event names from event definitions var intentEvents = new Dictionary>(); foreach (var (_, defAttr, _, _) in eventDefs) @@ -81,7 +84,7 @@ public static DocsManifest GenerateDocsManifest() .Select(ed => { var (type, defAttr, descAttr, stableAttr) = ed; - var payloadShape = BuildWebTypeShape(type); + var payloadShape = BuildWebTypeShape(type, enums); var hash = ComputeEventContractHash(type); return new EventManifest( @@ -121,14 +124,77 @@ public static DocsManifest GenerateDocsManifest() return new DtoManifest( type.Name, version, - BuildWebTypeShape(type), + BuildWebTypeShape(type, enums), prevType?.Name, changes); }) .OrderBy(d => d.Name) .ToList(); - return new DocsManifest(intents, events, rateLimits, dtos); + var enumManifests = enums + .Select(DescribeEnum) + .OrderBy(e => e.Name, StringComparer.Ordinal) + .ToList(); + + return new DocsManifest(intents, events, rateLimits, dtos, enumManifests); + } + + /// + /// An enum as a client has to read and write it: whether it travels as its number or as its + /// camelCase name, whether its values combine as flags, and every member's number. The names in + /// alone cannot say any of that. + /// + public static EnumManifest DescribeEnum(Type enumType) + { + var underlying = Enum.GetUnderlyingType(enumType); + var unsigned = underlying == typeof(ulong) || underlying == typeof(uint) + || underlying == typeof(ushort) || underlying == typeof(byte); + + var members = Enum.GetNames(enumType) + .Select(name => + { + var raw = Enum.Parse(enumType, name); + // ulong flags reach 1 << 57, past what a long holds once signed values are mixed in; + // each value keeps its own sign so it is written exactly. + object value = unsigned ? Convert.ToUInt64(raw) : Convert.ToInt64(raw); + return new EnumMemberManifest(ToCamelCase(name), value); + }) + .ToList(); + + return new EnumManifest( + enumType.Name, + IsStringEnum(enumType) ? "string" : "number", + GetWebBaseTypeName(underlying), + enumType.IsDefined(typeof(FlagsAttribute), false), + members); + } + + /// + /// True for an enum that is written as its camelCase name. Both serializers have to agree, so + /// the type carries a converter attribute for each (see ). + /// + public static bool IsStringEnum(Type enumType) + { + var stj = enumType.GetCustomAttribute()?.ConverterType; + if (stj is not null && IsStringEnumConverter(stj, typeof(System.Text.Json.Serialization.JsonStringEnumConverter<>))) + return true; + + var newtonsoft = enumType.GetCustomAttribute()?.ConverterType; + return newtonsoft is not null && typeof(Newtonsoft.Json.Converters.StringEnumConverter).IsAssignableFrom(newtonsoft); + } + + private static bool IsStringEnumConverter(Type converter, Type genericDefinition) + { + if (converter == typeof(System.Text.Json.Serialization.JsonStringEnumConverter)) + return true; + + for (var t = converter; t is not null; t = t.BaseType) + { + if (t.IsGenericType && t.GetGenericTypeDefinition() == genericDefinition) + return true; + } + + return false; } /// @@ -277,14 +343,14 @@ private static void AppendTypeShape(StringBuilder sb, Type type, string indent, // --- Structured type shapes for documentation (camelCase, JSON types) --- - private static List? BuildWebTypeShape(Type? type) + private static List? BuildWebTypeShape(Type? type, HashSet enums) { if (type is null) return null; - var result = BuildWebTypeProperties(type, []); + var result = BuildWebTypeProperties(type, [], enums); return result.Count > 0 ? result : null; } - private static List BuildWebTypeProperties(Type type, HashSet visited) + private static List BuildWebTypeProperties(Type type, HashSet visited, HashSet enums) { if (!visited.Add(type)) return []; @@ -306,27 +372,31 @@ private static List BuildWebTypeProperties(Type type, HashSet? variants = null; + string? enumType = null; + if (innerType.IsEnum) { enumValues = Enum.GetNames(innerType) .Select(ToCamelCase) .ToArray(); + enumType = innerType.Name; + enums.Add(innerType); } else if (IsIonUnionType(innerType)) { isUnion = true; - (discriminatorField, discriminatorType, variants) = BuildUnionVariants(innerType, visited); + (discriminatorField, discriminatorType, variants) = BuildUnionVariants(innerType, visited, enums); } else if (!IsSimpleType(innerType) && innerType.Assembly.FullName?.StartsWith("Argon") == true) { if (visited.Contains(innerType)) isCircular = true; else - children = BuildWebTypeProperties(innerType, visited); + children = BuildWebTypeProperties(innerType, visited, enums); } result.Add(new TypeProperty(camelName, typeName, isArray, isNullable, isCircular, children, enumValues, - isUnion, discriminatorField, discriminatorType, variants)); + isUnion, discriminatorField, discriminatorType, variants, enumType)); } visited.Remove(type); @@ -347,7 +417,7 @@ private static bool IsIonUnionType(Type type) /// Scans assemblies for all concrete implementations and extracts their property shapes. /// private static (string? DiscriminatorField, string? DiscriminatorType, List Variants) - BuildUnionVariants(Type unionInterfaceType, HashSet visited) + BuildUnionVariants(Type unionInterfaceType, HashSet visited, HashSet enums) { var concreteTypes = AppDomain.CurrentDomain.GetAssemblies() .Where(a => a.FullName?.StartsWith("Argon") == true) @@ -376,6 +446,7 @@ private static (string? DiscriminatorField, string? DiscriminatorType, List "string", "Object" => "any", "Byte" => "uint8", + "UInt16" => "uint16", + "SByte" => "int8", _ => type.Name }; } @@ -604,18 +687,38 @@ public sealed record TypeProperty( bool IsUnion = false, string? DiscriminatorField = null, string? DiscriminatorType = null, - List? Variants = null); + List? Variants = null, + string? EnumType = null); +/// The discriminator as it is written, when its enum travels as a number. public sealed record UnionVariant( string Name, string? DiscriminatorValue, - List Properties); + List Properties, + object? DiscriminatorNumber = null); public sealed record DocsManifest( List Intents, List Events, List RateLimits, - List Dtos); + List Dtos, + List Enums); + +/// number or string: how the value is written in JSON, in requests and events alike. +/// The JSON number type of the values, as for a property (int32, uint64…). +/// Values combine as a bitmask. +public sealed record EnumManifest( + string Name, + string Wire, + string UnderlyingType, + bool IsFlags, + List Members); + +/// camelCase, as a string enum writes it and as lists it. +/// The number (a long, or a ulong for unsigned enums). +public sealed record EnumMemberManifest( + string Name, + object Value); public sealed record IntentManifest( string Name, diff --git a/src/Argon.Core/Features/BotApi/BotOpenApi.cs b/src/Argon.Core/Features/BotApi/BotOpenApi.cs index c2eeae37..c9b2e07c 100644 --- a/src/Argon.Core/Features/BotApi/BotOpenApi.cs +++ b/src/Argon.Core/Features/BotApi/BotOpenApi.cs @@ -229,6 +229,36 @@ private static async Task TransformOperationAsync(OpenApiOperation operation, Op // In one pass: a route's own 429 (slow_mode, space_rate_limited) shares the status with the limiter's. DescribeErrors(operation, [..route?.Errors ?? [], BotErrors.Unauthorized, BotErrors.RateLimited]); CamelCaseQueryParameters(operation); + DescribeEventStream(operation); + } + + /// + /// The stream route declares as its payload type, but that record is the + /// server's internal envelope: only its Data is written, as the data: line of a frame + /// whose event: is the camelCase event name (EventsV1.ToSseItem). Describing the + /// envelope as the body would have clients parse fields that never arrive. + /// + private static void DescribeEventStream(OpenApiOperation operation) + { + if (operation.Responses is null) + return; + + foreach (var response in operation.Responses.Values) + { + if (response is not OpenApiResponse { Content: { } content } + || !content.TryGetValue("text/event-stream", out var media)) + continue; + + media.Schema = new OpenApiSchema + { + Type = JsonSchemaType.String, + Description = + "A stream of server-sent events. In each frame `event:` is the camelCase event name (e.g. " + + "`messageCreate`), `data:` is that event's payload as one line of JSON, and `id:` is an opaque " + + "cursor. There is no envelope around the payload. The payloads are described by the events in " + + "`api-manifest.json`, not by this document." + }; + } } /// @@ -347,6 +377,10 @@ private static async Task TransformSchemaAsync(OpenApiSchema schema, OpenApiSche if (type.IsEnum && schema.Type is null && schema.Enum is { Count: > 0 }) schema.Type = JsonSchemaType.String; + // Only used as optional fields, ButtonVariant and TextInputStyle reach here as Nullable. + if ((Nullable.GetUnderlyingType(type) ?? type) is { IsEnum: true } enumType && !BotContractVerifier.IsStringEnum(enumType)) + DescribeNumberEnum(schema, enumType); + // A request with a file field is a form, and may carry the parts it attaches. A list of files is also given // as fileIds in JSON, so its request is left as it is. if (type.GetProperties(BindingFlags.Public | BindingFlags.Instance).Any(p => p.PropertyType == typeof(BotInputFile))) @@ -380,6 +414,45 @@ private static async Task TransformSchemaAsync(OpenApiSchema schema, OpenApiSche } } + /// + /// A number enum otherwise comes out as a bare integer, which leaves a client to guess what + /// 10 means. A plain enum lists its values in enum and their names in + /// x-enum-varnames (the key client generators read); a flags enum takes any combination of + /// its bits, so it is not restricted by enum and maps names to bits in x-argon-flags. + /// + private static void DescribeNumberEnum(OpenApiSchema schema, Type type) + { + var description = BotContractVerifier.DescribeEnum(type); + + schema.Type ??= JsonSchemaType.Integer; + schema.Extensions ??= new Dictionary(); + + if (description.IsFlags) + { + var bits = new JsonObject(); + foreach (var member in description.Members) + bits[member.Name] = NumberNode(member.Value); + + schema.Extensions["x-argon-flags"] = new JsonNodeExtension(bits); + return; + } + + schema.Enum = description.Members.Select(m => NumberNode(m.Value)).ToList(); + + var names = new JsonArray(); + foreach (var member in description.Members) + names.Add(member.Name); + + schema.Extensions["x-enum-varnames"] = new JsonNodeExtension(names); + } + + // The OpenAPI writer drops a ulong node without a word, and Ion enums are unsigned. Every value + // fits a long except bits past 1 << 62, which only a decimal keeps exact. + private static JsonNode NumberNode(object value) + => value is ulong unsigned and > long.MaxValue + ? JsonValue.Create((decimal)unsigned) + : JsonValue.Create(Convert.ToInt64(value)); + private static void DescribeInputFile(OpenApiSchema schema, bool partOnly) { schema.Type = JsonSchemaType.String; diff --git a/tests/ArgonSharedLogicTest/BotContractVerifierTests.cs b/tests/ArgonSharedLogicTest/BotContractVerifierTests.cs index e8831624..86c019ff 100644 --- a/tests/ArgonSharedLogicTest/BotContractVerifierTests.cs +++ b/tests/ArgonSharedLogicTest/BotContractVerifierTests.cs @@ -105,6 +105,50 @@ public void GenerateDocsManifest_EachIntentBitMatchesItsValue() Assert.That(1L << intent.Bit, Is.EqualTo(intent.Value), $"intent {intent.Name}"); } + [Test] + public void GenerateDocsManifest_DescribesEnumsAsTheyAreWritten() + { + // Clients generated from the manifest have to send 10 for bold, not "bold": the names alone + // in enumValues used to be all a reader had to go on. + var enums = BotContractVerifier.GenerateDocsManifest().Enums.ToDictionary(e => e.Name); + + Assert.Multiple(() => + { + var entity = enums["BotEntityType"]; + Assert.That(entity.Wire, Is.EqualTo("number")); + Assert.That(entity.IsFlags, Is.False); + Assert.That(entity.Members.Single(m => m.Name == "bold").Value, Is.EqualTo(10L)); + + Assert.That(enums["BotExpressionKind"].Wire, Is.EqualTo("string")); + + var userFlags = enums["UserFlag"]; + Assert.That(userFlags.IsFlags, Is.True); + Assert.That(userFlags.Members.Single(m => m.Name == "verified").Value, Is.EqualTo(4L)); + + var entitlement = enums["ArgonEntitlement"]; + Assert.That(entitlement.UnderlyingType, Is.EqualTo("uint64")); + Assert.That(entitlement.Members.Single(m => m.Name == "manageExpressions").Value, Is.EqualTo(1UL << 57)); + }); + } + + [Test] + public void GenerateDocsManifest_EnumFieldsCarryTheirWireTypeAndEnum() + { + var docs = BotContractVerifier.GenerateDocsManifest(); + var message = docs.Events.Single(e => e.Name == "MessageCreate").PayloadTypeShape! + .Single(p => p.Name == "message").Properties!; + var type = message.Single(p => p.Name == "entities").Properties!.Single(p => p.Name == "type"); + var flags = message.Single(p => p.Name == "sender").Properties!.Single(p => p.Name == "flags"); + + Assert.Multiple(() => + { + Assert.That(type.Type, Is.EqualTo("int32")); + Assert.That(type.EnumType, Is.EqualTo("BotEntityType")); + Assert.That(flags.EnumType, Is.EqualTo("UserFlag")); + Assert.That(docs.Enums.Select(e => e.Name), Does.Contain("BotEntityType").And.Contain("UserFlag")); + }); + } + [Test] public void DiffDtoVersions_OfATypeAgainstItself_IsEmpty() => Assert.That( diff --git a/tests/ArgonSharedLogicTest/BotOpenApiTests.cs b/tests/ArgonSharedLogicTest/BotOpenApiTests.cs index 34e928c7..935f5ba9 100644 --- a/tests/ArgonSharedLogicTest/BotOpenApiTests.cs +++ b/tests/ArgonSharedLogicTest/BotOpenApiTests.cs @@ -243,6 +243,41 @@ public void ExpressionEnumsAreStrings() }); } + [Test] + public void NumberEnumsListTheirValuesAndNames() + { + var schemas = document.RootElement.GetProperty("components").GetProperty("schemas"); + + Assert.Multiple(() => + { + // Ion enums are unsigned, which the writer would drop if they were not written as longs. + var entity = schemas.GetProperty("EntityType"); + Assert.That(entity.GetProperty("type").GetString(), Is.EqualTo("integer")); + Assert.That(entity.GetProperty("enum").EnumerateArray().Select(e => e.GetInt32()), Does.Contain(10)); + Assert.That(entity.GetProperty("x-enum-varnames")[10].GetString(), Is.EqualTo("bold")); + + // Only ever an optional field, so the schema is made for Nullable. + var variant = schemas.GetProperty("ButtonVariant"); + Assert.That(variant.GetProperty("enum").EnumerateArray().Select(e => e.GetInt32()), Is.EqualTo(new[] { 0, 1 })); + Assert.That(variant.GetProperty("x-enum-varnames").EnumerateArray().Select(e => e.GetString()), + Is.EqualTo(new[] { "callback", "link" })); + }); + } + + [Test] + public void TheEventStreamIsDescribedWithoutTheInternalEnvelope() + { + var body = paths.GetProperty("/IEvents/v1/Stream").GetProperty("get").GetProperty("responses") + .GetProperty("200").GetProperty("content").GetProperty("text/event-stream").GetProperty("schema"); + + Assert.Multiple(() => + { + Assert.That(body.TryGetProperty("$ref", out _), Is.False, "the stream body is not a BotSseEvent"); + Assert.That(body.GetProperty("type").GetString(), Is.EqualTo("string")); + Assert.That(body.GetProperty("description").GetString(), Does.Contain("no envelope")); + }); + } + [Test] public void BotsAreGivenNoRouteThatDeletesOrReordersExpressions() {