Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/bot-api-docs
137 changes: 120 additions & 17 deletions src/Argon.Core/Features/BotApi/BotContractVerifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Type>();

// Build intent → event names from event definitions
var intentEvents = new Dictionary<BotIntent, List<string>>();
foreach (var (_, defAttr, _, _) in eventDefs)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
}

/// <summary>
/// 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
/// <see cref="TypeProperty.EnumValues"/> alone cannot say any of that.
/// </summary>
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);
}

/// <summary>
/// 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 <see cref="BotStringEnumConverter{TEnum}"/>).
/// </summary>
public static bool IsStringEnum(Type enumType)
{
var stj = enumType.GetCustomAttribute<System.Text.Json.Serialization.JsonConverterAttribute>()?.ConverterType;
if (stj is not null && IsStringEnumConverter(stj, typeof(System.Text.Json.Serialization.JsonStringEnumConverter<>)))
return true;

var newtonsoft = enumType.GetCustomAttribute<Newtonsoft.Json.JsonConverterAttribute>()?.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;
}

/// <summary>
Expand Down Expand Up @@ -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<TypeProperty>? BuildWebTypeShape(Type? type)
private static List<TypeProperty>? BuildWebTypeShape(Type? type, HashSet<Type> 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<TypeProperty> BuildWebTypeProperties(Type type, HashSet<Type> visited)
private static List<TypeProperty> BuildWebTypeProperties(Type type, HashSet<Type> visited, HashSet<Type> enums)
{
if (!visited.Add(type))
return [];
Expand All @@ -306,27 +372,31 @@ private static List<TypeProperty> BuildWebTypeProperties(Type type, HashSet<Type
string? discriminatorType = null;
List<UnionVariant>? 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);
Expand All @@ -347,7 +417,7 @@ private static bool IsIonUnionType(Type type)
/// Scans assemblies for all concrete implementations and extracts their property shapes.
/// </summary>
private static (string? DiscriminatorField, string? DiscriminatorType, List<UnionVariant> Variants)
BuildUnionVariants(Type unionInterfaceType, HashSet<Type> visited)
BuildUnionVariants(Type unionInterfaceType, HashSet<Type> visited, HashSet<Type> enums)
{
var concreteTypes = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => a.FullName?.StartsWith("Argon") == true)
Expand Down Expand Up @@ -376,6 +446,7 @@ private static (string? DiscriminatorField, string? DiscriminatorType, List<Unio
{
discriminatorField = char.ToLowerInvariant(enumProp.Name[0]) + enumProp.Name[1..];
discriminatorType = enumProp.PropertyType.Name;
enums.Add(enumProp.PropertyType);
}
}

Expand All @@ -384,6 +455,7 @@ private static (string? DiscriminatorField, string? DiscriminatorType, List<Unio
{
// Get discriminator value from enum property if available
string? discriminatorValue = null;
object? discriminatorNumber = null;
if (discriminatorField is not null)
{
var enumProp = concreteType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
Expand All @@ -404,6 +476,9 @@ private static (string? DiscriminatorField, string? DiscriminatorType, List<Unio
|| typeName.Contains(enumName, StringComparison.OrdinalIgnoreCase))
{
discriminatorValue = ToCamelCase(enumName);
discriminatorNumber = IsStringEnum(enumType)
? null
: Convert.ToInt64(Enum.Parse(enumType, enumName));
break;
}
}
Expand All @@ -430,23 +505,28 @@ private static (string? DiscriminatorField, string? DiscriminatorType, List<Unio
var isCircular = false;
string[]? enumValues = null;

string? enumType = null;

if (innerType.IsEnum)
{
enumValues = Enum.GetNames(innerType).Select(ToCamelCase).ToArray();
enumType = innerType.Name;
enums.Add(innerType);
}
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);
}

return new TypeProperty(camelName, webTypeName, isArray, isNullable, isCircular, children, enumValues);
return new TypeProperty(camelName, webTypeName, isArray, isNullable, isCircular, children, enumValues,
EnumType: enumType);
})
.ToList();

variants.Add(new UnionVariant(concreteType.Name, discriminatorValue, variantProps));
variants.Add(new UnionVariant(concreteType.Name, discriminatorValue, variantProps, discriminatorNumber));
}

return (discriminatorField, discriminatorType, variants);
Expand Down Expand Up @@ -498,7 +578,8 @@ private static (Type Inner, string TypeName, bool IsArray, bool IsNullable) Deco

private static string GetWebBaseTypeName(Type type)
{
if (type.IsEnum) return "string";
// An enum is written as its number unless it carries a string converter; see DescribeEnum.
if (type.IsEnum) return IsStringEnum(type) ? "string" : GetWebBaseTypeName(Enum.GetUnderlyingType(type));

return type.Name switch
{
Expand All @@ -519,6 +600,8 @@ private static string GetWebBaseTypeName(Type type)
"TimeSpan" => "string",
"Object" => "any",
"Byte" => "uint8",
"UInt16" => "uint16",
"SByte" => "int8",
_ => type.Name
};
}
Expand Down Expand Up @@ -604,18 +687,38 @@ public sealed record TypeProperty(
bool IsUnion = false,
string? DiscriminatorField = null,
string? DiscriminatorType = null,
List<UnionVariant>? Variants = null);
List<UnionVariant>? Variants = null,
string? EnumType = null);

/// <param name="DiscriminatorNumber">The discriminator as it is written, when its enum travels as a number.</param>
public sealed record UnionVariant(
string Name,
string? DiscriminatorValue,
List<TypeProperty> Properties);
List<TypeProperty> Properties,
object? DiscriminatorNumber = null);

public sealed record DocsManifest(
List<IntentManifest> Intents,
List<EventManifest> Events,
List<RateLimitManifest> RateLimits,
List<DtoManifest> Dtos);
List<DtoManifest> Dtos,
List<EnumManifest> Enums);

/// <param name="Wire"><c>number</c> or <c>string</c>: how the value is written in JSON, in requests and events alike.</param>
/// <param name="UnderlyingType">The JSON number type of the values, as for a property (<c>int32</c>, <c>uint64</c>…).</param>
/// <param name="IsFlags">Values combine as a bitmask.</param>
public sealed record EnumManifest(
string Name,
string Wire,
string UnderlyingType,
bool IsFlags,
List<EnumMemberManifest> Members);

/// <param name="Name">camelCase, as a string enum writes it and as <see cref="TypeProperty.EnumValues"/> lists it.</param>
/// <param name="Value">The number (a <c>long</c>, or a <c>ulong</c> for unsigned enums).</param>
public sealed record EnumMemberManifest(
string Name,
object Value);

public sealed record IntentManifest(
string Name,
Expand Down
73 changes: 73 additions & 0 deletions src/Argon.Core/Features/BotApi/BotOpenApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>
/// The stream route declares <see cref="BotSseEvent"/> as its payload type, but that record is the
/// server's internal envelope: only its <c>Data</c> is written, as the <c>data:</c> line of a frame
/// whose <c>event:</c> is the camelCase event name (<c>EventsV1.ToSseItem</c>). Describing the
/// envelope as the body would have clients parse fields that never arrive.
/// </summary>
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."
};
}
}

/// <summary>
Expand Down Expand Up @@ -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<T>.
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)))
Expand Down Expand Up @@ -380,6 +414,45 @@ private static async Task TransformSchemaAsync(OpenApiSchema schema, OpenApiSche
}
}

/// <summary>
/// A number enum otherwise comes out as a bare <c>integer</c>, which leaves a client to guess what
/// <c>10</c> means. A plain enum lists its values in <c>enum</c> and their names in
/// <c>x-enum-varnames</c> (the key client generators read); a flags enum takes any combination of
/// its bits, so it is not restricted by <c>enum</c> and maps names to bits in <c>x-argon-flags</c>.
/// </summary>
private static void DescribeNumberEnum(OpenApiSchema schema, Type type)
{
var description = BotContractVerifier.DescribeEnum(type);

schema.Type ??= JsonSchemaType.Integer;
schema.Extensions ??= new Dictionary<string, IOpenApiExtension>();

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;
Expand Down
Loading
Loading