From 06531fb3ddf72a9fab803a03cde361d6e3d0eb98 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Thu, 24 Sep 2026 21:58:43 +1000 Subject: [PATCH 1/6] Preserve explicit nullable values in overwrites --- .../EntityMergers/ReflectionEntityMerger.cs | 4 ++-- .../ReflectionEntityMergerTest.cs | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) 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.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; } From 6543fd1e6ca635faa44de20650f252a40f907ca2 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Thu, 24 Sep 2026 21:58:45 +1000 Subject: [PATCH 2/6] Render Swagger schemas through typed REST view models --- docs/docs/rest-api-docs.md | 2 + .../SplitRestApiToOperationLevel.cs | 1 + .../BuildRestApiDocument.cs | 53 +--- .../RestApiDocumentProcessor.cs | 2 +- .../SwaggerModelConverter.cs | 28 ++ .../SplitRestApiToTagLevel.cs | 4 +- .../RestApiArrayMergeHandler.cs | 36 +++ .../RestApiExternalDocumentationViewModel.cs | 26 ++ .../RestApiInfoViewModel.cs | 31 +++ .../RestApiParameterViewModel.cs | 5 + .../RestApiResponseViewModel.cs | 10 + .../RestApiRootItemViewModel.cs | 32 +++ .../RestApiSchemaViewModel.cs | 74 +++++ .../RestApiSecuritySchemeViewModel.cs | 21 ++ templates/common/RestApi.common.js | 256 +++++------------- .../default/partials/rest.child.tmpl.partial | 26 +- .../partials/rest.definition.tmpl.partial | 47 +--- .../partials/rest.examples.tmpl.partial | 11 + .../default/partials/rest.schema.tmpl.partial | 38 +++ templates/modern/src/rest.test.ts | 122 +++++++++ .../RestApiDocumentProcessorTest.cs | 57 +++- .../RestApiViewModelTest.cs | 55 ++++ .../SplitRestApiToOperationLevelTest.cs | 12 +- .../SplitRestApiToTagLevelTest.cs | 8 +- .../SwaggerOutputCompatibilityTest.cs | 21 +- 25 files changed, 653 insertions(+), 325 deletions(-) create mode 100644 src/Docfx.DataContracts.RestApi/RestApiArrayMergeHandler.cs create mode 100644 src/Docfx.DataContracts.RestApi/RestApiExternalDocumentationViewModel.cs create mode 100644 src/Docfx.DataContracts.RestApi/RestApiInfoViewModel.cs create mode 100644 src/Docfx.DataContracts.RestApi/RestApiSchemaViewModel.cs create mode 100644 src/Docfx.DataContracts.RestApi/RestApiSecuritySchemeViewModel.cs create mode 100644 templates/default/partials/rest.examples.tmpl.partial create mode 100644 templates/default/partials/rest.schema.tmpl.partial create mode 100644 templates/modern/src/rest.test.ts create mode 100644 test/Docfx.Build.RestApi.Tests/RestApiViewModelTest.cs diff --git a/docs/docs/rest-api-docs.md b/docs/docs/rest-api-docs.md index 02c8afc79e3..1cc8caa760e 100644 --- a/docs/docs/rest-api-docs.md +++ b/docs/docs/rest-api-docs.md @@ -16,6 +16,8 @@ To add REST API docs, include the swagger JSON file to the `build` config in `do Each swagger file produces one output HTML file. +Parameter, response, and definition schemas display nested properties, array items, allowed values, and examples. Schemas using `allOf` display each member under **All of**, preserving the individual schemas and their descriptions. + ## Organize REST APIs using Tags diff --git a/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs b/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs index 6d520c7b5a7..00320ed68a7 100644 --- a/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs +++ b/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs @@ -127,6 +127,7 @@ private static IEnumerable GenerateOperationModels(Res Tags = [], Metadata = MergeChildMetadata(root, child) }; + root.CopyDocumentContextTo(model); // Reset child's uid to "originalUid/operation", that is to say, overwrite of original Uid will show in operation page. child.Uid = string.Join('/', child.Uid, "operation"); diff --git a/src/Docfx.Build.RestApi/BuildRestApiDocument.cs b/src/Docfx.Build.RestApi/BuildRestApiDocument.cs index 8e2b79feafb..f3d146f7a77 100644 --- a/src/Docfx.Build.RestApi/BuildRestApiDocument.cs +++ b/src/Docfx.Build.RestApi/BuildRestApiDocument.cs @@ -8,15 +8,11 @@ using Docfx.DataContracts.RestApi; using Docfx.Plugins; -using Newtonsoft.Json.Linq; - namespace Docfx.Build.RestApi; [Export(nameof(RestApiDocumentProcessor), typeof(IDocumentBuildStep))] public class BuildRestApiDocument : BuildReferenceDocumentBase { - private static readonly HashSet MarkupKeys = ["description"]; - public override string Name => nameof(BuildRestApiDocument); protected override void BuildArticle(IHostService host, FileModel model) @@ -51,10 +47,11 @@ public static RestApiItemViewModelBase BuildItem(IHostService host, RestApiItemV if (item is RestApiRootItemViewModel rootModel) { - // Mark up recursively for swagger root except for children and tags - foreach (var jToken in rootModel.Metadata.Values.OfType()) + if (rootModel.Info != null) rootModel.Info.Description = Markup(host, rootModel.Info.Description, model, filter); + if (rootModel.ExternalDocs != null) rootModel.ExternalDocs.Description = Markup(host, rootModel.ExternalDocs.Description, model, filter); + foreach (var security in rootModel.SecurityDefinitions?.Values.AsEnumerable() ?? []) { - MarkupRecursive(jToken, host, model, filter); + if (security != null) security.Description = Markup(host, security.Description, model, filter); } } @@ -64,11 +61,7 @@ public static RestApiItemViewModelBase BuildItem(IHostService host, RestApiItemV foreach (var param in childModel.Parameters) { param.Description = Markup(host, param.Description, model, filter); - - foreach (var jToken in param.Metadata.Values.OfType()) - { - MarkupRecursive(jToken, host, model, filter); - } + MarkupSchema(param.Schema); } } if (childModel?.Responses != null) @@ -76,39 +69,19 @@ public static RestApiItemViewModelBase BuildItem(IHostService host, RestApiItemV foreach (var response in childModel.Responses) { response.Description = Markup(host, response.Description, model, filter); - - foreach (var jToken in response.Metadata.Values.OfType()) - { - MarkupRecursive(jToken, host, model, filter); - } + MarkupSchema(response.Schema); + foreach (var header in response.Headers?.Values.AsEnumerable() ?? []) MarkupSchema(header); } } return item; - } - private static void MarkupRecursive(JToken jToken, IHostService host, FileModel model, Func filter = null) - { - if (jToken is JArray jArray) + void MarkupSchema(RestApiSchemaViewModel schema) { - foreach (var item in jArray) - { - MarkupRecursive(item, host, model, filter); - } - } - - if (jToken is JObject jObject) - { - foreach (var pair in jObject) - { - if (MarkupKeys.Contains(pair.Key) && pair.Value != null) - { - if (pair.Value is JValue { Type: JTokenType.String } jValue) - { - jObject[pair.Key] = Markup(host, (string)jValue, model, filter); - } - } - MarkupRecursive(jObject[pair.Key], host, model, filter); - } + if (schema == null) return; + schema.Description = Markup(host, schema.Description, model, filter); + foreach (var property in schema.Properties?.Values.AsEnumerable() ?? []) MarkupSchema(property); + MarkupSchema(schema.Items); + foreach (var branch in schema.AllOf ?? []) MarkupSchema(branch); } } diff --git a/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs b/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs index 72bc22a9968..8f1a7ca721b 100644 --- a/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs +++ b/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs @@ -134,7 +134,7 @@ protected override FileModel LoadArticle(FileAndType file, ImmutableDictionary>(model.Metadata, "securityDefinitions"); + model.Info = JObject.FromObject(swagger.Info).ToObject(); + model.ExternalDocs = Take(model.Metadata, "externalDocs"); + foreach (var child in model.Children) + { + foreach (var parameter in child.Parameters ?? []) + { + parameter.Schema = Take(parameter.Metadata, "schema"); + if (parameter.Schema == null) + { + parameter.Schema = JObject.FromObject(parameter.Metadata).ToObject(); + } + } + foreach (var response in child.Responses ?? []) + { + response.Schema = Take(response.Metadata, "schema"); + response.Headers = Take>(response.Metadata, "headers"); + } + } + return model; + } + + private static T Take(Dictionary metadata, string name) where T : class => + metadata.Remove(name, out var value) && value != null ? JToken.FromObject(value).ToObject() : null; + #region Private methods [GeneratedRegex(@"\W")] diff --git a/src/Docfx.Build.TagLevelRestApi/SplitRestApiToTagLevel.cs b/src/Docfx.Build.TagLevelRestApi/SplitRestApiToTagLevel.cs index 021a9aabac4..03221938ef8 100644 --- a/src/Docfx.Build.TagLevelRestApi/SplitRestApiToTagLevel.cs +++ b/src/Docfx.Build.TagLevelRestApi/SplitRestApiToTagLevel.cs @@ -109,7 +109,7 @@ private static IEnumerable GenerateTagModels(RestApiRo var tagChildren = GetChildrenByTag(root, tag.Name).ToList(); if (tagChildren.Count > 0) { - yield return new RestApiRootItemViewModel + var model = new RestApiRootItemViewModel { Uid = tag.Uid, HtmlId = tag.HtmlId, @@ -121,6 +121,8 @@ private static IEnumerable GenerateTagModels(RestApiRo Tags = [], Metadata = MergeTagMetadata(root, tag) }; + root.CopyDocumentContextTo(model); + yield return model; } } } diff --git a/src/Docfx.DataContracts.RestApi/RestApiArrayMergeHandler.cs b/src/Docfx.DataContracts.RestApi/RestApiArrayMergeHandler.cs new file mode 100644 index 00000000000..4a7aeb3a885 --- /dev/null +++ b/src/Docfx.DataContracts.RestApi/RestApiArrayMergeHandler.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using Docfx.Common.EntityMergers; +using Docfx.Exceptions; + +namespace Docfx.DataContracts.RestApi; + +// REST arrays have positional overwrite semantics, including null placeholders. +// They must not use the entity merger's default key-based list matching. +public sealed class RestApiArrayMergeHandler : IMergeHandler +{ + public void Merge(ref object source, object overrides, IMergeContext context) + { + if (source == null) + { + source = overrides; + return; + } + var items = (IList)source; + var replacements = (IList)overrides; + if (items.Count != replacements.Count) + { + throw new DocfxException($"The count '{items.Count}' of REST array is different from overwrite list {replacements.Count}"); + } + var itemType = source.GetType().GetGenericArguments()[0]; + for (var i = 0; i < items.Count; i++) + { + if (replacements[i] == null) continue; + var item = items[i]; + context.Merger.Merge(ref item, replacements[i], itemType, context); + items[i] = item; + } + } +} diff --git a/src/Docfx.DataContracts.RestApi/RestApiExternalDocumentationViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiExternalDocumentationViewModel.cs new file mode 100644 index 00000000000..41dedb17354 --- /dev/null +++ b/src/Docfx.DataContracts.RestApi/RestApiExternalDocumentationViewModel.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; +using Newtonsoft.Json; +using YamlDotNet.Serialization; + +namespace Docfx.DataContracts.RestApi; + +public class RestApiExternalDocumentationViewModel +{ + [YamlMember(Alias = "url")] + [JsonProperty("url", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("url")] + public string Url { get; set; } + + [YamlMember(Alias = "description")] + [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("description")] + public string Description { get; set; } + + [Docfx.YamlSerialization.ExtensibleMember] + [Newtonsoft.Json.JsonExtensionData] + [System.Text.Json.Serialization.JsonExtensionData] + public Dictionary Metadata { get; set; } = []; +} diff --git a/src/Docfx.DataContracts.RestApi/RestApiInfoViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiInfoViewModel.cs new file mode 100644 index 00000000000..0bed20147d7 --- /dev/null +++ b/src/Docfx.DataContracts.RestApi/RestApiInfoViewModel.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; +using Newtonsoft.Json; +using YamlDotNet.Serialization; + +namespace Docfx.DataContracts.RestApi; + +public class RestApiInfoViewModel +{ + [YamlMember(Alias = "title")] + [JsonProperty("title", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("title")] + public string Title { get; set; } + + [YamlMember(Alias = "version")] + [JsonProperty("version", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("version")] + public string Version { get; set; } + + [YamlMember(Alias = "description")] + [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("description")] + public string Description { get; set; } + + [Docfx.YamlSerialization.ExtensibleMember] + [Newtonsoft.Json.JsonExtensionData] + [System.Text.Json.Serialization.JsonExtensionData] + public Dictionary Metadata { get; set; } = []; +} diff --git a/src/Docfx.DataContracts.RestApi/RestApiParameterViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiParameterViewModel.cs index 2407dad082a..170225e863c 100644 --- a/src/Docfx.DataContracts.RestApi/RestApiParameterViewModel.cs +++ b/src/Docfx.DataContracts.RestApi/RestApiParameterViewModel.cs @@ -11,6 +11,11 @@ namespace Docfx.DataContracts.RestApi; public class RestApiParameterViewModel { + [YamlMember(Alias = "schema")] + [JsonProperty("schema", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("schema")] + public RestApiSchemaViewModel Schema { get; set; } + [YamlMember(Alias = "description")] [JsonProperty("description")] [JsonPropertyName("description")] diff --git a/src/Docfx.DataContracts.RestApi/RestApiResponseViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiResponseViewModel.cs index b8823d01ef9..51d29d61d03 100644 --- a/src/Docfx.DataContracts.RestApi/RestApiResponseViewModel.cs +++ b/src/Docfx.DataContracts.RestApi/RestApiResponseViewModel.cs @@ -11,6 +11,16 @@ namespace Docfx.DataContracts.RestApi; public class RestApiResponseViewModel { + [YamlMember(Alias = "schema")] + [JsonProperty("schema", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("schema")] + public RestApiSchemaViewModel Schema { get; set; } + + [YamlMember(Alias = "headers")] + [JsonProperty("headers", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("headers")] + public Dictionary Headers { get; set; } + [YamlMember(Alias = "statusCode")] [JsonProperty("statusCode")] [JsonPropertyName("statusCode")] diff --git a/src/Docfx.DataContracts.RestApi/RestApiRootItemViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiRootItemViewModel.cs index 429a1fef1e5..5b81eaf26d3 100644 --- a/src/Docfx.DataContracts.RestApi/RestApiRootItemViewModel.cs +++ b/src/Docfx.DataContracts.RestApi/RestApiRootItemViewModel.cs @@ -4,12 +4,28 @@ using System.Text.Json.Serialization; using Docfx.Common.EntityMergers; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using YamlDotNet.Serialization; namespace Docfx.DataContracts.RestApi; public class RestApiRootItemViewModel : RestApiItemViewModelBase { + [YamlMember(Alias = "securityDefinitions")] + [JsonProperty("securityDefinitions", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("securityDefinitions")] + public Dictionary SecurityDefinitions { get; set; } + + [YamlMember(Alias = "info")] + [JsonProperty("info", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("info")] + public RestApiInfoViewModel Info { get; set; } + + [YamlMember(Alias = "externalDocs")] + [JsonProperty("externalDocs", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("externalDocs")] + public RestApiExternalDocumentationViewModel ExternalDocs { get; set; } + /// /// The original swagger.json content /// `_` prefix indicates that this metadata is generated @@ -29,4 +45,20 @@ public class RestApiRootItemViewModel : RestApiItemViewModelBase [JsonProperty("children")] [JsonPropertyName("children")] public List Children { get; set; } + + /// Copy document context to a split page before its independent Markdown build. + public void CopyDocumentContextTo(RestApiRootItemViewModel target) + { + target.Info = Inherit("info", Info); + target.ExternalDocs = Inherit("externalDocs", ExternalDocs); + target.SecurityDefinitions = Inherit("securityDefinitions", SecurityDefinitions); + + // Legacy tag/operation metadata may override document fields. Promote it to the + // same typed contract, then clone so split pages never mark up shared instances. + T Inherit(string name, T fallback) where T : class + { + var value = target.Metadata.Remove(name, out var overridden) ? overridden : fallback; + return value == null ? null : JToken.FromObject(value).ToObject(); + } + } } diff --git a/src/Docfx.DataContracts.RestApi/RestApiSchemaViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiSchemaViewModel.cs new file mode 100644 index 00000000000..553cb737ad7 --- /dev/null +++ b/src/Docfx.DataContracts.RestApi/RestApiSchemaViewModel.cs @@ -0,0 +1,74 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; +using Docfx.Common.EntityMergers; +using Newtonsoft.Json; +using YamlDotNet.Serialization; + +namespace Docfx.DataContracts.RestApi; + +public class RestApiSchemaViewModel +{ + [YamlMember(Alias = "type")] + [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("type")] + public string Type { get; set; } + + [YamlMember(Alias = "format")] + [JsonProperty("format", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("format")] + public string Format { get; set; } + + [YamlMember(Alias = "description")] + [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("description")] + public string Description { get; set; } + + [YamlMember(Alias = "x-internal-ref-name")] + [JsonProperty("x-internal-ref-name", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("x-internal-ref-name")] + public string ReferenceName { get; set; } + + [YamlMember(Alias = "x-internal-loop-ref-name")] + [JsonProperty("x-internal-loop-ref-name", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("x-internal-loop-ref-name")] + public string LoopReferenceName { get; set; } + + [YamlMember(Alias = "properties")] + [JsonProperty("properties", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } + + [YamlMember(Alias = "items")] + [JsonProperty("items", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("items")] + public RestApiSchemaViewModel Items { get; set; } + + [YamlMember(Alias = "allOf")] + [JsonProperty("allOf", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("allOf")] + [MergeOption(typeof(RestApiArrayMergeHandler))] + public List AllOf { get; set; } + + [YamlMember(Alias = "required")] + [JsonProperty("required", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("required")] + public object Required { get; set; } + + [YamlMember(Alias = "enum")] + [JsonProperty("enum", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("enum")] + [MergeOption(typeof(RestApiArrayMergeHandler))] + public List Enum { get; set; } + + [YamlMember(Alias = "example")] + [JsonProperty("example", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("example")] + public object Example { get; set; } + + [Docfx.YamlSerialization.ExtensibleMember] + [Newtonsoft.Json.JsonExtensionData] + [System.Text.Json.Serialization.JsonExtensionData] + public Dictionary Metadata { get; set; } = []; +} diff --git a/src/Docfx.DataContracts.RestApi/RestApiSecuritySchemeViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiSecuritySchemeViewModel.cs new file mode 100644 index 00000000000..1bde962b424 --- /dev/null +++ b/src/Docfx.DataContracts.RestApi/RestApiSecuritySchemeViewModel.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; +using Newtonsoft.Json; +using YamlDotNet.Serialization; + +namespace Docfx.DataContracts.RestApi; + +public class RestApiSecuritySchemeViewModel +{ + [YamlMember(Alias = "description")] + [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("description")] + public string Description { get; set; } + + [Docfx.YamlSerialization.ExtensibleMember] + [Newtonsoft.Json.JsonExtensionData] + [System.Text.Json.Serialization.JsonExtensionData] + public Dictionary Metadata { get; set; } = []; +} diff --git a/templates/common/RestApi.common.js b/templates/common/RestApi.common.js index cdb07596e22..3f0a3253c4b 100644 --- a/templates/common/RestApi.common.js +++ b/templates/common/RestApi.common.js @@ -3,6 +3,8 @@ var common = require('./common.js'); exports.transform = function (model) { + var definitions = Object.create(null); + var references = []; var _fileNameWithoutExt = common.path.getFileNameWithoutExtension(model._path); model._jsonPath = _fileNameWithoutExt + ".swagger.json"; model.title = model.title || model.name; @@ -26,8 +28,8 @@ exports.transform = function (model) { child.htmlId = common.getHtmlId(child.uid); formatExample(child.responses); - resolveAllOf(child); - transformReference(child); + (child.parameters || []).forEach(transformPayload); + (child.responses || []).forEach(transformPayload); }; if (!model.tags || model.tags.length === 0) { var childTags = []; @@ -81,24 +83,70 @@ exports.transform = function (model) { model.children = model.children.filter(function (o) { return o; }); } } - model.definitions = []; - if (model.tags) { - model.tags.forEach(function(tag) { - (tag.children || []).forEach(function(child) { - (child.parameters || []).forEach(function(parameter) { addComplexTypeMetadata(parameter.schema, model.definitions); }); - (child.responses || []).forEach(function(response) { addComplexTypeMetadata(response.schema, model.definitions); }); - }); + references.forEach(function (reference) { + reference.details.referenceId = definitions[reference.name] ? definitions[reference.name].id : ''; + }); + model.definitions = Object.keys(definitions).map(function (name) { + var entry = definitions[name]; + var details = Object.assign({}, entry.details, { id: entry.id, name: name }); + if (details.referenceName === name) { + details.referenceName = ''; + details.referenceId = ''; + } + return { schemaDetails: details }; + }); + + return model; + + function transformPayload(payload) { + payload.schemaDetails = schemaDetails(payload.schema); + payload.exampleDetails = exampleDetails(payload.examples); + } + + function schemaDetails(schema) { + if (!schema) return false; + var name = schema['x-internal-loop-ref-name'] || schema['x-internal-ref-name']; + // Null fields fall through to ancestor scopes in Docfx's Mustache renderer. + // Empty strings and false keep missing fields local to this schema. + var details = {}; + var registeredName = schema['x-internal-ref-name']; + if (registeredName && !definitions[registeredName]) { + definitions[registeredName] = { id: registeredName.replace(/\./g, '_'), details: details }; + } + if (name) references.push({ details: details, name: name }); + return Object.assign(details, { + type: schema.type || '', + format: schema.format || '', + description: schema.description || '', + referenceName: name || '', + referenceId: '', + properties: Object.keys(schema.properties || {}).map(function (key) { + return { + key: key, + required: schema.properties[key].required === true || + (Array.isArray(schema.required) && schema.required.indexOf(key) >= 0), + value: schemaDetails(schema.properties[key]) + }; + }), + items: schemaDetails(schema.items), + composition: (schema.allOf ? [{ kind: 'All of', schemas: schema.allOf }] : []).map(function (composition) { + return { kind: composition.kind, schemas: (composition.schemas || []).map(function (branch) { return schemaDetails(branch); }) }; + }), + enum: (schema.enum || []).map(function (value) { return { value: JSON.stringify(value) }; }), + exampleDetails: exampleDetails(schema.example !== undefined ? [{ content: JSON.stringify(schema.example) }] : []) }); } - if (model.children) { - model.children.forEach(function(child) { - (child.parameters || []).forEach(function(parameter) { addComplexTypeMetadata(parameter.schema, model.definitions); }); - (child.responses || []).forEach(function(response) { addComplexTypeMetadata(response.schema, model.definitions); }); + + function exampleDetails(examples) { + return (examples || []).map(function (example) { + return { + mimeType: example.mimeType || '', + content: typeof example.content === "string" ? example.content : '', + hasContent: typeof example.content === "string" + }; }); } - return model; - function getChildrenByTag(children, tag) { if (!children) return; return children.filter(function (child) { @@ -130,120 +178,6 @@ exports.transform = function (model) { } } - function resolveAllOf(obj) { - if (Array.isArray(obj)) { - for (var i = 0; i < obj.length; i++) { - resolveAllOf(obj[i]); - } - } - else if (typeof obj === "object") { - for (var key in obj) { - if (obj.hasOwnProperty(key)) { - if (key === "allOf" && Array.isArray(obj[key])) { - // find 'allOf' array and process - processAllOfArray(obj[key], obj); - // delete 'allOf' value - delete obj[key]; - } else { - resolveAllOf(obj[key]); - } - } - } - } - } - - function processAllOfArray(allOfArray, originalObj) { - // for each object in 'allOf' array, merge the values to those in the same level with 'allOf' - for (var i = 0; i < allOfArray.length; i++) { - var item = allOfArray[i]; - for (var key in item) { - if (originalObj.hasOwnProperty(key)) { - mergeObjByKey(originalObj[key], item[key]); - } else { - originalObj[key] = item[key]; - } - } - } - } - - function mergeObjByKey(targetObj, sourceObj) { - for (var key in sourceObj) { - // merge only when target object doesn't define the key - if (!targetObj.hasOwnProperty(key)) { - targetObj[key] = sourceObj[key]; - } - } - } - - function transformReference(obj) { - if (Array.isArray(obj)) { - for (var i = 0; i < obj.length; i++) { - transformReference(obj[i]); - } - } - else if (typeof obj === "object") { - for (var key in obj) { - if (obj.hasOwnProperty(key)) { - if (key === "schema") { - // transform schema.properties from obj to key value pair - transformProperties(obj[key]); - } else { - transformReference(obj[key]); - } - } - } - } - } - - function transformProperties(obj) { - if (obj.properties) { - if (obj.required && Array.isArray(obj.required)) { - for (var i = 0; i < obj.required.length; i++) { - var field = obj.required[i]; - if (obj.properties[field]) { - // add required field as property - obj.properties[field].required = true; - } - } - delete obj.required; - } - var array = []; - for (var key in obj.properties) { - if (obj.properties.hasOwnProperty(key)) { - var value = obj.properties[key]; - // set description to null incase mustache looks up - value.description = value.description || null; - - transformPropertiesValue(value); - array.push({ key: key, value: value }); - } - } - obj.properties = array; - } - } - - function transformPropertiesValue(obj) { - if (obj.type === "array" && obj.items) { - // expand array to transformProperties - obj.items.properties = obj.items.properties || null; - obj.items['x-internal-ref-name'] = obj.items['x-internal-ref-name'] || null; - obj.items['x-internal-loop-ref-name'] = obj.items['x-internal-loop-ref-name'] || null; - transformProperties(obj.items); - } else if (obj.properties && !obj.items) { - // fill obj.properties into obj.items.properties, to be rendered in the same way with array - obj.items = {}; - obj.items.properties = obj.properties || null; - delete obj.properties; - if (obj.required) { - obj.items.required = obj.required; - delete obj.required; - } - obj.items['x-internal-ref-name'] = obj['x-internal-ref-name'] || null; - obj.items['x-internal-loop-ref-name'] = obj['x-internal-loop-ref-name'] || null; - transformProperties(obj.items); - } - } - function appendQueryParamsToPath(path, parameters) { if (!path || !parameters) return path; @@ -273,71 +207,7 @@ exports.transform = function (model) { return path; } - function addDefinition(definition, definitions) { - - if (!definition) { - return; - } - - var xRefName = definition.items && definition.items['x-internal-ref-name'] - ? definition.items['x-internal-ref-name'] - : definition['x-internal-ref-name']; - - // Not complex type. - if (!xRefName) { - return; - } - - // Definition already exists return. - if (definitions.some(function(d) { return d['x-internal-ref-name'] == xRefName; })) { - return; - } - - // Create clone to not affect object structure used in original location - definition = JSON.parse(JSON.stringify(definition)); - - // Unify different object structure to be the same - - // Sometimes properties is under items sometimes not - if (definition.items && definition.items.properties) { - definition.properties = definition.items.properties; - } - - // Sometimes ref-name is under items sometimes not - definition['x-internal-ref-name'] = xRefName; - - // Sometimes properties are key/value pairs sometimes not - if (definition.properties && !Array.isArray(definition.properties)) { - definition.properties = Object.keys(definition.properties).map(function(key) { - return { - key: key, - value: definition.properties[key] - } - }); - } - - // Add definition to definitions list. - definitions.push(definition); - // Loop through properties that refer to other definitions. - (definition.properties || []).forEach(function(property) { - addComplexTypeMetadata(property.value, definitions); - }); - } - - function addComplexTypeMetadata(child, definitions) { - // Add variations of x-internal-ref-name to support - if (child && child['x-internal-ref-name']) { - child.cTypeId = child['x-internal-ref-name'].replace(/\./g, '_'); - child.cType = child['x-internal-ref-name'].replace(/([A-Z])/g, '$1'); - } - if (child && child.items && child.items['x-internal-ref-name']) { - child.cTypeId = child.items['x-internal-ref-name'].replace(/\./g, '_'); - child.cType = child.items['x-internal-ref-name'].replace(/([A-Z])/g, '$1'); - child.cTypeIsArray = true; - } - addDefinition(child, definitions); - } } exports.getBookmarks = function (model) { diff --git a/templates/default/partials/rest.child.tmpl.partial b/templates/default/partials/rest.child.tmpl.partial index a64d6b7dd8b..14cff47e84f 100644 --- a/templates/default/partials/rest.child.tmpl.partial +++ b/templates/default/partials/rest.child.tmpl.partial @@ -42,16 +42,7 @@ {{#required}}*{{/required}}{{name}} - {{^schema.cType}} - {{schema.type}} - {{#schema.format}} - ({{schema.format}}) - {{/schema.format}} - {{/schema.cType}} - - {{#schema.cType}} - {{{schema.cType}}}{{#schema.cTypeIsArray}}[]{{/schema.cTypeIsArray}} - {{/schema.cType}} + {{#schemaDetails}}{{>partials/rest.schema}}{{/schemaDetails}} {{default}} {{{description}}} @@ -79,22 +70,11 @@ {{statusCode}} - {{^schema.cType}} - {{schema.type}} - {{/schema.cType}} - - {{#schema.cType}} - {{{schema.cType}}}{{#schema.cTypeIsArray}}[]{{/schema.cTypeIsArray}} - {{/schema.cType}} + {{#schemaDetails}}{{>partials/rest.schema}}{{/schemaDetails}} {{{description}}} - {{#examples}} -
- Mime type: {{mimeType}} -
-
{{content}}
- {{/examples}} + {{>partials/rest.examples}} {{/responses}} diff --git a/templates/default/partials/rest.definition.tmpl.partial b/templates/default/partials/rest.definition.tmpl.partial index 83cc77ef00b..f3f244ce101 100644 --- a/templates/default/partials/rest.definition.tmpl.partial +++ b/templates/default/partials/rest.definition.tmpl.partial @@ -1,45 +1,6 @@ {{!Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license.}} -

{{{cType}}}

-{{#description}} -
{{{description}}}
-{{/description}} -{{#properties.0}} - - - - - - - - - - {{/properties.0}} - {{#properties}} - - - - - - {{/properties}} - {{#properties.0}} - -
NameTypeNotes
{{key}} - {{^value.cType}} - {{value.type}} - {{#value.format}} - ({{value.format}}) - {{/value.format}} - {{/value.cType}} - - {{#value.cType}} - {{{value.cType}}}{{#value.cTypeIsArray}}[]{{/value.cTypeIsArray}} - {{/value.cType}} - {{{value.description}}}
-{{/properties.0}} -{{#enum.0}} -
Enum Values
-{{#enum}} -{{.}}
-{{/enum}} -{{/enum.0}} +{{#schemaDetails}} +

{{name}}

+{{>partials/rest.schema}} +{{/schemaDetails}} diff --git a/templates/default/partials/rest.examples.tmpl.partial b/templates/default/partials/rest.examples.tmpl.partial new file mode 100644 index 00000000000..e4a8f449dfe --- /dev/null +++ b/templates/default/partials/rest.examples.tmpl.partial @@ -0,0 +1,11 @@ +{{!Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license.}} +{{#exampleDetails}} +{{#mimeType}} +
+ Mime type: {{mimeType}} +
+{{/mimeType}} +{{#hasContent}} +
{{content}}
+{{/hasContent}} +{{/exampleDetails}} diff --git a/templates/default/partials/rest.schema.tmpl.partial b/templates/default/partials/rest.schema.tmpl.partial new file mode 100644 index 00000000000..33927481700 --- /dev/null +++ b/templates/default/partials/rest.schema.tmpl.partial @@ -0,0 +1,38 @@ +{{!Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license.}} +
+ {{#referenceName}} + {{#referenceId}}{{referenceName}}{{/referenceId}} + {{^referenceId}}{{referenceName}}{{/referenceId}} + {{/referenceName}} + {{#type}}{{type}}{{/type}} + {{#format}}({{format}}){{/format}} + {{#description}}
{{{description}}}
{{/description}} + {{#enum.0}} +
Allowed values: {{#enum}}{{value}} {{/enum}}
+ {{/enum.0}} + {{#exampleDetails.0}} +
Examples{{>partials/rest.examples}}
+ {{/exampleDetails.0}} + {{#items}} +
Items{{>partials/rest.schema}}
+ {{/items}} + {{#properties.0}} + + + + {{#properties}} + + + + + {{/properties}} + +
NameSchema
{{key}}{{#required}} (Required){{/required}}{{#value}}{{>partials/rest.schema}}{{/value}}
+ {{/properties.0}} + {{#composition}} +
+ {{kind}} +
    {{#schemas}}
  • {{>partials/rest.schema}}
  • {{/schemas}}
+
+ {{/composition}} +
diff --git a/templates/modern/src/rest.test.ts b/templates/modern/src/rest.test.ts new file mode 100644 index 00000000000..a793b318478 --- /dev/null +++ b/templates/modern/src/rest.test.ts @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { runInThisContext } from 'node:vm' + +// Docfx loads these CommonJS scripts separately from the template's ES modules. +const common = {} +runInThisContext(`(function(exports) { + ${readFileSync(new URL('../../common/common.js', import.meta.url), 'utf8')} +})`)(common) +const rest = runInThisContext(`(function(require) { + const exports = {}; + ${readFileSync(new URL('../../common/RestApi.common.js', import.meta.url), 'utf8')} + return exports; +})`)(() => common) + +test('REST preserves query paths and renders allOf without flattening schemas', () => { + const schema = { + 'x-internal-ref-name': 'Item', + allOf: [ + { properties: { id: { type: 'integer' } } }, + { required: ['name'], properties: { name: { type: 'string' } } } + ] + } + const original = structuredClone(schema) + const model = rest.transform({ + uid: 'legacy', + _path: 'legacy.html', + children: [{ + uid: 'get', + operation: 'get', + path: '/items', + parameters: [{ name: 'filter', in: 'query', required: true }, { name: 'limit', in: 'query' }], + responses: [{ schema, examples: [{ mimeType: 'application/json', content: '{"id":1}' }] }] + }] + }) + const child = model.children[0] + assert.equal(model._jsonPath, 'legacy.swagger.json') + assert.equal(child.operation, 'GET') + assert.equal(child.path, '/items?filter[&limit]') + assert.equal(child.responses[0].examples[0].content, '{\n "id": 1\n}') + const details = child.responses[0].schemaDetails + assert.equal(details.referenceId, 'Item') + assert.deepEqual(details.composition[0].schemas.map(branch => branch.properties.map(property => property.key)), [['id'], ['name']]) + assert.equal(details.composition[0].schemas[1].properties[0].required, true) + assert.deepEqual(schema, original) + assert.equal(model.definitions.length, 1) + assert.equal(model.definitions[0].schemaDetails.id, 'Item') +}) + +test('REST resolves recursive links after collecting schemas from all operations', () => { + const model = rest.transform({ + uid: 'references', + _path: 'references.html', + children: [{ + uid: 'list', + tags: ['Trees'], + responses: [{ schema: { type: 'array', items: { 'x-internal-loop-ref-name': 'Tree.Node' } } }] + }, { + uid: 'create', + tags: ['Trees'], + parameters: [{ + schema: { + type: 'object', + 'x-internal-ref-name': 'Tree.Node', + properties: { + next: { 'x-internal-loop-ref-name': 'Tree.Node' }, + missing: { 'x-internal-loop-ref-name': 'Missing' } + } + } + }] + }] + }) + const [list, create] = model.tags[0].children + assert.equal(list.responses[0].schemaDetails.items.referenceId, 'Tree_Node') + assert.equal(create.parameters[0].schemaDetails.properties[0].value.referenceId, 'Tree_Node') + assert.equal(create.parameters[0].schemaDetails.properties[1].value.referenceName, 'Missing') + assert.equal(create.parameters[0].schemaDetails.properties[1].value.referenceId, '') + assert.equal(model.definitions.length, 1) + assert.equal(model.definitions[0].schemaDetails.referenceName, '') +}) + +test('REST preserves schema-shaped literals and prepares missing fields for Mustache scopes', () => { + const literal = { + description: '**literal**', + allOf: [{ type: 'string' }], + 'x-internal-ref-name': 'NotADefinition' + } + const schema = { + type: 'object', + description: '

Schema description.

', + example: literal, + properties: { + state: { type: 'string', enum: ['', 'active'] }, + active: { type: 'boolean', enum: [false] }, + count: { type: 'integer', enum: [0] }, + empty: { type: 'string', example: '' } + }, + 'x-literal': literal + } + const original = structuredClone(schema) + const model = rest.transform({ + uid: 'literal', + _path: 'literal.html', + children: [{ uid: 'get', responses: [{ schema }] }] + }) + const details = model.children[0].responses[0].schemaDetails + assert.deepEqual(schema, original) + assert.equal(details.exampleDetails[0].content, JSON.stringify(literal)) + assert.equal(details.exampleDetails[0].mimeType, '') + assert.equal(details.properties[0].value.description, '') + assert.equal(details.properties[0].value.items, false) + assert.deepEqual(details.properties[0].value.exampleDetails, []) + assert.deepEqual(details.properties[0].value.enum, [{ value: '""' }, { value: '"active"' }]) + assert.deepEqual(details.properties[1].value.enum, [{ value: 'false' }]) + assert.deepEqual(details.properties[2].value.enum, [{ value: '0' }]) + assert.equal(details.properties[3].value.exampleDetails[0].content, '""') + assert.deepEqual(model.definitions, []) +}) diff --git a/test/Docfx.Build.RestApi.Tests/RestApiDocumentProcessorTest.cs b/test/Docfx.Build.RestApi.Tests/RestApiDocumentProcessorTest.cs index e2dcb241d03..893aa09362a 100644 --- a/test/Docfx.Build.RestApi.Tests/RestApiDocumentProcessorTest.cs +++ b/test/Docfx.Build.RestApi.Tests/RestApiDocumentProcessorTest.cs @@ -109,7 +109,7 @@ public void ProcessSwaggerShouldSucceed() // When 'definitions' has direct child with $ref defined, should resolve it var item5 = model.Children[6]; - var parameter2 = (JObject)item5.Parameters[2].Metadata["schema"]; + var parameter2 = JObject.FromObject(item5.Parameters[2].Schema); Assert.Equal("string", parameter2["type"]); Assert.Equal("uri", parameter2["format"]); // Verify markup result of parameters @@ -121,7 +121,7 @@ public void ProcessSwaggerShouldSucceed() item5.Responses[0].Description); // Verify for markup result of securityDefinitions - var securityDefinitions = (JObject)model.Metadata.Single(m => m.Key == "securityDefinitions").Value; + var securityDefinitions = JObject.FromObject(model.SecurityDefinitions); var auth = (JObject)securityDefinitions["auth"]; Assert.Equal("

securityDefinitions description.

\n", auth["description"].ToString()); @@ -138,7 +138,7 @@ public void ProcessSwaggerWithExternalReferenceShouldSucceed() var model = JsonUtility.Deserialize(outputRawModelPath); var operation = model.Children.Single(c => c.OperationId == "get contact direct reports links"); - var externalSchema = operation.Parameters[2].Metadata["schema"]; + var externalSchema = JObject.FromObject(operation.Parameters[2].Schema); var externalParameters = ((JObject)externalSchema)["parameters"]; Assert.Equal("cache1", externalParameters["name"]); var scheduleEntries = externalParameters["parameters"]["properties"]["scheduleEntries"]; @@ -162,7 +162,7 @@ public void ProcessSwaggerWithExternalEmbeddedReferenceShouldSucceed() var model = JsonUtility.Deserialize(outputRawModelPath); var operation = model.Children.Single(c => c.OperationId == "update_contact_manager"); - var externalSchema = (JObject)operation.Parameters[2].Metadata["schema"]; + var externalSchema = JObject.FromObject(operation.Parameters[2].Schema); Assert.Equal("

uri description.

\n", externalSchema["description"].ToString()); Assert.Equal("string", externalSchema["type"]); Assert.Equal("uri", externalSchema["format"]); @@ -335,7 +335,7 @@ public void ProcessSwaggerWithParametersOverwriteShouldSucceed() var bodyparam = parametersForUpdate.Single(p => p.Name == "bodyparam"); Assert.Equal("

The new bodyparam description

\n", bodyparam.Description); - var properties = (JObject)((JObject)bodyparam.Metadata["schema"])["properties"]; + var properties = (JObject)(JObject.FromObject(bodyparam.Schema))["properties"]; var objectType = properties["objectType"]; Assert.Equal("string", objectType["type"]); Assert.Equal("this is overwrite objectType description", objectType["description"]); @@ -345,7 +345,7 @@ public void ProcessSwaggerWithParametersOverwriteShouldSucceed() Assert.Equal("this is overwrite errorDetail description", errorDetail["description"]); var paramForUpdateManager = model.Children.Single(c => c.OperationId == "get contact memberOf links").Parameters.Single(p => p.Name == "bodyparam"); - var paramForAllOf = ((JObject)paramForUpdateManager.Metadata["schema"])["allOf"]; + var paramForAllOf = (JObject.FromObject(paramForUpdateManager.Schema))["allOf"]; // First allOf item is not overwritten Assert.Equal("

original first allOf description

\n", paramForAllOf[0]["description"]); // Second allOf item is overwritten @@ -447,6 +447,51 @@ public void SystemKeysListShouldBeComplete() } } + [Fact] + public void ProcessSwaggerMarksUpSchemaDescriptionsWithoutChangingLiteralData() + { + var input = GetRandomFolder(); + var file = CreateFile("literal.json", """ + { + "swagger": "2.0", + "info": { "title": "Literal", "version": "1.0", "description": "**API**" }, + "x-payload": { "description": "**literal root**" }, + "paths": { "/items": { "get": { + "operationId": "getItems", + "responses": { "200": { + "description": "**Response**", + "headers": { "X-Count": { "type": "integer", "description": "**Count**" } }, + "schema": { + "type": "object", + "description": "**Schema**", + "example": { "description": "**literal example**" }, + "enum": [{ "description": "**literal enum**" }], + "x-payload": { "description": "**literal extension**" }, + "allOf": [{ "properties": { + "name": { "type": "string", "description": "**Name**" } + } }] + } + } } + } } } + } + """, input); + var files = new FileCollection(Directory.GetCurrentDirectory()); + files.Add(DocumentType.Article, [file], input); + BuildDocument(files); + + var model = JsonUtility.Deserialize(Path.Combine(_outputFolder, "literal.raw.json")); + var response = Assert.Single(Assert.Single(model.Children).Responses); + Assert.Contains(">API", model.Info.Description); + Assert.Contains(">Response", response.Description); + Assert.Contains(">Count", response.Headers["X-Count"].Description); + Assert.Contains(">Schema", response.Schema.Description); + Assert.Contains(">Name", Assert.Single(response.Schema.AllOf).Properties["name"].Description); + Assert.Equal("**literal root**", ((JObject)model.Metadata["x-payload"])["description"]); + Assert.Equal("**literal example**", ((JObject)response.Schema.Example)["description"]); + Assert.Equal("**literal enum**", ((JObject)Assert.Single(response.Schema.Enum))["description"]); + Assert.Equal("**literal extension**", ((JObject)response.Schema.Metadata["x-payload"])["description"]); + } + private void BuildDocument(FileCollection files) { var parameters = new DocumentBuildParameters diff --git a/test/Docfx.Build.RestApi.Tests/RestApiViewModelTest.cs b/test/Docfx.Build.RestApi.Tests/RestApiViewModelTest.cs new file mode 100644 index 00000000000..f30ca969b9c --- /dev/null +++ b/test/Docfx.Build.RestApi.Tests/RestApiViewModelTest.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Docfx.Common.EntityMergers; +using Docfx.DataContracts.RestApi; +using Docfx.Exceptions; +using Xunit; + +namespace Docfx.Build.RestApi.Tests; + +public class RestApiViewModelTest +{ + [Fact] + public void SchemaOverwritePreservesPositionalNullPlaceholders() + { + var schema = new RestApiSchemaViewModel + { + AllOf = [ + new() { Type = "object", Description = "First" }, + new() { Type = "object", Description = "Second" }] + }; + var merger = new MergerFacade(new KeyedListMerger(new ReflectionEntityMerger())); + merger.Merge(ref schema, new RestApiSchemaViewModel + { + AllOf = [null, new() { Description = "Updated" }] + }); + Assert.Equal("First", schema.AllOf[0].Description); + Assert.Equal("Updated", schema.AllOf[1].Description); + Assert.Equal("object", schema.AllOf[1].Type); + Assert.Throws(() => merger.Merge(ref schema, new RestApiSchemaViewModel { AllOf = [] })); + } + + [Fact] + public void SplitDocumentContextIsIndependentAndPreservesOverrides() + { + var root = new RestApiRootItemViewModel + { + Info = new() { Title = "API", Description = "**API**" }, + SecurityDefinitions = new() { ["key"] = new() { Description = "**Key**" } }, + ExternalDocs = new() { Url = "https://example.test/root" } + }; + var split = new RestApiRootItemViewModel + { + Metadata = new() { ["externalDocs"] = new { url = "https://example.test/tag" } } + }; + root.CopyDocumentContextTo(split); + Assert.Equal("https://example.test/tag", split.ExternalDocs.Url); + Assert.Equal("https://example.test/root", root.ExternalDocs.Url); + Assert.DoesNotContain("externalDocs", split.Metadata.Keys); + split.Info.Description = "

API

"; + split.SecurityDefinitions["key"].Description = "

Key

"; + Assert.Equal("**API**", root.Info.Description); + Assert.Equal("**Key**", root.SecurityDefinitions["key"].Description); + } +} diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/SplitRestApiToOperationLevelTest.cs b/test/Docfx.Build.RestApi.WithPlugins.Tests/SplitRestApiToOperationLevelTest.cs index db65cfef4c8..74b60742135 100644 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/SplitRestApiToOperationLevelTest.cs +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/SplitRestApiToOperationLevelTest.cs @@ -55,7 +55,7 @@ public void SplitRestApiToOperationLevelShouldSucceed() Assert.Empty(model.Children); Assert.True((bool)model.Metadata["_isSplittedByOperation"]); Assert.Empty(model.Tags); - Assert.Equal("

Find out more about Swagger

\n", ((JObject)model.Metadata["externalDocs"])["description"]); + Assert.Equal("

Find out more about Swagger

\n", model.ExternalDocs.Description); } { // Verify splitted operation page @@ -70,13 +70,13 @@ public void SplitRestApiToOperationLevelShouldSucceed() Assert.Empty(model.Tags); Assert.Equal("swagger/petstore/addPet.html", model.Metadata["_path"]); Assert.Equal("TestData/swagger/petstore/addPet.json", model.Metadata["_key"]); - Assert.True(model.Metadata.ContainsKey("externalDocs")); + Assert.NotNull(model.ExternalDocs); Assert.True((bool)model.Metadata["_isSplittedToOperation"]); Assert.Single(model.Children); Assert.Empty(model.Tags); // Test overwritten metadata - Assert.Equal("

Find out more about addPet

\n", ((JObject)model.Metadata["externalDocs"])["description"]); + Assert.Equal("

Find out more about addPet

\n", model.ExternalDocs.Description); var child = model.Children[0]; Assert.Equal("petstore.swagger.io/v2/Swagger Petstore/1.0.0/addPet/operation", child.Uid); @@ -117,7 +117,7 @@ public void SplitRestApiToOperationLevelWithTocShouldSucceed() Assert.Equal("swagger/petstore/addPet.html", model.Metadata["_path"]); Assert.Equal("TestData/swagger/petstore/addPet.json", model.Metadata["_key"]); Assert.Equal("../toc.yml", model.Metadata["_tocRel"]); - Assert.True(model.Metadata.ContainsKey("externalDocs")); + Assert.NotNull(model.ExternalDocs); Assert.Single(model.Children); Assert.Empty(model.Tags); @@ -175,7 +175,7 @@ public void SplitRestApiToTagAndOperationLevelWithTocShouldSucceed() Assert.Empty(model.Tags); Assert.Equal("swagger/petstore/pet.html", model.Metadata["_path"]); Assert.Equal("TestData/swagger/petstore/pet.json", model.Metadata["_key"]); - Assert.True(model.Metadata.ContainsKey("externalDocs")); + Assert.NotNull(model.ExternalDocs); Assert.True((bool)model.Metadata["_isSplittedToTag"]); Assert.True((bool)model.Metadata["_isSplittedByOperation"]); } @@ -193,7 +193,7 @@ public void SplitRestApiToTagAndOperationLevelWithTocShouldSucceed() Assert.Equal("swagger/petstore/pet/addPet.html", model.Metadata["_path"]); Assert.Equal("TestData/swagger/petstore/pet/addPet.json", model.Metadata["_key"]); Assert.Equal("../../toc.yml", model.Metadata["_tocRel"]); - Assert.True(model.Metadata.ContainsKey("externalDocs")); + Assert.NotNull(model.ExternalDocs); Assert.Single(model.Children); Assert.True((bool)model.Metadata["_isSplittedToOperation"]); diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/SplitRestApiToTagLevelTest.cs b/test/Docfx.Build.RestApi.WithPlugins.Tests/SplitRestApiToTagLevelTest.cs index 22a06714040..b58025ec501 100644 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/SplitRestApiToTagLevelTest.cs +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/SplitRestApiToTagLevelTest.cs @@ -56,7 +56,7 @@ public void ProcessRestApiShouldSucceed() Assert.Empty(model.Children); Assert.Empty(model.Tags); Assert.True((bool)model.Metadata["_isSplittedByTag"]); - Assert.Equal("

Find out more about Swagger

\n", ((JObject)model.Metadata["externalDocs"])["description"]); + Assert.Equal("

Find out more about Swagger

\n", model.ExternalDocs.Description); } { // Verify splitted tag page @@ -72,11 +72,11 @@ public void ProcessRestApiShouldSucceed() Assert.Empty(model.Children[0].Tags); Assert.Equal("swagger/petstore/pet.html", model.Metadata["_path"]); Assert.Equal("TestData/swagger/petstore/pet.json", model.Metadata["_key"]); - Assert.True(model.Metadata.ContainsKey("externalDocs")); + Assert.NotNull(model.ExternalDocs); Assert.True((bool)model.Metadata["_isSplittedToTag"]); // Test overwritten metadata - Assert.Equal("

Find out more about pets

\n", ((JObject)model.Metadata["externalDocs"])["description"]); + Assert.Equal("

Find out more about pets

\n", model.ExternalDocs.Description); } } @@ -111,7 +111,7 @@ public void ProcessRestApiWithTocShouldSucceed() Assert.Empty(model.Children[0].Tags); Assert.Equal("swagger/petstore/pet.html", model.Metadata["_path"]); Assert.Equal("TestData/swagger/petstore/pet.json", model.Metadata["_key"]); - Assert.True(model.Metadata.ContainsKey("externalDocs")); + Assert.NotNull(model.ExternalDocs); } { // Verify toc page diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs b/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs index 7cc9d361a05..0209288a670 100644 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs @@ -336,12 +336,17 @@ public void PreservesSwaggerDocumentation(string template, bool splitTags, bool Assert.Equal("201", (string)Assert.Single(create["responses"])["statusCode"]); Assert.Equal("Item", (string)create["responses"][0]["schema"]["x-internal-ref-name"]); - var viewBody = viewOperations["createItem"]["parameters"][0]["schema"]; - Assert.Equal("Item", (string)viewBody["cTypeId"]); - Assert.Equal("literal-schema-example", (string)viewBody["example"]["$ref"]); - Assert.Equal(["id", "name", "state"], viewBody["properties"].Select(property => (string)property["key"])); - var viewName = viewBody["properties"][1]["value"]; - Assert.True((bool)viewName["required"]); + var viewBody = viewOperations["createItem"]["parameters"][0]["schemaDetails"]; + Assert.Equal("Item", (string)viewBody["referenceId"]); + Assert.Equal("literal-schema-example", (string)JObject.Parse((string)Assert.Single(viewBody["exampleDetails"])["content"])["$ref"]); + var viewProperties = viewBody["composition"][0]["schemas"].SelectMany(branch => branch["properties"]).ToArray(); + Assert.Equal(["id", "name", "state"], viewProperties.Select(property => (string)property["key"])); + var viewName = viewProperties[1]["value"]; + Assert.True((bool)viewProperties[1]["required"]); + Assert.NotNull(articles[splitOperations ? (splitTags ? "service/items/createItem" : "service/createItem") : splitTags ? "service/items" : "service"] + .SelectSingleNode(".//h3[@id='Item']")); + Assert.NotNull(articles[splitOperations ? (splitTags ? "service/items/createItem" : "service/createItem") : splitTags ? "service/items" : "service"] + .SelectSingleNode(".//div[@class='schema-composition']/strong[text()='All of']")); var listPage = splitTags ? "service/items" : "service"; if (splitOperations) { @@ -355,8 +360,8 @@ public void PreservesSwaggerDocumentation(string template, bool splitTags, bool if (overwrite) { - Assert.Equal("Updated name description.", (string)schema["allOf"][1]["properties"]["name"]["description"]); - Assert.Equal("Updated name description.", (string)viewName["description"]); + Assert.Equal("Updated name description.", HtmlNode.CreateNode((string)schema["allOf"][1]["properties"]["name"]["description"]).InnerText.Trim()); + Assert.Equal("Updated name description.", HtmlNode.CreateNode((string)viewName["description"]).InnerText.Trim()); foreach (var level in new[] { "Document", "Tag", "Operation" }) { Assert.NotNull(articles["service"].SelectSingleNode($".//p[text()='{level}-level conceptual content.']")); From 46956c2d03e3450521e2143c71d4e6d45cb067a7 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Thu, 24 Sep 2026 22:49:44 +1000 Subject: [PATCH 3/6] Keep document inheritance in REST split steps --- .../SplitRestApiToOperationLevel.cs | 20 ++++-- .../SplitRestApiToTagLevel.cs | 20 ++++-- .../RestApiRootItemViewModel.cs | 17 ------ .../RestApiViewModelTest.cs | 23 ------- .../SwaggerOutputCompatibilityTest.cs | 61 +++++++++++++++++++ 5 files changed, 91 insertions(+), 50 deletions(-) diff --git a/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs b/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs index 00320ed68a7..c59ba132413 100644 --- a/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs +++ b/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs @@ -9,6 +9,7 @@ using Docfx.DataContracts.Common; using Docfx.DataContracts.RestApi; using Docfx.Plugins; +using Newtonsoft.Json.Linq; namespace Docfx.Build.OperationLevelRestApi; @@ -124,10 +125,9 @@ private static IEnumerable GenerateOperationModels(Res Remarks = child.Remarks, Documentation = child.Documentation, Children = [child], - Tags = [], - Metadata = MergeChildMetadata(root, child) + Tags = [] }; - root.CopyDocumentContextTo(model); + MergeChildMetadata(root, child, model); // Reset child's uid to "originalUid/operation", that is to say, overwrite of original Uid will show in operation page. child.Uid = string.Join('/', child.Uid, "operation"); @@ -180,7 +180,7 @@ private static TreeItem ConvertToTreeItem(RestApiRootItemViewModel root) }; } - private static Dictionary MergeChildMetadata(RestApiRootItemViewModel root, RestApiChildItemViewModel child) + private static void MergeChildMetadata(RestApiRootItemViewModel root, RestApiChildItemViewModel child, RestApiRootItemViewModel model) { var result = new Dictionary(child.Metadata); foreach (var pair in root.Metadata) @@ -188,6 +188,16 @@ private static Dictionary MergeChildMetadata(RestApiRootItemView // Child metadata wins for the same key result.TryAdd(pair.Key, pair.Value); } - return result; + model.Metadata = result; + model.Info = Inherit("info", root.Info); + model.ExternalDocs = Inherit("externalDocs", root.ExternalDocs); + model.SecurityDefinitions = Inherit("securityDefinitions", root.SecurityDefinitions); + + T Inherit(string name, T fallback) where T : class + { + var value = result.Remove(name, out var overridden) ? overridden : fallback; + // Each split page marks up its own descriptions. + return value == null ? null : JToken.FromObject(value).ToObject(); + } } } diff --git a/src/Docfx.Build.TagLevelRestApi/SplitRestApiToTagLevel.cs b/src/Docfx.Build.TagLevelRestApi/SplitRestApiToTagLevel.cs index 03221938ef8..1173e200d22 100644 --- a/src/Docfx.Build.TagLevelRestApi/SplitRestApiToTagLevel.cs +++ b/src/Docfx.Build.TagLevelRestApi/SplitRestApiToTagLevel.cs @@ -9,6 +9,7 @@ using Docfx.DataContracts.Common; using Docfx.DataContracts.RestApi; using Docfx.Plugins; +using Newtonsoft.Json.Linq; namespace Docfx.Build.TagLevelRestApi; @@ -118,10 +119,9 @@ private static IEnumerable GenerateTagModels(RestApiRo Description = tag.Description, Documentation = tag.Documentation, Children = tagChildren, - Tags = [], - Metadata = MergeTagMetadata(root, tag) + Tags = [] }; - root.CopyDocumentContextTo(model); + MergeTagMetadata(root, tag, model); yield return model; } } @@ -187,7 +187,7 @@ private static TreeItem ConvertToTreeItem(RestApiRootItemViewModel root) }; } - private static Dictionary MergeTagMetadata(RestApiRootItemViewModel root, RestApiTagViewModel tag) + private static void MergeTagMetadata(RestApiRootItemViewModel root, RestApiTagViewModel tag, RestApiRootItemViewModel model) { var result = new Dictionary(tag.Metadata); foreach (var pair in root.Metadata) @@ -195,6 +195,16 @@ private static Dictionary MergeTagMetadata(RestApiRootItemViewMo // Tag metadata wins for the same key result.TryAdd(pair.Key, pair.Value); } - return result; + model.Metadata = result; + model.Info = Inherit("info", root.Info); + model.ExternalDocs = Inherit("externalDocs", root.ExternalDocs); + model.SecurityDefinitions = Inherit("securityDefinitions", root.SecurityDefinitions); + + T Inherit(string name, T fallback) where T : class + { + var value = result.Remove(name, out var overridden) ? overridden : fallback; + // Each split page marks up its own descriptions. + return value == null ? null : JToken.FromObject(value).ToObject(); + } } } diff --git a/src/Docfx.DataContracts.RestApi/RestApiRootItemViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiRootItemViewModel.cs index 5b81eaf26d3..a4faddbbb9e 100644 --- a/src/Docfx.DataContracts.RestApi/RestApiRootItemViewModel.cs +++ b/src/Docfx.DataContracts.RestApi/RestApiRootItemViewModel.cs @@ -4,7 +4,6 @@ using System.Text.Json.Serialization; using Docfx.Common.EntityMergers; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using YamlDotNet.Serialization; namespace Docfx.DataContracts.RestApi; @@ -45,20 +44,4 @@ public class RestApiRootItemViewModel : RestApiItemViewModelBase [JsonProperty("children")] [JsonPropertyName("children")] public List Children { get; set; } - - /// Copy document context to a split page before its independent Markdown build. - public void CopyDocumentContextTo(RestApiRootItemViewModel target) - { - target.Info = Inherit("info", Info); - target.ExternalDocs = Inherit("externalDocs", ExternalDocs); - target.SecurityDefinitions = Inherit("securityDefinitions", SecurityDefinitions); - - // Legacy tag/operation metadata may override document fields. Promote it to the - // same typed contract, then clone so split pages never mark up shared instances. - T Inherit(string name, T fallback) where T : class - { - var value = target.Metadata.Remove(name, out var overridden) ? overridden : fallback; - return value == null ? null : JToken.FromObject(value).ToObject(); - } - } } diff --git a/test/Docfx.Build.RestApi.Tests/RestApiViewModelTest.cs b/test/Docfx.Build.RestApi.Tests/RestApiViewModelTest.cs index f30ca969b9c..50bd5e0ef84 100644 --- a/test/Docfx.Build.RestApi.Tests/RestApiViewModelTest.cs +++ b/test/Docfx.Build.RestApi.Tests/RestApiViewModelTest.cs @@ -29,27 +29,4 @@ public void SchemaOverwritePreservesPositionalNullPlaceholders() Assert.Equal("object", schema.AllOf[1].Type); Assert.Throws(() => merger.Merge(ref schema, new RestApiSchemaViewModel { AllOf = [] })); } - - [Fact] - public void SplitDocumentContextIsIndependentAndPreservesOverrides() - { - var root = new RestApiRootItemViewModel - { - Info = new() { Title = "API", Description = "**API**" }, - SecurityDefinitions = new() { ["key"] = new() { Description = "**Key**" } }, - ExternalDocs = new() { Url = "https://example.test/root" } - }; - var split = new RestApiRootItemViewModel - { - Metadata = new() { ["externalDocs"] = new { url = "https://example.test/tag" } } - }; - root.CopyDocumentContextTo(split); - Assert.Equal("https://example.test/tag", split.ExternalDocs.Url); - Assert.Equal("https://example.test/root", root.ExternalDocs.Url); - Assert.DoesNotContain("externalDocs", split.Metadata.Keys); - split.Info.Description = "

API

"; - split.SecurityDefinitions["key"].Description = "

Key

"; - Assert.Equal("**API**", root.Info.Description); - Assert.Equal("**Key**", root.SecurityDefinitions["key"].Description); - } } diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs b/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs index 0209288a670..70ba4d37047 100644 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs @@ -376,6 +376,67 @@ public void PreservesSwaggerDocumentation(string template, bool splitTags, bool } } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SplitPageMetadataOverridesDoNotChangeTheRoot(bool splitTags) + { + var input = GetRandomFolder(); + var output = GetRandomFolder(); + var file = CreateFile("service.json", """ + { + "swagger": "2.0", + "info": { "title": "Isolation", "version": "1.0", "description": "**API**" }, + "externalDocs": { "url": "https://example.test/root" }, + "securityDefinitions": { + "key": { "type": "apiKey", "name": "X-Key", "in": "header", "description": "**Key**" } + }, + "tags": [{ "name": "items", "externalDocs": { "url": "https://example.test/tag" } }], + "paths": { "/items": { "get": { + "operationId": "getItems", + "tags": ["items"], + "externalDocs": { "url": "https://example.test/operation" }, + "responses": { "200": { "description": "OK" } } + } } } + } + """, input); + var uid = splitTags ? "Isolation/1.0/tag/items" : "Isolation/1.0/getItems"; + var overwrite = CreateFile("overwrite.md", $$""" + --- + uid: {{uid}} + info: + description: Changed info + securityDefinitions: + key: + description: Changed key + --- + """, input); + var files = new FileCollection(Directory.GetCurrentDirectory()); + files.Add(DocumentType.Article, [file], input); + files.Add(DocumentType.Overwrite, [overwrite], input); + using var builder = new DocumentBuilder(GetAssemblies(splitTags, !splitTags), []); + 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"); + var split = ReadModel(output, splitTags ? "service/items.raw.json" : "service/getItems.raw.json"); + Assert.Equal("https://example.test/root", (string)root["externalDocs"]["url"]); + Assert.Equal(splitTags ? "https://example.test/tag" : "https://example.test/operation", (string)split["externalDocs"]["url"]); + Assert.Contains(">API", (string)root["info"]["description"]); + Assert.Contains(">Key", (string)root["securityDefinitions"]["key"]["description"]); + Assert.Equal("Isolation", (string)split["info"]["title"]); + Assert.Contains("Changed info", (string)split["info"]["description"]); + Assert.Contains("Changed key", (string)split["securityDefinitions"]["key"]["description"]); + } + private static IEnumerable GetAssemblies(bool splitTags, bool splitOperations) { yield return typeof(RestApiDocumentProcessor).Assembly; From 86533e4c2068a9e5db20e4c0a4a3002087fa5930 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Thu, 24 Sep 2026 22:57:24 +1000 Subject: [PATCH 4/6] Keep REST usage documentation focused --- docs/docs/rest-api-docs.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/docs/rest-api-docs.md b/docs/docs/rest-api-docs.md index 1cc8caa760e..02c8afc79e3 100644 --- a/docs/docs/rest-api-docs.md +++ b/docs/docs/rest-api-docs.md @@ -16,8 +16,6 @@ To add REST API docs, include the swagger JSON file to the `build` config in `do Each swagger file produces one output HTML file. -Parameter, response, and definition schemas display nested properties, array items, allowed values, and examples. Schemas using `allOf` display each member under **All of**, preserving the individual schemas and their descriptions. - ## Organize REST APIs using Tags From 626f83ac5239582967b1ee6ecaa75641f0aa39b1 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Thu, 24 Sep 2026 23:13:04 +1000 Subject: [PATCH 5/6] Establish Swagger metadata compatibility baseline --- .../SplitRestApiToOperationLevel.cs | 19 +- .../BuildRestApiDocument.cs | 53 +++- .../RestApiDocumentProcessor.cs | 2 +- .../SwaggerModelConverter.cs | 28 -- .../SplitRestApiToTagLevel.cs | 22 +- .../RestApiArrayMergeHandler.cs | 36 --- .../RestApiExternalDocumentationViewModel.cs | 26 -- .../RestApiInfoViewModel.cs | 31 --- .../RestApiParameterViewModel.cs | 5 - .../RestApiResponseViewModel.cs | 10 - .../RestApiRootItemViewModel.cs | 15 - .../RestApiSchemaViewModel.cs | 74 ----- .../RestApiSecuritySchemeViewModel.cs | 21 -- templates/common/RestApi.common.js | 256 +++++++++++++----- .../default/partials/rest.child.tmpl.partial | 26 +- .../partials/rest.definition.tmpl.partial | 47 +++- .../partials/rest.examples.tmpl.partial | 11 - .../default/partials/rest.schema.tmpl.partial | 38 --- templates/modern/src/rest.test.ts | 122 --------- .../RestApiDocumentProcessorTest.cs | 57 +--- .../RestApiViewModelTest.cs | 32 --- .../SwaggerCompatibilityTest.cs | 37 +++ .../SplitRestApiToOperationLevelTest.cs | 12 +- .../SplitRestApiToTagLevelTest.cs | 8 +- .../SwaggerOutputCompatibilityTest.cs | 198 +++++++++----- 25 files changed, 486 insertions(+), 700 deletions(-) delete mode 100644 src/Docfx.DataContracts.RestApi/RestApiArrayMergeHandler.cs delete mode 100644 src/Docfx.DataContracts.RestApi/RestApiExternalDocumentationViewModel.cs delete mode 100644 src/Docfx.DataContracts.RestApi/RestApiInfoViewModel.cs delete mode 100644 src/Docfx.DataContracts.RestApi/RestApiSchemaViewModel.cs delete mode 100644 src/Docfx.DataContracts.RestApi/RestApiSecuritySchemeViewModel.cs delete mode 100644 templates/default/partials/rest.examples.tmpl.partial delete mode 100644 templates/default/partials/rest.schema.tmpl.partial delete mode 100644 templates/modern/src/rest.test.ts delete mode 100644 test/Docfx.Build.RestApi.Tests/RestApiViewModelTest.cs diff --git a/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs b/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs index c59ba132413..6d520c7b5a7 100644 --- a/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs +++ b/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs @@ -9,7 +9,6 @@ using Docfx.DataContracts.Common; using Docfx.DataContracts.RestApi; using Docfx.Plugins; -using Newtonsoft.Json.Linq; namespace Docfx.Build.OperationLevelRestApi; @@ -125,9 +124,9 @@ private static IEnumerable GenerateOperationModels(Res Remarks = child.Remarks, Documentation = child.Documentation, Children = [child], - Tags = [] + Tags = [], + Metadata = MergeChildMetadata(root, child) }; - MergeChildMetadata(root, child, model); // Reset child's uid to "originalUid/operation", that is to say, overwrite of original Uid will show in operation page. child.Uid = string.Join('/', child.Uid, "operation"); @@ -180,7 +179,7 @@ private static TreeItem ConvertToTreeItem(RestApiRootItemViewModel root) }; } - private static void MergeChildMetadata(RestApiRootItemViewModel root, RestApiChildItemViewModel child, RestApiRootItemViewModel model) + private static Dictionary MergeChildMetadata(RestApiRootItemViewModel root, RestApiChildItemViewModel child) { var result = new Dictionary(child.Metadata); foreach (var pair in root.Metadata) @@ -188,16 +187,6 @@ private static void MergeChildMetadata(RestApiRootItemViewModel root, RestApiChi // Child metadata wins for the same key result.TryAdd(pair.Key, pair.Value); } - model.Metadata = result; - model.Info = Inherit("info", root.Info); - model.ExternalDocs = Inherit("externalDocs", root.ExternalDocs); - model.SecurityDefinitions = Inherit("securityDefinitions", root.SecurityDefinitions); - - T Inherit(string name, T fallback) where T : class - { - var value = result.Remove(name, out var overridden) ? overridden : fallback; - // Each split page marks up its own descriptions. - return value == null ? null : JToken.FromObject(value).ToObject(); - } + return result; } } diff --git a/src/Docfx.Build.RestApi/BuildRestApiDocument.cs b/src/Docfx.Build.RestApi/BuildRestApiDocument.cs index f3d146f7a77..8e2b79feafb 100644 --- a/src/Docfx.Build.RestApi/BuildRestApiDocument.cs +++ b/src/Docfx.Build.RestApi/BuildRestApiDocument.cs @@ -8,11 +8,15 @@ using Docfx.DataContracts.RestApi; using Docfx.Plugins; +using Newtonsoft.Json.Linq; + namespace Docfx.Build.RestApi; [Export(nameof(RestApiDocumentProcessor), typeof(IDocumentBuildStep))] public class BuildRestApiDocument : BuildReferenceDocumentBase { + private static readonly HashSet MarkupKeys = ["description"]; + public override string Name => nameof(BuildRestApiDocument); protected override void BuildArticle(IHostService host, FileModel model) @@ -47,11 +51,10 @@ public static RestApiItemViewModelBase BuildItem(IHostService host, RestApiItemV if (item is RestApiRootItemViewModel rootModel) { - if (rootModel.Info != null) rootModel.Info.Description = Markup(host, rootModel.Info.Description, model, filter); - if (rootModel.ExternalDocs != null) rootModel.ExternalDocs.Description = Markup(host, rootModel.ExternalDocs.Description, model, filter); - foreach (var security in rootModel.SecurityDefinitions?.Values.AsEnumerable() ?? []) + // Mark up recursively for swagger root except for children and tags + foreach (var jToken in rootModel.Metadata.Values.OfType()) { - if (security != null) security.Description = Markup(host, security.Description, model, filter); + MarkupRecursive(jToken, host, model, filter); } } @@ -61,7 +64,11 @@ public static RestApiItemViewModelBase BuildItem(IHostService host, RestApiItemV foreach (var param in childModel.Parameters) { param.Description = Markup(host, param.Description, model, filter); - MarkupSchema(param.Schema); + + foreach (var jToken in param.Metadata.Values.OfType()) + { + MarkupRecursive(jToken, host, model, filter); + } } } if (childModel?.Responses != null) @@ -69,19 +76,39 @@ public static RestApiItemViewModelBase BuildItem(IHostService host, RestApiItemV foreach (var response in childModel.Responses) { response.Description = Markup(host, response.Description, model, filter); - MarkupSchema(response.Schema); - foreach (var header in response.Headers?.Values.AsEnumerable() ?? []) MarkupSchema(header); + + foreach (var jToken in response.Metadata.Values.OfType()) + { + MarkupRecursive(jToken, host, model, filter); + } } } return item; + } - void MarkupSchema(RestApiSchemaViewModel schema) + private static void MarkupRecursive(JToken jToken, IHostService host, FileModel model, Func filter = null) + { + if (jToken is JArray jArray) { - if (schema == null) return; - schema.Description = Markup(host, schema.Description, model, filter); - foreach (var property in schema.Properties?.Values.AsEnumerable() ?? []) MarkupSchema(property); - MarkupSchema(schema.Items); - foreach (var branch in schema.AllOf ?? []) MarkupSchema(branch); + foreach (var item in jArray) + { + MarkupRecursive(item, host, model, filter); + } + } + + if (jToken is JObject jObject) + { + foreach (var pair in jObject) + { + if (MarkupKeys.Contains(pair.Key) && pair.Value != null) + { + if (pair.Value is JValue { Type: JTokenType.String } jValue) + { + jObject[pair.Key] = Markup(host, (string)jValue, model, filter); + } + } + MarkupRecursive(jObject[pair.Key], host, model, filter); + } } } diff --git a/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs b/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs index 8f1a7ca721b..72bc22a9968 100644 --- a/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs +++ b/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs @@ -134,7 +134,7 @@ protected override FileModel LoadArticle(FileAndType file, ImmutableDictionary>(model.Metadata, "securityDefinitions"); - model.Info = JObject.FromObject(swagger.Info).ToObject(); - model.ExternalDocs = Take(model.Metadata, "externalDocs"); - foreach (var child in model.Children) - { - foreach (var parameter in child.Parameters ?? []) - { - parameter.Schema = Take(parameter.Metadata, "schema"); - if (parameter.Schema == null) - { - parameter.Schema = JObject.FromObject(parameter.Metadata).ToObject(); - } - } - foreach (var response in child.Responses ?? []) - { - response.Schema = Take(response.Metadata, "schema"); - response.Headers = Take>(response.Metadata, "headers"); - } - } - return model; - } - - private static T Take(Dictionary metadata, string name) where T : class => - metadata.Remove(name, out var value) && value != null ? JToken.FromObject(value).ToObject() : null; - #region Private methods [GeneratedRegex(@"\W")] diff --git a/src/Docfx.Build.TagLevelRestApi/SplitRestApiToTagLevel.cs b/src/Docfx.Build.TagLevelRestApi/SplitRestApiToTagLevel.cs index 1173e200d22..021a9aabac4 100644 --- a/src/Docfx.Build.TagLevelRestApi/SplitRestApiToTagLevel.cs +++ b/src/Docfx.Build.TagLevelRestApi/SplitRestApiToTagLevel.cs @@ -9,7 +9,6 @@ using Docfx.DataContracts.Common; using Docfx.DataContracts.RestApi; using Docfx.Plugins; -using Newtonsoft.Json.Linq; namespace Docfx.Build.TagLevelRestApi; @@ -110,7 +109,7 @@ private static IEnumerable GenerateTagModels(RestApiRo var tagChildren = GetChildrenByTag(root, tag.Name).ToList(); if (tagChildren.Count > 0) { - var model = new RestApiRootItemViewModel + yield return new RestApiRootItemViewModel { Uid = tag.Uid, HtmlId = tag.HtmlId, @@ -119,10 +118,9 @@ private static IEnumerable GenerateTagModels(RestApiRo Description = tag.Description, Documentation = tag.Documentation, Children = tagChildren, - Tags = [] + Tags = [], + Metadata = MergeTagMetadata(root, tag) }; - MergeTagMetadata(root, tag, model); - yield return model; } } } @@ -187,7 +185,7 @@ private static TreeItem ConvertToTreeItem(RestApiRootItemViewModel root) }; } - private static void MergeTagMetadata(RestApiRootItemViewModel root, RestApiTagViewModel tag, RestApiRootItemViewModel model) + private static Dictionary MergeTagMetadata(RestApiRootItemViewModel root, RestApiTagViewModel tag) { var result = new Dictionary(tag.Metadata); foreach (var pair in root.Metadata) @@ -195,16 +193,6 @@ private static void MergeTagMetadata(RestApiRootItemViewModel root, RestApiTagVi // Tag metadata wins for the same key result.TryAdd(pair.Key, pair.Value); } - model.Metadata = result; - model.Info = Inherit("info", root.Info); - model.ExternalDocs = Inherit("externalDocs", root.ExternalDocs); - model.SecurityDefinitions = Inherit("securityDefinitions", root.SecurityDefinitions); - - T Inherit(string name, T fallback) where T : class - { - var value = result.Remove(name, out var overridden) ? overridden : fallback; - // Each split page marks up its own descriptions. - return value == null ? null : JToken.FromObject(value).ToObject(); - } + return result; } } diff --git a/src/Docfx.DataContracts.RestApi/RestApiArrayMergeHandler.cs b/src/Docfx.DataContracts.RestApi/RestApiArrayMergeHandler.cs deleted file mode 100644 index 4a7aeb3a885..00000000000 --- a/src/Docfx.DataContracts.RestApi/RestApiArrayMergeHandler.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections; -using Docfx.Common.EntityMergers; -using Docfx.Exceptions; - -namespace Docfx.DataContracts.RestApi; - -// REST arrays have positional overwrite semantics, including null placeholders. -// They must not use the entity merger's default key-based list matching. -public sealed class RestApiArrayMergeHandler : IMergeHandler -{ - public void Merge(ref object source, object overrides, IMergeContext context) - { - if (source == null) - { - source = overrides; - return; - } - var items = (IList)source; - var replacements = (IList)overrides; - if (items.Count != replacements.Count) - { - throw new DocfxException($"The count '{items.Count}' of REST array is different from overwrite list {replacements.Count}"); - } - var itemType = source.GetType().GetGenericArguments()[0]; - for (var i = 0; i < items.Count; i++) - { - if (replacements[i] == null) continue; - var item = items[i]; - context.Merger.Merge(ref item, replacements[i], itemType, context); - items[i] = item; - } - } -} diff --git a/src/Docfx.DataContracts.RestApi/RestApiExternalDocumentationViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiExternalDocumentationViewModel.cs deleted file mode 100644 index 41dedb17354..00000000000 --- a/src/Docfx.DataContracts.RestApi/RestApiExternalDocumentationViewModel.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json.Serialization; -using Newtonsoft.Json; -using YamlDotNet.Serialization; - -namespace Docfx.DataContracts.RestApi; - -public class RestApiExternalDocumentationViewModel -{ - [YamlMember(Alias = "url")] - [JsonProperty("url", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("url")] - public string Url { get; set; } - - [YamlMember(Alias = "description")] - [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("description")] - public string Description { get; set; } - - [Docfx.YamlSerialization.ExtensibleMember] - [Newtonsoft.Json.JsonExtensionData] - [System.Text.Json.Serialization.JsonExtensionData] - public Dictionary Metadata { get; set; } = []; -} diff --git a/src/Docfx.DataContracts.RestApi/RestApiInfoViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiInfoViewModel.cs deleted file mode 100644 index 0bed20147d7..00000000000 --- a/src/Docfx.DataContracts.RestApi/RestApiInfoViewModel.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json.Serialization; -using Newtonsoft.Json; -using YamlDotNet.Serialization; - -namespace Docfx.DataContracts.RestApi; - -public class RestApiInfoViewModel -{ - [YamlMember(Alias = "title")] - [JsonProperty("title", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("title")] - public string Title { get; set; } - - [YamlMember(Alias = "version")] - [JsonProperty("version", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("version")] - public string Version { get; set; } - - [YamlMember(Alias = "description")] - [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("description")] - public string Description { get; set; } - - [Docfx.YamlSerialization.ExtensibleMember] - [Newtonsoft.Json.JsonExtensionData] - [System.Text.Json.Serialization.JsonExtensionData] - public Dictionary Metadata { get; set; } = []; -} diff --git a/src/Docfx.DataContracts.RestApi/RestApiParameterViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiParameterViewModel.cs index 170225e863c..2407dad082a 100644 --- a/src/Docfx.DataContracts.RestApi/RestApiParameterViewModel.cs +++ b/src/Docfx.DataContracts.RestApi/RestApiParameterViewModel.cs @@ -11,11 +11,6 @@ namespace Docfx.DataContracts.RestApi; public class RestApiParameterViewModel { - [YamlMember(Alias = "schema")] - [JsonProperty("schema", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("schema")] - public RestApiSchemaViewModel Schema { get; set; } - [YamlMember(Alias = "description")] [JsonProperty("description")] [JsonPropertyName("description")] diff --git a/src/Docfx.DataContracts.RestApi/RestApiResponseViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiResponseViewModel.cs index 51d29d61d03..b8823d01ef9 100644 --- a/src/Docfx.DataContracts.RestApi/RestApiResponseViewModel.cs +++ b/src/Docfx.DataContracts.RestApi/RestApiResponseViewModel.cs @@ -11,16 +11,6 @@ namespace Docfx.DataContracts.RestApi; public class RestApiResponseViewModel { - [YamlMember(Alias = "schema")] - [JsonProperty("schema", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("schema")] - public RestApiSchemaViewModel Schema { get; set; } - - [YamlMember(Alias = "headers")] - [JsonProperty("headers", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("headers")] - public Dictionary Headers { get; set; } - [YamlMember(Alias = "statusCode")] [JsonProperty("statusCode")] [JsonPropertyName("statusCode")] diff --git a/src/Docfx.DataContracts.RestApi/RestApiRootItemViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiRootItemViewModel.cs index a4faddbbb9e..429a1fef1e5 100644 --- a/src/Docfx.DataContracts.RestApi/RestApiRootItemViewModel.cs +++ b/src/Docfx.DataContracts.RestApi/RestApiRootItemViewModel.cs @@ -10,21 +10,6 @@ namespace Docfx.DataContracts.RestApi; public class RestApiRootItemViewModel : RestApiItemViewModelBase { - [YamlMember(Alias = "securityDefinitions")] - [JsonProperty("securityDefinitions", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("securityDefinitions")] - public Dictionary SecurityDefinitions { get; set; } - - [YamlMember(Alias = "info")] - [JsonProperty("info", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("info")] - public RestApiInfoViewModel Info { get; set; } - - [YamlMember(Alias = "externalDocs")] - [JsonProperty("externalDocs", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("externalDocs")] - public RestApiExternalDocumentationViewModel ExternalDocs { get; set; } - /// /// The original swagger.json content /// `_` prefix indicates that this metadata is generated diff --git a/src/Docfx.DataContracts.RestApi/RestApiSchemaViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiSchemaViewModel.cs deleted file mode 100644 index 553cb737ad7..00000000000 --- a/src/Docfx.DataContracts.RestApi/RestApiSchemaViewModel.cs +++ /dev/null @@ -1,74 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json.Serialization; -using Docfx.Common.EntityMergers; -using Newtonsoft.Json; -using YamlDotNet.Serialization; - -namespace Docfx.DataContracts.RestApi; - -public class RestApiSchemaViewModel -{ - [YamlMember(Alias = "type")] - [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("type")] - public string Type { get; set; } - - [YamlMember(Alias = "format")] - [JsonProperty("format", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("format")] - public string Format { get; set; } - - [YamlMember(Alias = "description")] - [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("description")] - public string Description { get; set; } - - [YamlMember(Alias = "x-internal-ref-name")] - [JsonProperty("x-internal-ref-name", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("x-internal-ref-name")] - public string ReferenceName { get; set; } - - [YamlMember(Alias = "x-internal-loop-ref-name")] - [JsonProperty("x-internal-loop-ref-name", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("x-internal-loop-ref-name")] - public string LoopReferenceName { get; set; } - - [YamlMember(Alias = "properties")] - [JsonProperty("properties", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("properties")] - public Dictionary Properties { get; set; } - - [YamlMember(Alias = "items")] - [JsonProperty("items", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("items")] - public RestApiSchemaViewModel Items { get; set; } - - [YamlMember(Alias = "allOf")] - [JsonProperty("allOf", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("allOf")] - [MergeOption(typeof(RestApiArrayMergeHandler))] - public List AllOf { get; set; } - - [YamlMember(Alias = "required")] - [JsonProperty("required", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("required")] - public object Required { get; set; } - - [YamlMember(Alias = "enum")] - [JsonProperty("enum", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("enum")] - [MergeOption(typeof(RestApiArrayMergeHandler))] - public List Enum { get; set; } - - [YamlMember(Alias = "example")] - [JsonProperty("example", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("example")] - public object Example { get; set; } - - [Docfx.YamlSerialization.ExtensibleMember] - [Newtonsoft.Json.JsonExtensionData] - [System.Text.Json.Serialization.JsonExtensionData] - public Dictionary Metadata { get; set; } = []; -} diff --git a/src/Docfx.DataContracts.RestApi/RestApiSecuritySchemeViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiSecuritySchemeViewModel.cs deleted file mode 100644 index 1bde962b424..00000000000 --- a/src/Docfx.DataContracts.RestApi/RestApiSecuritySchemeViewModel.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json.Serialization; -using Newtonsoft.Json; -using YamlDotNet.Serialization; - -namespace Docfx.DataContracts.RestApi; - -public class RestApiSecuritySchemeViewModel -{ - [YamlMember(Alias = "description")] - [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("description")] - public string Description { get; set; } - - [Docfx.YamlSerialization.ExtensibleMember] - [Newtonsoft.Json.JsonExtensionData] - [System.Text.Json.Serialization.JsonExtensionData] - public Dictionary Metadata { get; set; } = []; -} diff --git a/templates/common/RestApi.common.js b/templates/common/RestApi.common.js index 3f0a3253c4b..cdb07596e22 100644 --- a/templates/common/RestApi.common.js +++ b/templates/common/RestApi.common.js @@ -3,8 +3,6 @@ var common = require('./common.js'); exports.transform = function (model) { - var definitions = Object.create(null); - var references = []; var _fileNameWithoutExt = common.path.getFileNameWithoutExtension(model._path); model._jsonPath = _fileNameWithoutExt + ".swagger.json"; model.title = model.title || model.name; @@ -28,8 +26,8 @@ exports.transform = function (model) { child.htmlId = common.getHtmlId(child.uid); formatExample(child.responses); - (child.parameters || []).forEach(transformPayload); - (child.responses || []).forEach(transformPayload); + resolveAllOf(child); + transformReference(child); }; if (!model.tags || model.tags.length === 0) { var childTags = []; @@ -83,70 +81,24 @@ exports.transform = function (model) { model.children = model.children.filter(function (o) { return o; }); } } - references.forEach(function (reference) { - reference.details.referenceId = definitions[reference.name] ? definitions[reference.name].id : ''; - }); - model.definitions = Object.keys(definitions).map(function (name) { - var entry = definitions[name]; - var details = Object.assign({}, entry.details, { id: entry.id, name: name }); - if (details.referenceName === name) { - details.referenceName = ''; - details.referenceId = ''; - } - return { schemaDetails: details }; - }); - - return model; - - function transformPayload(payload) { - payload.schemaDetails = schemaDetails(payload.schema); - payload.exampleDetails = exampleDetails(payload.examples); - } - - function schemaDetails(schema) { - if (!schema) return false; - var name = schema['x-internal-loop-ref-name'] || schema['x-internal-ref-name']; - // Null fields fall through to ancestor scopes in Docfx's Mustache renderer. - // Empty strings and false keep missing fields local to this schema. - var details = {}; - var registeredName = schema['x-internal-ref-name']; - if (registeredName && !definitions[registeredName]) { - definitions[registeredName] = { id: registeredName.replace(/\./g, '_'), details: details }; - } - if (name) references.push({ details: details, name: name }); - return Object.assign(details, { - type: schema.type || '', - format: schema.format || '', - description: schema.description || '', - referenceName: name || '', - referenceId: '', - properties: Object.keys(schema.properties || {}).map(function (key) { - return { - key: key, - required: schema.properties[key].required === true || - (Array.isArray(schema.required) && schema.required.indexOf(key) >= 0), - value: schemaDetails(schema.properties[key]) - }; - }), - items: schemaDetails(schema.items), - composition: (schema.allOf ? [{ kind: 'All of', schemas: schema.allOf }] : []).map(function (composition) { - return { kind: composition.kind, schemas: (composition.schemas || []).map(function (branch) { return schemaDetails(branch); }) }; - }), - enum: (schema.enum || []).map(function (value) { return { value: JSON.stringify(value) }; }), - exampleDetails: exampleDetails(schema.example !== undefined ? [{ content: JSON.stringify(schema.example) }] : []) + model.definitions = []; + if (model.tags) { + model.tags.forEach(function(tag) { + (tag.children || []).forEach(function(child) { + (child.parameters || []).forEach(function(parameter) { addComplexTypeMetadata(parameter.schema, model.definitions); }); + (child.responses || []).forEach(function(response) { addComplexTypeMetadata(response.schema, model.definitions); }); + }); }); } - - function exampleDetails(examples) { - return (examples || []).map(function (example) { - return { - mimeType: example.mimeType || '', - content: typeof example.content === "string" ? example.content : '', - hasContent: typeof example.content === "string" - }; + if (model.children) { + model.children.forEach(function(child) { + (child.parameters || []).forEach(function(parameter) { addComplexTypeMetadata(parameter.schema, model.definitions); }); + (child.responses || []).forEach(function(response) { addComplexTypeMetadata(response.schema, model.definitions); }); }); } + return model; + function getChildrenByTag(children, tag) { if (!children) return; return children.filter(function (child) { @@ -178,6 +130,120 @@ exports.transform = function (model) { } } + function resolveAllOf(obj) { + if (Array.isArray(obj)) { + for (var i = 0; i < obj.length; i++) { + resolveAllOf(obj[i]); + } + } + else if (typeof obj === "object") { + for (var key in obj) { + if (obj.hasOwnProperty(key)) { + if (key === "allOf" && Array.isArray(obj[key])) { + // find 'allOf' array and process + processAllOfArray(obj[key], obj); + // delete 'allOf' value + delete obj[key]; + } else { + resolveAllOf(obj[key]); + } + } + } + } + } + + function processAllOfArray(allOfArray, originalObj) { + // for each object in 'allOf' array, merge the values to those in the same level with 'allOf' + for (var i = 0; i < allOfArray.length; i++) { + var item = allOfArray[i]; + for (var key in item) { + if (originalObj.hasOwnProperty(key)) { + mergeObjByKey(originalObj[key], item[key]); + } else { + originalObj[key] = item[key]; + } + } + } + } + + function mergeObjByKey(targetObj, sourceObj) { + for (var key in sourceObj) { + // merge only when target object doesn't define the key + if (!targetObj.hasOwnProperty(key)) { + targetObj[key] = sourceObj[key]; + } + } + } + + function transformReference(obj) { + if (Array.isArray(obj)) { + for (var i = 0; i < obj.length; i++) { + transformReference(obj[i]); + } + } + else if (typeof obj === "object") { + for (var key in obj) { + if (obj.hasOwnProperty(key)) { + if (key === "schema") { + // transform schema.properties from obj to key value pair + transformProperties(obj[key]); + } else { + transformReference(obj[key]); + } + } + } + } + } + + function transformProperties(obj) { + if (obj.properties) { + if (obj.required && Array.isArray(obj.required)) { + for (var i = 0; i < obj.required.length; i++) { + var field = obj.required[i]; + if (obj.properties[field]) { + // add required field as property + obj.properties[field].required = true; + } + } + delete obj.required; + } + var array = []; + for (var key in obj.properties) { + if (obj.properties.hasOwnProperty(key)) { + var value = obj.properties[key]; + // set description to null incase mustache looks up + value.description = value.description || null; + + transformPropertiesValue(value); + array.push({ key: key, value: value }); + } + } + obj.properties = array; + } + } + + function transformPropertiesValue(obj) { + if (obj.type === "array" && obj.items) { + // expand array to transformProperties + obj.items.properties = obj.items.properties || null; + obj.items['x-internal-ref-name'] = obj.items['x-internal-ref-name'] || null; + obj.items['x-internal-loop-ref-name'] = obj.items['x-internal-loop-ref-name'] || null; + transformProperties(obj.items); + } else if (obj.properties && !obj.items) { + // fill obj.properties into obj.items.properties, to be rendered in the same way with array + obj.items = {}; + obj.items.properties = obj.properties || null; + delete obj.properties; + if (obj.required) { + obj.items.required = obj.required; + delete obj.required; + } + obj.items['x-internal-ref-name'] = obj['x-internal-ref-name'] || null; + obj.items['x-internal-loop-ref-name'] = obj['x-internal-loop-ref-name'] || null; + transformProperties(obj.items); + } + } + function appendQueryParamsToPath(path, parameters) { if (!path || !parameters) return path; @@ -207,7 +273,71 @@ exports.transform = function (model) { return path; } + function addDefinition(definition, definitions) { + + if (!definition) { + return; + } + + var xRefName = definition.items && definition.items['x-internal-ref-name'] + ? definition.items['x-internal-ref-name'] + : definition['x-internal-ref-name']; + + // Not complex type. + if (!xRefName) { + return; + } + + // Definition already exists return. + if (definitions.some(function(d) { return d['x-internal-ref-name'] == xRefName; })) { + return; + } + + // Create clone to not affect object structure used in original location + definition = JSON.parse(JSON.stringify(definition)); + + // Unify different object structure to be the same + + // Sometimes properties is under items sometimes not + if (definition.items && definition.items.properties) { + definition.properties = definition.items.properties; + } + + // Sometimes ref-name is under items sometimes not + definition['x-internal-ref-name'] = xRefName; + + // Sometimes properties are key/value pairs sometimes not + if (definition.properties && !Array.isArray(definition.properties)) { + definition.properties = Object.keys(definition.properties).map(function(key) { + return { + key: key, + value: definition.properties[key] + } + }); + } + + // Add definition to definitions list. + definitions.push(definition); + // Loop through properties that refer to other definitions. + (definition.properties || []).forEach(function(property) { + addComplexTypeMetadata(property.value, definitions); + }); + } + + function addComplexTypeMetadata(child, definitions) { + // Add variations of x-internal-ref-name to support + if (child && child['x-internal-ref-name']) { + child.cTypeId = child['x-internal-ref-name'].replace(/\./g, '_'); + child.cType = child['x-internal-ref-name'].replace(/([A-Z])/g, '$1'); + } + if (child && child.items && child.items['x-internal-ref-name']) { + child.cTypeId = child.items['x-internal-ref-name'].replace(/\./g, '_'); + child.cType = child.items['x-internal-ref-name'].replace(/([A-Z])/g, '$1'); + child.cTypeIsArray = true; + } + addDefinition(child, definitions); + } } exports.getBookmarks = function (model) { diff --git a/templates/default/partials/rest.child.tmpl.partial b/templates/default/partials/rest.child.tmpl.partial index 14cff47e84f..a64d6b7dd8b 100644 --- a/templates/default/partials/rest.child.tmpl.partial +++ b/templates/default/partials/rest.child.tmpl.partial @@ -42,7 +42,16 @@ {{#required}}*{{/required}}{{name}} - {{#schemaDetails}}{{>partials/rest.schema}}{{/schemaDetails}} + {{^schema.cType}} + {{schema.type}} + {{#schema.format}} + ({{schema.format}}) + {{/schema.format}} + {{/schema.cType}} + + {{#schema.cType}} + {{{schema.cType}}}{{#schema.cTypeIsArray}}[]{{/schema.cTypeIsArray}} + {{/schema.cType}} {{default}} {{{description}}} @@ -70,11 +79,22 @@ {{statusCode}} - {{#schemaDetails}}{{>partials/rest.schema}}{{/schemaDetails}} + {{^schema.cType}} + {{schema.type}} + {{/schema.cType}} + + {{#schema.cType}} + {{{schema.cType}}}{{#schema.cTypeIsArray}}[]{{/schema.cTypeIsArray}} + {{/schema.cType}} {{{description}}} - {{>partials/rest.examples}} + {{#examples}} +
+ Mime type: {{mimeType}} +
+
{{content}}
+ {{/examples}} {{/responses}} diff --git a/templates/default/partials/rest.definition.tmpl.partial b/templates/default/partials/rest.definition.tmpl.partial index f3f244ce101..83cc77ef00b 100644 --- a/templates/default/partials/rest.definition.tmpl.partial +++ b/templates/default/partials/rest.definition.tmpl.partial @@ -1,6 +1,45 @@ {{!Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license.}} -{{#schemaDetails}} -

{{name}}

-{{>partials/rest.schema}} -{{/schemaDetails}} +

{{{cType}}}

+{{#description}} +
{{{description}}}
+{{/description}} +{{#properties.0}} + + + + + + + + + + {{/properties.0}} + {{#properties}} + + + + + + {{/properties}} + {{#properties.0}} + +
NameTypeNotes
{{key}} + {{^value.cType}} + {{value.type}} + {{#value.format}} + ({{value.format}}) + {{/value.format}} + {{/value.cType}} + + {{#value.cType}} + {{{value.cType}}}{{#value.cTypeIsArray}}[]{{/value.cTypeIsArray}} + {{/value.cType}} + {{{value.description}}}
+{{/properties.0}} +{{#enum.0}} +
Enum Values
+{{#enum}} +{{.}}
+{{/enum}} +{{/enum.0}} diff --git a/templates/default/partials/rest.examples.tmpl.partial b/templates/default/partials/rest.examples.tmpl.partial deleted file mode 100644 index e4a8f449dfe..00000000000 --- a/templates/default/partials/rest.examples.tmpl.partial +++ /dev/null @@ -1,11 +0,0 @@ -{{!Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license.}} -{{#exampleDetails}} -{{#mimeType}} -
- Mime type: {{mimeType}} -
-{{/mimeType}} -{{#hasContent}} -
{{content}}
-{{/hasContent}} -{{/exampleDetails}} diff --git a/templates/default/partials/rest.schema.tmpl.partial b/templates/default/partials/rest.schema.tmpl.partial deleted file mode 100644 index 33927481700..00000000000 --- a/templates/default/partials/rest.schema.tmpl.partial +++ /dev/null @@ -1,38 +0,0 @@ -{{!Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license.}} -
- {{#referenceName}} - {{#referenceId}}{{referenceName}}{{/referenceId}} - {{^referenceId}}{{referenceName}}{{/referenceId}} - {{/referenceName}} - {{#type}}{{type}}{{/type}} - {{#format}}({{format}}){{/format}} - {{#description}}
{{{description}}}
{{/description}} - {{#enum.0}} -
Allowed values: {{#enum}}{{value}} {{/enum}}
- {{/enum.0}} - {{#exampleDetails.0}} -
Examples{{>partials/rest.examples}}
- {{/exampleDetails.0}} - {{#items}} -
Items{{>partials/rest.schema}}
- {{/items}} - {{#properties.0}} - - - - {{#properties}} - - - - - {{/properties}} - -
NameSchema
{{key}}{{#required}} (Required){{/required}}{{#value}}{{>partials/rest.schema}}{{/value}}
- {{/properties.0}} - {{#composition}} -
- {{kind}} -
    {{#schemas}}
  • {{>partials/rest.schema}}
  • {{/schemas}}
-
- {{/composition}} -
diff --git a/templates/modern/src/rest.test.ts b/templates/modern/src/rest.test.ts deleted file mode 100644 index a793b318478..00000000000 --- a/templates/modern/src/rest.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -import test from 'node:test' -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import { runInThisContext } from 'node:vm' - -// Docfx loads these CommonJS scripts separately from the template's ES modules. -const common = {} -runInThisContext(`(function(exports) { - ${readFileSync(new URL('../../common/common.js', import.meta.url), 'utf8')} -})`)(common) -const rest = runInThisContext(`(function(require) { - const exports = {}; - ${readFileSync(new URL('../../common/RestApi.common.js', import.meta.url), 'utf8')} - return exports; -})`)(() => common) - -test('REST preserves query paths and renders allOf without flattening schemas', () => { - const schema = { - 'x-internal-ref-name': 'Item', - allOf: [ - { properties: { id: { type: 'integer' } } }, - { required: ['name'], properties: { name: { type: 'string' } } } - ] - } - const original = structuredClone(schema) - const model = rest.transform({ - uid: 'legacy', - _path: 'legacy.html', - children: [{ - uid: 'get', - operation: 'get', - path: '/items', - parameters: [{ name: 'filter', in: 'query', required: true }, { name: 'limit', in: 'query' }], - responses: [{ schema, examples: [{ mimeType: 'application/json', content: '{"id":1}' }] }] - }] - }) - const child = model.children[0] - assert.equal(model._jsonPath, 'legacy.swagger.json') - assert.equal(child.operation, 'GET') - assert.equal(child.path, '/items?filter[&limit]') - assert.equal(child.responses[0].examples[0].content, '{\n "id": 1\n}') - const details = child.responses[0].schemaDetails - assert.equal(details.referenceId, 'Item') - assert.deepEqual(details.composition[0].schemas.map(branch => branch.properties.map(property => property.key)), [['id'], ['name']]) - assert.equal(details.composition[0].schemas[1].properties[0].required, true) - assert.deepEqual(schema, original) - assert.equal(model.definitions.length, 1) - assert.equal(model.definitions[0].schemaDetails.id, 'Item') -}) - -test('REST resolves recursive links after collecting schemas from all operations', () => { - const model = rest.transform({ - uid: 'references', - _path: 'references.html', - children: [{ - uid: 'list', - tags: ['Trees'], - responses: [{ schema: { type: 'array', items: { 'x-internal-loop-ref-name': 'Tree.Node' } } }] - }, { - uid: 'create', - tags: ['Trees'], - parameters: [{ - schema: { - type: 'object', - 'x-internal-ref-name': 'Tree.Node', - properties: { - next: { 'x-internal-loop-ref-name': 'Tree.Node' }, - missing: { 'x-internal-loop-ref-name': 'Missing' } - } - } - }] - }] - }) - const [list, create] = model.tags[0].children - assert.equal(list.responses[0].schemaDetails.items.referenceId, 'Tree_Node') - assert.equal(create.parameters[0].schemaDetails.properties[0].value.referenceId, 'Tree_Node') - assert.equal(create.parameters[0].schemaDetails.properties[1].value.referenceName, 'Missing') - assert.equal(create.parameters[0].schemaDetails.properties[1].value.referenceId, '') - assert.equal(model.definitions.length, 1) - assert.equal(model.definitions[0].schemaDetails.referenceName, '') -}) - -test('REST preserves schema-shaped literals and prepares missing fields for Mustache scopes', () => { - const literal = { - description: '**literal**', - allOf: [{ type: 'string' }], - 'x-internal-ref-name': 'NotADefinition' - } - const schema = { - type: 'object', - description: '

Schema description.

', - example: literal, - properties: { - state: { type: 'string', enum: ['', 'active'] }, - active: { type: 'boolean', enum: [false] }, - count: { type: 'integer', enum: [0] }, - empty: { type: 'string', example: '' } - }, - 'x-literal': literal - } - const original = structuredClone(schema) - const model = rest.transform({ - uid: 'literal', - _path: 'literal.html', - children: [{ uid: 'get', responses: [{ schema }] }] - }) - const details = model.children[0].responses[0].schemaDetails - assert.deepEqual(schema, original) - assert.equal(details.exampleDetails[0].content, JSON.stringify(literal)) - assert.equal(details.exampleDetails[0].mimeType, '') - assert.equal(details.properties[0].value.description, '') - assert.equal(details.properties[0].value.items, false) - assert.deepEqual(details.properties[0].value.exampleDetails, []) - assert.deepEqual(details.properties[0].value.enum, [{ value: '""' }, { value: '"active"' }]) - assert.deepEqual(details.properties[1].value.enum, [{ value: 'false' }]) - assert.deepEqual(details.properties[2].value.enum, [{ value: '0' }]) - assert.equal(details.properties[3].value.exampleDetails[0].content, '""') - assert.deepEqual(model.definitions, []) -}) diff --git a/test/Docfx.Build.RestApi.Tests/RestApiDocumentProcessorTest.cs b/test/Docfx.Build.RestApi.Tests/RestApiDocumentProcessorTest.cs index 893aa09362a..e2dcb241d03 100644 --- a/test/Docfx.Build.RestApi.Tests/RestApiDocumentProcessorTest.cs +++ b/test/Docfx.Build.RestApi.Tests/RestApiDocumentProcessorTest.cs @@ -109,7 +109,7 @@ public void ProcessSwaggerShouldSucceed() // When 'definitions' has direct child with $ref defined, should resolve it var item5 = model.Children[6]; - var parameter2 = JObject.FromObject(item5.Parameters[2].Schema); + var parameter2 = (JObject)item5.Parameters[2].Metadata["schema"]; Assert.Equal("string", parameter2["type"]); Assert.Equal("uri", parameter2["format"]); // Verify markup result of parameters @@ -121,7 +121,7 @@ public void ProcessSwaggerShouldSucceed() item5.Responses[0].Description); // Verify for markup result of securityDefinitions - var securityDefinitions = JObject.FromObject(model.SecurityDefinitions); + var securityDefinitions = (JObject)model.Metadata.Single(m => m.Key == "securityDefinitions").Value; var auth = (JObject)securityDefinitions["auth"]; Assert.Equal("

securityDefinitions description.

\n", auth["description"].ToString()); @@ -138,7 +138,7 @@ public void ProcessSwaggerWithExternalReferenceShouldSucceed() var model = JsonUtility.Deserialize(outputRawModelPath); var operation = model.Children.Single(c => c.OperationId == "get contact direct reports links"); - var externalSchema = JObject.FromObject(operation.Parameters[2].Schema); + var externalSchema = operation.Parameters[2].Metadata["schema"]; var externalParameters = ((JObject)externalSchema)["parameters"]; Assert.Equal("cache1", externalParameters["name"]); var scheduleEntries = externalParameters["parameters"]["properties"]["scheduleEntries"]; @@ -162,7 +162,7 @@ public void ProcessSwaggerWithExternalEmbeddedReferenceShouldSucceed() var model = JsonUtility.Deserialize(outputRawModelPath); var operation = model.Children.Single(c => c.OperationId == "update_contact_manager"); - var externalSchema = JObject.FromObject(operation.Parameters[2].Schema); + var externalSchema = (JObject)operation.Parameters[2].Metadata["schema"]; Assert.Equal("

uri description.

\n", externalSchema["description"].ToString()); Assert.Equal("string", externalSchema["type"]); Assert.Equal("uri", externalSchema["format"]); @@ -335,7 +335,7 @@ public void ProcessSwaggerWithParametersOverwriteShouldSucceed() var bodyparam = parametersForUpdate.Single(p => p.Name == "bodyparam"); Assert.Equal("

The new bodyparam description

\n", bodyparam.Description); - var properties = (JObject)(JObject.FromObject(bodyparam.Schema))["properties"]; + var properties = (JObject)((JObject)bodyparam.Metadata["schema"])["properties"]; var objectType = properties["objectType"]; Assert.Equal("string", objectType["type"]); Assert.Equal("this is overwrite objectType description", objectType["description"]); @@ -345,7 +345,7 @@ public void ProcessSwaggerWithParametersOverwriteShouldSucceed() Assert.Equal("this is overwrite errorDetail description", errorDetail["description"]); var paramForUpdateManager = model.Children.Single(c => c.OperationId == "get contact memberOf links").Parameters.Single(p => p.Name == "bodyparam"); - var paramForAllOf = (JObject.FromObject(paramForUpdateManager.Schema))["allOf"]; + var paramForAllOf = ((JObject)paramForUpdateManager.Metadata["schema"])["allOf"]; // First allOf item is not overwritten Assert.Equal("

original first allOf description

\n", paramForAllOf[0]["description"]); // Second allOf item is overwritten @@ -447,51 +447,6 @@ public void SystemKeysListShouldBeComplete() } } - [Fact] - public void ProcessSwaggerMarksUpSchemaDescriptionsWithoutChangingLiteralData() - { - var input = GetRandomFolder(); - var file = CreateFile("literal.json", """ - { - "swagger": "2.0", - "info": { "title": "Literal", "version": "1.0", "description": "**API**" }, - "x-payload": { "description": "**literal root**" }, - "paths": { "/items": { "get": { - "operationId": "getItems", - "responses": { "200": { - "description": "**Response**", - "headers": { "X-Count": { "type": "integer", "description": "**Count**" } }, - "schema": { - "type": "object", - "description": "**Schema**", - "example": { "description": "**literal example**" }, - "enum": [{ "description": "**literal enum**" }], - "x-payload": { "description": "**literal extension**" }, - "allOf": [{ "properties": { - "name": { "type": "string", "description": "**Name**" } - } }] - } - } } - } } } - } - """, input); - var files = new FileCollection(Directory.GetCurrentDirectory()); - files.Add(DocumentType.Article, [file], input); - BuildDocument(files); - - var model = JsonUtility.Deserialize(Path.Combine(_outputFolder, "literal.raw.json")); - var response = Assert.Single(Assert.Single(model.Children).Responses); - Assert.Contains(">API", model.Info.Description); - Assert.Contains(">Response", response.Description); - Assert.Contains(">Count", response.Headers["X-Count"].Description); - Assert.Contains(">Schema", response.Schema.Description); - Assert.Contains(">Name", Assert.Single(response.Schema.AllOf).Properties["name"].Description); - Assert.Equal("**literal root**", ((JObject)model.Metadata["x-payload"])["description"]); - Assert.Equal("**literal example**", ((JObject)response.Schema.Example)["description"]); - Assert.Equal("**literal enum**", ((JObject)Assert.Single(response.Schema.Enum))["description"]); - Assert.Equal("**literal extension**", ((JObject)response.Schema.Metadata["x-payload"])["description"]); - } - private void BuildDocument(FileCollection files) { var parameters = new DocumentBuildParameters diff --git a/test/Docfx.Build.RestApi.Tests/RestApiViewModelTest.cs b/test/Docfx.Build.RestApi.Tests/RestApiViewModelTest.cs deleted file mode 100644 index 50bd5e0ef84..00000000000 --- a/test/Docfx.Build.RestApi.Tests/RestApiViewModelTest.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Docfx.Common.EntityMergers; -using Docfx.DataContracts.RestApi; -using Docfx.Exceptions; -using Xunit; - -namespace Docfx.Build.RestApi.Tests; - -public class RestApiViewModelTest -{ - [Fact] - public void SchemaOverwritePreservesPositionalNullPlaceholders() - { - var schema = new RestApiSchemaViewModel - { - AllOf = [ - new() { Type = "object", Description = "First" }, - new() { Type = "object", Description = "Second" }] - }; - var merger = new MergerFacade(new KeyedListMerger(new ReflectionEntityMerger())); - merger.Merge(ref schema, new RestApiSchemaViewModel - { - AllOf = [null, new() { Description = "Updated" }] - }); - Assert.Equal("First", schema.AllOf[0].Description); - Assert.Equal("Updated", schema.AllOf[1].Description); - Assert.Equal("object", schema.AllOf[1].Type); - Assert.Throws(() => merger.Merge(ref schema, new RestApiSchemaViewModel { AllOf = [] })); - } -} diff --git a/test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs b/test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs index 2bd7f0b87c5..55c57579790 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 PreservesLegacyInfoShapes() + { + 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 legacy parser, even outside the Swagger 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/SplitRestApiToOperationLevelTest.cs b/test/Docfx.Build.RestApi.WithPlugins.Tests/SplitRestApiToOperationLevelTest.cs index 74b60742135..db65cfef4c8 100644 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/SplitRestApiToOperationLevelTest.cs +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/SplitRestApiToOperationLevelTest.cs @@ -55,7 +55,7 @@ public void SplitRestApiToOperationLevelShouldSucceed() Assert.Empty(model.Children); Assert.True((bool)model.Metadata["_isSplittedByOperation"]); Assert.Empty(model.Tags); - Assert.Equal("

Find out more about Swagger

\n", model.ExternalDocs.Description); + Assert.Equal("

Find out more about Swagger

\n", ((JObject)model.Metadata["externalDocs"])["description"]); } { // Verify splitted operation page @@ -70,13 +70,13 @@ public void SplitRestApiToOperationLevelShouldSucceed() Assert.Empty(model.Tags); Assert.Equal("swagger/petstore/addPet.html", model.Metadata["_path"]); Assert.Equal("TestData/swagger/petstore/addPet.json", model.Metadata["_key"]); - Assert.NotNull(model.ExternalDocs); + Assert.True(model.Metadata.ContainsKey("externalDocs")); Assert.True((bool)model.Metadata["_isSplittedToOperation"]); Assert.Single(model.Children); Assert.Empty(model.Tags); // Test overwritten metadata - Assert.Equal("

Find out more about addPet

\n", model.ExternalDocs.Description); + Assert.Equal("

Find out more about addPet

\n", ((JObject)model.Metadata["externalDocs"])["description"]); var child = model.Children[0]; Assert.Equal("petstore.swagger.io/v2/Swagger Petstore/1.0.0/addPet/operation", child.Uid); @@ -117,7 +117,7 @@ public void SplitRestApiToOperationLevelWithTocShouldSucceed() Assert.Equal("swagger/petstore/addPet.html", model.Metadata["_path"]); Assert.Equal("TestData/swagger/petstore/addPet.json", model.Metadata["_key"]); Assert.Equal("../toc.yml", model.Metadata["_tocRel"]); - Assert.NotNull(model.ExternalDocs); + Assert.True(model.Metadata.ContainsKey("externalDocs")); Assert.Single(model.Children); Assert.Empty(model.Tags); @@ -175,7 +175,7 @@ public void SplitRestApiToTagAndOperationLevelWithTocShouldSucceed() Assert.Empty(model.Tags); Assert.Equal("swagger/petstore/pet.html", model.Metadata["_path"]); Assert.Equal("TestData/swagger/petstore/pet.json", model.Metadata["_key"]); - Assert.NotNull(model.ExternalDocs); + Assert.True(model.Metadata.ContainsKey("externalDocs")); Assert.True((bool)model.Metadata["_isSplittedToTag"]); Assert.True((bool)model.Metadata["_isSplittedByOperation"]); } @@ -193,7 +193,7 @@ public void SplitRestApiToTagAndOperationLevelWithTocShouldSucceed() Assert.Equal("swagger/petstore/pet/addPet.html", model.Metadata["_path"]); Assert.Equal("TestData/swagger/petstore/pet/addPet.json", model.Metadata["_key"]); Assert.Equal("../../toc.yml", model.Metadata["_tocRel"]); - Assert.NotNull(model.ExternalDocs); + Assert.True(model.Metadata.ContainsKey("externalDocs")); Assert.Single(model.Children); Assert.True((bool)model.Metadata["_isSplittedToOperation"]); diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/SplitRestApiToTagLevelTest.cs b/test/Docfx.Build.RestApi.WithPlugins.Tests/SplitRestApiToTagLevelTest.cs index b58025ec501..22a06714040 100644 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/SplitRestApiToTagLevelTest.cs +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/SplitRestApiToTagLevelTest.cs @@ -56,7 +56,7 @@ public void ProcessRestApiShouldSucceed() Assert.Empty(model.Children); Assert.Empty(model.Tags); Assert.True((bool)model.Metadata["_isSplittedByTag"]); - Assert.Equal("

Find out more about Swagger

\n", model.ExternalDocs.Description); + Assert.Equal("

Find out more about Swagger

\n", ((JObject)model.Metadata["externalDocs"])["description"]); } { // Verify splitted tag page @@ -72,11 +72,11 @@ public void ProcessRestApiShouldSucceed() Assert.Empty(model.Children[0].Tags); Assert.Equal("swagger/petstore/pet.html", model.Metadata["_path"]); Assert.Equal("TestData/swagger/petstore/pet.json", model.Metadata["_key"]); - Assert.NotNull(model.ExternalDocs); + Assert.True(model.Metadata.ContainsKey("externalDocs")); Assert.True((bool)model.Metadata["_isSplittedToTag"]); // Test overwritten metadata - Assert.Equal("

Find out more about pets

\n", model.ExternalDocs.Description); + Assert.Equal("

Find out more about pets

\n", ((JObject)model.Metadata["externalDocs"])["description"]); } } @@ -111,7 +111,7 @@ public void ProcessRestApiWithTocShouldSucceed() Assert.Empty(model.Children[0].Tags); Assert.Equal("swagger/petstore/pet.html", model.Metadata["_path"]); Assert.Equal("TestData/swagger/petstore/pet.json", model.Metadata["_key"]); - Assert.NotNull(model.ExternalDocs); + Assert.True(model.Metadata.ContainsKey("externalDocs")); } { // Verify toc page diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs b/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs index 70ba4d37047..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")] @@ -336,17 +452,12 @@ public void PreservesSwaggerDocumentation(string template, bool splitTags, bool Assert.Equal("201", (string)Assert.Single(create["responses"])["statusCode"]); Assert.Equal("Item", (string)create["responses"][0]["schema"]["x-internal-ref-name"]); - var viewBody = viewOperations["createItem"]["parameters"][0]["schemaDetails"]; - Assert.Equal("Item", (string)viewBody["referenceId"]); - Assert.Equal("literal-schema-example", (string)JObject.Parse((string)Assert.Single(viewBody["exampleDetails"])["content"])["$ref"]); - var viewProperties = viewBody["composition"][0]["schemas"].SelectMany(branch => branch["properties"]).ToArray(); - Assert.Equal(["id", "name", "state"], viewProperties.Select(property => (string)property["key"])); - var viewName = viewProperties[1]["value"]; - Assert.True((bool)viewProperties[1]["required"]); - Assert.NotNull(articles[splitOperations ? (splitTags ? "service/items/createItem" : "service/createItem") : splitTags ? "service/items" : "service"] - .SelectSingleNode(".//h3[@id='Item']")); - Assert.NotNull(articles[splitOperations ? (splitTags ? "service/items/createItem" : "service/createItem") : splitTags ? "service/items" : "service"] - .SelectSingleNode(".//div[@class='schema-composition']/strong[text()='All of']")); + var viewBody = viewOperations["createItem"]["parameters"][0]["schema"]; + Assert.Equal("Item", (string)viewBody["cTypeId"]); + Assert.Equal("literal-schema-example", (string)viewBody["example"]["$ref"]); + Assert.Equal(["id", "name", "state"], viewBody["properties"].Select(property => (string)property["key"])); + var viewName = viewBody["properties"][1]["value"]; + Assert.True((bool)viewName["required"]); var listPage = splitTags ? "service/items" : "service"; if (splitOperations) { @@ -360,8 +471,8 @@ public void PreservesSwaggerDocumentation(string template, bool splitTags, bool if (overwrite) { - Assert.Equal("Updated name description.", HtmlNode.CreateNode((string)schema["allOf"][1]["properties"]["name"]["description"]).InnerText.Trim()); - Assert.Equal("Updated name description.", HtmlNode.CreateNode((string)viewName["description"]).InnerText.Trim()); + Assert.Equal("Updated name description.", (string)schema["allOf"][1]["properties"]["name"]["description"]); + Assert.Equal("Updated name description.", (string)viewName["description"]); foreach (var level in new[] { "Document", "Tag", "Operation" }) { Assert.NotNull(articles["service"].SelectSingleNode($".//p[text()='{level}-level conceptual content.']")); @@ -376,67 +487,6 @@ public void PreservesSwaggerDocumentation(string template, bool splitTags, bool } } - [Theory] - [InlineData(true)] - [InlineData(false)] - public void SplitPageMetadataOverridesDoNotChangeTheRoot(bool splitTags) - { - var input = GetRandomFolder(); - var output = GetRandomFolder(); - var file = CreateFile("service.json", """ - { - "swagger": "2.0", - "info": { "title": "Isolation", "version": "1.0", "description": "**API**" }, - "externalDocs": { "url": "https://example.test/root" }, - "securityDefinitions": { - "key": { "type": "apiKey", "name": "X-Key", "in": "header", "description": "**Key**" } - }, - "tags": [{ "name": "items", "externalDocs": { "url": "https://example.test/tag" } }], - "paths": { "/items": { "get": { - "operationId": "getItems", - "tags": ["items"], - "externalDocs": { "url": "https://example.test/operation" }, - "responses": { "200": { "description": "OK" } } - } } } - } - """, input); - var uid = splitTags ? "Isolation/1.0/tag/items" : "Isolation/1.0/getItems"; - var overwrite = CreateFile("overwrite.md", $$""" - --- - uid: {{uid}} - info: - description: Changed info - securityDefinitions: - key: - description: Changed key - --- - """, input); - var files = new FileCollection(Directory.GetCurrentDirectory()); - files.Add(DocumentType.Article, [file], input); - files.Add(DocumentType.Overwrite, [overwrite], input); - using var builder = new DocumentBuilder(GetAssemblies(splitTags, !splitTags), []); - 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"); - var split = ReadModel(output, splitTags ? "service/items.raw.json" : "service/getItems.raw.json"); - Assert.Equal("https://example.test/root", (string)root["externalDocs"]["url"]); - Assert.Equal(splitTags ? "https://example.test/tag" : "https://example.test/operation", (string)split["externalDocs"]["url"]); - Assert.Contains(">API", (string)root["info"]["description"]); - Assert.Contains(">Key", (string)root["securityDefinitions"]["key"]["description"]); - Assert.Equal("Isolation", (string)split["info"]["title"]); - Assert.Contains("Changed info", (string)split["info"]["description"]); - Assert.Contains("Changed key", (string)split["securityDefinitions"]["key"]["description"]); - } - private static IEnumerable GetAssemblies(bool splitTags, bool splitOperations) { yield return typeof(RestApiDocumentProcessor).Assembly; From 0b9e76ce41c06b5f561c2a740c273f23efa31866 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Thu, 24 Sep 2026 23:16:10 +1000 Subject: [PATCH 6/6] Name Swagger 2.0 metadata compatibility test explicitly --- test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs b/test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs index 55c57579790..99689a3daea 100644 --- a/test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs +++ b/test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs @@ -17,7 +17,7 @@ namespace Docfx.Build.RestApi.Tests; public class SwaggerCompatibilityTest : TestBase { [Fact] - public void PreservesLegacyInfoShapes() + public void PreservesSwagger2InfoMetadataValues() { var file = CreateFile("swagger.json", """ { @@ -35,7 +35,7 @@ public void PreservesLegacyInfoShapes() } """, GetRandomFolder()); - // These values are accepted by the legacy parser, even outside the Swagger specification. + // These values are accepted by the Swagger 2.0 parser, even outside the specification. var swagger = SwaggerJsonParser.Parse(file); AssertJson(""" {