Skip to content
Merged
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 @@ -103,13 +103,22 @@ private static void ApplySchemaAttribute(IOpenApiSchema schema, SwaggerSchemaAtt
{
// See https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/3387
// See https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/3936
//
// Where nullability is recorded depends on the shape of the schema, and each branch
// below has a mirror image in the non-nullable case:
// * allOf: on "type", as adding a null member breaks the OpenAPI 3.0 output
// * anyOf/oneOf: as an extra "null" member, as the composition constrains the value
// * otherwise: on "type", but only when a concrete type is already present
if (nullable)
{
if (concrete.AllOf is { Count: > 0 })
{
// Do not restructure the composition here: wrapping the allOf in an anyOf
// prevents client generators from resolving the referenced schema when the
// document is serialized as OpenAPI 3.0, where the null member is dropped.
// This makes the OpenAPI 3.1 schema contradictory (the value must be null
// and satisfy the allOf), which is accepted so that the 3.0 output stays
// idiomatic and usable by client generators.
concrete.Type ??= JsonSchemaType.Null;
concrete.Type |= JsonSchemaType.Null;
}
Expand All @@ -124,12 +133,24 @@ private static void ApplySchemaAttribute(IOpenApiSchema schema, SwaggerSchemaAtt
else if (concrete.Type.HasValue)
{
// A schema without a "type" already validates every JSON type, including null,
// so the null flag is only added when a concrete type is present.
// so the null flag is only added when a concrete type is present. That reasoning
// does not extend to the compositions above: their members restrict which types
// are valid, so an absent "type" does not admit null and it must be added.
concrete.Type |= JsonSchemaType.Null;
}
}
else if (concrete.AnyOf is { Count: > 0 } anyOf)
{
TryRemoveNullSchema(anyOf);
}
else if (concrete.OneOf is { Count: > 0 } oneOf)
{
TryRemoveNullSchema(oneOf);
}
else if (concrete.Type.HasValue)
{
// An allOf composition falls through to here, as "type" is where the
// nullable case above records its nullability.
concrete.Type &= ~JsonSchemaType.Null;
}
}
Expand All @@ -152,4 +173,30 @@ private static void TryAddNullSchema(IList<IOpenApiSchema> schemas)
schemas.Add(new OpenApiSchema { Type = JsonSchemaType.Null });
}
}

private static void TryRemoveNullSchema(IList<IOpenApiSchema> schemas)
{
for (var i = schemas.Count - 1; i >= 0; i--)
{
if (schemas[i] is not OpenApiSchema { Type: { } type } member ||
!type.HasFlag(JsonSchemaType.Null))
{
continue;
}

if (type == JsonSchemaType.Null)
{
// A lone null member is left in place, as an empty anyOf/oneOf is not valid.
if (schemas.Count > 1)
{
schemas.RemoveAt(i);
}
}
else
{
// The member permits other types too, so only the null flag is cleared.
member.Type = type & ~JsonSchemaType.Null;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -625,13 +625,22 @@ 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
//
// Where nullability is recorded depends on the shape of the schema, and each branch
// below has a mirror image in the non-nullable case:
// * allOf: on "type", as adding a null member breaks the OpenAPI 3.0 output
// * anyOf/oneOf: as an extra "null" member, as the composition constrains the value
// * otherwise: on "type", but only when a concrete type is already present
if (nullable)
{
if (schema.AllOf is { Count: > 0 })
{
// Do not restructure the composition here: wrapping the allOf in an anyOf
// prevents client generators from resolving the referenced schema when the
// document is serialized as OpenAPI 3.0, where the null member is dropped.
// This makes the OpenAPI 3.1 schema contradictory (the value must be null
// and satisfy the allOf), which is accepted so that the 3.0 output stays
// idiomatic and usable by client generators.
schema.Type ??= JsonSchemaType.Null;
schema.Type |= JsonSchemaType.Null;
}
Expand All @@ -646,12 +655,24 @@ private static void SetNullable(OpenApiSchema schema, bool nullable)
else if (schema.Type.HasValue)
{
// A schema without a "type" already validates every JSON type, including null,
// so the null flag is only added when a concrete type is present.
// so the null flag is only added when a concrete type is present. That reasoning
// does not extend to the compositions above: their members restrict which types
// are valid, so an absent "type" does not admit null and it must be added.
schema.Type |= JsonSchemaType.Null;
}
}
else if (schema.AnyOf is { Count: > 0 } anyOf)
{
TryRemoveNullSchema(anyOf);
}
else if (schema.OneOf is { Count: > 0 } oneOf)
{
TryRemoveNullSchema(oneOf);
}
else if (schema.Type.HasValue)
{
// An allOf composition falls through to here, as "type" is where the
// nullable case above records its nullability.
schema.Type &= ~JsonSchemaType.Null;
}
}
Expand All @@ -664,6 +685,32 @@ private static void TryAddNullSchema(IList<IOpenApiSchema> schemas)
}
}

private static void TryRemoveNullSchema(IList<IOpenApiSchema> schemas)
{
for (var i = schemas.Count - 1; i >= 0; i--)
{
if (schemas[i] is not OpenApiSchema { Type: { } type } member ||
!type.HasFlag(JsonSchemaType.Null))
{
continue;
}

if (type == JsonSchemaType.Null)
{
// A lone null member is left in place, as an empty anyOf/oneOf is not valid.
if (schemas.Count > 1)
{
schemas.RemoveAt(i);
}
}
else
{
// The member permits other types too, so only the null flag is cleared.
member.Type = type & ~JsonSchemaType.Null;
}
}
}

private static JsonSchemaType FromDataType(DataType dataType) =>
Enum.Parse<JsonSchemaType>(dataType.ToString());
}
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,104 @@ public void Apply_PreservesAllOf_IfNullableSchemaHasAllOf()
Assert.True(schema.Type.Value.HasFlag(JsonSchemaType.Null));
}

[Fact]
public void Apply_RemovesNullSchema_IfNonNullableSchemaHasAnyOf()
{
var schema = new OpenApiSchema
{
AnyOf =
[
new OpenApiSchema { Type = JsonSchemaTypes.String },
new OpenApiSchema { Type = JsonSchemaType.Null },
]
};

Subject().Apply(schema, ContextForProperty(typeof(TypeWithNonNullableComposedSchemas), nameof(TypeWithNonNullableComposedSchemas.AnyOfProperty)));

// The mirror image of Apply_AddsNullSchema_IfNullableSchemaHasAnyOf: nullability is
// expressed as a member of the composition, so that is where it is removed from.
Assert.NotNull(schema.AnyOf);
Assert.Single(schema.AnyOf);
Assert.DoesNotContain(schema.AnyOf, s => s.Type == JsonSchemaType.Null);
}

[Fact]
public void Apply_RemovesNullSchema_IfNonNullableSchemaHasOneOf()
{
var schema = new OpenApiSchema
{
OneOf =
[
new OpenApiSchema { Type = JsonSchemaTypes.String },
new OpenApiSchema { Type = JsonSchemaType.Null },
]
};

Subject().Apply(schema, ContextForProperty(typeof(TypeWithNonNullableComposedSchemas), nameof(TypeWithNonNullableComposedSchemas.OneOfProperty)));

Assert.NotNull(schema.OneOf);
Assert.Single(schema.OneOf);
Assert.DoesNotContain(schema.OneOf, s => s.Type == JsonSchemaType.Null);
}

[Fact]
public void Apply_ClearsNullFlag_IfNonNullableSchemaAnyOfMemberAllowsOtherTypes()
{
var schema = new OpenApiSchema
{
AnyOf =
[
new OpenApiSchema { Type = JsonSchemaTypes.String | JsonSchemaType.Null },
]
};

Subject().Apply(schema, ContextForProperty(typeof(TypeWithNonNullableComposedSchemas), nameof(TypeWithNonNullableComposedSchemas.AnyOfProperty)));

// The member is not removed, as that would discard the other types it permits.
Assert.NotNull(schema.AnyOf);
Assert.Single(schema.AnyOf);
Assert.Equal(JsonSchemaTypes.String, schema.AnyOf[0].Type);
}

[Fact]
public void Apply_PreservesLoneNullSchema_IfNonNullableSchemaAnyOfOnlyAllowsNull()
{
var schema = new OpenApiSchema
{
AnyOf =
[
new OpenApiSchema { Type = JsonSchemaType.Null },
]
};

Subject().Apply(schema, ContextForProperty(typeof(TypeWithNonNullableComposedSchemas), nameof(TypeWithNonNullableComposedSchemas.AnyOfProperty)));

// Removing the only member would emit an empty anyOf, which is not valid.
Assert.NotNull(schema.AnyOf);
Assert.Single(schema.AnyOf);
Assert.Equal(JsonSchemaType.Null, schema.AnyOf[0].Type);
}

[Fact]
public void Apply_RemovesNullFlagFromType_IfNonNullableSchemaHasAllOf()
{
var schema = new OpenApiSchema
{
Type = JsonSchemaTypes.String | JsonSchemaType.Null,
AllOf =
[
new OpenApiSchema { Type = JsonSchemaTypes.String }
]
};

Subject().Apply(schema, ContextForProperty(typeof(TypeWithNonNullableComposedSchemas), nameof(TypeWithNonNullableComposedSchemas.AllOfProperty)));

// An allOf records its nullability on "type", so that is where it is removed from.
Assert.NotNull(schema.AllOf);
Assert.Single(schema.AllOf);
Assert.Equal(JsonSchemaTypes.String, schema.Type);
}

[Theory]
[InlineData(typeof(SwaggerAnnotatedType))]
[InlineData(typeof(SwaggerAnnotatedStruct))]
Expand All @@ -200,8 +298,11 @@ public void Apply_DelegatesToSpecifiedFilter_IfTypeDecoratedWithFilterAttribute(
}

private static SchemaFilterContext ContextForProperty(string propertyName)
=> ContextForProperty(typeof(TypeWithNullableComposedSchemas), propertyName);

private static SchemaFilterContext ContextForProperty(Type declaringType, string propertyName)
{
var propertyInfo = typeof(TypeWithNullableComposedSchemas).GetProperty(propertyName);
var propertyInfo = declaringType.GetProperty(propertyName);
return new SchemaFilterContext(
type: propertyInfo.PropertyType,
schemaGenerator: null,
Expand All @@ -221,6 +322,18 @@ private class TypeWithNullableComposedSchemas
public object AllOfProperty { get; set; }
}

private class TypeWithNonNullableComposedSchemas
{
[SwaggerSchema(Nullable = false)]
public object AnyOfProperty { get; set; }

[SwaggerSchema(Nullable = false)]
public object OneOfProperty { get; set; }

[SwaggerSchema(Nullable = false)]
public object AllOfProperty { get; set; }
}

private static AnnotationsSchemaFilter Subject()
{
// A service provider is required from .NET 8 onwards.
Expand Down
Loading
Loading