Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,10 @@ private static void ApplySchemaAttribute(IOpenApiSchema schema, SwaggerSchemaAtt
if (schemaAttribute.NullableFlag is { } nullable)
{
// See https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/3387
// See https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/3936
if (nullable)
{
concrete.Type ??= JsonSchemaType.Null;
concrete.Type ??= JsonSchemaType.Boolean | JsonSchemaType.Integer | JsonSchemaType.Number | JsonSchemaType.String | JsonSchemaType.Object | JsonSchemaType.Array;
concrete.Type |= JsonSchemaType.Null;
}
else if (concrete.Type.HasValue)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ private IOpenApiSchema GenerateSchemaForType(Type modelType, SchemaRepository sc
: GenerateConcreteSchema(dataContract, schemaRepository);

ApplyFilters(schema, modelType, schemaRepository);

if (Nullable.GetUnderlyingType(modelType) != null && schema is OpenApiSchema concrete)
{
SetNullable(concrete, true);
Expand Down Expand Up @@ -632,10 +632,29 @@ private System.Text.Json.Nodes.JsonNode GenerateDefaultValue(
private static void SetNullable(OpenApiSchema schema, bool nullable)
{
// See https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/3387
// See https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/3936
if (nullable)
{
schema.Type ??= JsonSchemaType.Null;
schema.Type |= JsonSchemaType.Null;
if (schema.AllOf is { Count: > 0 } allOf)
{
schema.Type ??= JsonSchemaType.Null;
}
else if (schema.AnyOf is { Count: > 0 } anyOf)
{
anyOf.Add(new OpenApiSchema { Type = JsonSchemaType.Null });
}
else if (schema.OneOf is { Count: > 0 } oneOf)
{
oneOf.Add(new OpenApiSchema { Type = JsonSchemaType.Null });
}
else
{
schema.Type ??= JsonSchemaType.Boolean | JsonSchemaType.Integer | JsonSchemaType.Number | JsonSchemaType.String | JsonSchemaType.Object | JsonSchemaType.Array;
}
if (schema.Type.HasValue)
{
schema.Type |= JsonSchemaType.Null;
}
}
else if (schema.Type.HasValue)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public void GenerateSchema_GeneratesFileSchema_BinaryStringResultType(Type type)
Assert.Equal("binary", schema.Format);
}

public static TheoryData<Type, JsonSchemaType, string> PrimitiveTypeData => new()
public static TheoryData<Type, JsonSchemaType?, string> PrimitiveTypeData => new()
{
{ typeof(bool), JsonSchemaTypes.Boolean, null },
{ typeof(byte), JsonSchemaTypes.Integer, "int32" },
Expand Down Expand Up @@ -67,13 +67,14 @@ public void GenerateSchema_GeneratesFileSchema_BinaryStringResultType(Type type)
{ typeof(Int128?), JsonSchemaTypes.Integer | JsonSchemaType.Null, "int128" },
{ typeof(UInt128), JsonSchemaTypes.Integer, "int128" },
{ typeof(UInt128?), JsonSchemaTypes.Integer | JsonSchemaType.Null, "int128" },
{ typeof(object), null, null },
};

[Theory]
[MemberData(nameof(PrimitiveTypeData))]
public void GenerateSchema_GeneratesPrimitiveSchema_IfPrimitiveOrNullablePrimitiveType(
Type type,
JsonSchemaType expectedSchemaType,
JsonSchemaType? expectedSchemaType,
string expectedFormat)
{
var schema = Subject().GenerateSchema(type, new SchemaRepository());
Expand Down Expand Up @@ -122,9 +123,13 @@ public void GenerateSchema_DedupesEnumValues_IfEnumTypeHasDuplicateValues()
public static TheoryData<Type, JsonSchemaType?> CollectionTypeData => new()
{
{ typeof(IDictionary<string, int>), JsonSchemaTypes.Integer },
{ typeof(IDictionary<string, int?>), JsonSchemaTypes.Integer | JsonSchemaType.Null },
{ typeof(IDictionary<EmptyIntEnum, int>), JsonSchemaTypes.Integer },
{ typeof(IReadOnlyDictionary<string, bool>), JsonSchemaTypes.Boolean },
{ typeof(IReadOnlyDictionary<string, bool?>), JsonSchemaTypes.Boolean | JsonSchemaType.Null },
{ typeof(IDictionary), null },
{ typeof(IDictionary<string, object>), null },
{ typeof(IDictionary<string, object?>), null },
{ typeof(ExpandoObject), null },
};

Expand All @@ -141,6 +146,109 @@ public void GenerateSchema_GeneratesDictionarySchema_IfDictionaryType(
Assert.NotNull(schema.AdditionalProperties);
Assert.Equal(expectedAdditionalPropertiesType, schema.AdditionalProperties.Type);
}

public static TheoryData<Type, JsonSchemaType?, bool> DictionaryWrappersData => new()
{
{ typeof(TestRecordStringInteger), JsonSchemaTypes.Integer, false },
{ typeof(TestRecordStringIntegerNullable), JsonSchemaTypes.Integer | JsonSchemaType.Null, false },
{ typeof(TestRecordEmptyIntEnumInteger), JsonSchemaTypes.Integer, false },
{ typeof(TestRecordStringBoolean), JsonSchemaTypes.Boolean, false },
{ typeof(TestRecordStringBooleanNullable), JsonSchemaTypes.Boolean | JsonSchemaType.Null, false },
{ typeof(TestRecordDictionary), null, false },
{ typeof(TestRecordStringObject), null, false },
{ typeof(TestRecordStringObjectNullable), JsonSchemaTypes.Null | JsonSchemaTypes.Boolean | JsonSchemaTypes.Integer | JsonSchemaTypes.Number | JsonSchemaTypes.String | JsonSchemaTypes.Array | JsonSchemaTypes.Object, false },
{ typeof(TestRecordExpandoObject), null, false },
{ typeof(TestRecordStringComplexType), null, true },
{ typeof(TestRecordStringComplexTypeNullable), null, true },
};

[Theory]
[MemberData(nameof(DictionaryWrappersData))]
public void GenerateSchema_GeneratesDictionarySchema_IfDictionaryTypeMember(
Type type,
JsonSchemaType? expectedAdditionalPropertiesType,
bool additionalPropertiesContainsReference)
{
var memberInfo = type.GetProperties().First();
var schemaRepository = new SchemaRepository();
var schema = Subject(configureGenerator: g => g.SupportNonNullableReferenceTypes = true)
.GenerateSchema(memberInfo.PropertyType, schemaRepository, memberInfo: memberInfo);

Assert.Equal(JsonSchemaTypes.Object, schema.Type);
Assert.True(schema.AdditionalPropertiesAllowed);
Assert.NotNull(schema.AdditionalProperties);

if (additionalPropertiesContainsReference)
{
var additionalPropertiesReference = Assert.IsType<OpenApiSchemaReference>(schema.AdditionalProperties);
Assert.Contains(additionalPropertiesReference.Reference.Id, schemaRepository.Schemas.Keys);
return;
}

Assert.Equal(expectedAdditionalPropertiesType, schema.AdditionalProperties.Type);
}

[Fact]
public void GenerateSchema_SetsNullableFlag_IfMemberSchemaHasAnyOf()
{
var subject = Subject(configureGenerator: c =>
{
c.SupportNonNullableReferenceTypes = true;
c.CustomTypeMappings.Add(typeof(CustomAnyOfType), () => new OpenApiSchema
{
AnyOf =
[
new OpenApiSchema { Type = JsonSchemaTypes.String }
]
});
});
var schemaRepository = new SchemaRepository();

var referenceSchema = Assert.IsType<OpenApiSchemaReference>(subject.GenerateSchema(typeof(TypeWithNullableCustomAnyOfProperty), schemaRepository));
Assert.NotNull(referenceSchema.Reference);
var schemaReference = referenceSchema.Reference!;
Assert.NotNull(schemaReference.Id);
var schemaId = schemaReference.Id!;
var generatedSchema = schemaRepository.Schemas[schemaId];
var properties = Assert.IsType<Dictionary<string, IOpenApiSchema>>(generatedSchema.Properties);
var propertySchema = properties[nameof(TypeWithNullableCustomAnyOfProperty.Property)];

Assert.NotNull(propertySchema.AnyOf);
Assert.Equal(2, propertySchema.AnyOf.Count);
Assert.Contains(propertySchema.AnyOf, s => s.Type == JsonSchemaType.Null);
Assert.Null(propertySchema.Type);
}

[Fact]
public void GenerateSchema_SetsNullableFlag_IfMemberSchemaHasOneOf()
{
var subject = Subject(configureGenerator: c =>
{
c.SupportNonNullableReferenceTypes = true;
c.CustomTypeMappings.Add(typeof(CustomOneOfType), () => new OpenApiSchema
{
OneOf =
[
new OpenApiSchema { Type = JsonSchemaTypes.String }
]
});
});
var schemaRepository = new SchemaRepository();

var referenceSchema = Assert.IsType<OpenApiSchemaReference>(subject.GenerateSchema(typeof(TypeWithNullableCustomOneOfProperty), schemaRepository));
Assert.NotNull(referenceSchema.Reference);
var schemaReference = referenceSchema.Reference!;
Assert.NotNull(schemaReference.Id);
var schemaId = schemaReference.Id!;
var generatedSchema = schemaRepository.Schemas[schemaId];
var properties = Assert.IsType<Dictionary<string, IOpenApiSchema>>(generatedSchema.Properties);
var propertySchema = properties[nameof(TypeWithNullableCustomOneOfProperty.Property)];

Assert.NotNull(propertySchema.OneOf);
Assert.Equal(2, propertySchema.OneOf.Count);
Assert.Contains(propertySchema.OneOf, s => s.Type == JsonSchemaType.Null);
Assert.Null(propertySchema.Type);
}
#nullable restore

[Fact]
Expand Down Expand Up @@ -414,18 +522,6 @@ public void GenerateSchema_SetsReadOnlyAndWriteOnlyFlags_IfPropertyIsRestricted(
Assert.True(schema.Properties["WriteOnlyProperty"].WriteOnly);
}

public class TypeWithRequiredProperties
{
public required string RequiredString { get; set; }
public required int RequiredInt { get; set; }
}

public class TypeWithRequiredPropertyAndValidationAttribute
{
[MinLength(1)]
public required string RequiredProperty { get; set; }
}

[Fact]
public void GenerateSchema_SetsRequired_IfPropertyHasRequiredKeyword()
{
Expand Down Expand Up @@ -455,13 +551,6 @@ public void GenerateSchema_SetsRequired_IfPropertyHasRequiredKeywordAndValidatio
Assert.Equal(["RequiredProperty"], schema.Required);
}

#nullable enable
public class TypeWithNullableReferenceTypes
{
public required string? RequiredNullableString { get; set; }
public required string RequiredNonNullableString { get; set; }
}

[Fact]
public void GenerateSchema_SetsRequiredAndNullable_IfPropertyHasRequiredKeywordAndIsNullable()
{
Expand All @@ -483,7 +572,7 @@ public void GenerateSchema_SetsRequiredAndNullable_IfPropertyHasRequiredKeywordA
AssertIsNullable(schema.Properties["RequiredNonNullableString"], false);
Assert.Contains("RequiredNonNullableString", schema.Required.ToArray());
}
#nullable disable


[Theory]
[InlineData(typeof(TypeWithParameterizedConstructor), nameof(TypeWithParameterizedConstructor.Id), false)]
Expand Down Expand Up @@ -772,21 +861,6 @@ public void GenerateSchema_PreservesIntermediateBaseProperties_WhenUsingOneOfPol
Assert.False(cSchema.Properties.ContainsKey(nameof(ModelOfA.PropertyOfA)));
}

public abstract class ModelOfA
{
public string PropertyOfA { get; set; }
}

public class ModelOfB : ModelOfA
{
public string PropertyOfB { get; set; }
}

public class ModelOfC : ModelOfB
{
public string PropertyOfC { get; set; }
}

[Fact]
public void GenerateSchema_SupportsOption_UseAllOfToExtendReferenceSchemas()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.Collections;
using System.ComponentModel.DataAnnotations;
using System.Dynamic;

namespace Swashbuckle.AspNetCore.TestSupport;

#nullable enable

public record TestRecordStringInteger(IDictionary<string, int> Property);

public record TestRecordStringIntegerNullable(IDictionary<string, int?> Property);

public record TestRecordEmptyIntEnumInteger(IDictionary<EmptyIntEnum, int> Property);

public record TestRecordStringBoolean(IDictionary<string, bool> Property);

public record TestRecordStringBooleanNullable(IDictionary<string, bool?> Property);

public record TestRecordDictionary(IDictionary Property);

public record TestRecordStringObject(IDictionary<string, object> Property);

public record TestRecordStringObjectNullable(IDictionary<string, object?> Property);

public record TestRecordExpandoObject(ExpandoObject Property);

public record TestRecordStringComplexType(IDictionary<string, ComplexType> Property);

public record TestRecordStringComplexTypeNullable(IDictionary<string, ComplexType?> Property);

public class CustomAnyOfType;

public class CustomOneOfType;

public class TypeWithNullableCustomAnyOfProperty
{
public CustomAnyOfType? Property { get; set; }
}

public class TypeWithNullableCustomOneOfProperty
{
public CustomOneOfType? Property { get; set; }
}

public class TypeWithRequiredProperties
{
public required string RequiredString { get; set; }

public required int RequiredInt { get; set; }
}

public class TypeWithRequiredPropertyAndValidationAttribute
{
[MinLength(1)]
public required string RequiredProperty { get; set; }
}

public class TypeWithNullableReferenceTypes
{
public required string? RequiredNullableString { get; set; }

public required string RequiredNonNullableString { get; set; }
}

#nullable restore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Swashbuckle.AspNetCore.TestSupport;

public abstract class ModelOfA
{
public string PropertyOfA { get; set; }
}

public class ModelOfB : ModelOfA
{
public string PropertyOfB { get; set; }
}

public class ModelOfC : ModelOfB
{
public string PropertyOfC { get; set; }
}
Loading