From 12386e0c8ac2d50ab838bfc4e291d5ccfbcab6a8 Mon Sep 17 00:00:00 2001 From: desjoerd Date: Sun, 14 Jun 2026 20:48:32 +0200 Subject: [PATCH 1/2] Update swashbuckle example to the latest version. --- .config/dotnet-tools.json | 2 +- .../OptionalValues.Examples.Swashbuckle.csproj | 14 ++++++++------ .../OptionalValues.Examples.Swashbuckle/Program.cs | 3 ++- .../openapi.yaml | 10 ++++------ 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 340f52a..ed6876d 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -8,7 +8,7 @@ "rollForward": false }, "swashbuckle.aspnetcore.cli": { - "version": "7.0.0", + "version": "10.2.1", "commands": ["swagger"], "rollForward": false } diff --git a/examples/OptionalValues.Examples.Swashbuckle/OptionalValues.Examples.Swashbuckle.csproj b/examples/OptionalValues.Examples.Swashbuckle/OptionalValues.Examples.Swashbuckle.csproj index 6bcb941..320edab 100644 --- a/examples/OptionalValues.Examples.Swashbuckle/OptionalValues.Examples.Swashbuckle.csproj +++ b/examples/OptionalValues.Examples.Swashbuckle/OptionalValues.Examples.Swashbuckle.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable @@ -10,19 +10,21 @@ - + - + - - + + - + \ No newline at end of file diff --git a/examples/OptionalValues.Examples.Swashbuckle/Program.cs b/examples/OptionalValues.Examples.Swashbuckle/Program.cs index 824292b..0e4726c 100644 --- a/examples/OptionalValues.Examples.Swashbuckle/Program.cs +++ b/examples/OptionalValues.Examples.Swashbuckle/Program.cs @@ -1,4 +1,4 @@ -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using OptionalValues; using OptionalValues.DataAnnotations; @@ -9,6 +9,7 @@ builder.Services.AddSwaggerGen(options => { options.SwaggerDoc("v1", new OpenApiInfo { Title = "OptionalValues.Examples.Swashbuckle", Version = "1.0" }); + options.SchemaGeneratorOptions.SupportNonNullableReferenceTypes = true; }); // Add OptionalValue support to Swashbuckle builder.Services.AddSwaggerGenOptionalValueSupport(); diff --git a/examples/OptionalValues.Examples.Swashbuckle/openapi.yaml b/examples/OptionalValues.Examples.Swashbuckle/openapi.yaml index 8650523..2edb425 100644 --- a/examples/OptionalValues.Examples.Swashbuckle/openapi.yaml +++ b/examples/OptionalValues.Examples.Swashbuckle/openapi.yaml @@ -1,4 +1,4 @@ -openapi: 3.0.4 +openapi: '3.1.1' info: title: OptionalValues.Examples.Swashbuckle version: '1.0' @@ -31,7 +31,6 @@ components: properties: street: type: string - nullable: true city: type: string state: @@ -39,7 +38,6 @@ components: type: string zip: type: string - nullable: true additionalProperties: false Company: required: @@ -55,7 +53,6 @@ components: maxLength: 50 minLength: 0 type: string - nullable: true contact: $ref: '#/components/schemas/Person' additionalProperties: false @@ -64,7 +61,6 @@ components: properties: name: type: string - nullable: true age: maximum: 120 minimum: 0 @@ -72,4 +68,6 @@ components: format: int32 address: $ref: '#/components/schemas/Address' - additionalProperties: false \ No newline at end of file + additionalProperties: false +tags: + - name: Example \ No newline at end of file From c6a0979dd4501f231849d37b3e6c3afd4263934e Mon Sep 17 00:00:00 2001 From: desjoerd Date: Sun, 14 Jun 2026 21:01:09 +0200 Subject: [PATCH 2/2] Add test to verify swashbuckle nullable behavior with OptionalValue. And align OptionalValueDataContractResolver to work more like OpenApi Schema Transformer. --- .../OptionalValueDataContractResolver.cs | 70 ++++++++++++------- .../SchemaGeneratorTest.cs | 48 ++++++++++++- 2 files changed, 88 insertions(+), 30 deletions(-) diff --git a/src/OptionalValues.Swashbuckle/OptionalValueDataContractResolver.cs b/src/OptionalValues.Swashbuckle/OptionalValueDataContractResolver.cs index 18f962d..179ea75 100644 --- a/src/OptionalValues.Swashbuckle/OptionalValueDataContractResolver.cs +++ b/src/OptionalValues.Swashbuckle/OptionalValueDataContractResolver.cs @@ -44,60 +44,76 @@ public virtual DataContract GetDataContractForType(Type type) } DataContract? dataContract = _inner.GetDataContractForType(effectiveType); - if (dataContract is { DataType: DataType.Object }) + if (dataContract is not + { DataType: DataType.Object }) { - var effectiveProperties = new List(); - foreach (DataProperty property in dataContract.ObjectProperties) - { - DataProperty effectiveProperty = property; - if (OptionalValue.IsOptionalValueType(property.MemberType)) - { - Type underLyingType = OptionalValue.GetUnderlyingType(property.MemberType); - var isNullable = Nullable.GetUnderlyingType(underLyingType) != null || GetNullabilityFromRuntimeInformationFlags(property.MemberInfo); + return dataContract; + } - var isRequired = property.MemberInfo.GetCustomAttributes(true).Any(a => a.GetType().FullName == "OptionalValues.DataAnnotations.SpecifiedAttribute"); - effectiveProperty = new DataProperty(property.Name, property.MemberType, isRequired, isNullable, property.IsReadOnly, property.IsWriteOnly, property.MemberInfo); - } + var effectiveProperties = new List(); + foreach (DataProperty property in dataContract.ObjectProperties) + { + DataProperty effectiveProperty = property; + if (OptionalValue.IsOptionalValueType(property.MemberType)) + { + Type underLyingType = OptionalValue.GetUnderlyingType(property.MemberType); + var isNullable = Nullable.GetUnderlyingType(underLyingType) != null + || GetOptionalValueIsNullable(property.MemberInfo); - effectiveProperties.Add(effectiveProperty); + var isRequired = property.MemberInfo.GetCustomAttributes(true).Any(a => a.GetType().FullName == "OptionalValues.DataAnnotations.SpecifiedAttribute"); + effectiveProperty = new DataProperty(property.Name, property.MemberType, isRequired, isNullable, property.IsReadOnly, property.IsWriteOnly, property.MemberInfo); } - dataContract = DataContract.ForObject( - dataContract.UnderlyingType, - effectiveProperties, - dataContract.ObjectExtensionDataType, - dataContract.ObjectTypeNameProperty, - dataContract.ObjectTypeNameProperty, - dataContract.JsonConverter); + effectiveProperties.Add(effectiveProperty); } + dataContract = DataContract.ForObject( + dataContract.UnderlyingType, + effectiveProperties, + dataContract.ObjectExtensionDataType, + dataContract.ObjectTypeNameProperty, + dataContract.ObjectTypeNameProperty, + dataContract.JsonConverter); + return dataContract; } - private static bool GetNullabilityFromRuntimeInformationFlags(MemberInfo memberInfo) + private static bool GetOptionalValueIsNullable(ICustomAttributeProvider? memberInfo) { - NullabilityInfo? nullabilityInfo = GetNullabilityInfo(memberInfo); + NullabilityInfo? nullabilityInfo = GetOptionalValueNullabilityInfo(memberInfo); if (nullabilityInfo == null) { return false; } - if (OptionalValue.IsOptionalValueType(nullabilityInfo.Type)) + return nullabilityInfo.ReadState == NullabilityState.Nullable; + } + + private static NullabilityInfo? GetOptionalValueNullabilityInfo(ICustomAttributeProvider? memberInfo) + { + if (memberInfo == null) + { + return null; + } + + NullabilityInfo? nullabilityInfo = GetNullabilityInfo(memberInfo); + if (nullabilityInfo == null + || !OptionalValue.IsOptionalValueType(nullabilityInfo.Type)) { - return nullabilityInfo.GenericTypeArguments[0].ReadState == NullabilityState.Nullable; + return null; } - return nullabilityInfo.ReadState == NullabilityState.Nullable; + return nullabilityInfo.GenericTypeArguments[0]; } - private static NullabilityInfo? GetNullabilityInfo(MemberInfo memberInfo) + private static NullabilityInfo? GetNullabilityInfo(ICustomAttributeProvider memberInfo) { var nullabilityInfoContext = new NullabilityInfoContext(); - return memberInfo switch { PropertyInfo propertyInfo => nullabilityInfoContext.Create(propertyInfo), FieldInfo fieldInfo => nullabilityInfoContext.Create(fieldInfo), + ParameterInfo parameterInfo => nullabilityInfoContext.Create(parameterInfo), _ => null, }; } diff --git a/test/OptionalValues.Swashbuckle.V10.Tests/SchemaGeneratorTest.cs b/test/OptionalValues.Swashbuckle.V10.Tests/SchemaGeneratorTest.cs index 09c3fcf..3b1a9ea 100644 --- a/test/OptionalValues.Swashbuckle.V10.Tests/SchemaGeneratorTest.cs +++ b/test/OptionalValues.Swashbuckle.V10.Tests/SchemaGeneratorTest.cs @@ -50,16 +50,46 @@ public void Should_Generate_The_Same_Schema_With_OptionalValues() schemaDefault.Properties?.Count.ShouldBe(4); } + [Fact(Skip = "Skip this test until Swashbuckle does not override nullability for ValueTypes, (that is, OptionalValue)")] + public void Should_Generate_The_Same_Schema_For_Nullable_Reference_Types_With_OptionalValues() + { + var schemaRepositoryForOptionalValues = new SchemaRepository(); + var schemaRepositoryForDefault = new SchemaRepository(); + + IOpenApiSchema schemaOptionalValuesAsRef = SchemaGeneratorOptionalValues.GenerateSchema(typeof(ExamplesOptionalValues.NullableReferences), schemaRepositoryForOptionalValues); + schemaOptionalValuesAsRef.ShouldNotBeNull(); + + IOpenApiSchema schemaDefaultAsRef = SchemaGeneratorDefault.GenerateSchema(typeof(ExamplesPlain.NullableReferences), schemaRepositoryForDefault); + schemaDefaultAsRef.ShouldNotBeNull(); + + var refId1 = ((OpenApiSchemaReference)schemaOptionalValuesAsRef).Reference.Id; + var refId2 = ((OpenApiSchemaReference)schemaDefaultAsRef).Reference.Id; + + IOpenApiSchema schemaOptionalValues = schemaRepositoryForOptionalValues.Schemas[refId1!]; + IOpenApiSchema schemaDefault = schemaRepositoryForDefault.Schemas[refId2!]; + + var schemaOptionalValuesJson = SerializeSchema(schemaOptionalValues); + var schemaDefaultJson = SerializeSchema(schemaDefault); + + schemaOptionalValuesJson.ShouldBe(schemaDefaultJson); + schemaOptionalValues.Properties?.Count.ShouldBe(2); + schemaDefault.Properties?.Count.ShouldBe(2); + } + [Fact] - public void OptionalValue_Support_Should_Not_Change_Behavior() + public void OptionalValue_Support_Should_Not_Change_Default_Behavior() { var schemaRepositoryForOptionalValues = new SchemaRepository(); var schemaRepositoryForDefault = new SchemaRepository(); - IOpenApiSchema schemaOptionalValuesAsRef = SchemaGeneratorOptionalValues.GenerateSchema(typeof(ExamplesPlain.Primitives), schemaRepositoryForOptionalValues); + // Generate the schema for the ExamplesPlain.Primitives type with both SchemaGenerators. + // They should generate the same schema, because the OptionalValue support should not change the behavior for types that are not OptionalValue + IOpenApiSchema schemaOptionalValuesAsRef = SchemaGeneratorOptionalValues + .GenerateSchema(typeof(ExamplesPlain.Primitives), schemaRepositoryForOptionalValues); schemaOptionalValuesAsRef.ShouldNotBeNull(); - IOpenApiSchema schemaDefaultAsRef = SchemaGeneratorDefault.GenerateSchema(typeof(ExamplesPlain.Primitives), schemaRepositoryForDefault); + IOpenApiSchema schemaDefaultAsRef = SchemaGeneratorDefault + .GenerateSchema(typeof(ExamplesPlain.Primitives), schemaRepositoryForDefault); schemaDefaultAsRef.ShouldNotBeNull(); var refId1 = ((OpenApiSchemaReference)schemaOptionalValuesAsRef).Reference.Id; @@ -92,6 +122,12 @@ public class Primitives public OptionalValue BoolValue { get; set; } public OptionalValue GuidValue { get; set; } } + + public class NullableReferences + { + public OptionalValue StringValue { get; set; } + public OptionalValue NullableStringValue { get; set; } + } } private static class ExamplesPlain @@ -103,5 +139,11 @@ public class Primitives public bool BoolValue { get; set; } public Guid GuidValue { get; set; } } + + public class NullableReferences + { + public string StringValue { get; set; } = null!; + public string? NullableStringValue { get; set; } + } } }