Skip to content

Commit 025ae09

Browse files
authored
Exclude inherited properties only when base added to AllOf (#3692)
Exclude inherited properties only when base added to AllOf * test: add regression test ... (generated) * attempt a snapshot test * fix my dotnet9 setup and add relevant snapshot * test: add FakeControllerWithInheritance + add a 'response' test * test: add B to test, also add GenerateSchema_PreservesMultiLevelInheritance, which reveals a problem fixing in next commit * fix: handle multi-level inheritance where all levels of the hierarchy are to be included in the output schema
1 parent 2c0a92b commit 025ae09

10 files changed

+1008
-3
lines changed

src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGenerator.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,8 @@ private OpenApiSchema CreateObjectSchema(DataContract dataContract, SchemaReposi
405405
}
406406

407407
applicableDataProperties = applicableDataProperties
408-
.Where(dataProperty => dataProperty.MemberInfo.DeclaringType == dataContract.UnderlyingType);
408+
// if the property is declared on a type other than (the one we just added as a base or one of its parents)
409+
.Where(dataProperty => !baseTypeDataContract.UnderlyingType.IsAssignableTo(dataProperty.MemberInfo.DeclaringType));
409410
}
410411

411412
if (IsBaseTypeWithKnownTypesDefined(dataContract, out var knownTypesDataContracts))
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
3+
namespace Swashbuckle.AspNetCore.SwaggerGen.Test;
4+
5+
public class FakeControllerWithInheritance
6+
{
7+
public void ActionWithDerivedObjectParameter([FromBody] AbcTests_C param)
8+
{ }
9+
10+
public List<AbcTests_A> ActionWithDerivedObjectResponse()
11+
{
12+
return null!;
13+
}
14+
15+
public AbcTests_B ActionWithDerivedObjectResponse_ExcludedFromInheritanceConfig()
16+
{
17+
return null!;
18+
}
19+
20+
// Helper test types for GenerateSchema_PreservesIntermediateBaseProperties_WhenUsingOneOfPolymorphism
21+
public abstract class AbcTests_A
22+
{
23+
public string PropA { get; set; }
24+
}
25+
26+
public class AbcTests_B : AbcTests_A
27+
{
28+
public string PropB { get; set; }
29+
}
30+
31+
public class AbcTests_C : AbcTests_B
32+
{
33+
public string PropC { get; set; }
34+
}
35+
}

test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,60 @@ public void GenerateSchema_SupportsOption_UseAllOfForPolymorphism()
717717
Assert.Equal(["Property2"], subType2Schema.Properties.Keys);
718718
}
719719

720+
[Fact]
721+
public void GenerateSchema_PreservesIntermediateBaseProperties_WhenUsingOneOfPolymorphism()
722+
{
723+
// Arrange - define a type hierarchy A <- B <- C where only A and C are selected as known subtypes
724+
var subject = Subject(configureGenerator: c =>
725+
{
726+
c.UseOneOfForPolymorphism = true;
727+
c.SubTypesSelector = (type) => type == typeof(AbcTests_A) ? new[] { typeof(AbcTests_C) } : Array.Empty<Type>();
728+
});
729+
730+
var schemaRepository = new SchemaRepository();
731+
732+
// Act
733+
var schema = subject.GenerateSchema(typeof(AbcTests_A), schemaRepository);
734+
735+
// Assert - polymorphic schema should be present
736+
Assert.NotNull(schema.OneOf);
737+
738+
// Ensure base A schema contains PropA
739+
Assert.True(schemaRepository.Schemas.ContainsKey(nameof(AbcTests_A)));
740+
var aSchema = schemaRepository.Schemas[nameof(AbcTests_A)];
741+
Assert.True(aSchema.Properties.ContainsKey(nameof(AbcTests_A.PropA)));
742+
743+
// Find the C schema in the OneOf and assert it preserves B's properties while not duplicating A's
744+
var cRef = schema.OneOf
745+
.OfType<OpenApiSchemaReference>()
746+
.First(r => r.Reference.Id == nameof(AbcTests_C));
747+
748+
var cSchema = schemaRepository.Schemas[nameof(AbcTests_C)];
749+
750+
// C should include PropC and properties declared on intermediate B
751+
Assert.True(cSchema.Properties.ContainsKey(nameof(AbcTests_C.PropC)));
752+
Assert.True(cSchema.Properties.ContainsKey(nameof(AbcTests_B.PropB)));
753+
754+
// A's property should not be in C's inline properties because it's provided by the referenced base schema
755+
Assert.False(cSchema.Properties.ContainsKey(nameof(AbcTests_A.PropA)));
756+
}
757+
758+
// Helper test types for the A/B/C regression
759+
public abstract class AbcTests_A
760+
{
761+
public string PropA { get; set; }
762+
}
763+
764+
public class AbcTests_B : AbcTests_A
765+
{
766+
public string PropB { get; set; }
767+
}
768+
769+
public class AbcTests_C : AbcTests_B
770+
{
771+
public string PropC { get; set; }
772+
}
773+
720774
[Fact]
721775
public void GenerateSchema_SupportsOption_UseAllOfToExtendReferenceSchemas()
722776
{

test/Swashbuckle.AspNetCore.SwaggerGen.Test/VerifyTests.cs

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,6 +1112,146 @@ public async Task ActionHavingFromFormAttributeWithSwaggerIgnore()
11121112
await Verify(document);
11131113
}
11141114

1115+
[Fact]
1116+
public async Task GenerateSchema_PreservesIntermediateBaseProperties_WhenUsingOneOfPolymorphism()
1117+
{
1118+
var subject = Subject(
1119+
apiDescriptions:
1120+
[
1121+
ApiDescriptionFactory.Create<FakeControllerWithInheritance>(
1122+
c => nameof(c.ActionWithDerivedObjectParameter),
1123+
groupName: "v1",
1124+
httpMethod: "POST",
1125+
relativePath: "resource",
1126+
parameterDescriptions:
1127+
[
1128+
new ApiParameterDescription
1129+
{
1130+
Name = "param1",
1131+
Source = BindingSource.Body,
1132+
Type = typeof(FakeControllerWithInheritance.AbcTests_C), // most derived type
1133+
ModelMetadata = ModelMetadataFactory.CreateForType(typeof(FakeControllerWithInheritance.AbcTests_C)),
1134+
},
1135+
],
1136+
supportedRequestFormats:
1137+
[
1138+
new ApiRequestFormat { MediaType = "application/json" },
1139+
]),
1140+
ApiDescriptionFactory.Create<FakeControllerWithInheritance>(
1141+
c => nameof(c.ActionWithDerivedObjectResponse),
1142+
groupName: "v1",
1143+
httpMethod: "GET",
1144+
relativePath: "resource",
1145+
parameterDescriptions: [],
1146+
supportedResponseTypes: [
1147+
new ApiResponseType
1148+
{
1149+
ApiResponseFormats = [new ApiResponseFormat { MediaType = "application/json" }],
1150+
StatusCode = 200,
1151+
Type = typeof(FakeControllerWithInheritance.AbcTests_A),
1152+
},
1153+
]),
1154+
ApiDescriptionFactory.Create<FakeControllerWithInheritance>(
1155+
c => nameof(c.ActionWithDerivedObjectResponse_ExcludedFromInheritanceConfig),
1156+
groupName: "v1",
1157+
httpMethod: "GET",
1158+
relativePath: "resourceB",
1159+
parameterDescriptions: [],
1160+
supportedResponseTypes: [
1161+
new ApiResponseType
1162+
{
1163+
ApiResponseFormats = [new ApiResponseFormat { MediaType = "application/json" }],
1164+
StatusCode = 200,
1165+
Type = typeof(FakeControllerWithInheritance.AbcTests_B),
1166+
},
1167+
]),
1168+
],
1169+
configureSchemaGeneratorOptions: c =>
1170+
{
1171+
c.UseOneOfForPolymorphism = true;
1172+
c.SubTypesSelector =
1173+
(type) => (Type[])(
1174+
type == typeof(FakeControllerWithInheritance.AbcTests_A)
1175+
? [typeof(FakeControllerWithInheritance.AbcTests_C)]
1176+
: []
1177+
);
1178+
}
1179+
);
1180+
var document = subject.GetSwagger("v1");
1181+
1182+
await Verify(document);
1183+
}
1184+
1185+
[Fact]
1186+
public async Task GenerateSchema_PreservesMultiLevelInheritance()
1187+
{
1188+
var subject = Subject(
1189+
apiDescriptions:
1190+
[
1191+
ApiDescriptionFactory.Create<FakeControllerWithInheritance>(
1192+
c => nameof(c.ActionWithDerivedObjectParameter),
1193+
groupName: "v1",
1194+
httpMethod: "POST",
1195+
relativePath: "resource",
1196+
parameterDescriptions:
1197+
[
1198+
new ApiParameterDescription
1199+
{
1200+
Name = "param1",
1201+
Source = BindingSource.Body,
1202+
Type = typeof(FakeControllerWithInheritance.AbcTests_C), // most derived type
1203+
ModelMetadata = ModelMetadataFactory.CreateForType(typeof(FakeControllerWithInheritance.AbcTests_C)),
1204+
},
1205+
],
1206+
supportedRequestFormats:
1207+
[
1208+
new ApiRequestFormat { MediaType = "application/json" },
1209+
]),
1210+
ApiDescriptionFactory.Create<FakeControllerWithInheritance>(
1211+
c => nameof(c.ActionWithDerivedObjectResponse),
1212+
groupName: "v1",
1213+
httpMethod: "GET",
1214+
relativePath: "resource",
1215+
parameterDescriptions: [],
1216+
supportedResponseTypes: [
1217+
new ApiResponseType
1218+
{
1219+
ApiResponseFormats = [new ApiResponseFormat { MediaType = "application/json" }],
1220+
StatusCode = 200,
1221+
Type = typeof(FakeControllerWithInheritance.AbcTests_A),
1222+
},
1223+
]),
1224+
ApiDescriptionFactory.Create<FakeControllerWithInheritance>(
1225+
c => nameof(c.ActionWithDerivedObjectResponse_ExcludedFromInheritanceConfig),
1226+
groupName: "v1",
1227+
httpMethod: "GET",
1228+
relativePath: "resourceB",
1229+
parameterDescriptions: [],
1230+
supportedResponseTypes: [
1231+
new ApiResponseType
1232+
{
1233+
ApiResponseFormats = [new ApiResponseFormat { MediaType = "application/json" }],
1234+
StatusCode = 200,
1235+
Type = typeof(FakeControllerWithInheritance.AbcTests_B),
1236+
},
1237+
]),
1238+
],
1239+
configureSchemaGeneratorOptions: c =>
1240+
{
1241+
c.UseOneOfForPolymorphism = true;
1242+
c.SubTypesSelector =
1243+
(type) => (Type[])(
1244+
type == typeof(FakeControllerWithInheritance.AbcTests_A) ? [typeof(FakeControllerWithInheritance.AbcTests_B), typeof(FakeControllerWithInheritance.AbcTests_C)]
1245+
: type == typeof(FakeControllerWithInheritance.AbcTests_B) ? [typeof(FakeControllerWithInheritance.AbcTests_C)]
1246+
: []
1247+
);
1248+
}
1249+
);
1250+
var document = subject.GetSwagger("v1");
1251+
1252+
await Verify(document);
1253+
}
1254+
11151255
[Fact]
11161256
public async Task GetSwagger_Works_As_Expected_When_FromFormObject()
11171257
{
@@ -1465,12 +1605,16 @@ private static SwaggerGenerator Subject(
14651605
IEnumerable<ApiDescription> apiDescriptions,
14661606
SwaggerGeneratorOptions options = null,
14671607
IEnumerable<AuthenticationScheme> authenticationSchemes = null,
1468-
List<ISchemaFilter> schemaFilters = null)
1608+
List<ISchemaFilter> schemaFilters = null,
1609+
Action<SchemaGeneratorOptions> configureSchemaGeneratorOptions = null)
14691610
{
1611+
var schemaGeneratorOptions = new SchemaGeneratorOptions() { SchemaFilters = schemaFilters ?? [] };
1612+
configureSchemaGeneratorOptions?.Invoke(schemaGeneratorOptions);
1613+
14701614
return new SwaggerGenerator(
14711615
options ?? DefaultOptions,
14721616
new FakeApiDescriptionGroupCollectionProvider(apiDescriptions),
1473-
new SchemaGenerator(new SchemaGeneratorOptions() { SchemaFilters = schemaFilters ?? [] }, new JsonSerializerDataContractResolver(new JsonSerializerOptions())),
1617+
new SchemaGenerator(schemaGeneratorOptions, new JsonSerializerDataContractResolver(new JsonSerializerOptions())),
14741618
new FakeAuthenticationSchemeProvider(authenticationSchemes ?? [])
14751619
);
14761620
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
{
2+
"openapi": "3.0.4",
3+
"info": {
4+
"title": "Test API",
5+
"version": "V1"
6+
},
7+
"paths": {
8+
"/resource": {
9+
"post": {
10+
"tags": [
11+
"FakeWithInheritance"
12+
],
13+
"requestBody": {
14+
"content": {
15+
"application/json": {
16+
"schema": {
17+
"$ref": "#/components/schemas/AbcTests_C"
18+
}
19+
}
20+
}
21+
},
22+
"responses": {
23+
"200": {
24+
"description": "OK"
25+
}
26+
}
27+
},
28+
"get": {
29+
"tags": [
30+
"FakeWithInheritance"
31+
],
32+
"responses": {
33+
"200": {
34+
"description": "OK",
35+
"content": {
36+
"application/json": {
37+
"schema": {
38+
"type": "array",
39+
"items": {
40+
"oneOf": [
41+
{
42+
"$ref": "#/components/schemas/AbcTests_C"
43+
}
44+
]
45+
}
46+
}
47+
}
48+
}
49+
}
50+
}
51+
}
52+
},
53+
"/resourceB": {
54+
"get": {
55+
"tags": [
56+
"FakeWithInheritance"
57+
],
58+
"responses": {
59+
"200": {
60+
"description": "OK",
61+
"content": {
62+
"application/json": {
63+
"schema": {
64+
"$ref": "#/components/schemas/AbcTests_B"
65+
}
66+
}
67+
}
68+
}
69+
}
70+
}
71+
}
72+
},
73+
"components": {
74+
"schemas": {
75+
"AbcTests_A": {
76+
"type": "object",
77+
"properties": {
78+
"PropA": {
79+
"type": "string",
80+
"nullable": true
81+
}
82+
},
83+
"additionalProperties": false
84+
},
85+
"AbcTests_B": {
86+
"type": "object",
87+
"properties": {
88+
"PropA": {
89+
"type": "string",
90+
"nullable": true
91+
},
92+
"PropB": {
93+
"type": "string",
94+
"nullable": true
95+
}
96+
},
97+
"additionalProperties": false
98+
},
99+
"AbcTests_C": {
100+
"type": "object",
101+
"allOf": [
102+
{
103+
"$ref": "#/components/schemas/AbcTests_A"
104+
}
105+
],
106+
"properties": {
107+
"PropB": {
108+
"type": "string",
109+
"nullable": true
110+
},
111+
"PropC": {
112+
"type": "string",
113+
"nullable": true
114+
}
115+
},
116+
"additionalProperties": false
117+
}
118+
}
119+
},
120+
"tags": [
121+
{
122+
"name": "FakeWithInheritance"
123+
}
124+
]
125+
}

0 commit comments

Comments
 (0)