using System.Text; using Microsoft.CodeAnalysis; namespace StellaOps.Microservice.SourceGen; /// /// Generates JSON Schema (draft 2020-12) from C# types at compile time. /// internal static class SchemaGenerator { /// /// Generates a JSON Schema string from a type symbol. /// /// The type to generate schema for. /// The compilation context. /// A JSON Schema string, or null if generation fails. public static string? GenerateSchema(ITypeSymbol typeSymbol, Compilation compilation) { if (typeSymbol is null) return null; try { var context = new SchemaContext(compilation); var schema = GenerateTypeSchema(typeSymbol, context, isRoot: true); return schema; } catch { return null; } } private static string GenerateTypeSchema(ITypeSymbol typeSymbol, SchemaContext context, bool isRoot) { var sb = new StringBuilder(); sb.AppendLine("{"); if (isRoot) { sb.AppendLine(" \"$schema\": \"https://json-schema.org/draft/2020-12/schema\","); } // Check for simple types first var simpleType = GetSimpleTypeSchema(typeSymbol); if (simpleType is not null) { sb.Append(simpleType); sb.AppendLine(); sb.Append("}"); return sb.ToString(); } // Check for nullable if (typeSymbol.NullableAnnotation == NullableAnnotation.Annotated || (typeSymbol is INamedTypeSymbol namedType && namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T)) { var underlyingType = GetUnderlyingType(typeSymbol); if (underlyingType is not null) { var underlyingSimple = GetSimpleTypeSchema(underlyingType); if (underlyingSimple is not null) { // Nullable simple type var nullableSchema = underlyingSimple.Replace("\"type\":", "\"type\": [") + ", \"null\"]"; sb.Append(nullableSchema); sb.AppendLine(); sb.Append("}"); return sb.ToString(); } } } // Check for arrays/lists if (IsCollectionType(typeSymbol, out var elementType)) { sb.AppendLine(" \"type\": \"array\","); if (elementType is not null) { var elementSchema = GetSimpleTypeSchema(elementType); if (elementSchema is not null) { sb.AppendLine(" \"items\": {"); sb.Append(" "); sb.AppendLine(elementSchema); sb.AppendLine(" }"); } else { sb.AppendLine(" \"items\": { \"type\": \"object\" }"); } } else { sb.AppendLine(" \"items\": {}"); } sb.Append("}"); return sb.ToString(); } // Object type sb.AppendLine(" \"type\": \"object\","); // Get properties var properties = GetPublicProperties(typeSymbol); var requiredProps = new List(); if (properties.Count > 0) { sb.AppendLine(" \"properties\": {"); var propIndex = 0; foreach (var prop in properties.OrderBy(p => p.Name)) { var propSchema = GeneratePropertySchema(prop, context); var isRequired = IsPropertyRequired(prop); if (isRequired) { requiredProps.Add(ToCamelCase(prop.Name)); } sb.Append($" \"{ToCamelCase(prop.Name)}\": "); sb.Append(propSchema); if (propIndex < properties.Count - 1) { sb.AppendLine(","); } else { sb.AppendLine(); } propIndex++; } sb.AppendLine(" },"); } // Required array if (requiredProps.Count > 0) { sb.Append(" \"required\": ["); sb.Append(string.Join(", ", requiredProps.Select(p => $"\"{p}\""))); sb.AppendLine("],"); } sb.AppendLine(" \"additionalProperties\": false"); sb.Append("}"); return sb.ToString(); } private static string GeneratePropertySchema(IPropertySymbol prop, SchemaContext context) { var type = prop.Type; // Handle nullable types var isNullable = type.NullableAnnotation == NullableAnnotation.Annotated; if (isNullable) { type = GetUnderlyingType(type) ?? type; } var simpleSchema = GetSimpleTypeSchema(type); if (simpleSchema is not null) { if (isNullable) { // Convert "type": "X" to "type": ["X", "null"] return "{ " + MakeTypeNullable(simpleSchema) + " }"; } return "{ " + simpleSchema + " }"; } // Collections if (IsCollectionType(type, out var elementType)) { var itemSchema = elementType is not null ? GetSimpleTypeSchema(elementType) ?? "\"type\": \"object\"" : "\"type\": \"object\""; return $"{{ \"type\": \"array\", \"items\": {{ {itemSchema} }} }}"; } // Complex object - just use object type for now return "{ \"type\": \"object\" }"; } private static string? GetSimpleTypeSchema(ITypeSymbol type) { var fullName = type.ToDisplayString(); return fullName switch { "string" or "System.String" => "\"type\": \"string\"", "int" or "System.Int32" => "\"type\": \"integer\"", "long" or "System.Int64" => "\"type\": \"integer\"", "short" or "System.Int16" => "\"type\": \"integer\"", "byte" or "System.Byte" => "\"type\": \"integer\"", "uint" or "System.UInt32" => "\"type\": \"integer\"", "ulong" or "System.UInt64" => "\"type\": \"integer\"", "ushort" or "System.UInt16" => "\"type\": \"integer\"", "sbyte" or "System.SByte" => "\"type\": \"integer\"", "float" or "System.Single" => "\"type\": \"number\"", "double" or "System.Double" => "\"type\": \"number\"", "decimal" or "System.Decimal" => "\"type\": \"number\"", "bool" or "System.Boolean" => "\"type\": \"boolean\"", "System.DateTime" or "System.DateTimeOffset" => "\"type\": \"string\", \"format\": \"date-time\"", "System.DateOnly" => "\"type\": \"string\", \"format\": \"date\"", "System.TimeOnly" or "System.TimeSpan" => "\"type\": \"string\", \"format\": \"time\"", "System.Guid" => "\"type\": \"string\", \"format\": \"uuid\"", "System.Uri" => "\"type\": \"string\", \"format\": \"uri\"", _ => null }; } private static ITypeSymbol? GetUnderlyingType(ITypeSymbol type) { if (type is INamedTypeSymbol namedType) { if (namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && namedType.TypeArguments.Length > 0) { return namedType.TypeArguments[0]; } } // For reference types with nullable annotation, just return the type itself return type; } private static bool IsCollectionType(ITypeSymbol type, out ITypeSymbol? elementType) { elementType = null; if (type is IArrayTypeSymbol arrayType) { elementType = arrayType.ElementType; return true; } if (type is INamedTypeSymbol namedType) { // Check for List, IList, IEnumerable, ICollection, etc. var fullName = namedType.OriginalDefinition.ToDisplayString(); if ((fullName.StartsWith("System.Collections.Generic.List") || fullName.StartsWith("System.Collections.Generic.IList") || fullName.StartsWith("System.Collections.Generic.IEnumerable") || fullName.StartsWith("System.Collections.Generic.ICollection") || fullName.StartsWith("System.Collections.Generic.IReadOnlyList") || fullName.StartsWith("System.Collections.Generic.IReadOnlyCollection")) && namedType.TypeArguments.Length > 0) { elementType = namedType.TypeArguments[0]; return true; } } return false; } private static List GetPublicProperties(ITypeSymbol type) { var properties = new List(); foreach (var member in type.GetMembers()) { if (member is IPropertySymbol prop && prop.DeclaredAccessibility == Accessibility.Public && !prop.IsStatic && !prop.IsIndexer && prop.GetMethod is not null) { properties.Add(prop); } } return properties; } private static bool IsPropertyRequired(IPropertySymbol prop) { // Check for [Required] attribute foreach (var attr in prop.GetAttributes()) { var attrName = attr.AttributeClass?.ToDisplayString(); if (attrName == "System.ComponentModel.DataAnnotations.RequiredAttribute") return true; } // Non-nullable reference types are required if (prop.Type.IsReferenceType && prop.NullableAnnotation != NullableAnnotation.Annotated && prop.Type.NullableAnnotation != NullableAnnotation.Annotated) { return true; } return false; } private static string MakeTypeNullable(string simpleSchema) { // Convert "type": "X" to "type": ["X", "null"] // Input: "type": "string" // Output: "type": ["string", "null"] const string typePrefix = "\"type\": \""; var idx = simpleSchema.IndexOf(typePrefix, StringComparison.Ordinal); if (idx < 0) return simpleSchema; var startOfType = idx + typePrefix.Length; var endOfType = simpleSchema.IndexOf('"', startOfType); if (endOfType < 0) return simpleSchema; var typeName = simpleSchema.Substring(startOfType, endOfType - startOfType); var rest = simpleSchema.Substring(endOfType + 1); return $"\"type\": [\"{typeName}\", \"null\"]{rest}"; } private static string ToCamelCase(string name) { if (string.IsNullOrEmpty(name)) return name; if (name.Length == 1) return name.ToLowerInvariant(); return char.ToLowerInvariant(name[0]) + name.Substring(1); } private sealed class SchemaContext { public Compilation Compilation { get; } public Dictionary Definitions { get; } = new(); public SchemaContext(Compilation compilation) { Compilation = compilation; } } }