diff --git a/src/Docfx.Common/EntityMergers/ReflectionEntityMerger.cs b/src/Docfx.Common/EntityMergers/ReflectionEntityMerger.cs index b18f6f463ec..10220666caa 100644 --- a/src/Docfx.Common/EntityMergers/ReflectionEntityMerger.cs +++ b/src/Docfx.Common/EntityMergers/ReflectionEntityMerger.cs @@ -144,7 +144,7 @@ public void Merge(ref object source, object overrides, IMergeContext context) } var type = o.GetType(); - if (type.IsValueType) + if (type.IsValueType && Nullable.GetUnderlyingType(prop.Prop.PropertyType) == null) { var defaultValue = Activator.CreateInstance(type); if (object.Equals(defaultValue, o)) @@ -184,7 +184,7 @@ public void Merge(ref object source, object overrides, IMergeContext context) } var type = o.GetType(); - if (type.IsValueType) + if (type.IsValueType && Nullable.GetUnderlyingType(prop.Prop.PropertyType) == null) { var defaultValue = Activator.CreateInstance(type); if (object.Equals(defaultValue, o)) diff --git a/test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs b/test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs index 2bd7f0b87c5..99689a3daea 100644 --- a/test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs +++ b/test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs @@ -16,6 +16,43 @@ namespace Docfx.Build.RestApi.Tests; [Trait("Category", "SwaggerCompatibility")] public class SwaggerCompatibilityTest : TestBase { + [Fact] + public void PreservesSwagger2InfoMetadataValues() + { + var file = CreateFile("swagger.json", """ + { + "swagger": "2.0", + "info": { + "title": "Compatibility", "version": "1", + "description": { "en": "Hello" }, + "contact": "custom contact", + "custom": { "enabled": false, "values": [0, "", null] } + }, + "tags": [{ "name": "items", "info": "tag metadata" }], + "paths": { "/items": { "get": { + "operationId": "getItems", "info": ["operation metadata", false] + } } } + } + """, GetRandomFolder()); + + // These values are accepted by the Swagger 2.0 parser, even outside the specification. + var swagger = SwaggerJsonParser.Parse(file); + AssertJson(""" + { + "description": { "en": "Hello" }, + "contact": "custom contact", + "custom": { "enabled": false, "values": [0, "", null] } + } + """, JToken.FromObject(swagger.Info.PatternedObjects)); + + var model = SwaggerModelConverter.FromSwaggerModel(swagger); + Assert.Equal("Compatibility", model.Name); + Assert.Equal("Compatibility/1", model.Uid); + Assert.Equal("tag metadata", Assert.Single(model.Tags).Metadata["info"]); + AssertJson("""["operation metadata", false]""", + Assert.IsType(Assert.Single(model.Children).Metadata["info"])); + } + [Fact] public void AllSevenSwaggerMethodsPreserveDocumentOrderAndOperationIdentity() { diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs b/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs index 7cc9d361a05..d7f0a3571e5 100644 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs @@ -23,6 +23,122 @@ public class SwaggerOutputCompatibilityTest : TestBase private const string RootUid = "api.example.test/v1/Compatibility API/1.0"; private const string RootHtmlId = "api_example_test_v1_Compatibility_API_1_0"; + [Theory] + [InlineData(false, false, false)] + [InlineData(true, false, false)] + [InlineData(false, true, false)] + [InlineData(true, true, false)] + [InlineData(false, false, true)] + [InlineData(true, false, true)] + [InlineData(false, true, true)] + [InlineData(true, true, true)] + public void PreservesFreeMetadataThroughSplittingAndOverwrite(bool splitTags, bool splitOperations, bool overwrite) + { + var input = GetRandomFolder(); + var output = GetRandomFolder(); + var file = CreateFile("service.json", """ + { + "swagger": "2.0", + "info": { "title": "Metadata", "version": "1.0", "description": { "en": "Hello" } }, + "custom": { "owner": "root", "keep": false }, + "inherited": { "values": [0, "", null] }, + "tags": [{ + "name": "items", "info": "tag metadata", + "custom": { "owner": "tag", "keep": false }, + "tagOnly": { "enabled": false } + }], + "paths": { "/items": { "get": { + "operationId": "getItems", "tags": ["items"], + "info": ["operation metadata", false], + "custom": { "owner": "operation", "keep": false }, + "responses": { "200": { "description": "OK" } } + } } } + } + """, input); + var files = new FileCollection(Directory.GetCurrentDirectory()); + files.Add(DocumentType.Article, [file], input); + if (overwrite) + { + var overwriteFile = CreateFile("overwrite.md", """ + --- + uid: Metadata/1.0 + info: root overwrite + custom: + owner: root overwrite + --- + + --- + uid: Metadata/1.0/tag/items + info: tag overwrite + custom: + owner: tag overwrite + --- + + --- + uid: Metadata/1.0/getItems + info: operation overwrite + custom: + owner: operation overwrite + --- + """, input); + files.Add(DocumentType.Overwrite, [overwriteFile], input); + } + + using var builder = new DocumentBuilder(GetAssemblies(splitTags, splitOperations), []); + builder.Build(new DocumentBuildParameters + { + Files = files, + OutputBaseDir = output, + ApplyTemplateSettings = new ApplyTemplateSettings(input, output) + { + TransformDocument = false, + RawModelExportSettings = { Export = true } + } + }); + + var root = ReadModel(output, "service.raw.json"); + Assert.Equal(overwrite ? "root overwrite" : null, (string)root["info"]); + Assert.Equal(overwrite ? "root overwrite" : "root", (string)root["custom"]["owner"]); + Assert.False((bool)root["custom"]["keep"]); + Assert.True(JToken.DeepEquals(JObject.Parse("""{"values":[0,"",null]}"""), root["inherited"])); + + var tag = splitTags ? ReadModel(output, "service/items.raw.json") + : splitOperations ? null : Assert.Single(root["tags"]); + if (tag != null) + { + Assert.Equal(overwrite ? "tag overwrite" : "tag metadata", (string)tag["info"]); + Assert.Equal(overwrite ? "tag overwrite" : "tag", (string)tag["custom"]["owner"]); + Assert.False((bool)tag["custom"]["keep"]); + Assert.False((bool)tag["tagOnly"]["enabled"]); + if (splitTags) + { + Assert.True(JToken.DeepEquals(root["inherited"], tag["inherited"])); + } + } + + var operation = splitOperations + ? ReadModel(output, splitTags ? "service/items/getItems.raw.json" : "service/getItems.raw.json") + : Assert.Single((splitTags ? tag : root)["children"]); + if (overwrite) + { + Assert.Equal("operation overwrite", (string)operation["info"]); + } + else + { + Assert.True(JToken.DeepEquals(JArray.Parse("""["operation metadata", false]"""), operation["info"])); + } + Assert.Equal(overwrite ? "operation overwrite" : "operation", (string)operation["custom"]["owner"]); + Assert.False((bool)operation["custom"]["keep"]); + if (splitOperations) + { + Assert.True(JToken.DeepEquals(root["inherited"], operation["inherited"])); + if (splitTags) + { + Assert.True(JToken.DeepEquals(tag["tagOnly"], operation["tagOnly"])); + } + } + } + [Theory] [InlineData("trace")] [InlineData("custom")] diff --git a/test/Docfx.Common.Tests/ReflectionEntityMergerTest.cs b/test/Docfx.Common.Tests/ReflectionEntityMergerTest.cs index 0cf58d41f20..149ce52f7ae 100644 --- a/test/Docfx.Common.Tests/ReflectionEntityMergerTest.cs +++ b/test/Docfx.Common.Tests/ReflectionEntityMergerTest.cs @@ -46,6 +46,26 @@ public void TestReflectionEntityMergerWithBasicScenarios() Assert.Same(overrides.Nested.Nested, sample.Nested.Nested); } + [Fact] + public void NullableDefaultsAreExplicitOverwriteValues() + { + var sample = new NullableDefaults { Required = true, Count = 1 }; + var merger = new MergerFacade(new ReflectionEntityMerger()); + merger.Merge(ref sample, new NullableDefaults()); + Assert.True(sample.Required); + Assert.Equal(1, sample.Count); + merger.Merge(ref sample, new NullableDefaults { Required = false, Count = 0 }); + Assert.False(sample.Required); + Assert.Equal(0, sample.Count); + } + + public class NullableDefaults + { + public bool? Required { get; set; } + [MergeOption(MergeOption.Replace)] + public int? Count { get; set; } + } + public class BasicSample { public int IntValue { get; set; }