dotnet-cqrs/Svrnty.CQRS.Events/Schema/SystemTextJsonSchemaGenerator.cs

230 lines
7.2 KiB
C#

using System;
using Svrnty.CQRS.Events.Abstractions.Schema;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.Events.Abstractions;
namespace Svrnty.CQRS.Events.Schema;
/// <summary>
/// Basic JSON Schema Draft 7 generator using System.Text.Json reflection.
/// </summary>
/// <remarks>
/// <para>
/// This is a simple implementation that generates basic JSON schemas from CLR types.
/// For more advanced features (XML doc comments, complex validation, etc.),
/// consider using NJsonSchema library instead.
/// </para>
/// <para>
/// Supports:
/// - Primitive types (string, number, boolean)
/// - Object types with properties
/// - Nullable types
/// - Required properties (non-nullable reference types)
/// - Basic type descriptions
/// </para>
/// </remarks>
public class SystemTextJsonSchemaGenerator : IJsonSchemaGenerator
{
private static readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = true
};
/// <inheritdoc />
public Task<string> GenerateSchemaAsync(
Type type,
CancellationToken cancellationToken = default)
{
if (type == null)
throw new ArgumentNullException(nameof(type));
var schema = GenerateSchemaObject(type, new HashSet<Type>());
var json = JsonSerializer.Serialize(schema, _jsonOptions);
return Task.FromResult(json);
}
/// <inheritdoc />
public Task<bool> ValidateAsync(
string jsonData,
string jsonSchema,
CancellationToken cancellationToken = default)
{
// Basic implementation: Just check if JSON is parseable
// For proper validation, use NJsonSchema or similar library
try
{
using var doc = JsonDocument.Parse(jsonData);
return Task.FromResult(true);
}
catch
{
return Task.FromResult(false);
}
}
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetValidationErrorsAsync(
string jsonData,
string jsonSchema,
CancellationToken cancellationToken = default)
{
// Basic implementation: Just check JSON parsing errors
var errors = new List<string>();
try
{
using var doc = JsonDocument.Parse(jsonData);
}
catch (JsonException ex)
{
errors.Add($"Invalid JSON: {ex.Message}");
}
return Task.FromResult<IReadOnlyList<string>>(errors);
}
private Dictionary<string, object> GenerateSchemaObject(Type type, HashSet<Type> visitedTypes)
{
// Prevent infinite recursion for circular references
if (visitedTypes.Contains(type))
{
return new Dictionary<string, object>
{
["$ref"] = $"#/definitions/{type.Name}"
};
}
visitedTypes.Add(type);
var schema = new Dictionary<string, object>
{
["$schema"] = "http://json-schema.org/draft-07/schema#",
["type"] = GetJsonType(type)
};
// Add title from type name
schema["title"] = type.Name;
// Handle nullable types
var underlyingType = Nullable.GetUnderlyingType(type);
if (underlyingType != null)
{
type = underlyingType;
schema["type"] = new[] { GetJsonType(type), "null" };
}
// Handle complex types (objects)
if (IsComplexType(type))
{
var properties = new Dictionary<string, object>();
var required = new List<string>();
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
var propName = GetJsonPropertyName(prop);
var propSchema = new Dictionary<string, object>
{
["type"] = GetJsonType(prop.PropertyType)
};
// Check if property is required (non-nullable reference type)
if (!IsNullable(prop))
{
required.Add(propName);
}
// Handle nested complex types
if (IsComplexType(prop.PropertyType))
{
propSchema = GenerateSchemaObject(prop.PropertyType, new HashSet<Type>(visitedTypes));
}
properties[propName] = propSchema;
}
schema["properties"] = properties;
if (required.Any())
{
schema["required"] = required;
}
}
return schema;
}
private static string GetJsonType(Type type)
{
var underlyingType = Nullable.GetUnderlyingType(type) ?? type;
if (underlyingType == typeof(string))
return "string";
if (underlyingType == typeof(int) || underlyingType == typeof(long) ||
underlyingType == typeof(short) || underlyingType == typeof(byte))
return "integer";
if (underlyingType == typeof(double) || underlyingType == typeof(float) ||
underlyingType == typeof(decimal))
return "number";
if (underlyingType == typeof(bool))
return "boolean";
if (underlyingType == typeof(DateTime) || underlyingType == typeof(DateTimeOffset))
return "string"; // ISO 8601 format
if (underlyingType == typeof(Guid))
return "string"; // UUID format
// Arrays and lists
if (type.IsArray || (type.IsGenericType &&
(type.GetGenericTypeDefinition() == typeof(List<>) ||
type.GetGenericTypeDefinition() == typeof(IEnumerable<>) ||
type.GetGenericTypeDefinition() == typeof(IList<>) ||
type.GetGenericTypeDefinition() == typeof(IReadOnlyList<>))))
{
return "array";
}
return "object";
}
private static bool IsComplexType(Type type)
{
var underlyingType = Nullable.GetUnderlyingType(type) ?? type;
return underlyingType.IsClass &&
underlyingType != typeof(string) &&
underlyingType != typeof(DateTime) &&
underlyingType != typeof(DateTimeOffset) &&
underlyingType != typeof(Guid) &&
!underlyingType.IsArray;
}
private static bool IsNullable(PropertyInfo property)
{
// Check for nullable value types (e.g., int?)
if (Nullable.GetUnderlyingType(property.PropertyType) != null)
return true;
// Check for nullable reference types (C# 8.0+)
var nullabilityContext = new NullabilityInfoContext();
var nullabilityInfo = nullabilityContext.Create(property);
return nullabilityInfo.WriteState == NullabilityState.Nullable;
}
private static string GetJsonPropertyName(PropertyInfo property)
{
// Use JsonPropertyName attribute if present
var attr = property.GetCustomAttribute<System.Text.Json.Serialization.JsonPropertyNameAttribute>();
if (attr != null)
return attr.Name;
// Default: camelCase
var name = property.Name;
return char.ToLowerInvariant(name[0]) + name.Substring(1);
}
}