using System.Collections; using System.Reflection; using EonaCat.DoxaApi.Models; namespace EonaCat.DoxaApi.Generation { public sealed class SchemaBuilder { private readonly Dictionary _registry; private readonly HashSet _inProgress = new(); public SchemaBuilder(Dictionary registry) { _registry = registry; } public SchemaModel Build(Type type) { type = UnwrapAsyncAndActionResult(type); var underlying = Nullable.GetUnderlyingType(type); bool nullable = underlying is not null; if (underlying is not null) { type = underlying; } var schema = BuildCore(type); schema.Nullable = nullable || schema.Nullable; return schema; } private static Type UnwrapAsyncAndActionResult(Type type) { if (type == typeof(void)) { return type; } if (type.IsGenericType) { var def = type.GetGenericTypeDefinition(); if (def == typeof(Task<>) || def.Name.StartsWith("ValueTask`")) { return UnwrapAsyncAndActionResult(type.GetGenericArguments()[0]); } if (def.Name is "ActionResult`1") { return UnwrapAsyncAndActionResult(type.GetGenericArguments()[0]); } } if (type == typeof(Task)) { return typeof(void); } return type; } private SchemaModel BuildCore(Type type) { if (type == typeof(void)) { return new SchemaModel { Type = "void" }; } if (type == typeof(string) || type == typeof(char)) { return new SchemaModel { Type = "string" }; } if (type == typeof(bool)) { return new SchemaModel { Type = "boolean" }; } if (type == typeof(byte) || type == typeof(sbyte) || type == typeof(short) || type == typeof(ushort) || type == typeof(int) || type == typeof(uint) || type == typeof(long) || type == typeof(ulong)) { return new SchemaModel { Type = "integer", Format = type.Name.ToLowerInvariant() }; } if (type == typeof(float) || type == typeof(double) || type == typeof(decimal)) { return new SchemaModel { Type = "number", Format = type.Name.ToLowerInvariant() }; } if (type == typeof(DateTime) || type == typeof(DateTimeOffset)) { return new SchemaModel { Type = "string", Format = "date-time" }; } if (type == typeof(DateOnly)) { return new SchemaModel { Type = "string", Format = "date" }; } if (type == typeof(TimeOnly) || type == typeof(TimeSpan)) { return new SchemaModel { Type = "string", Format = "time" }; } if (type == typeof(Guid)) { return new SchemaModel { Type = "string", Format = "uuid" }; } if (type == typeof(Uri)) { return new SchemaModel { Type = "string", Format = "uri" }; } if (type == typeof(object)) { return new SchemaModel { Type = "object" }; } if (type.IsEnum) { return new SchemaModel { Type = "enum", EnumValues = Enum.GetNames(type).ToList() }; } if (IsDictionary(type, out var valueType)) { return new SchemaModel { Type = "object", Items = Build(valueType!) }; } var elementType = GetEnumerableElementType(type); if (elementType is not null) { return new SchemaModel { Type = "array", Items = Build(elementType) }; } var refName = FriendlyTypeName(type); if (_registry.ContainsKey(refName)) { return new SchemaModel { Type = "object", RefName = refName }; } if (_inProgress.Contains(type)) { return new SchemaModel { Type = "object", RefName = refName }; } _inProgress.Add(type); var props = new Dictionary(); var required = new List(); foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) { if (prop.GetIndexParameters().Length > 0) { continue; } if (!prop.CanRead) { continue; } var propSchema = Build(prop.PropertyType); props[ToCamelCase(prop.Name)] = propSchema; if (!propSchema.Nullable && Nullable.GetUnderlyingType(prop.PropertyType) is null && (prop.PropertyType.IsValueType || IsNonNullableReferenceProperty(prop))) { required.Add(ToCamelCase(prop.Name)); } } _registry[refName] = new SchemaModel { Type = "object", Properties = props, Required = required.Count > 0 ? required : null }; _inProgress.Remove(type); return new SchemaModel { Type = "object", RefName = refName }; } private static bool IsNonNullableReferenceProperty(PropertyInfo prop) { try { var ctx = new NullabilityInfoContext(); var info = ctx.Create(prop); return info.ReadState == NullabilityState.NotNull; } catch { return false; } } private static Type? GetEnumerableElementType(Type type) { if (type == typeof(string)) { return null; } if (type.IsArray) { return type.GetElementType(); } if (type.IsGenericType) { var def = type.GetGenericTypeDefinition(); if (def == typeof(List<>) || def == typeof(IList<>) || def == typeof(ICollection<>) || def == typeof(IEnumerable<>) || def == typeof(IReadOnlyList<>) || def == typeof(IReadOnlyCollection<>) || def == typeof(HashSet<>) || def == typeof(Queue<>) || def == typeof(Stack<>)) { return type.GetGenericArguments()[0]; } } foreach (var iface in type.GetInterfaces()) { if (iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { return iface.GetGenericArguments()[0]; } } if (typeof(IEnumerable).IsAssignableFrom(type) && type != typeof(string)) { return typeof(object); } return null; } private static bool IsDictionary(Type type, out Type? valueType) { valueType = null; if (!type.IsGenericType) { return false; } var def = type.GetGenericTypeDefinition(); if (def == typeof(Dictionary<,>) || def == typeof(IDictionary<,>) || def == typeof(IReadOnlyDictionary<,>)) { valueType = type.GetGenericArguments()[1]; return true; } return false; } private static string FriendlyTypeName(Type type) { if (!type.IsGenericType) { return type.Name; } var name = type.Name[..type.Name.IndexOf('`')]; var args = string.Join(",", type.GetGenericArguments().Select(FriendlyTypeName)); return $"{name}<{args}>"; } private static string ToCamelCase(string name) { if (string.IsNullOrEmpty(name) || char.IsLower(name[0])) { return name; } return char.ToLowerInvariant(name[0]) + name[1..]; } } }