From e838066b6f1dd1df40661608e7e2d832f1dca2af Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 24 Jun 2025 23:09:46 -0400 Subject: [PATCH 1/2] Augment AIJsonUtilities.CreateJsonSchema for more types and annotations --- .../AIJsonUtilities.Schema.Create.cs | 292 ++++++++++++- .../OpenAIClientExtensions.cs | 60 +++ .../Utilities/AIJsonUtilitiesTests.cs | 413 ++++++++++++++++-- .../ChatClientIntegrationTests.cs | 43 +- 4 files changed, 766 insertions(+), 42 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs index e182d4149bb..a58d1ea0c74 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs @@ -3,6 +3,9 @@ using System; using System.ComponentModel; +#if NET +using System.ComponentModel.DataAnnotations; +#endif using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Reflection; @@ -14,11 +17,11 @@ using System.Threading; using Microsoft.Shared.Diagnostics; -#pragma warning disable S1121 // Assignments should not be made from within sub-expressions #pragma warning disable S107 // Methods should not have too many parameters +#pragma warning disable S109 // Magic numbers should not be used #pragma warning disable S1075 // URIs should not be hardcoded +#pragma warning disable S1121 // Assignments should not be made from within sub-expressions #pragma warning disable SA1118 // Parameter should not span multiple lines -#pragma warning disable S109 // Magic numbers should not be used namespace Microsoft.Extensions.AI; @@ -38,6 +41,19 @@ public static partial class AIJsonUtilities private const string AdditionalPropertiesPropertyName = "additionalProperties"; private const string DefaultPropertyName = "default"; private const string RefPropertyName = "$ref"; + private const string FormatPropertyName = "format"; +#if NET + private const string ContentEncodingPropertyName = "contentEncoding"; + private const string ContentMediaTypePropertyName = "contentMediaType"; + private const string MinLengthStringPropertyName = "minLength"; + private const string MaxLengthStringPropertyName = "maxLength"; + private const string MinLengthCollectionPropertyName = "minItems"; + private const string MaxLengthCollectionPropertyName = "maxItems"; + private const string MinRangePropertyName = "minimum"; + private const string MaxRangePropertyName = "maximum"; + private const string MinExclusiveRangePropertyName = "exclusiveMinimum"; + private const string MaxExclusiveRangePropertyName = "exclusiveMaximum"; +#endif /// The uri used when populating the $schema keyword in created schemas. private const string SchemaKeywordUri = "https://json-schema.org/draft/2020-12/schema"; @@ -318,6 +334,9 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js ConvertSchemaToObject(ref schema).InsertAtStart(SchemaPropertyName, (JsonNode)SchemaKeywordUri); } + ApplyDataTypeFormats(parameterName, ref schema, ctx); + ApplyDataAnnotations(parameterName, ref schema, ctx); + // Finally, apply any user-defined transformations if specified. if (inferenceOptions.TransformSchemaNode is { } transformer) { @@ -345,6 +364,275 @@ static JsonObject ConvertSchemaToObject(ref JsonNode schema) return obj; } } + + static void ApplyDataTypeFormats(string? parameterName, ref JsonNode schema, AIJsonSchemaCreateContext ctx) + { + Type t = ctx.TypeInfo.Type; + + if (Nullable.GetUnderlyingType(t) is { } underlyingType) + { + t = underlyingType; + } + + if (t == typeof(DateTime) || t == typeof(DateTimeOffset)) + { + ConvertSchemaToObject(ref schema)[FormatPropertyName] = "date-time"; + } +#if NET + else if (t == typeof(DateOnly)) + { + ConvertSchemaToObject(ref schema)[FormatPropertyName] = "date"; + } + else if (t == typeof(TimeOnly)) + { + ConvertSchemaToObject(ref schema)[FormatPropertyName] = "time"; + } +#endif + else if (t == typeof(TimeSpan)) + { + ConvertSchemaToObject(ref schema)[FormatPropertyName] = "duration"; + } + else if (t == typeof(Guid)) + { + ConvertSchemaToObject(ref schema)[FormatPropertyName] = "uuid"; + } + else if (t == typeof(Uri)) + { + ConvertSchemaToObject(ref schema)[FormatPropertyName] = "uri"; + } + } + + void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSchemaCreateContext ctx) + { + if (ctx.GetCustomAttribute() is { } displayNameAttribute) + { + ConvertSchemaToObject(ref schema)[TitlePropertyName] ??= displayNameAttribute.DisplayName; + } + +#if NET + if (ctx.GetCustomAttribute() is { } base64Attribute) + { + ConvertSchemaToObject(ref schema)[ContentEncodingPropertyName] ??= "base64"; + } + + if (ctx.GetCustomAttribute() is { } emailAttribute) + { + ConvertSchemaToObject(ref schema)[FormatPropertyName] ??= "email"; + } + + if (ctx.GetCustomAttribute() is { } urlAttribute) + { + ConvertSchemaToObject(ref schema)[FormatPropertyName] ??= "uri"; + } + + if (ctx.GetCustomAttribute() is { } regexAttribute) + { + ConvertSchemaToObject(ref schema)[PatternPropertyName] ??= regexAttribute.Pattern; + } + + if (ctx.GetCustomAttribute() is { } stringLengthAttribute) + { + JsonObject obj = ConvertSchemaToObject(ref schema); + + if (stringLengthAttribute.MinimumLength > 0) + { + obj[MinLengthStringPropertyName] ??= stringLengthAttribute.MinimumLength; + } + + obj[MaxLengthStringPropertyName] ??= stringLengthAttribute.MaximumLength; + } + + if (ctx.GetCustomAttribute() is { } lengthAttribute) + { + JsonObject obj = ConvertSchemaToObject(ref schema); + + if (obj[TypePropertyName] is JsonNode typeNode && typeNode.GetValueKind() is JsonValueKind.String && typeNode.GetValue() is "string") + { + if (lengthAttribute.MinimumLength > 0) + { + obj[MinLengthStringPropertyName] ??= lengthAttribute.MinimumLength; + } + + obj[MaxLengthStringPropertyName] ??= lengthAttribute.MaximumLength; + } + else + { + if (lengthAttribute.MinimumLength > 0) + { + obj[MinLengthCollectionPropertyName] ??= lengthAttribute.MinimumLength; + } + + obj[MaxLengthCollectionPropertyName] ??= lengthAttribute.MaximumLength; + } + } + + if (ctx.GetCustomAttribute() is { } minLengthAttribute) + { + JsonObject obj = ConvertSchemaToObject(ref schema); + if (obj[TypePropertyName] is JsonNode typeNode && typeNode.GetValueKind() is JsonValueKind.String && typeNode.GetValue() is "string") + { + obj[MinLengthStringPropertyName] ??= minLengthAttribute.Length; + } + else + { + obj[MinLengthCollectionPropertyName] ??= minLengthAttribute.Length; + } + } + + if (ctx.GetCustomAttribute() is { } maxLengthAttribute) + { + JsonObject obj = ConvertSchemaToObject(ref schema); + if (obj[TypePropertyName] is JsonNode typeNode && typeNode.GetValueKind() is JsonValueKind.String && typeNode.GetValue() is "string") + { + obj[MaxLengthStringPropertyName] ??= maxLengthAttribute.Length; + } + else + { + obj[MaxLengthCollectionPropertyName] ??= maxLengthAttribute.Length; + } + } + + if (ctx.GetCustomAttribute() is { } rangeAttribute) + { + JsonObject obj = ConvertSchemaToObject(ref schema); + + JsonNode? minNode = null; + JsonNode? maxNode = null; + switch (rangeAttribute.Minimum) + { + case int minInt32 when rangeAttribute.Maximum is int maxInt32: + maxNode = maxInt32; + if (!rangeAttribute.MinimumIsExclusive || minInt32 > 0) + { + minNode = minInt32; + } + + break; + + case double minDouble when rangeAttribute.Maximum is double maxDouble: + maxNode = maxDouble; + if (!rangeAttribute.MinimumIsExclusive || minDouble > 0) + { + minNode = minDouble; + } + + break; + + case string minString when rangeAttribute.Maximum is string maxString: + maxNode = maxString; + minNode = minString; + break; + } + + if (minNode is not null) + { + if (rangeAttribute.MinimumIsExclusive) + { + obj[MinExclusiveRangePropertyName] ??= minNode; + } + else + { + obj[MinRangePropertyName] ??= minNode; + } + } + + if (maxNode is not null) + { + if (rangeAttribute.MaximumIsExclusive) + { + obj[MaxExclusiveRangePropertyName] ??= maxNode; + } + else + { + obj[MaxRangePropertyName] ??= maxNode; + } + } + } + + if (ctx.GetCustomAttribute() is { } allowedValuesAttribute) + { + JsonObject obj = ConvertSchemaToObject(ref schema); + if (!obj.ContainsKey(EnumPropertyName)) + { + if (CreateJsonArray(allowedValuesAttribute.Values, serializerOptions) is { Count: > 0 } enumArray) + { + obj[EnumPropertyName] = enumArray; + } + } + } + + if (ctx.GetCustomAttribute() is { } deniedValuesAttribute) + { + JsonObject obj = ConvertSchemaToObject(ref schema); + + JsonNode? notNode = obj[NotPropertyName]; + if (notNode is null or JsonObject) + { + JsonObject notObj = + notNode as JsonObject ?? + (JsonObject)(obj[NotPropertyName] = new JsonObject()); + + if (notObj[EnumPropertyName] is null) + { + if (CreateJsonArray(deniedValuesAttribute.Values, serializerOptions) is { Count: > 0 } enumArray) + { + notObj[EnumPropertyName] = enumArray; + } + } + } + } + + static JsonArray CreateJsonArray(object?[] values, JsonSerializerOptions serializerOptions) + { + JsonArray enumArray = new(); + foreach (object? allowedValue in values) + { + if (allowedValue is not null && JsonSerializer.SerializeToNode(allowedValue, serializerOptions.GetTypeInfo(allowedValue.GetType())) is { } valueNode) + { + enumArray.Add(valueNode); + } + } + + return enumArray; + } + + if (ctx.GetCustomAttribute() is { } dataTypeAttribute) + { + JsonObject obj = ConvertSchemaToObject(ref schema); + switch (dataTypeAttribute.DataType) + { + case DataType.DateTime: + obj[FormatPropertyName] ??= "date-time"; + break; + + case DataType.Date: + obj[FormatPropertyName] ??= "date"; + break; + + case DataType.Time: + obj[FormatPropertyName] ??= "time"; + break; + + case DataType.Duration: + obj[FormatPropertyName] ??= "duration"; + break; + + case DataType.EmailAddress: + obj[FormatPropertyName] ??= "email"; + break; + + case DataType.Url: + obj[FormatPropertyName] ??= "uri"; + break; + + case DataType.ImageUrl: + obj[FormatPropertyName] ??= "uri"; + obj[ContentMediaTypePropertyName] ??= "image/*"; + break; + } + } +#endif + } } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index 19cd54306fe..3f6fbd8b897 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -3,7 +3,9 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using OpenAI; using OpenAI.Assistants; using OpenAI.Audio; @@ -11,6 +13,9 @@ using OpenAI.Embeddings; using OpenAI.Responses; +#pragma warning disable S1067 // Expressions should not be too complex +#pragma warning disable CA1305 // Specify IFormatProvider + namespace Microsoft.Extensions.AI; /// Provides extension methods for working with s. @@ -34,6 +39,61 @@ public static class OpenAIClientExtensions ConvertBooleanSchemas = true, MoveDefaultKeywordToDescription = true, RequireAllProperties = true, + TransformSchemaNode = (ctx, node) => + { + // Move content from common but unsupported properties to description. In particular, we focus on properties that + // the AIJsonUtilities schema generator might produce. + // Based on guidance at: + // https://platform.openai.com/docs/guides/structured-outputs#supported-properties + + if (node is JsonObject schemaObj) + { + StringBuilder? additionalDescription = null; + + foreach (string propName in (ReadOnlySpan)["contentEncoding", "contentMediaType", "minLength", "maxLength", "not"]) + { + if (schemaObj[propName] is { } propNode) + { + _ = schemaObj.Remove(propName); + AppendLine(ref additionalDescription, propName, propNode); + } + } + + if (schemaObj["format"] is { } formatNode) + { + if (formatNode.GetValueKind() != JsonValueKind.String || + formatNode.GetValue() is not string format || + format is not ("date-time" or "date" or "time" or "duration" or "email" or "hostname" or "ipv4" or "ipv6" or "uuid")) + { + _ = schemaObj.Remove("format"); + AppendLine(ref additionalDescription, "format", formatNode); + } + } + + if (additionalDescription is not null) + { + schemaObj["description"] = schemaObj["description"] is { } descriptionNode && descriptionNode.GetValueKind() == JsonValueKind.String ? + $"{descriptionNode.GetValue()}{Environment.NewLine}{additionalDescription}" : + additionalDescription.ToString(); + } + + return node; + + static void AppendLine(ref StringBuilder? sb, string propName, JsonNode propNode) + { + sb ??= new(); + + if (sb.Length > 0) + { + _ = sb.AppendLine(); + } + + _ = sb.Append(propName).Append(": ").Append(propNode); + } + } + + return node; + }, }); /// Gets an for use with this . diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs index 78db93e9380..3a91f48a7cb 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -4,6 +4,9 @@ using System; using System.Collections.Generic; using System.ComponentModel; +#if NET +using System.ComponentModel.DataAnnotations; +#endif using System.Linq; using System.Reflection; using System.Text.Encodings.Web; @@ -15,6 +18,8 @@ using Microsoft.Extensions.AI.JsonSchemaExporter; using Xunit; +#pragma warning disable SA1114 // parameter list should follow declaration + namespace Microsoft.Extensions.AI; public static partial class AIJsonUtilitiesTests @@ -258,44 +263,6 @@ public static void CreateJsonSchema_UserDefinedTransformer() AssertDeepEquals(expected, actual); } - [Fact] - public static void CreateJsonSchema_FiltersDisallowedKeywords() - { - JsonElement expected = JsonDocument.Parse(""" - { - "type": "object", - "properties": { - "Date": { - "type": "string" - }, - "TimeSpan": { - "$comment": "Represents a System.TimeSpan value.", - "type": "string" - }, - "Char" : { - "type": "string" - } - } - } - """).RootElement; - - JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(PocoWithTypesWithOpenAIUnsupportedKeywords), serializerOptions: JsonContext.Default.Options); - - AssertDeepEquals(expected, actual); - } - - public class PocoWithTypesWithOpenAIUnsupportedKeywords - { - // Uses the unsupported "format" keyword - public DateTimeOffset Date { get; init; } - - // Uses the unsupported "pattern" keyword - public TimeSpan TimeSpan { get; init; } - - // Uses the unsupported "minLength" and "maxLength" keywords - public char Char { get; init; } - } - [Fact] public static void CreateFunctionJsonSchema_ReturnsExpectedValue() { @@ -480,6 +447,373 @@ public static void CreateJsonSchema_NullableEnum_IncludesTypeKeyword() AssertDeepEquals(expectedSchema, schema); } + [Fact] + public static void CreateJsonSchema_IncorporatesTypesAndAnnotations() + { + AssertDeepEquals(JsonSerializer.Deserialize( +#if NET + """ + { + "type": "object", + "properties": { + "DisplayNameProp": { + "type": [ + "string", + "null" + ], + "title": "Display Name Title" + }, + "DateTimeProp": { + "type": "string", + "format": "date-time" + }, + "DateTimeOffsetProp": { + "type": "string", + "format": "date-time" + }, + "TimeSpanProp": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "format": "duration" + }, + "GuidProp": { + "type": "string", + "format": "uuid" + }, + "UriProp": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "Base64Prop": { + "type": [ + "string", + "null" + ], + "contentEncoding": "base64" + }, + "RegexProp": { + "type": [ + "string", + "null" + ], + "pattern": "[abc]|[def]" + }, + "EmailProp": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "UrlProp": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "RangeProp": { + "type": "integer", + "minimum": 12, + "maximum": 34 + }, + "AllowedStringValuesProp": { + "type": [ + "string", + "null" + ], + "enum": [ + "abc", + "def", + "ghi" + ] + }, + "AllowedInt32ValuesProp": { + "type": "integer", + "enum": [ + 1, + 3, + 5 + ] + }, + "AllowedDoubleValuesProp": { + "type": "number", + "enum": [ + 1.2, + 3.4 + ] + }, + "DeniedValuesProp": { + "type": [ + "string", + "null" + ], + "not": { + "enum": [ + "jkl", + "mnop" + ] + } + }, + "DataTypeDateTimeProp": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "DataTypeDateProp": { + "type": [ + "string", + "null" + ], + "format": "date" + }, + "DataTypeTimeProp": { + "type": [ + "string", + "null" + ], + "format": "time" + }, + "DataTypeDurationProp": { + "type": [ + "string", + "null" + ], + "format": "duration" + }, + "DataTypeEmailProp": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "DataTypeUrlProp": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "DataTypeImageUrlProp": { + "type": [ + "string", + "null" + ], + "format": "uri", + "contentMediaType": "image/*" + }, + "DateOnlyProp": { + "type": "string", + "format": "date" + }, + "TimeOnlyProp": { + "type": "string", + "format": "time" + }, + "StringLengthProp": { + "type": [ + "string", + "null" + ], + "minLength": 10, + "maxLength": 100 + }, + "MinLengthProp": { + "type": [ + "string", + "null" + ], + "minItems": 5 + }, + "MaxLengthProp": { + "type": [ + "string", + "null" + ], + "maxItems": 50 + }, + "LengthProp": { + "type": [ + "string", + "null" + ], + "minItems": 3, + "maxItems": 10 + }, + "MinLengthArrayProp": { + "type": [ + "array", + "null" + ], + "items": { + "type": "integer" + }, + "minItems": 2 + }, + "MaxLengthArrayProp": { + "type": [ + "array", + "null" + ], + "items": { + "type": "integer" + }, + "maxItems": 8 + }, + "LengthArrayProp": { + "type": [ + "array", + "null" + ], + "items": { + "type": "integer" + }, + "minItems": 1, + "maxItems": 4 + } + } + } + """, +#else + """ + { + "type": "object", + "properties": { + "DisplayNameProp": { + "type": [ + "string", + "null" + ], + "title": "Display Name Title" + }, + "DateTimeProp": { + "type": "string", + "format": "date-time" + }, + "DateTimeOffsetProp": { + "type": "string", + "format": "date-time" + }, + "TimeSpanProp": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "format": "duration" + }, + "GuidProp": { + "type": "string", + "format": "uuid" + }, + "UriProp": { + "type": [ + "string", + "null" + ], + "format": "uri" + } + } + } + """, +#endif + JsonContext.Default.JsonElement), + AIJsonUtilities.CreateJsonSchema(typeof(CreateJsonSchema_IncorporatesTypesAndAnnotations_Type), serializerOptions: JsonContext.Default.Options)); + } + + private sealed class CreateJsonSchema_IncorporatesTypesAndAnnotations_Type + { + [DisplayName("Display Name Title")] + public string? DisplayNameProp { get; set; } + + public DateTime DateTimeProp { get; set; } + + public DateTimeOffset DateTimeOffsetProp { get; set; } + + public TimeSpan TimeSpanProp { get; set; } + + public Guid GuidProp { get; set; } + + public Uri? UriProp { get; set; } + +#if NET + [Base64String] + public string? Base64Prop { get; set; } + + [RegularExpression("[abc]|[def]")] + public string? RegexProp { get; set; } + + [EmailAddress] + public string? EmailProp { get; set; } + + [Url] + public Uri? UrlProp { get; set; } + + [Range(12, 34)] + public int RangeProp { get; set; } + + [AllowedValues("abc", "def", "ghi")] + public string? AllowedStringValuesProp { get; set; } + + [AllowedValues(1, 3, 5)] + public int AllowedInt32ValuesProp { get; set; } + + [AllowedValues(1.2, 3.4)] + public double AllowedDoubleValuesProp { get; set; } + + [DeniedValues("jkl", "mnop")] + public string? DeniedValuesProp { get; set; } + + [DataType(DataType.DateTime)] + public string? DataTypeDateTimeProp { get; set; } + + [DataType(DataType.Date)] + public string? DataTypeDateProp { get; set; } + + [DataType(DataType.Time)] + public string? DataTypeTimeProp { get; set; } + + [DataType(DataType.Duration)] + public string? DataTypeDurationProp { get; set; } + + [DataType(DataType.EmailAddress)] + public string? DataTypeEmailProp { get; set; } + + [DataType(DataType.Url)] + public Uri? DataTypeUrlProp { get; set; } + + [DataType(DataType.ImageUrl)] + public Uri? DataTypeImageUrlProp { get; set; } + + public DateOnly DateOnlyProp { get; set; } + public TimeOnly TimeOnlyProp { get; set; } + + [StringLength(100, MinimumLength = 10)] + public string? StringLengthProp { get; set; } + + [MinLength(5)] + public string? MinLengthProp { get; set; } + + [MaxLength(50)] + public string? MaxLengthProp { get; set; } + + [Length(3, 10)] + public string? LengthProp { get; set; } + + [MinLength(2)] + public int[]? MinLengthArrayProp { get; set; } + + [MaxLength(8)] + public int[]? MaxLengthArrayProp { get; set; } + + [Length(1, 4)] + public int[]? LengthArrayProp { get; set; } +#endif + } + [Fact] public static void AddAIContentType_DerivedAIContent() { @@ -863,9 +1197,10 @@ private class DerivedAIContent : AIContent public int DerivedValue { get; set; } } + [JsonSerializable(typeof(JsonElement))] + [JsonSerializable(typeof(CreateJsonSchema_IncorporatesTypesAndAnnotations_Type))] [JsonSerializable(typeof(DerivedAIContent))] [JsonSerializable(typeof(MyPoco))] - [JsonSerializable(typeof(PocoWithTypesWithOpenAIUnsupportedKeywords))] [JsonSerializable(typeof(MyEnumValue?))] private partial class JsonContext : JsonSerializerContext; diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index ab46a0e0c58..d84d767fd4c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -423,7 +424,13 @@ private async Task AvailableTools_SchemasAreAccepted(bool strict) AIFunctionFactory.Create((long l) => l, createOptions()), AIFunctionFactory.Create((char c) => c, createOptions()), AIFunctionFactory.Create((DateTime dt) => dt, createOptions()), - AIFunctionFactory.Create((DateTime? dt) => dt, createOptions()), + AIFunctionFactory.Create((DateTimeOffset? dt) => dt, createOptions()), + AIFunctionFactory.Create((TimeSpan ts) => ts, createOptions()), +#if NET + AIFunctionFactory.Create((DateOnly d) => d, createOptions()), + AIFunctionFactory.Create((TimeOnly t) => t, createOptions()), +#endif + AIFunctionFactory.Create((Uri uri) => uri, createOptions()), AIFunctionFactory.Create((Guid guid) => guid, createOptions()), AIFunctionFactory.Create((List list) => list, createOptions()), AIFunctionFactory.Create((int[] arr, ComplexObject? co) => arr, createOptions()), @@ -475,11 +482,45 @@ private sealed class CustomAIFunction(string name, string jsonSchema, IReadOnlyD private class ComplexObject { + [DisplayName("Something cool")] +#if NET + [DeniedValues("abc", "def", "default")] +#endif public string? SomeString { get; set; } +#if NET + [AllowedValues("abc", "def", "default")] +#endif public string AnotherString { get; set; } = "default"; +#if NET + [Range(25, 75)] +#endif public int Value { get; set; } + + [EmailAddress] + public string? Email { get; set; } + + [RegularExpression("[abc]")] + public string? RegexString { get; set; } + + [StringLength(42)] + public string MeasuredString { get; set; } = "default"; + +#if NET + [Length(1, 2)] +#endif + public int[]? MeasuredArray1 { get; set; } + +#if NET + [MinLength(1)] +#endif + public int[]? MeasuredArray2 { get; set; } + +#if NET + [MaxLength(10)] +#endif + public int[]? MeasuredArray3 { get; set; } } protected virtual bool SupportsParallelFunctionCalling => true; From 3f07308a0386aa6f27512635039f3ae3ddc2ac1a Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 25 Jun 2025 08:38:08 -0400 Subject: [PATCH 2/2] Stop suppressing existing format handling, and allow most annotations on netfx --- ...icrosoft.Extensions.AI.Abstractions.csproj | 1 + .../AIJsonUtilities.Schema.Create.cs | 141 +++++-------- .../OpenAIClientExtensions.cs | 39 ++-- ...ft.Extensions.AI.Abstractions.Tests.csproj | 1 + .../Utilities/AIJsonUtilitiesTests.cs | 197 ++++++++++++++---- 5 files changed, 234 insertions(+), 145 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj index 4e472e78473..f5472854def 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -36,6 +36,7 @@ + diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs index a58d1ea0c74..a9d3ac3e3ee 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs @@ -3,7 +3,7 @@ using System; using System.ComponentModel; -#if NET +#if NET || NETFRAMEWORK using System.ComponentModel.DataAnnotations; #endif using System.Diagnostics; @@ -21,6 +21,7 @@ #pragma warning disable S109 // Magic numbers should not be used #pragma warning disable S1075 // URIs should not be hardcoded #pragma warning disable S1121 // Assignments should not be made from within sub-expressions +#pragma warning disable S1199 // Nested block #pragma warning disable SA1118 // Parameter should not span multiple lines namespace Microsoft.Extensions.AI; @@ -41,16 +42,18 @@ public static partial class AIJsonUtilities private const string AdditionalPropertiesPropertyName = "additionalProperties"; private const string DefaultPropertyName = "default"; private const string RefPropertyName = "$ref"; +#if NET || NETFRAMEWORK private const string FormatPropertyName = "format"; -#if NET - private const string ContentEncodingPropertyName = "contentEncoding"; - private const string ContentMediaTypePropertyName = "contentMediaType"; private const string MinLengthStringPropertyName = "minLength"; private const string MaxLengthStringPropertyName = "maxLength"; private const string MinLengthCollectionPropertyName = "minItems"; private const string MaxLengthCollectionPropertyName = "maxItems"; private const string MinRangePropertyName = "minimum"; private const string MaxRangePropertyName = "maximum"; +#endif +#if NET + private const string ContentEncodingPropertyName = "contentEncoding"; + private const string ContentMediaTypePropertyName = "contentMediaType"; private const string MinExclusiveRangePropertyName = "exclusiveMinimum"; private const string MaxExclusiveRangePropertyName = "exclusiveMaximum"; #endif @@ -58,10 +61,6 @@ public static partial class AIJsonUtilities /// The uri used when populating the $schema keyword in created schemas. private const string SchemaKeywordUri = "https://json-schema.org/draft/2020-12/schema"; - // List of keywords used by JsonSchemaExporter but explicitly disallowed by some AI vendors. - // cf. https://platform.openai.com/docs/guides/structured-outputs#some-type-specific-keywords-are-not-yet-supported - private static readonly string[] _schemaKeywordsDisallowedByAIVendors = ["minLength", "maxLength", "pattern", "format"]; - /// /// Determines a JSON schema for the provided method. /// @@ -296,12 +295,6 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js objSchema.InsertAtStart(TypePropertyName, new JsonArray { (JsonNode)"string", (JsonNode)"null" }); } - // Filter potentially disallowed keywords. - foreach (string keyword in _schemaKeywordsDisallowedByAIVendors) - { - _ = objSchema.Remove(keyword); - } - // Some consumers of the JSON schema, including Ollama as of v0.3.13, don't understand // schemas with "type": [...], and only understand "type" being a single value. // In certain configurations STJ represents .NET numeric types as ["string", "number"], which will then lead to an error. @@ -334,7 +327,6 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js ConvertSchemaToObject(ref schema).InsertAtStart(SchemaPropertyName, (JsonNode)SchemaKeywordUri); } - ApplyDataTypeFormats(parameterName, ref schema, ctx); ApplyDataAnnotations(parameterName, ref schema, ctx); // Finally, apply any user-defined transformations if specified. @@ -365,43 +357,6 @@ static JsonObject ConvertSchemaToObject(ref JsonNode schema) } } - static void ApplyDataTypeFormats(string? parameterName, ref JsonNode schema, AIJsonSchemaCreateContext ctx) - { - Type t = ctx.TypeInfo.Type; - - if (Nullable.GetUnderlyingType(t) is { } underlyingType) - { - t = underlyingType; - } - - if (t == typeof(DateTime) || t == typeof(DateTimeOffset)) - { - ConvertSchemaToObject(ref schema)[FormatPropertyName] = "date-time"; - } -#if NET - else if (t == typeof(DateOnly)) - { - ConvertSchemaToObject(ref schema)[FormatPropertyName] = "date"; - } - else if (t == typeof(TimeOnly)) - { - ConvertSchemaToObject(ref schema)[FormatPropertyName] = "time"; - } -#endif - else if (t == typeof(TimeSpan)) - { - ConvertSchemaToObject(ref schema)[FormatPropertyName] = "duration"; - } - else if (t == typeof(Guid)) - { - ConvertSchemaToObject(ref schema)[FormatPropertyName] = "uuid"; - } - else if (t == typeof(Uri)) - { - ConvertSchemaToObject(ref schema)[FormatPropertyName] = "uri"; - } - } - void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSchemaCreateContext ctx) { if (ctx.GetCustomAttribute() is { } displayNameAttribute) @@ -409,12 +364,7 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche ConvertSchemaToObject(ref schema)[TitlePropertyName] ??= displayNameAttribute.DisplayName; } -#if NET - if (ctx.GetCustomAttribute() is { } base64Attribute) - { - ConvertSchemaToObject(ref schema)[ContentEncodingPropertyName] ??= "base64"; - } - +#if NET || NETFRAMEWORK if (ctx.GetCustomAttribute() is { } emailAttribute) { ConvertSchemaToObject(ref schema)[FormatPropertyName] ??= "email"; @@ -442,30 +392,6 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche obj[MaxLengthStringPropertyName] ??= stringLengthAttribute.MaximumLength; } - if (ctx.GetCustomAttribute() is { } lengthAttribute) - { - JsonObject obj = ConvertSchemaToObject(ref schema); - - if (obj[TypePropertyName] is JsonNode typeNode && typeNode.GetValueKind() is JsonValueKind.String && typeNode.GetValue() is "string") - { - if (lengthAttribute.MinimumLength > 0) - { - obj[MinLengthStringPropertyName] ??= lengthAttribute.MinimumLength; - } - - obj[MaxLengthStringPropertyName] ??= lengthAttribute.MaximumLength; - } - else - { - if (lengthAttribute.MinimumLength > 0) - { - obj[MinLengthCollectionPropertyName] ??= lengthAttribute.MinimumLength; - } - - obj[MaxLengthCollectionPropertyName] ??= lengthAttribute.MaximumLength; - } - } - if (ctx.GetCustomAttribute() is { } minLengthAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); @@ -502,7 +428,11 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche { case int minInt32 when rangeAttribute.Maximum is int maxInt32: maxNode = maxInt32; - if (!rangeAttribute.MinimumIsExclusive || minInt32 > 0) + if ( +#if NET + !rangeAttribute.MinimumIsExclusive || +#endif + minInt32 > 0) { minNode = minInt32; } @@ -511,7 +441,11 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche case double minDouble when rangeAttribute.Maximum is double maxDouble: maxNode = maxDouble; - if (!rangeAttribute.MinimumIsExclusive || minDouble > 0) + if ( +#if NET + !rangeAttribute.MinimumIsExclusive || +#endif + minDouble > 0) { minNode = minDouble; } @@ -526,11 +460,13 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche if (minNode is not null) { +#if NET if (rangeAttribute.MinimumIsExclusive) { obj[MinExclusiveRangePropertyName] ??= minNode; } else +#endif { obj[MinRangePropertyName] ??= minNode; } @@ -538,16 +474,49 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche if (maxNode is not null) { +#if NET if (rangeAttribute.MaximumIsExclusive) { obj[MaxExclusiveRangePropertyName] ??= maxNode; } else +#endif { obj[MaxRangePropertyName] ??= maxNode; } } } +#endif + +#if NET + if (ctx.GetCustomAttribute() is { } base64Attribute) + { + ConvertSchemaToObject(ref schema)[ContentEncodingPropertyName] ??= "base64"; + } + + if (ctx.GetCustomAttribute() is { } lengthAttribute) + { + JsonObject obj = ConvertSchemaToObject(ref schema); + + if (obj[TypePropertyName] is JsonNode typeNode && typeNode.GetValueKind() is JsonValueKind.String && typeNode.GetValue() is "string") + { + if (lengthAttribute.MinimumLength > 0) + { + obj[MinLengthStringPropertyName] ??= lengthAttribute.MinimumLength; + } + + obj[MaxLengthStringPropertyName] ??= lengthAttribute.MaximumLength; + } + else + { + if (lengthAttribute.MinimumLength > 0) + { + obj[MinLengthCollectionPropertyName] ??= lengthAttribute.MinimumLength; + } + + obj[MaxLengthCollectionPropertyName] ??= lengthAttribute.MaximumLength; + } + } if (ctx.GetCustomAttribute() is { } allowedValuesAttribute) { @@ -613,10 +582,6 @@ static JsonArray CreateJsonArray(object?[] values, JsonSerializerOptions seriali obj[FormatPropertyName] ??= "time"; break; - case DataType.Duration: - obj[FormatPropertyName] ??= "duration"; - break; - case DataType.EmailAddress: obj[FormatPropertyName] ??= "email"; break; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index 3f6fbd8b897..3b55280f5cd 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -13,7 +13,9 @@ using OpenAI.Embeddings; using OpenAI.Responses; +#pragma warning disable S103 // Lines should not be too long #pragma warning disable S1067 // Expressions should not be too complex +#pragma warning disable SA1515 // Single-line comment should be preceded by blank line #pragma warning disable CA1305 // Specify IFormatProvider namespace Microsoft.Extensions.AI; @@ -31,7 +33,8 @@ public static class OpenAIClientExtensions internal static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer"); /// - /// Gets the JSON schema transformer cache conforming to OpenAI strict restrictions per https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas. + /// Gets the JSON schema transformer cache conforming to OpenAI strict / structured output restrictions per + /// https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas. /// internal static AIJsonSchemaTransformCache StrictSchemaTransformCache { get; } = new(new() { @@ -42,15 +45,30 @@ public static class OpenAIClientExtensions TransformSchemaNode = (ctx, node) => { // Move content from common but unsupported properties to description. In particular, we focus on properties that - // the AIJsonUtilities schema generator might produce. - // Based on guidance at: - // https://platform.openai.com/docs/guides/structured-outputs#supported-properties + // the AIJsonUtilities schema generator might produce and/or that are explicitly mentioned in the OpenAI documentation. if (node is JsonObject schemaObj) { StringBuilder? additionalDescription = null; - foreach (string propName in (ReadOnlySpan)["contentEncoding", "contentMediaType", "minLength", "maxLength", "not"]) + ReadOnlySpan unsupportedProperties = + [ + // Produced by AIJsonUtilities but not in allow list at https://platform.openai.com/docs/guides/structured-outputs#supported-properties: + "contentEncoding", "contentMediaType", "not", + + // Explicitly mentioned at https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#key-ordering as being unsupported with some models: + "minLength", "maxLength", "pattern", "format", + "minimum", "maximum", "multipleOf", + "patternProperties", + "minItems", "maxItems", + + // Explicitly mentioned at https://learn.microsoft.com/azure/ai-services/openai/how-to/structured-outputs?pivots=programming-language-csharp&tabs=python-secure%2Cdotnet-entra-id#unsupported-type-specific-keywords + // as being unsupported with Azure OpenAI: + "unevaluatedProperties", "propertyNames", "minProperties", "maxProperties", + "unevaluatedItems", "contains", "minContains", "maxContains", "uniqueItems", + ]; + + foreach (string propName in unsupportedProperties) { if (schemaObj[propName] is { } propNode) { @@ -59,17 +77,6 @@ public static class OpenAIClientExtensions } } - if (schemaObj["format"] is { } formatNode) - { - if (formatNode.GetValueKind() != JsonValueKind.String || - formatNode.GetValue() is not string format || - format is not ("date-time" or "date" or "time" or "duration" or "email" or "hostname" or "ipv4" or "ipv6" or "uuid")) - { - _ = schemaObj.Remove("format"); - AppendLine(ref additionalDescription, "format", formatNode); - } - } - if (additionalDescription is not null) { schemaObj["description"] = schemaObj["description"] is { } descriptionNode && descriptionNode.GetValueKind() == JsonValueKind.String ? diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj index 0e608d0d953..d2ae2802123 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj @@ -38,5 +38,6 @@ + diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs index 3a91f48a7cb..19b2fc8bb48 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -4,11 +4,10 @@ using System; using System.Collections.Generic; using System.ComponentModel; -#if NET using System.ComponentModel.DataAnnotations; -#endif using System.Linq; using System.Reflection; +using System.Runtime.InteropServices; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; @@ -16,6 +15,7 @@ using System.Text.Json.Serialization.Metadata; using System.Threading; using Microsoft.Extensions.AI.JsonSchemaExporter; +using Microsoft.TestUtilities; using Xunit; #pragma warning disable SA1114 // parameter list should follow declaration @@ -405,6 +405,7 @@ public static void CreateJsonSchema_ValidateWithTestData(ITestData testData) JsonElement schema = AIJsonUtilities.CreateJsonSchema(testData.Type, serializerOptions: options, inferenceOptions: createOptions); JsonNode? schemaAsNode = JsonSerializer.SerializeToNode(schema, options); + // NOTE: This is not validating the schemas match, only that they have the same top-level kind. Assert.NotNull(schemaAsNode); Assert.Equal(testData.ExpectedJsonSchema.GetValueKind(), schemaAsNode.GetValueKind()); @@ -447,11 +448,15 @@ public static void CreateJsonSchema_NullableEnum_IncludesTypeKeyword() AssertDeepEquals(expectedSchema, schema); } - [Fact] - public static void CreateJsonSchema_IncorporatesTypesAndAnnotations() + [ConditionalFact] + public static void CreateJsonSchema_IncorporatesTypesAndAnnotations_Net() { + if (RuntimeInformation.FrameworkDescription.Contains(".NET Framework")) + { + return; + } + AssertDeepEquals(JsonSerializer.Deserialize( -#if NET """ { "type": "object", @@ -474,7 +479,7 @@ public static void CreateJsonSchema_IncorporatesTypesAndAnnotations() "TimeSpanProp": { "$comment": "Represents a System.TimeSpan value.", "type": "string", - "format": "duration" + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" }, "GuidProp": { "type": "string", @@ -579,13 +584,6 @@ public static void CreateJsonSchema_IncorporatesTypesAndAnnotations() ], "format": "time" }, - "DataTypeDurationProp": { - "type": [ - "string", - "null" - ], - "format": "duration" - }, "DataTypeEmailProp": { "type": [ "string", @@ -680,7 +678,21 @@ public static void CreateJsonSchema_IncorporatesTypesAndAnnotations() } } """, -#else + JsonContext.Default.JsonElement), + AIJsonUtilities.CreateJsonSchema(typeof(CreateJsonSchema_IncorporatesTypesAndAnnotations_Type), serializerOptions: JsonContext.Default.Options)); + } + + // .NET Framework only has a subset of the available data annotation attributes. + // .NET Standard doesn't have any (the M.E.AI.Abstractions library doesn't reference the additional package). + [ConditionalFact] + public static void CreateJsonSchema_IncorporatesTypesAndAnnotations_NetFx() + { + if (!RuntimeInformation.FrameworkDescription.Contains(".NET Framework")) + { + return; + } + + AssertDeepEquals(JsonSerializer.Deserialize( """ { "type": "object", @@ -703,7 +715,7 @@ public static void CreateJsonSchema_IncorporatesTypesAndAnnotations() "TimeSpanProp": { "$comment": "Represents a System.TimeSpan value.", "type": "string", - "format": "duration" + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" }, "GuidProp": { "type": "string", @@ -715,11 +727,116 @@ public static void CreateJsonSchema_IncorporatesTypesAndAnnotations() "null" ], "format": "uri" + }, + "RegexProp": { + "type": [ + "string", + "null" + ], + "pattern": "[abc]|[def]" + }, + "EmailProp": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "UrlProp": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "RangeProp": { + "type": "integer", + "minimum": 12, + "maximum": 34 + }, + "DataTypeDateTimeProp": { + "type": [ + "string", + "null" + ] + }, + "DataTypeDateProp": { + "type": [ + "string", + "null" + ] + }, + "DataTypeTimeProp": { + "type": [ + "string", + "null" + ] + }, + "DataTypeEmailProp": { + "type": [ + "string", + "null" + ] + }, + "DataTypeUrlProp": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "DataTypeImageUrlProp": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "StringLengthProp": { + "type": [ + "string", + "null" + ], + "minLength": 10, + "maxLength": 100 + }, + "MinLengthProp": { + "type": [ + "string", + "null" + ], + "minItems": 5 + }, + "MaxLengthProp": { + "type": [ + "string", + "null" + ], + "maxItems": 50 + }, + "MinLengthArrayProp": { + "type": [ + "array", + "null" + ], + "items": { + "type": "integer" + }, + "minItems": 2 + }, + "MaxLengthArrayProp": { + "type": [ + "array", + "null" + ], + "items": { + "type": "integer" + }, + "maxItems": 8 } } } """, -#endif JsonContext.Default.JsonElement), AIJsonUtilities.CreateJsonSchema(typeof(CreateJsonSchema_IncorporatesTypesAndAnnotations_Type), serializerOptions: JsonContext.Default.Options)); } @@ -739,10 +856,6 @@ private sealed class CreateJsonSchema_IncorporatesTypesAndAnnotations_Type public Uri? UriProp { get; set; } -#if NET - [Base64String] - public string? Base64Prop { get; set; } - [RegularExpression("[abc]|[def]")] public string? RegexProp { get; set; } @@ -755,18 +868,6 @@ private sealed class CreateJsonSchema_IncorporatesTypesAndAnnotations_Type [Range(12, 34)] public int RangeProp { get; set; } - [AllowedValues("abc", "def", "ghi")] - public string? AllowedStringValuesProp { get; set; } - - [AllowedValues(1, 3, 5)] - public int AllowedInt32ValuesProp { get; set; } - - [AllowedValues(1.2, 3.4)] - public double AllowedDoubleValuesProp { get; set; } - - [DeniedValues("jkl", "mnop")] - public string? DeniedValuesProp { get; set; } - [DataType(DataType.DateTime)] public string? DataTypeDateTimeProp { get; set; } @@ -776,9 +877,6 @@ private sealed class CreateJsonSchema_IncorporatesTypesAndAnnotations_Type [DataType(DataType.Time)] public string? DataTypeTimeProp { get; set; } - [DataType(DataType.Duration)] - public string? DataTypeDurationProp { get; set; } - [DataType(DataType.EmailAddress)] public string? DataTypeEmailProp { get; set; } @@ -788,9 +886,6 @@ private sealed class CreateJsonSchema_IncorporatesTypesAndAnnotations_Type [DataType(DataType.ImageUrl)] public Uri? DataTypeImageUrlProp { get; set; } - public DateOnly DateOnlyProp { get; set; } - public TimeOnly TimeOnlyProp { get; set; } - [StringLength(100, MinimumLength = 10)] public string? StringLengthProp { get; set; } @@ -800,15 +895,35 @@ private sealed class CreateJsonSchema_IncorporatesTypesAndAnnotations_Type [MaxLength(50)] public string? MaxLengthProp { get; set; } - [Length(3, 10)] - public string? LengthProp { get; set; } - [MinLength(2)] public int[]? MinLengthArrayProp { get; set; } [MaxLength(8)] public int[]? MaxLengthArrayProp { get; set; } +#if NET + [Base64String] + public string? Base64Prop { get; set; } + + [AllowedValues("abc", "def", "ghi")] + public string? AllowedStringValuesProp { get; set; } + + [AllowedValues(1, 3, 5)] + public int AllowedInt32ValuesProp { get; set; } + + [AllowedValues(1.2, 3.4)] + public double AllowedDoubleValuesProp { get; set; } + + [DeniedValues("jkl", "mnop")] + public string? DeniedValuesProp { get; set; } + + public DateOnly DateOnlyProp { get; set; } + + public TimeOnly TimeOnlyProp { get; set; } + + [Length(3, 10)] + public string? LengthProp { get; set; } + [Length(1, 4)] public int[]? LengthArrayProp { get; set; } #endif