From e8cf97ff358a74c4a2d7ce4bef86c1ab9bb1fd8e Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Sat, 19 Sep 2026 17:30:08 +1000 Subject: [PATCH 01/16] test: add Swagger 2.0 compatibility coverage Characterize existing Swagger parsing, document processing and rendered outputs before the OpenAPI v3 work. Preserve golden outputs recorded with unchanged production code at bd097d04a7b2c1eb7533b8f6e045764e20d15967. Related to #11151 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../SwaggerCompatibilityTest.cs | 534 ++++++ .../SwaggerDocumentCompatibilityTest.cs | 241 +++ ...cfx.Build.RestApi.WithPlugins.Tests.csproj | 7 + .../SwaggerOutputCompatibilityTest.cs | 287 ++++ .../TestData/compatibility/components.json | 10 + ...alse-operations-False-overwrite-False.json | 859 ++++++++++ ...False-operations-False-overwrite-True.json | 863 ++++++++++ ...False-operations-True-overwrite-False.json | 1277 +++++++++++++++ ...True-operations-False-overwrite-False.json | 968 +++++++++++ ...-True-operations-True-overwrite-False.json | 1425 +++++++++++++++++ ...alse-operations-False-overwrite-False.json | 859 ++++++++++ ...alse-operations-False-overwrite-False.json | 912 +++++++++++ .../TestData/compatibility/overwrite.md | 29 + .../compatibility/service.swagger.json | 139 ++ .../TestData/compatibility/toc.yml | 2 + 15 files changed, 8412 insertions(+) create mode 100644 test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs create mode 100644 test/Docfx.Build.RestApi.Tests/SwaggerDocumentCompatibilityTest.cs create mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs create mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/components.json create mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-False-overwrite-False.json create mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-False-overwrite-True.json create mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-True-overwrite-False.json create mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-True-operations-False-overwrite-False.json create mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-True-operations-True-overwrite-False.json create mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/modern-tags-False-operations-False-overwrite-False.json create mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/statictoc-tags-False-operations-False-overwrite-False.json create mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/overwrite.md create mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/service.swagger.json create mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/toc.yml diff --git a/test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs b/test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs new file mode 100644 index 00000000000..a2f1f9de0ab --- /dev/null +++ b/test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs @@ -0,0 +1,534 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Docfx.Build.RestApi.Swagger; +using Docfx.DataContracts.RestApi; +using Docfx.Exceptions; +using Docfx.Tests.Common; + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Docfx.Build.RestApi.Tests; + +[Collection("docfx STA")] +[Trait("Category", "SwaggerCompatibility")] +public class SwaggerCompatibilityTest : TestBase +{ + [Fact] + public void AllSevenSwaggerMethodsPreserveDocumentOrderAndOperationIdentity() + { + string[] methods = ["head", "patch", "options", "delete", "post", "put", "get"]; + var path = new JObject(); + foreach (var method in methods) + { + path[method] = new JObject + { + ["operationId"] = method + " item", + ["summary"] = method + " summary", + ["responses"] = new JObject { ["204"] = new JObject { ["description"] = "No content" } } + }; + } + path["x-path-extension"] = new JObject { ["enabled"] = false }; + + var model = Convert($$""" + "host": "api.example.com", + "basePath": "/v1/", + "paths": { "/items/{id}": {{path}} } + """); + + Assert.Equal(methods, model.Children.Select(child => child.OperationName)); + foreach (var child in model.Children) + { + Assert.Equal("/items/{id}", child.Path); + Assert.Equal(child.OperationName + " item", child.OperationId); + Assert.Equal(child.OperationName + " summary", child.Summary); + Assert.Equal("api.example.com/v1/Compatibility/1/" + child.OperationId, child.Uid); + Assert.Equal("204", Assert.Single(child.Responses).HttpStatusCode); + Assert.Null(child.Parameters); + } + } + + [Fact] + public void ParameterMergeUsesBothNameAndLocationAndKeepsOperationThenInheritedOrder() + { + var model = Convert(""" + "parameters": { + "QueryId": { "name": "id", "in": "query", "type": "string", "description": "inherited query" } + }, + "paths": { + "/items/{id}": { + "parameters": [ + { "name": "id", "in": "path", "type": "string", "required": true, "description": "inherited path" }, + { "$ref": "#/parameters/QueryId" }, + { "name": "token", "in": "header", "type": "string", "description": "inherited header" }, + { "name": "ID", "in": "query", "type": "string", "description": "case-sensitive name" } + ], + "get": { + "operationId": "get", + "parameters": [ + { "name": "token", "in": "query", "type": "string", "description": "operation query" }, + { "name": "id", "in": "query", "type": "integer", "description": "override" }, + { "name": "id", "in": "header", "type": "string", "description": "operation header" } + ] + } + } + } + """); + + var parameters = Assert.Single(model.Children).Parameters; + Assert.Equal( + ["token:query", "id:query", "id:header", "id:path", "token:header", "ID:query"], + parameters.Select(parameter => $"{parameter.Name}:{parameter.Metadata["in"]}")); + Assert.Equal( + ["operation query", "override", "operation header", "inherited path", "inherited header", "case-sensitive name"], + parameters.Select(parameter => parameter.Description)); + Assert.Equal("integer", parameters[1].Metadata["type"]); + Assert.DoesNotContain(parameters, parameter => parameter.Description == "inherited query"); + } + + [Theory] + [InlineData("")] + [InlineData("\"parameters\": [],")] + [InlineData("\"parameters\": null,")] + public void AbsentEmptyAndNullOperationParametersKeepInheritedParameters(string operationParameters) + { + var model = Convert($$""" + "paths": { + "/items": { + "parameters": [ + { "name": "first", "in": "query", "type": "string" }, + { "name": "second", "in": "header", "type": "string" } + ], + "get": { {{operationParameters}} "operationId": "get" } + } + } + """); + + Assert.Equal(["first", "second"], Assert.Single(model.Children).Parameters.Select(parameter => parameter.Name)); + } + + [Fact] + public void PrimitiveConstraintsAndFalsyDefaultsSurviveParsingAndConversion() + { + const string parameters = """ + [ + { "name": "enabled", "in": "query", "type": "boolean", "required": false, "default": false }, + { "name": "count", "in": "query", "type": "integer", "format": "int32", + "default": 0, "minimum": 0, "maximum": 10, "exclusiveMinimum": false, + "exclusiveMaximum": true, "multipleOf": 2 }, + { "name": "text", "in": "query", "type": "string", "default": "", "minLength": 0, + "maxLength": 10, "pattern": "^[a-z]*$", "enum": ["", "valid"], "allowEmptyValue": true }, + { "name": "ids", "in": "query", "type": "array", "items": { "type": "integer", "format": "int64" }, + "collectionFormat": "pipes", "minItems": 0, "maxItems": 3, "uniqueItems": false, "default": [] }, + { "name": "nullable", "in": "query", "type": "string", "default": null, "enum": [null, ""] } + ] + """; + + var swagger = Parse($$""" + "paths": { "/items": { "get": { "operationId": "get", "parameters": {{parameters}} } } } + """); + AssertJson(parameters, GetOperation(swagger, "/items", "get")["parameters"]); + + var converted = Assert.Single(SwaggerModelConverter.FromSwaggerModel(swagger).Children).Parameters; + AssertParameters(parameters, converted); + Assert.True(converted[4].Metadata.ContainsKey("default")); + Assert.Null(converted[4].Metadata["default"]); + } + + [Fact] + public void BodyFormDataAndFileParametersKeepTheirDistinctMetadata() + { + const string body = """ + [ + { "name": "body", "in": "body", "description": "Request body", "required": true, + "schema": { "type": "object", "required": ["name"], + "properties": { "name": { "type": "string" }, "id": { "type": "integer", "readOnly": true } }, + "additionalProperties": false }, "x-body": { "enabled": false } } + ] + """; + const string form = """ + [ + { "name": "upload", "in": "formData", "type": "file", "description": "Upload file", + "required": false, "x-content-type": "image/png" }, + { "name": "labels", "in": "formData", "type": "array", "items": { "type": "string" }, + "collectionFormat": "multi", "required": true }, + { "name": "caption", "in": "formData", "type": "string", "default": "", "allowEmptyValue": true } + ] + """; + + var model = Convert($$""" + "paths": { + "/body": { "post": { "operationId": "body", "parameters": {{body}}, "consumes": ["application/json"] } }, + "/upload": { "post": { "operationId": "upload", "parameters": {{form}}, "consumes": ["multipart/form-data"] } } + } + """); + + Assert.Equal(2, model.Children.Count); + AssertParameters(body, model.Children[0].Parameters); + AssertParameters(form, model.Children[1].Parameters); + AssertJson("""["application/json"]""", JToken.FromObject(model.Children[0].Metadata["consumes"])); + AssertJson("""["multipart/form-data"]""", JToken.FromObject(model.Children[1].Metadata["consumes"])); + } + + [Fact] + public void SecurityMediaTypesAndExtensionsStayAtTheirDeclaredLevel() + { + var swagger = Parse(""" + "schemes": ["https", "http"], + "consumes": ["application/json"], + "produces": ["application/json", "text/plain"], + "security": [{ "oauth": ["read"] }, { "apiKey": [] }], + "securityDefinitions": { + "oauth": { "type": "oauth2", "flow": "implicit", "authorizationUrl": "https://example.com/auth", + "scopes": { "read": "Read items" }, "x-auth": false }, + "apiKey": { "type": "apiKey", "name": "X-Key", "in": "header" } + }, + "x-root": { "false": false, "zero": 0, "empty": "", "null": null }, + "paths": { + "/items": { + "get": { "operationId": "inherited" }, + "post": { + "operationId": "override", "security": [{ "oauth": ["write"] }], + "consumes": ["application/xml"], "produces": ["application/xml"], + "deprecated": false, "x-operation": { "enabled": false, "values": [0, "", null] }, + "responses": { + "default": { "description": "Failure", "schema": { "type": "string" }, + "headers": { "Retry-After": { "type": "integer", "minimum": 0 } }, + "x-response": { "code": 0 } } + } + }, + "delete": { "operationId": "anonymous", "security": [], "consumes": [], "produces": [] } + } + } + """); + var model = SwaggerModelConverter.FromSwaggerModel(swagger); + + AssertJson("""["https", "http"]""", JToken.FromObject(model.Metadata["schemes"])); + AssertJson("""["application/json"]""", JToken.FromObject(model.Metadata["consumes"])); + AssertJson("""["application/json", "text/plain"]""", JToken.FromObject(model.Metadata["produces"])); + AssertJson("""[{"oauth":["read"]},{"apiKey":[]}]""", JToken.FromObject(model.Metadata["security"])); + AssertJson(""" + { + "oauth": { "type": "oauth2", "flow": "implicit", "authorizationUrl": "https://example.com/auth", + "scopes": { "read": "Read items" }, "x-auth": false }, + "apiKey": { "type": "apiKey", "name": "X-Key", "in": "header" } + } + """, JToken.FromObject(model.Metadata["securityDefinitions"])); + AssertJson("""{"false":false,"zero":0,"empty":"","null":null}""", JToken.FromObject(model.Metadata["x-root"])); + + var inherited = model.Children.Single(child => child.OperationId == "inherited"); + foreach (var key in new[] { "security", "consumes", "produces", "x-root" }) + { + Assert.False(inherited.Metadata.ContainsKey(key)); + } + var overridden = model.Children.Single(child => child.OperationId == "override"); + AssertJson("""[{"oauth":["write"]}]""", JToken.FromObject(overridden.Metadata["security"])); + AssertJson("""["application/xml"]""", JToken.FromObject(overridden.Metadata["consumes"])); + AssertJson("""["application/xml"]""", JToken.FromObject(overridden.Metadata["produces"])); + Assert.Equal(false, overridden.Metadata["deprecated"]); + AssertJson("""{"enabled":false,"values":[0,"",null]}""", JToken.FromObject(overridden.Metadata["x-operation"])); + var response = Assert.Single(overridden.Responses); + Assert.Equal("default", response.HttpStatusCode); + Assert.Equal("Failure", response.Description); + AssertJson(""" + { "schema": { "type": "string" }, "headers": { "Retry-After": { "type": "integer", "minimum": 0 } }, + "x-response": { "code": 0 } } + """, JToken.FromObject(response.Metadata)); + + var anonymous = model.Children.Single(child => child.OperationId == "anonymous"); + foreach (var key in new[] { "security", "consumes", "produces" }) + { + Assert.Empty(Assert.IsType(anonymous.Metadata[key])); + } + } + + [Theory] + [InlineData("#/definitions/a~1b~0c", "a/b~c", "a~1b~0c")] + [InlineData("/definitions/a~1b~0c", "a/b~c", "a~1b~0c")] + [InlineData("#/definitions/~01", "~1", "~01")] + public void EscapedReferenceNamesResolveButKeepEscapesInInternalName(string reference, string definition, string marker) + { + var model = Convert($$""" + "definitions": { "{{definition}}": { "type": "string", "description": "Escaped name" } }, + "paths": { "/items": { "get": { "responses": { "200": { + "description": "OK", "schema": { "$ref": "{{reference}}" } + } } } } } + """); + + var schema = Assert.IsType(Assert.Single(Assert.Single(model.Children).Responses).Metadata["schema"]); + AssertJson($$""" + { "type": "string", "description": "Escaped name", "x-internal-ref-name": "{{marker}}" } + """, schema); + } + + [Theory] + [InlineData("internal", "Local description", true)] + [InlineData("embedded", "Local description", true)] + [InlineData("direct", "Target description", false)] + public void ReferenceKindsPreserveTheirLegacySiblingPrecedence(string kind, string description, bool hasReferenceName) + { + var folder = GetRandomFolder(); + const string target = """{ "type": "string", "description": "Target description", "x-target": true }"""; + CreateFile("target.json", kind == "direct" ? target : $$"""{ "definitions": { "Value": {{target}} } }""", folder); + var reference = kind switch + { + "internal" => "#/definitions/Value", + "embedded" => "target.json#/definitions/Value", + _ => "target.json" + }; + var swagger = Parse($$""" + "definitions": { "Value": {{target}} }, + "paths": { "/items": { "get": { "responses": { "200": { + "description": "OK", + "schema": { "$ref": "{{reference}}", "description": "Local description", "x-local": 0 } + } } } } } + """, folder); + + var model = SwaggerModelConverter.FromSwaggerModel(swagger); + var schema = Assert.IsType(Assert.Single(Assert.Single(model.Children).Responses).Metadata["schema"]); + var expected = new JObject + { + ["type"] = "string", + ["description"] = description, + ["x-target"] = true, + ["x-local"] = 0 + }; + if (hasReferenceName) + { + expected["x-internal-ref-name"] = "Value"; + } + AssertJson(expected.ToString(), schema); + } + + [Theory] + [InlineData("loopref_swagger2.json", "ProvisioningError")] + [InlineData("externalLoopRef_A.json", "Provision%ing|Error")] + public void ConversionPreservesInternalAndCrossFileRecursionBoundaries(string fixture, string nestedName) + { + var swagger = SwaggerJsonParser.Parse(Path.Combine("TestData", "swagger", fixture)); + var original = GetOperation(swagger, "/contacts", "patch")["parameters"][0]["schema"]; + var model = SwaggerModelConverter.FromSwaggerModel(swagger); + var schema = Assert.IsType(Assert.Single(Assert.Single(model.Children).Parameters).Metadata["schema"]); + + AssertJson(original.ToString(), schema); + Assert.Equal("contact", (string)schema["x-internal-ref-name"]); + var nested = schema["properties"]["provisioningErrors"]["items"]; + Assert.Equal(nestedName, (string)nested["x-internal-ref-name"]); + AssertJson(""" + { "x-internal-loop-ref-name": "contact", "x-internal-loop-token": {} } + """, nested["properties"]["errorDetail"]["items"]); + Assert.DoesNotContain(schema.Descendants().OfType(), property => property.Name == "$ref"); + } + + [Fact] + public void LiteralExamplesPreserveReferencesDatesAndFalsyValues() + { + const string literal = """ + { "date": "2024-01-02T03:04:05.120+02:30", "$ref": "not-a-reference", + "nested": [{ "$ref": 17 }], "false": false, "zero": 0, "empty": "", "null": null } + """; + var swagger = Parse($$""" + "x-ms-examples": {{literal}}, + "definitions": { + "Item": { + "type": "object", "example": {{literal}}, + "properties": { "value": { "type": "object", "example": {{literal}} } } + } + }, + "paths": { "/items": { "get": { "responses": { + "200": { "description": "OK", "schema": { "$ref": "#/definitions/Item" }, + "examples": { + "application/json": {{literal}}, "text/date": "2024-01-02T03:04:05.120+02:30", + "text/false": false, "text/zero": 0, "text/empty": "", "text/null": null + } + } + } } } } + """); + var definitions = Assert.IsType(swagger.Definitions); + AssertJson(literal, definitions["Item"]["example"]); + AssertJson(literal, definitions["Item"]["properties"]["value"]["example"]); + Assert.Equal(JTokenType.String, definitions["Item"]["example"]["date"].Type); + + var model = SwaggerModelConverter.FromSwaggerModel(swagger); + AssertJson(literal, Assert.IsType(model.Metadata["x-ms-examples"])); + var response = Assert.Single(Assert.Single(model.Children).Responses); + var schema = Assert.IsType(response.Metadata["schema"]); + AssertJson(literal, schema["example"]); + var examples = response.Examples.ToDictionary(example => example.MimeType, example => example.Content); + Assert.Equal(6, examples.Count); + AssertJson(literal, ReadJson(examples["application/json"])); + Assert.Equal("\"2024-01-02T03:04:05.120+02:30\"", examples["text/date"]); + Assert.Equal("false", examples["text/false"]); + Assert.Equal("0", examples["text/zero"]); + Assert.Equal("\"\"", examples["text/empty"]); + Assert.Null(examples["text/null"]); + } + + [Fact] + public void LeadingReferenceInResponseExampleSurvivesParsingButFailsConversion() + { + const string literal = """{"$ref":"not-a-reference","date":"2024-01-02T03:04:05.120+02:30"}"""; + var swagger = Parse($$""" + "paths": { "/items": { "get": { "responses": { + "200": { "description": "OK", "examples": { "application/json": {{literal}} } } + } } } } + """); + AssertJson(literal, GetOperation(swagger, "/items", "get")["responses"]["200"]["examples"]["application/json"]); + + // The converter's default Json.NET serializer interprets a leading $ref as serializer metadata. + var exception = Assert.Throws(() => SwaggerModelConverter.FromSwaggerModel(swagger)); + Assert.Equal( + "Additional content found in JSON reference object. A JSON reference object should only have a $ref property. " + + "Path 'responses.200.examples['application/json'].date'.", + exception.Message); + } + + [Fact] + public void InlineSchemaExamplesAreResolvedUnlikeDefinitionExamples() + { + var swagger = Parse(""" + "definitions": { "Value": { "type": "string" } }, + "paths": { "/items": { "post": { "parameters": [ + { "name": "body", "in": "body", + "schema": { "type": "object", "example": { "$ref": "#/definitions/Value" } } } + ] } } } + """); + + var schema = GetOperation(swagger, "/items", "post")["parameters"][0]["schema"]; + AssertJson("""{"type":"string","x-internal-ref-name":"Value"}""", schema["example"]); + } + + [Theory] + [InlineData("42", "Integer")] + [InlineData("false", "Boolean")] + [InlineData("{}", "Object")] + [InlineData("[]", "Array")] + public void NonStringReferencesFailWithTheirTokenTypeAndLocation(string value, string tokenType) + { + var exception = Assert.Throws(() => Parse($$""" + "definitions": { "Bad": { "$ref": {{value}} } } + """)); + + Assert.Equal( + $"JSON reference $ref property must have a string or null value, instead of {tokenType}, location: definitions.Bad.$ref.", + exception.Message); + } + + [Fact] + public void NullReferenceFailsInReferenceFormatter() + { + var exception = Assert.Throws(() => Parse(""" + "definitions": { "Bad": { "$ref": null } } + """)); + + Assert.Equal("reference", exception.ParamName); + } + + [Theory] + [InlineData("#/definitions/Missing", typeof(JsonException), "Could not resolve reference '/definitions/Missing' in the document.")] + [InlineData("https://example.com/schema.json", typeof(InvalidOperationException), "Reference path \"https://example.com/schema.json\" is not supported now.")] + [InlineData("#", typeof(InvalidOperationException), "External file path '' should end with .json")] + [InlineData("file.yaml#/definitions/Value", typeof(InvalidOperationException), "External file path 'file.yaml' should end with .json")] + [InlineData("file.json#/definitions/Value#extra", typeof(InvalidOperationException), "Reference path 'file.json#/definitions/Value#extra' should contain only one '#' character.")] + public void InvalidReferencePathsHaveSpecificFailureContracts(string reference, Type exceptionType, string message) + { + var exception = Assert.Throws(exceptionType, () => Parse($$""" + "definitions": { "Bad": { "$ref": "{{reference}}" } } + """)); + + Assert.Equal(message, exception.Message); + } + + [Fact] + public void MissingDirectExternalFileReportsItsResolvedLocalPath() + { + var path = Path.Combine("TestData", "swagger", "externalRefNotExist.json"); + var exception = Assert.Throws(() => SwaggerJsonParser.Parse(path)); + + Assert.Equal($"External swagger path not exist: {Path.Combine("TestData", "swagger", "file.json")}.", exception.Message); + } + + [Fact] + public void DirectExternalReferencesRejectNestedReferencesRatherThanFollowingThem() + { + var path = Path.Combine("TestData", "swagger", "externalRefWithRefInside.json"); + var exception = Assert.Throws(() => SwaggerJsonParser.Parse(path)); + + Assert.Equal("$ref in refWithRefInside.json is not supported in external reference currently.", exception.Message); + } + + [Fact] + public void MalformedJsonReportsReaderPathAndSourcePosition() + { + const string json = """{"swagger":"2.0","paths":]}"""; + var file = CreateFile("malformed.json", json, GetRandomFolder()); + var exception = Assert.Throws(() => SwaggerJsonParser.Parse(file)); + + Assert.Equal("", exception.Path); + Assert.Equal(1, exception.LineNumber); + Assert.Equal(26, exception.LinePosition); + Assert.Equal( + "JsonToken EndArray is not valid for closing JsonType Object. Path '', line 1, position 26.", + exception.Message); + } + + [Theory] + [InlineData("null")] + [InlineData("[]")] + [InlineData("1")] + public void NonObjectOperationsFailAtConversionRatherThanParsing(string value) + { + var swagger = Parse($$""" + "paths": { "/items": { "get": {{value}} } } + """); + var exception = Assert.Throws(() => SwaggerModelConverter.FromSwaggerModel(swagger)); + + Assert.Equal("Value of get should be JObject", exception.Message); + } + + private SwaggerModel Parse(string members, string folder = null) + { + var file = CreateFile("swagger.json", $$""" + { + "swagger": "2.0", + "info": { "title": "Compatibility", "version": "1" }, + {{members}} + } + """, folder ?? GetRandomFolder()); + return SwaggerJsonParser.Parse(file); + } + + private RestApiRootItemViewModel Convert(string members) => SwaggerModelConverter.FromSwaggerModel(Parse(members)); + + private static JObject GetOperation(SwaggerModel swagger, string path, string method) => + Assert.IsType(swagger.Paths[path].Metadata[method]); + + private static void AssertParameters(string expected, List actual) + { + var parameters = Assert.IsType(ReadJson(expected)); + Assert.Equal(parameters.Count, actual.Count); + for (var i = 0; i < parameters.Count; i++) + { + var parameter = Assert.IsType(parameters[i]); + Assert.Equal((string)parameter["name"], actual[i].Name); + Assert.Equal((string)parameter["description"], actual[i].Description); + parameter.Remove("name"); + parameter.Remove("description"); + AssertJson(parameter.ToString(), JToken.FromObject(actual[i].Metadata)); + } + } + + private static void AssertJson(string expected, JToken actual) + { + var expectedToken = ReadJson(expected); + Assert.True(JToken.DeepEquals(expectedToken, actual), $"Expected: {expectedToken}\nActual: {actual}"); + } + + private static JToken ReadJson(string json) + { + using var reader = new JsonTextReader(new StringReader(json)) { DateParseHandling = DateParseHandling.None }; + return JToken.ReadFrom(reader); + } +} diff --git a/test/Docfx.Build.RestApi.Tests/SwaggerDocumentCompatibilityTest.cs b/test/Docfx.Build.RestApi.Tests/SwaggerDocumentCompatibilityTest.cs new file mode 100644 index 00000000000..9567f65f8a4 --- /dev/null +++ b/test/Docfx.Build.RestApi.Tests/SwaggerDocumentCompatibilityTest.cs @@ -0,0 +1,241 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Docfx.Build.Engine; +using Docfx.Common; +using Docfx.DataContracts.RestApi; +using Docfx.Plugins; +using Docfx.Tests.Common; + +using Xunit; + +namespace Docfx.Build.RestApi.Tests; + +[Collection("docfx STA")] +[Trait("Category", "SwaggerCompatibility")] +public class SwaggerDocumentCompatibilityTest : TestBase +{ + private const string Document = """ + { + "swagger": "2.0", + "info": { "title": "Compatibility API", "version": "1.0" }, + "host": "api.example.com:8443", + "basePath": "/v1/", + "paths": { + "/items": { + "get": { + "operationId": "get items", + "responses": { "200": { "description": "OK" } } + } + } + }, + "tags": [{ "name": "items" }] + } + """; + + [Theory] + [InlineData(".json")] + [InlineData("_swagger2.json")] + [InlineData("_swagger.json")] + [InlineData(".swagger.json")] + [InlineData(".swagger2.json")] + [InlineData(".JSON")] + [InlineData("_SWAGGER2.JSON")] + [InlineData("_Swagger.Json")] + [InlineData(".SWAGGER.JSON")] + [InlineData(".Swagger2.Json")] + public void LegacySuffixesAreRecognizedAndBuildToTheSameFilenameAndUids(string suffix) + { + var input = GetRandomFolder(); + var fileName = Path.Combine("api", "a.b" + suffix); + CreateFile(fileName, Document, input); + var file = new FileAndType(Path.GetFullPath(input), fileName, DocumentType.Article); + Assert.Equal(ProcessingPriority.Normal, new RestApiDocumentProcessor().GetProcessingPriority(file)); + + var (output, diagnostics) = Build(input, fileName); + + Assert.Empty(diagnostics); + var rawFile = Assert.Single(Directory.GetFiles(output, "*.raw.json", SearchOption.AllDirectories)); + Assert.Equal(Path.Combine(output, "api", "a.b.raw.json"), rawFile); + var model = JsonUtility.Deserialize(rawFile); + const string uid = "api.example.com:8443/v1/Compatibility API/1.0"; + Assert.Equal(uid, model.Uid); + Assert.Equal("Compatibility API", model.Name); + Assert.Equal("api_example_com_8443_v1_Compatibility_API_1_0", model.HtmlId); + Assert.Equal("RestApi", model.Metadata["documentType"]); + Assert.Equal(Document, model.Raw); + + var operation = Assert.Single(model.Children); + Assert.Equal(uid + "/get items", operation.Uid); + Assert.Equal("api_example_com_8443_v1_Compatibility_API_1_0_get_items", operation.HtmlId); + Assert.Equal("/items", operation.Path); + Assert.Equal("get", operation.OperationName); + Assert.Equal("get items", operation.OperationId); + Assert.Equal(uid + "/tag/items", Assert.Single(model.Tags).Uid); + + var xrefs = YamlUtility.Deserialize(Path.Combine(output, XRefArchive.MajorFileName)).References; + Assert.Equal( + [uid, uid + "/get items", uid + "/tag/items"], + xrefs.Select(xref => xref.Uid).OrderBy(value => value, StringComparer.Ordinal)); + Assert.All(xrefs, xref => Assert.Equal("api/a.b.json", xref.Href)); + } + + [Theory] + [InlineData("api.json", DocumentType.Article, ProcessingPriority.Normal)] + [InlineData("api.md", DocumentType.Article, ProcessingPriority.NotSupported)] + [InlineData("api.yaml", DocumentType.Article, ProcessingPriority.NotSupported)] + [InlineData("api.md", DocumentType.Overwrite, ProcessingPriority.Normal)] + [InlineData("api.MD", DocumentType.Overwrite, ProcessingPriority.Normal)] + [InlineData("api.json", DocumentType.Overwrite, ProcessingPriority.NotSupported)] + [InlineData("api_swagger2.json", DocumentType.Overwrite, ProcessingPriority.NotSupported)] + [InlineData("api.json", DocumentType.Resource, ProcessingPriority.NotSupported)] + [InlineData("api.md", DocumentType.Resource, ProcessingPriority.NotSupported)] + public void ClassificationDependsOnDocumentTypeAndExtension(string fileName, DocumentType type, ProcessingPriority expected) + { + var folder = GetRandomFolder(); + CreateFile(fileName, type == DocumentType.Overwrite ? "Overwrite content is not inspected during recognition." : Document, folder); + var file = new FileAndType(Path.GetFullPath(folder), fileName, type); + + Assert.Equal(expected, new RestApiDocumentProcessor().GetProcessingPriority(file)); + } + + [Theory] + [InlineData("{}")] + [InlineData("""{"name":"ordinary JSON","paths":{}}""")] + [InlineData("""{"swagger":"1.2"}""")] + [InlineData("""{"Swagger":"2.0"}""")] + [InlineData("[]")] + [InlineData("null")] + [InlineData("""{"swagger":"2.0","paths":]}""")] + [InlineData("{\"swagger\":\"2.0\"")] + public void OrdinaryAndMalformedJsonAreNotRecognizedAsSwagger(string content) + { + var folder = GetRandomFolder(); + CreateFile("api_swagger2.json", content, folder); + var file = new FileAndType(Path.GetFullPath(folder), "api_swagger2.json", DocumentType.Article); + + Assert.Equal(ProcessingPriority.NotSupported, new RestApiDocumentProcessor().GetProcessingPriority(file)); + } + + [Fact] + public void MissingJsonFileIsNotRecognizedAsSwagger() + { + var file = new FileAndType(Path.GetFullPath(GetRandomFolder()), "missing.json", DocumentType.Article); + + Assert.Equal(ProcessingPriority.NotSupported, new RestApiDocumentProcessor().GetProcessingPriority(file)); + } + + [Theory] + [InlineData("missing-operation-id")] + [InlineData("missing-internal-reference")] + [InlineData("invalid-reference-value")] + [InlineData("invalid-internal-reference")] + [InlineData("missing-external-file")] + [InlineData("missing-external-fragment")] + [InlineData("nested-direct-external-reference")] + public void InvalidSwaggerReportsInvalidInputFileAndDoesNotExportARawModel(string failure) + { + var input = GetRandomFolder(); + var invalidName = Path.Combine("api", "bad_swagger2.json"); + var file = new FileAndType(Path.GetFullPath(input), invalidName, DocumentType.Article); + var loadPath = Path.Combine(file.BaseDir, file.File); + var externalPath = Path.Combine(Path.GetDirectoryName(loadPath), "missing.json"); + var (content, message) = failure switch + { + "missing-operation-id" => ( + Document.Replace("\"operationId\": \"get items\",", ""), + $"operationId should exist in operation 'get' of path '/items' for swagger file '{file.File}'"), + "missing-internal-reference" => ( + WithReference("\"#/definitions/Missing\""), + "Could not resolve reference '/definitions/Missing' in the document."), + "invalid-reference-value" => ( + WithReference("42"), + "JSON reference $ref property must have a string or null value, instead of Integer, location: definitions.Invalid.$ref."), + "invalid-internal-reference" => ( + WithReference("\"#\""), + "External file path '' should end with .json"), + "missing-external-file" => ( + WithReference("\"missing.json\""), + $"External swagger path not exist: {externalPath}."), + "missing-external-fragment" => ( + WithReference("\"target.json#/definitions/Missing\""), + "Could not resolve reference '/definitions/Missing' in the document."), + "nested-direct-external-reference" => ( + WithReference("\"target.json\""), + "$ref in target.json is not supported in external reference currently."), + _ => throw new ArgumentOutOfRangeException(nameof(failure)) + }; + CreateFile(Path.Combine("api", "target.json"), """ + { + "definitions": { "Value": { "type": "string" } }, + "properties": { "nested": { "$ref": "#/definitions/Value" } } + } + """, input); + CreateFile(invalidName, content, input); + var validName = Path.Combine("api", "good.json"); + CreateFile(validName, Document, input); + Assert.Equal(ProcessingPriority.Normal, new RestApiDocumentProcessor().GetProcessingPriority(file)); + + var (output, diagnostics) = Build(input, invalidName, validName); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal(LogLevel.Error, diagnostic.LogLevel); + Assert.Equal("InvalidInputFile", diagnostic.Code); + Assert.Equal(file.File, diagnostic.File); + Assert.Equal( + $"Unable to load file '{file.File}' via processor 'RestApiDocumentProcessor': {message}", + diagnostic.Message); + Assert.Equal( + Path.Combine(output, "api", "good.raw.json"), + Assert.Single(Directory.GetFiles(output, "*.raw.json", SearchOption.AllDirectories))); + Assert.False(File.Exists(Path.Combine(output, "api", "bad.raw.json"))); + Assert.False(File.Exists(Path.Combine(output, "api", "bad_swagger2.raw.json"))); + } + + private static string WithReference(string value) => $$""" + { + "swagger": "2.0", + "info": { "title": "Invalid API", "version": "1" }, + "paths": {}, + "definitions": { "Invalid": { "$ref": {{value}} } } + } + """; + + private (string Output, List Diagnostics) Build(string input, params string[] fileNames) + { + var output = GetRandomFolder(); + var files = new FileCollection(input); + files.Add(DocumentType.Article, fileNames); + var parameters = new DocumentBuildParameters + { + Files = files, + OutputBaseDir = output, + MaxParallelism = 1, + DisableGitFeatures = true, + ApplyTemplateSettings = new ApplyTemplateSettings(input, output) + { + TransformDocument = false, + RawModelExportSettings = { Export = true } + } + }; + var listener = new TestLoggerListener(item => item.LogLevel >= LogLevel.Warning); + Logger.RegisterListener(listener); + try + { + using var builder = new DocumentBuilder([typeof(RestApiDocumentProcessor).Assembly], []); + builder.Build(parameters); + } + finally + { + Logger.UnregisterListener(listener); + } + + // Template availability is irrelevant to raw-model builds; retain every other warning and error. + var diagnostics = listener.Items.Where(item => + !(item.LogLevel == LogLevel.Warning && + (item.Code == "UnknownContentTypeForTemplate" || + item.Message == "No template bundles were found, no template will be applied to the documents. 1) Check your docfx.json 2) the templates subfolder exists inside your application folder or your docfx.json directory."))) + .ToList(); + return (output, diagnostics); + } +} diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/Docfx.Build.RestApi.WithPlugins.Tests.csproj b/test/Docfx.Build.RestApi.WithPlugins.Tests/Docfx.Build.RestApi.WithPlugins.Tests.csproj index 610b6f5bc54..af36cff69f4 100644 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/Docfx.Build.RestApi.WithPlugins.Tests.csproj +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/Docfx.Build.RestApi.WithPlugins.Tests.csproj @@ -1,4 +1,11 @@ + + + + + + + diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs b/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs new file mode 100644 index 00000000000..17a6fb5ace7 --- /dev/null +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs @@ -0,0 +1,287 @@ +// 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.Immutable; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using Docfx.Build.Engine; +using Docfx.Build.OperationLevelRestApi; +using Docfx.Build.TagLevelRestApi; +using Docfx.Common; +using Docfx.DataContracts.Common; +using Docfx.Plugins; +using Docfx.Tests.Common; +using HtmlAgilityPack; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Docfx.Build.RestApi.WithPlugins.Tests; + +[Collection("docfx STA")] +[Trait("Category", "SwaggerCompatibility")] +public class SwaggerOutputCompatibilityTest : TestBase +{ + // Expected outputs were recorded with unchanged production code at this commit. + private const string BaselineCommit = "bd097d04a7b2c1eb7533b8f6e045764e20d15967"; + private const string InputDirectory = "TestData/compatibility"; + + [Theory] + [InlineData("trace")] + [InlineData("custom")] + public void PreservesUnsupportedSwaggerOperationBehavior(string method) + { + var input = GetRandomFolder(); + var output = GetRandomFolder(); + var file = CreateFile("unsupported.json", $$""" + { + "swagger": "2.0", + "info": { "title": "Operations", "version": "1.0" }, + "paths": { + "/items": { + "{{method}}": { "operationId": "ignored", "responses": { "200": { "description": "OK" } } } + } + } + } + """, input); + var files = new FileCollection(Directory.GetCurrentDirectory()); + files.Add(DocumentType.Article, [file], input); + using var builder = new DocumentBuilder([typeof(RestApiDocumentProcessor).Assembly], []); + builder.Build(new DocumentBuildParameters + { + Files = files, + OutputBaseDir = output, + ApplyTemplateSettings = new ApplyTemplateSettings(input, output) + { + TransformDocument = false, + RawModelExportSettings = { Export = true } + } + }); + + var model = JObject.Parse(File.ReadAllText(Path.Combine(output, "unsupported.raw.json"))); + Assert.Empty(model["children"]); + var xrefs = YamlUtility.Deserialize(Path.Combine(output, XRefArchive.MajorFileName)); + Assert.Equal("Operations/1.0", Assert.Single(xrefs.References).Uid); + } + + [Fact] + public void PreservesLegacyPathExtensionDiagnostic() + { + var input = GetRandomFolder(); + var output = GetRandomFolder(); + var file = CreateFile("extension.json", """ + { + "swagger": "2.0", + "info": { "title": "Extensions", "version": "1.0" }, + "paths": { + "/items": { + "get": { "operationId": "listItems", "responses": { "200": { "description": "OK" } } }, + "x-owner": { "team": "documentation" } + } + } + } + """, input); + var files = new FileCollection(Directory.GetCurrentDirectory()); + files.Add(DocumentType.Article, [file], input); + using var listener = new TestListenerScope(); + using var builder = new DocumentBuilder([typeof(RestApiDocumentProcessor).Assembly], []); + builder.Build(new DocumentBuildParameters + { + Files = files, + OutputBaseDir = output, + ApplyTemplateSettings = new ApplyTemplateSettings(input, output) + { + TransformDocument = false, + RawModelExportSettings = { Export = true } + } + }); + + // This is a legacy limitation, not a Swagger specification requirement. + var diagnostic = Assert.Single(listener.Items, i => i.Code == "InvalidInputFile"); + Assert.Contains("operationId should exist in operation 'x-owner' of path '/items'", diagnostic.Message); + Assert.False(File.Exists(Path.Combine(output, "extension.raw.json"))); + } + + [Theory] + [InlineData("default", false, false, false)] + [InlineData("default", false, false, true)] + [InlineData("default", true, false, false)] + [InlineData("default", false, true, false)] + [InlineData("default", true, true, false)] + [InlineData("statictoc", false, false, false)] + [InlineData("modern", false, false, false)] + public void PreservesSwaggerDocumentation(string template, bool splitTags, bool splitOperations, bool overwrite) + { + var output = GetRandomFolder(); + var files = new FileCollection(Directory.GetCurrentDirectory()); + files.Add(DocumentType.Article, [$"{InputDirectory}/service.swagger.json", $"{InputDirectory}/toc.yml"], InputDirectory); + if (overwrite) + { + files.Add(DocumentType.Overwrite, [$"{InputDirectory}/overwrite.md"], InputDirectory); + } + + var templates = new List { "common", "default" }; + if (template != "default") + { + templates.Add(template); + } + + var settings = new ApplyTemplateSettings(GetRandomFolder(), output) + { + RawModelExportSettings = { Export = true }, + ViewModelExportSettings = { Export = true } + }; + var parameters = new DocumentBuildParameters + { + Files = files, + OutputBaseDir = output, + ApplyTemplateSettings = settings, + TemplateManager = new TemplateManager(templates, null, "templates"), + Metadata = new Dictionary + { + ["meta"] = "Compatibility metadata", + ["_disableContribution"] = true, + ["_disableSearch"] = true + }.ToImmutableDictionary() + }; + + var gitFeaturesDisabled = EnvironmentContext.GitFeaturesDisabled; + using var listener = new TestListenerScope(); + try + { + EnvironmentContext.SetGitFeaturesDisabled(true); + using var builder = new DocumentBuilder(GetAssemblies(splitTags, splitOperations), []); + builder.Build(parameters); + } + finally + { + EnvironmentContext.SetGitFeaturesDisabled(gitFeaturesDisabled); + } + + Assert.Empty(listener.Items); + var actual = CaptureOutput(output); + var name = $"{template}-tags-{splitTags}-operations-{splitOperations}-overwrite-{overwrite}"; + var expectedPath = Path.Combine(InputDirectory, "expected", name + ".json"); + var expected = File.Exists(expectedPath) ? JObject.Parse(File.ReadAllText(expectedPath)) : null; + if (expected == null || !JToken.DeepEquals(expected, actual)) + { + // Never update a baseline from a candidate implementation automatically. + var actualDirectory = Path.Combine(AppContext.BaseDirectory, "TestResults", "SwaggerCompatibility"); + Directory.CreateDirectory(actualDirectory); + var actualPath = Path.Combine(actualDirectory, name + ".actual.json"); + File.WriteAllText(actualPath, actual.ToString()); + Assert.Fail($"Swagger output differs from baseline {BaselineCommit}. Expected: {expectedPath}. Actual: {actualPath}"); + } + } + + private static IEnumerable GetAssemblies(bool splitTags, bool splitOperations) + { + yield return typeof(RestApiDocumentProcessor).Assembly; + if (splitTags) + { + yield return typeof(SplitRestApiToTagLevel).Assembly; + } + if (splitOperations) + { + yield return typeof(SplitRestApiToOperationLevel).Assembly; + } + } + + private static JObject CaptureOutput(string output) + { + var models = new JObject(); + var html = new JObject(); + foreach (var path in Directory.GetFiles(output, "*", SearchOption.AllDirectories).Order(StringComparer.Ordinal)) + { + var relative = Path.GetRelativePath(output, path).Replace('\\', '/'); + if (relative.EndsWith(".raw.json", StringComparison.Ordinal) || relative.EndsWith(".view.json", StringComparison.Ordinal)) + { + var model = JObject.Parse(File.ReadAllText(path)); + if (model["_raw"]?.Type == JTokenType.String) + { + Assert.Equal(File.ReadAllText($"{InputDirectory}/service.swagger.json"), (string)model["_raw"]); + } + model.Remove("_raw"); + model.Remove("__global"); + model.Remove(Constants.PropertyName.SystemKeys); + models[relative] = Canonicalize(model); + } + else if (relative.EndsWith(".html", StringComparison.Ordinal)) + { + var document = new HtmlDocument(); + document.Load(path); + var article = document.DocumentNode.SelectSingleNode("//article"); + if (article != null) + { + html[relative] = CanonicalHtml(article); + } + } + } + + Assert.NotEmpty(models); + Assert.NotEmpty(html); + var xrefs = YamlUtility.Deserialize(Path.Combine(output, XRefArchive.MajorFileName)); + var manifest = JObject.Parse(File.ReadAllText(Path.Combine(output, "manifest.json"))); + return new JObject + { + ["baselineCommit"] = BaselineCommit, + ["models"] = models, + ["html"] = html, + ["xrefs"] = Canonicalize(JArray.FromObject(xrefs.References.OrderBy(r => r.Uid, StringComparer.Ordinal))), + ["manifest"] = Canonicalize(new JArray(((JArray)manifest["files"]) + .OrderBy(f => (string)f["source_relative_path"], StringComparer.Ordinal) + .ThenBy(f => (string)f["output"]?[".html"]?["relative_path"], StringComparer.Ordinal))) + }; + } + + private static JToken Canonicalize(JToken token) + { + return token switch + { + JObject obj => new JObject(obj.Properties().OrderBy(p => p.Name, StringComparer.Ordinal) + .Select(p => new JProperty(p.Name, Canonicalize(p.Value)))), + JArray array => new JArray(array.Select(Canonicalize)), + JValue { Type: JTokenType.String } value => new JValue(((string)value).Replace("\r\n", "\n")), + _ => token.DeepClone() + }; + } + + private static string CanonicalHtml(HtmlNode node) + { + var result = new StringBuilder(); + Append(node, false); + return result.ToString(); + + void Append(HtmlNode current, bool preserveWhitespace) + { + if (current.NodeType == HtmlNodeType.Comment) + { + return; + } + if (current is HtmlTextNode text) + { + if (preserveWhitespace) + { + result.Append(text.Text.Replace("\r\n", "\n")); + } + else if (!string.IsNullOrWhiteSpace(text.Text)) + { + result.Append(Regex.Replace(text.Text, @"\s+", " ")); + } + return; + } + + result.Append('<').Append(current.Name); + foreach (var attribute in current.Attributes.OrderBy(a => a.Name, StringComparer.Ordinal)) + { + result.Append(' ').Append(attribute.Name).Append("=\"").Append(attribute.Value).Append('"'); + } + result.Append('>'); + foreach (var child in current.ChildNodes) + { + Append(child, preserveWhitespace || current.Name == "pre"); + } + result.Append("'); + } + } +} diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/components.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/components.json new file mode 100644 index 00000000000..66558d7d03e --- /dev/null +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/components.json @@ -0,0 +1,10 @@ +{ + "definitions": { + "Error": { + "type": "object", + "properties": { + "message": { "type": "string", "description": "Error **details**." } + } + } + } +} diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-False-overwrite-False.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-False-overwrite-False.json new file mode 100644 index 00000000000..6d061561681 --- /dev/null +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-False-overwrite-False.json @@ -0,0 +1,859 @@ +{ + "baselineCommit": "bd097d04a7b2c1eb7533b8f6e045764e20d15967", + "models": { + "service.html.view.json": { + "_disableContribution": true, + "_disableNextArticle": true, + "_disableSearch": true, + "_disableToc": true, + "_jsonPath": "service.swagger.json", + "_key": "TestData/compatibility/service.swagger.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "service.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "children": [ + { + "conceptual": "", + "deprecated": true, + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", + "operation": "DELETE", + "operationId": "deleteItem", + "parameters": [ + { + "description": "

Item ID.

\n", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "path": "/items/{id}", + "remarks": "", + "responses": [ + { + "description": "

No content.

\n", + "statusCode": "204" + } + ], + "security": [], + "source": null, + "sourceurl": "", + "summary": "

Delete an item.

\n", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + } + ], + "consumes": [ + "application/json" + ], + "definitions": [ + { + "cType": "Item", + "cTypeId": "Item", + "cTypeIsArray": true, + "items": { + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + }, + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "array", + "x-internal-ref-name": "Item" + }, + { + "cType": "Error", + "cTypeId": "Error", + "properties": [ + { + "key": "message", + "value": { + "description": "

Error details.

\n", + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Error" + } + ], + "description": "

Use the API guide.

\n", + "documentType": "RestApi", + "docurl": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0", + "isTagLayout": true, + "meta": "Compatibility metadata", + "name": "Compatibility API", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "sourceurl": "", + "summary": "

A stable API.

\n", + "swagger": "2.0", + "tags": [ + { + "children": [ + { + "conceptual": "", + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", + "includedInTags": true, + "operation": "GET", + "operationId": "listItems", + "parameters": [ + { + "default": "2.0", + "description": "

An optional version.

\n", + "in": "query", + "name": "api-version", + "required": false, + "type": "string" + }, + { + "default": 0, + "description": "

Maximum number of items.

\n", + "in": "query", + "minimum": 0, + "name": "limit", + "type": "integer" + } + ], + "path": "/items[?api-version&limit]", + "remarks": "", + "responses": [ + { + "description": "

The items.

\n", + "examples": [ + { + "content": "[\n {\n \"id\": \"one\",\n \"name\": \"First\",\n \"$ref\": \"literal-example\"\n }\n]", + "mimeType": "application/json" + }, + { + "content": "\"one\"", + "mimeType": "text/plain" + } + ], + "headers": { + "X-Count": { + "description": "

Total count.

\n", + "type": "integer" + } + }, + "schema": { + "cType": "Item", + "cTypeId": "Item", + "cTypeIsArray": true, + "items": { + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + }, + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "type": "array" + }, + "statusCode": "200" + }, + { + "description": "

A request error.

\n", + "schema": { + "cType": "Error", + "cTypeId": "Error", + "properties": [ + { + "key": "message", + "value": { + "description": "

Error details.

\n", + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Error" + }, + "statusCode": "default", + "x-internal-ref-name": "Error" + } + ], + "source": null, + "sourceurl": "", + "summary": "

List items.

\n", + "tags": [ + "items" + ], + "uid": "api.example.test/v1/Compatibility API/1.0/listItems" + }, + { + "conceptual": "", + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", + "includedInTags": true, + "operation": "POST", + "operationId": "createItem", + "parameters": [ + { + "description": "

The new item.

\n", + "in": "body", + "name": "body", + "required": true, + "schema": { + "cType": "Item", + "cTypeId": "Item", + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "required": true, + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "description": null, + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Item" + } + }, + { + "default": "1.0", + "description": "

The API version.

\n", + "in": "query", + "name": "api-version", + "required": true, + "type": "string", + "x-internal-ref-name": "Version" + } + ], + "path": "/items?api-version", + "remarks": "", + "responses": [ + { + "description": "

Created.

\n", + "examples": [ + { + "content": "{\n \"id\": \"one\",\n \"name\": \"First\"\n}", + "mimeType": "application/json" + } + ], + "schema": { + "cType": "Item", + "cTypeId": "Item", + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "required": true, + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "description": null, + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "statusCode": "201" + } + ], + "source": null, + "sourceurl": "", + "summary": "

Create an item.

\n", + "tags": [ + "items" + ], + "uid": "api.example.test/v1/Compatibility API/1.0/createItem" + } + ], + "conceptual": "", + "description": "

Manage items.

\n", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_tag_items", + "name": "items", + "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" + } + ], + "title": "Compatibility API", + "uid": "api.example.test/v1/Compatibility API/1.0", + "x-owner": { + "team": "documentation" + } + }, + "service.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_key": "TestData/compatibility/service.swagger.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "service.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "children": [ + { + "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", + "operation": "get", + "operationId": "listItems", + "parameters": [ + { + "default": "2.0", + "description": "

An optional version.

\n", + "in": "query", + "name": "api-version", + "required": false, + "type": "string" + }, + { + "default": 0, + "description": "

Maximum number of items.

\n", + "in": "query", + "minimum": 0, + "name": "limit", + "type": "integer" + } + ], + "path": "/items", + "responses": [ + { + "description": "

The items.

\n", + "examples": [ + { + "content": "[{\"id\":\"one\",\"name\":\"First\",\"$ref\":\"literal-example\"}]", + "mimeType": "application/json" + }, + { + "content": "\"one\"", + "mimeType": "text/plain" + } + ], + "headers": { + "X-Count": { + "description": "

Total count.

\n", + "type": "integer" + } + }, + "schema": { + "items": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + }, + "type": "array" + }, + "statusCode": "200" + }, + { + "description": "

A request error.

\n", + "schema": { + "properties": { + "message": { + "description": "

Error details.

\n", + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Error" + }, + "statusCode": "default", + "x-internal-ref-name": "Error" + } + ], + "source": null, + "summary": "

List items.

\n", + "tags": [ + "items" + ], + "uid": "api.example.test/v1/Compatibility API/1.0/listItems" + }, + { + "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", + "operation": "post", + "operationId": "createItem", + "parameters": [ + { + "description": "

The new item.

\n", + "in": "body", + "name": "body", + "required": true, + "schema": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + } + }, + { + "default": "1.0", + "description": "

The API version.

\n", + "in": "query", + "name": "api-version", + "required": true, + "type": "string", + "x-internal-ref-name": "Version" + } + ], + "path": "/items", + "responses": [ + { + "description": "

Created.

\n", + "examples": [ + { + "content": "{\"id\":\"one\",\"name\":\"First\"}", + "mimeType": "application/json" + } + ], + "schema": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + }, + "statusCode": "201" + } + ], + "source": null, + "summary": "

Create an item.

\n", + "tags": [ + "items" + ], + "uid": "api.example.test/v1/Compatibility API/1.0/createItem" + }, + { + "deprecated": true, + "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", + "operation": "delete", + "operationId": "deleteItem", + "parameters": [ + { + "description": "

Item ID.

\n", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "path": "/items/{id}", + "responses": [ + { + "description": "

No content.

\n", + "statusCode": "204" + } + ], + "security": [], + "source": null, + "summary": "

Delete an item.

\n", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + } + ], + "consumes": [ + "application/json" + ], + "description": "

Use the API guide.

\n", + "documentType": "RestApi", + "htmlId": "api_example_test_v1_Compatibility_API_1_0", + "meta": "Compatibility metadata", + "name": "Compatibility API", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "summary": "

A stable API.

\n", + "swagger": "2.0", + "tags": [ + { + "description": "

Manage items.

\n", + "htmlId": "items-tag", + "name": "items", + "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" + } + ], + "uid": "api.example.test/v1/Compatibility API/1.0", + "x-owner": { + "team": "documentation" + } + }, + "toc.html.view.json": { + "_disableContribution": true, + "_disableSearch": true, + "_disableToc": true, + "_key": "TestData/compatibility/toc.yml", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "toc.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "items": [ + { + "href": "service.html", + "items": [], + "leaf": true, + "level": 2, + "name": "Compatibility API", + "tocHref": null, + "topicHref": "service.html" + } + ], + "leaf": false, + "level": 1, + "meta": "Compatibility metadata", + "name": null, + "title": "Table of Content", + "tocHref": null, + "topicHref": null + }, + "toc.json.view.json": { + "content": "{\"items\":[{\"name\":\"Compatibility API\",\"href\":\"service.html\",\"topicHref\":\"service.html\"}],\"meta\":\"Compatibility metadata\"}" + }, + "toc.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_key": "TestData/compatibility/toc.yml", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "toc.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "items": [ + { + "href": "service.html", + "name": "Compatibility API", + "topicHref": "service.html" + } + ], + "meta": "Compatibility metadata" + } + }, + "html": { + "service.html": "

Compatibility API

A stable API.

Use the API guide.

items

Manage items.

listItems

List items.

Request
GET /items[?api-version&limit]
Parameters
NameTypeDefaultNotes
api-version2.0

An optional version.

limit0

Maximum number of items.

Responses
Status CodeTypeDescriptionSamples
200Item[]

The items.

Mime type: application/json
[\n  {\n    "id": "one",\n    "name": "First",\n    "$ref": "literal-example"\n  }\n]
Mime type: text/plain
"one"
defaultError

A request error.

createItem

Create an item.

Request
POST /items?api-version
Parameters
NameTypeDefaultNotes
*bodyItem

The new item.

*api-version1.0

The API version.

Responses
Status CodeTypeDescriptionSamples
201Item

Created.

Mime type: application/json
{\n  "id": "one",\n  "name": "First"\n}

Other APIs

deleteItem

Delete an item.

Request
DELETE /items/{id}
Parameters
NameTypeDefaultNotes
*id

Item ID.

Responses
Status CodeTypeDescriptionSamples
204

No content.

Definitions

Item

Use the API guide.

NameTypeNotes
id string

The item identifier.

name string

The display name.

state string

Error

Use the API guide.

NameTypeNotes
message string

Error details.

" + }, + "xrefs": [ + { + "href": "service.html", + "name": "Compatibility API", + "uid": "api.example.test/v1/Compatibility API/1.0" + }, + { + "href": "service.html#api_example_test_v1_Compatibility_API_1_0_createItem", + "name": "createItem", + "uid": "api.example.test/v1/Compatibility API/1.0/createItem" + }, + { + "href": "service.html#api_example_test_v1_Compatibility_API_1_0_deleteItem", + "name": "deleteItem", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + }, + { + "href": "service.html#api_example_test_v1_Compatibility_API_1_0_listItems", + "name": "listItems", + "uid": "api.example.test/v1/Compatibility API/1.0/listItems" + }, + { + "href": "service.html#api_example_test_v1_Compatibility_API_1_0_tag_items", + "name": "items", + "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" + } + ], + "manifest": [ + { + "output": { + ".html": { + "relative_path": "service.html" + } + }, + "source_relative_path": "TestData/compatibility/service.swagger.json", + "type": "RestApi" + }, + { + "output": { + ".html": { + "relative_path": "toc.html" + }, + ".json": { + "relative_path": "toc.json" + } + }, + "source_relative_path": "TestData/compatibility/toc.yml", + "type": "Toc" + } + ] +} \ No newline at end of file diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-False-overwrite-True.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-False-overwrite-True.json new file mode 100644 index 00000000000..59ea83492cd --- /dev/null +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-False-overwrite-True.json @@ -0,0 +1,863 @@ +{ + "baselineCommit": "bd097d04a7b2c1eb7533b8f6e045764e20d15967", + "models": { + "service.html.view.json": { + "_disableContribution": true, + "_disableNextArticle": true, + "_disableSearch": true, + "_disableToc": true, + "_jsonPath": "service.swagger.json", + "_key": "TestData/compatibility/service.swagger.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "service.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "children": [ + { + "conceptual": "", + "deprecated": true, + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", + "operation": "DELETE", + "operationId": "deleteItem", + "parameters": [ + { + "description": "

Item ID.

\n", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "path": "/items/{id}", + "remarks": "", + "responses": [ + { + "description": "

No content.

\n", + "statusCode": "204" + } + ], + "security": [], + "source": null, + "sourceurl": "", + "summary": "

Delete an item.

\n", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + } + ], + "conceptual": "\n

Document-level conceptual content.

\n", + "consumes": [ + "application/json" + ], + "definitions": [ + { + "cType": "Item", + "cTypeId": "Item", + "cTypeIsArray": true, + "items": { + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + }, + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "array", + "x-internal-ref-name": "Item" + }, + { + "cType": "Error", + "cTypeId": "Error", + "properties": [ + { + "key": "message", + "value": { + "description": "

Error details.

\n", + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Error" + } + ], + "description": "

Use the API guide.

\n", + "documentType": "RestApi", + "docurl": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0", + "isTagLayout": true, + "meta": "Compatibility metadata", + "name": "Compatibility API", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "sourceurl": "", + "summary": "

Updated API summary.

\n", + "swagger": "2.0", + "tags": [ + { + "children": [ + { + "conceptual": "", + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", + "includedInTags": true, + "operation": "GET", + "operationId": "listItems", + "parameters": [ + { + "default": "2.0", + "description": "

An optional version.

\n", + "in": "query", + "name": "api-version", + "required": false, + "type": "string" + }, + { + "default": 0, + "description": "

Maximum number of items.

\n", + "in": "query", + "minimum": 0, + "name": "limit", + "type": "integer" + } + ], + "path": "/items[?api-version&limit]", + "remarks": "", + "responses": [ + { + "description": "

The items.

\n", + "examples": [ + { + "content": "[\n {\n \"id\": \"one\",\n \"name\": \"First\",\n \"$ref\": \"literal-example\"\n }\n]", + "mimeType": "application/json" + }, + { + "content": "\"one\"", + "mimeType": "text/plain" + } + ], + "headers": { + "X-Count": { + "description": "

Total count.

\n", + "type": "integer" + } + }, + "schema": { + "cType": "Item", + "cTypeId": "Item", + "cTypeIsArray": true, + "items": { + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + }, + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "type": "array" + }, + "statusCode": "200" + }, + { + "description": "

A request error.

\n", + "schema": { + "cType": "Error", + "cTypeId": "Error", + "properties": [ + { + "key": "message", + "value": { + "description": "

Error details.

\n", + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Error" + }, + "statusCode": "default", + "x-internal-ref-name": "Error" + } + ], + "source": null, + "sourceurl": "", + "summary": "

List items.

\n", + "tags": [ + "items" + ], + "uid": "api.example.test/v1/Compatibility API/1.0/listItems" + }, + { + "conceptual": "\n

Operation-level conceptual content.

\n", + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", + "includedInTags": true, + "operation": "POST", + "operationId": "createItem", + "parameters": [ + { + "description": "

Updated body description.

\n", + "in": "body", + "name": "body", + "required": true, + "schema": { + "cType": "Item", + "cTypeId": "Item", + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "Updated name description.", + "required": true, + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "description": null, + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Item" + } + }, + { + "default": "1.0", + "description": "

The API version.

\n", + "in": "query", + "name": "api-version", + "required": true, + "type": "string", + "x-internal-ref-name": "Version" + } + ], + "path": "/items?api-version", + "remarks": "", + "responses": [ + { + "description": "

Created.

\n", + "examples": [ + { + "content": "{\n \"id\": \"one\",\n \"name\": \"First\"\n}", + "mimeType": "application/json" + } + ], + "schema": { + "cType": "Item", + "cTypeId": "Item", + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "required": true, + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "description": null, + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "statusCode": "201" + } + ], + "source": null, + "sourceurl": "", + "summary": "

Updated create summary.

\n", + "tags": [ + "items" + ], + "uid": "api.example.test/v1/Compatibility API/1.0/createItem" + } + ], + "conceptual": "

Tag-level conceptual content.

\n", + "description": "

Updated items tag.

\n", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_tag_items", + "name": "items", + "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" + } + ], + "title": "Compatibility API", + "uid": "api.example.test/v1/Compatibility API/1.0", + "x-owner": { + "team": "documentation" + } + }, + "service.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_key": "TestData/compatibility/service.swagger.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "service.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "children": [ + { + "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", + "operation": "get", + "operationId": "listItems", + "parameters": [ + { + "default": "2.0", + "description": "

An optional version.

\n", + "in": "query", + "name": "api-version", + "required": false, + "type": "string" + }, + { + "default": 0, + "description": "

Maximum number of items.

\n", + "in": "query", + "minimum": 0, + "name": "limit", + "type": "integer" + } + ], + "path": "/items", + "responses": [ + { + "description": "

The items.

\n", + "examples": [ + { + "content": "[{\"id\":\"one\",\"name\":\"First\",\"$ref\":\"literal-example\"}]", + "mimeType": "application/json" + }, + { + "content": "\"one\"", + "mimeType": "text/plain" + } + ], + "headers": { + "X-Count": { + "description": "

Total count.

\n", + "type": "integer" + } + }, + "schema": { + "items": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + }, + "type": "array" + }, + "statusCode": "200" + }, + { + "description": "

A request error.

\n", + "schema": { + "properties": { + "message": { + "description": "

Error details.

\n", + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Error" + }, + "statusCode": "default", + "x-internal-ref-name": "Error" + } + ], + "source": null, + "summary": "

List items.

\n", + "tags": [ + "items" + ], + "uid": "api.example.test/v1/Compatibility API/1.0/listItems" + }, + { + "conceptual": "\n

Operation-level conceptual content.

\n", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", + "operation": "post", + "operationId": "createItem", + "parameters": [ + { + "description": "

Updated body description.

\n", + "in": "body", + "name": "body", + "required": true, + "schema": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "Updated name description.", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + } + }, + { + "default": "1.0", + "description": "

The API version.

\n", + "in": "query", + "name": "api-version", + "required": true, + "type": "string", + "x-internal-ref-name": "Version" + } + ], + "path": "/items", + "responses": [ + { + "description": "

Created.

\n", + "examples": [ + { + "content": "{\"id\":\"one\",\"name\":\"First\"}", + "mimeType": "application/json" + } + ], + "schema": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + }, + "statusCode": "201" + } + ], + "source": null, + "summary": "

Updated create summary.

\n", + "tags": [ + "items" + ], + "uid": "api.example.test/v1/Compatibility API/1.0/createItem" + }, + { + "deprecated": true, + "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", + "operation": "delete", + "operationId": "deleteItem", + "parameters": [ + { + "description": "

Item ID.

\n", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "path": "/items/{id}", + "responses": [ + { + "description": "

No content.

\n", + "statusCode": "204" + } + ], + "security": [], + "source": null, + "summary": "

Delete an item.

\n", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + } + ], + "conceptual": "\n

Document-level conceptual content.

\n", + "consumes": [ + "application/json" + ], + "description": "

Use the API guide.

\n", + "documentType": "RestApi", + "htmlId": "api_example_test_v1_Compatibility_API_1_0", + "meta": "Compatibility metadata", + "name": "Compatibility API", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "summary": "

Updated API summary.

\n", + "swagger": "2.0", + "tags": [ + { + "conceptual": "

Tag-level conceptual content.

\n", + "description": "

Updated items tag.

\n", + "htmlId": "items-tag", + "name": "items", + "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" + } + ], + "uid": "api.example.test/v1/Compatibility API/1.0", + "x-owner": { + "team": "documentation" + } + }, + "toc.html.view.json": { + "_disableContribution": true, + "_disableSearch": true, + "_disableToc": true, + "_key": "TestData/compatibility/toc.yml", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "toc.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "items": [ + { + "href": "service.html", + "items": [], + "leaf": true, + "level": 2, + "name": "Compatibility API", + "tocHref": null, + "topicHref": "service.html" + } + ], + "leaf": false, + "level": 1, + "meta": "Compatibility metadata", + "name": null, + "title": "Table of Content", + "tocHref": null, + "topicHref": null + }, + "toc.json.view.json": { + "content": "{\"items\":[{\"name\":\"Compatibility API\",\"href\":\"service.html\",\"topicHref\":\"service.html\"}],\"meta\":\"Compatibility metadata\"}" + }, + "toc.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_key": "TestData/compatibility/toc.yml", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "toc.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "items": [ + { + "href": "service.html", + "name": "Compatibility API", + "topicHref": "service.html" + } + ], + "meta": "Compatibility metadata" + } + }, + "html": { + "service.html": "

Compatibility API

Updated API summary.

Use the API guide.

Document-level conceptual content.

items

Updated items tag.

Tag-level conceptual content.

listItems

List items.

Request
GET /items[?api-version&limit]
Parameters
NameTypeDefaultNotes
api-version2.0

An optional version.

limit0

Maximum number of items.

Responses
Status CodeTypeDescriptionSamples
200Item[]

The items.

Mime type: application/json
[\n  {\n    "id": "one",\n    "name": "First",\n    "$ref": "literal-example"\n  }\n]
Mime type: text/plain
"one"
defaultError

A request error.

createItem

Updated create summary.

Operation-level conceptual content.

Request
POST /items?api-version
Parameters
NameTypeDefaultNotes
*bodyItem

Updated body description.

*api-version1.0

The API version.

Responses
Status CodeTypeDescriptionSamples
201Item

Created.

Mime type: application/json
{\n  "id": "one",\n  "name": "First"\n}

Other APIs

deleteItem

Delete an item.

Request
DELETE /items/{id}
Parameters
NameTypeDefaultNotes
*id

Item ID.

Responses
Status CodeTypeDescriptionSamples
204

No content.

Definitions

Item

Use the API guide.

NameTypeNotes
id string

The item identifier.

name string

The display name.

state string

Error

Use the API guide.

NameTypeNotes
message string

Error details.

" + }, + "xrefs": [ + { + "href": "service.html", + "name": "Compatibility API", + "uid": "api.example.test/v1/Compatibility API/1.0" + }, + { + "href": "service.html#api_example_test_v1_Compatibility_API_1_0_createItem", + "name": "createItem", + "uid": "api.example.test/v1/Compatibility API/1.0/createItem" + }, + { + "href": "service.html#api_example_test_v1_Compatibility_API_1_0_deleteItem", + "name": "deleteItem", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + }, + { + "href": "service.html#api_example_test_v1_Compatibility_API_1_0_listItems", + "name": "listItems", + "uid": "api.example.test/v1/Compatibility API/1.0/listItems" + }, + { + "href": "service.html#api_example_test_v1_Compatibility_API_1_0_tag_items", + "name": "items", + "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" + } + ], + "manifest": [ + { + "output": { + ".html": { + "relative_path": "service.html" + } + }, + "source_relative_path": "TestData/compatibility/service.swagger.json", + "type": "RestApi" + }, + { + "output": { + ".html": { + "relative_path": "toc.html" + }, + ".json": { + "relative_path": "toc.json" + } + }, + "source_relative_path": "TestData/compatibility/toc.yml", + "type": "Toc" + } + ] +} \ No newline at end of file diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-True-overwrite-False.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-True-overwrite-False.json new file mode 100644 index 00000000000..a0ebe461b50 --- /dev/null +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-True-overwrite-False.json @@ -0,0 +1,1277 @@ +{ + "baselineCommit": "bd097d04a7b2c1eb7533b8f6e045764e20d15967", + "models": { + "service.html.view.json": { + "_disableContribution": true, + "_disableNextArticle": true, + "_disableSearch": true, + "_disableToc": true, + "_isSplittedByOperation": true, + "_jsonPath": "service.swagger.json", + "_key": "TestData/compatibility/service.swagger.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "service.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "children": [], + "consumes": [ + "application/json" + ], + "definitions": [], + "description": "

Use the API guide.

\n", + "documentType": "RestApi", + "docurl": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0", + "meta": "Compatibility metadata", + "name": "Compatibility API", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "sourceurl": "", + "summary": "

A stable API.

\n", + "swagger": "2.0", + "tags": [], + "title": "Compatibility API", + "uid": "api.example.test/v1/Compatibility API/1.0", + "x-owner": { + "team": "documentation" + } + }, + "service.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_isSplittedByOperation": true, + "_key": "TestData/compatibility/service.swagger.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "service.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "children": [], + "consumes": [ + "application/json" + ], + "description": "

Use the API guide.

\n", + "documentType": "RestApi", + "htmlId": "api_example_test_v1_Compatibility_API_1_0", + "meta": "Compatibility metadata", + "name": "Compatibility API", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "summary": "

A stable API.

\n", + "swagger": "2.0", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0", + "x-owner": { + "team": "documentation" + } + }, + "service/createItem.html.view.json": { + "_disableContribution": true, + "_disableNextArticle": true, + "_disableSearch": true, + "_disableToc": true, + "_isSplittedToOperation": true, + "_jsonPath": "createItem.swagger.json", + "_key": "TestData/compatibility/service/createItem.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "../toc.html", + "_path": "service/createItem.html", + "_rel": "../", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "../toc.html", + "children": [ + { + "conceptual": "", + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem_operation", + "operation": "POST", + "operationId": "createItem", + "parameters": [ + { + "description": "

The new item.

\n", + "in": "body", + "name": "body", + "required": true, + "schema": { + "cType": "Item", + "cTypeId": "Item", + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "required": true, + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "description": null, + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Item" + } + }, + { + "default": "1.0", + "description": "

The API version.

\n", + "in": "query", + "name": "api-version", + "required": true, + "type": "string", + "x-internal-ref-name": "Version" + } + ], + "path": "/items?api-version", + "remarks": "", + "responses": [ + { + "description": "

Created.

\n", + "examples": [ + { + "content": "{\n \"id\": \"one\",\n \"name\": \"First\"\n}", + "mimeType": "application/json" + } + ], + "schema": { + "cType": "Item", + "cTypeId": "Item", + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "required": true, + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "description": null, + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "statusCode": "201" + } + ], + "source": null, + "sourceurl": "", + "summary": "", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/createItem/operation" + } + ], + "consumes": [ + "application/json" + ], + "definitions": [ + { + "cType": "Item", + "cTypeId": "Item", + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "required": true, + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "description": null, + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Item" + } + ], + "documentType": "RestApi", + "docurl": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", + "meta": "Compatibility metadata", + "name": "createItem", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "source": null, + "sourceurl": "", + "summary": "

Create an item.

\n", + "swagger": "2.0", + "tags": [], + "title": "createItem", + "uid": "api.example.test/v1/Compatibility API/1.0/createItem", + "x-owner": { + "team": "documentation" + } + }, + "service/createItem.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_isSplittedToOperation": true, + "_key": "TestData/compatibility/service/createItem.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "../toc.html", + "_path": "service/createItem.html", + "_rel": "../", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "../toc.html", + "children": [ + { + "operation": "post", + "operationId": "createItem", + "parameters": [ + { + "description": "

The new item.

\n", + "in": "body", + "name": "body", + "required": true, + "schema": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + } + }, + { + "default": "1.0", + "description": "

The API version.

\n", + "in": "query", + "name": "api-version", + "required": true, + "type": "string", + "x-internal-ref-name": "Version" + } + ], + "path": "/items", + "responses": [ + { + "description": "

Created.

\n", + "examples": [ + { + "content": "{\"id\":\"one\",\"name\":\"First\"}", + "mimeType": "application/json" + } + ], + "schema": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + }, + "statusCode": "201" + } + ], + "source": null, + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/createItem/operation" + } + ], + "consumes": [ + "application/json" + ], + "documentType": "RestApi", + "meta": "Compatibility metadata", + "name": "createItem", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "source": null, + "summary": "

Create an item.

\n", + "swagger": "2.0", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/createItem", + "x-owner": { + "team": "documentation" + } + }, + "service/deleteItem.html.view.json": { + "_disableContribution": true, + "_disableNextArticle": true, + "_disableSearch": true, + "_disableToc": true, + "_isSplittedToOperation": true, + "_jsonPath": "deleteItem.swagger.json", + "_key": "TestData/compatibility/service/deleteItem.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "../toc.html", + "_path": "service/deleteItem.html", + "_rel": "../", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "../toc.html", + "children": [ + { + "conceptual": "", + "deprecated": true, + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem_operation", + "operation": "DELETE", + "operationId": "deleteItem", + "parameters": [ + { + "description": "

Item ID.

\n", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "path": "/items/{id}", + "remarks": "", + "responses": [ + { + "description": "

No content.

\n", + "statusCode": "204" + } + ], + "security": [], + "source": null, + "sourceurl": "", + "summary": "", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem/operation" + } + ], + "consumes": [ + "application/json" + ], + "definitions": [], + "deprecated": true, + "documentType": "RestApi", + "docurl": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", + "meta": "Compatibility metadata", + "name": "deleteItem", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "source": null, + "sourceurl": "", + "summary": "

Delete an item.

\n", + "swagger": "2.0", + "tags": [], + "title": "deleteItem", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem", + "x-owner": { + "team": "documentation" + } + }, + "service/deleteItem.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_isSplittedToOperation": true, + "_key": "TestData/compatibility/service/deleteItem.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "../toc.html", + "_path": "service/deleteItem.html", + "_rel": "../", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "../toc.html", + "children": [ + { + "deprecated": true, + "operation": "delete", + "operationId": "deleteItem", + "parameters": [ + { + "description": "

Item ID.

\n", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "path": "/items/{id}", + "responses": [ + { + "description": "

No content.

\n", + "statusCode": "204" + } + ], + "security": [], + "source": null, + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem/operation" + } + ], + "consumes": [ + "application/json" + ], + "deprecated": true, + "documentType": "RestApi", + "meta": "Compatibility metadata", + "name": "deleteItem", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "source": null, + "summary": "

Delete an item.

\n", + "swagger": "2.0", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem", + "x-owner": { + "team": "documentation" + } + }, + "service/listItems.html.view.json": { + "_disableContribution": true, + "_disableNextArticle": true, + "_disableSearch": true, + "_disableToc": true, + "_isSplittedToOperation": true, + "_jsonPath": "listItems.swagger.json", + "_key": "TestData/compatibility/service/listItems.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "../toc.html", + "_path": "service/listItems.html", + "_rel": "../", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "../toc.html", + "children": [ + { + "conceptual": "", + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems_operation", + "operation": "GET", + "operationId": "listItems", + "parameters": [ + { + "default": "2.0", + "description": "

An optional version.

\n", + "in": "query", + "name": "api-version", + "required": false, + "type": "string" + }, + { + "default": 0, + "description": "

Maximum number of items.

\n", + "in": "query", + "minimum": 0, + "name": "limit", + "type": "integer" + } + ], + "path": "/items[?api-version&limit]", + "remarks": "", + "responses": [ + { + "description": "

The items.

\n", + "examples": [ + { + "content": "[\n {\n \"id\": \"one\",\n \"name\": \"First\",\n \"$ref\": \"literal-example\"\n }\n]", + "mimeType": "application/json" + }, + { + "content": "\"one\"", + "mimeType": "text/plain" + } + ], + "headers": { + "X-Count": { + "description": "

Total count.

\n", + "type": "integer" + } + }, + "schema": { + "cType": "Item", + "cTypeId": "Item", + "cTypeIsArray": true, + "items": { + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + }, + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "type": "array" + }, + "statusCode": "200" + }, + { + "description": "

A request error.

\n", + "schema": { + "cType": "Error", + "cTypeId": "Error", + "properties": [ + { + "key": "message", + "value": { + "description": "

Error details.

\n", + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Error" + }, + "statusCode": "default", + "x-internal-ref-name": "Error" + } + ], + "source": null, + "sourceurl": "", + "summary": "", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/listItems/operation" + } + ], + "consumes": [ + "application/json" + ], + "definitions": [ + { + "cType": "Item", + "cTypeId": "Item", + "cTypeIsArray": true, + "items": { + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + }, + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "array", + "x-internal-ref-name": "Item" + }, + { + "cType": "Error", + "cTypeId": "Error", + "properties": [ + { + "key": "message", + "value": { + "description": "

Error details.

\n", + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Error" + } + ], + "documentType": "RestApi", + "docurl": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", + "meta": "Compatibility metadata", + "name": "listItems", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "source": null, + "sourceurl": "", + "summary": "

List items.

\n", + "swagger": "2.0", + "tags": [], + "title": "listItems", + "uid": "api.example.test/v1/Compatibility API/1.0/listItems", + "x-owner": { + "team": "documentation" + } + }, + "service/listItems.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_isSplittedToOperation": true, + "_key": "TestData/compatibility/service/listItems.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "../toc.html", + "_path": "service/listItems.html", + "_rel": "../", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "../toc.html", + "children": [ + { + "operation": "get", + "operationId": "listItems", + "parameters": [ + { + "default": "2.0", + "description": "

An optional version.

\n", + "in": "query", + "name": "api-version", + "required": false, + "type": "string" + }, + { + "default": 0, + "description": "

Maximum number of items.

\n", + "in": "query", + "minimum": 0, + "name": "limit", + "type": "integer" + } + ], + "path": "/items", + "responses": [ + { + "description": "

The items.

\n", + "examples": [ + { + "content": "[{\"id\":\"one\",\"name\":\"First\",\"$ref\":\"literal-example\"}]", + "mimeType": "application/json" + }, + { + "content": "\"one\"", + "mimeType": "text/plain" + } + ], + "headers": { + "X-Count": { + "description": "

Total count.

\n", + "type": "integer" + } + }, + "schema": { + "items": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + }, + "type": "array" + }, + "statusCode": "200" + }, + { + "description": "

A request error.

\n", + "schema": { + "properties": { + "message": { + "description": "

Error details.

\n", + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Error" + }, + "statusCode": "default", + "x-internal-ref-name": "Error" + } + ], + "source": null, + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/listItems/operation" + } + ], + "consumes": [ + "application/json" + ], + "documentType": "RestApi", + "meta": "Compatibility metadata", + "name": "listItems", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "source": null, + "summary": "

List items.

\n", + "swagger": "2.0", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/listItems", + "x-owner": { + "team": "documentation" + } + }, + "toc.html.view.json": { + "_disableContribution": true, + "_disableSearch": true, + "_disableToc": true, + "_key": "TestData/compatibility/toc.yml", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "toc.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "items": [ + { + "href": "service.html", + "items": [ + { + "href": "service/createItem.html", + "items": [], + "leaf": true, + "level": 3, + "name": "createItem", + "tocHref": null, + "topicHref": "service/createItem.html", + "topicUid": "api.example.test/v1/Compatibility API/1.0/createItem" + }, + { + "href": "service/deleteItem.html", + "items": [], + "leaf": true, + "level": 3, + "name": "deleteItem", + "tocHref": null, + "topicHref": "service/deleteItem.html", + "topicUid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + }, + { + "href": "service/listItems.html", + "items": [], + "leaf": true, + "level": 3, + "name": "listItems", + "tocHref": null, + "topicHref": "service/listItems.html", + "topicUid": "api.example.test/v1/Compatibility API/1.0/listItems" + } + ], + "level": 2, + "name": "Compatibility API", + "tocHref": null, + "topicHref": "service.html" + } + ], + "leaf": false, + "level": 1, + "meta": "Compatibility metadata", + "name": null, + "title": "Table of Content", + "tocHref": null, + "topicHref": null + }, + "toc.json.view.json": { + "content": "{\"items\":[{\"name\":\"Compatibility API\",\"href\":\"service.html\",\"topicHref\":\"service.html\",\"items\":[{\"name\":\"createItem\",\"href\":\"service/createItem.html\",\"topicHref\":\"service/createItem.html\",\"topicUid\":\"api.example.test/v1/Compatibility API/1.0/createItem\"},{\"name\":\"deleteItem\",\"href\":\"service/deleteItem.html\",\"topicHref\":\"service/deleteItem.html\",\"topicUid\":\"api.example.test/v1/Compatibility API/1.0/deleteItem\"},{\"name\":\"listItems\",\"href\":\"service/listItems.html\",\"topicHref\":\"service/listItems.html\",\"topicUid\":\"api.example.test/v1/Compatibility API/1.0/listItems\"}]}],\"meta\":\"Compatibility metadata\"}" + }, + "toc.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_key": "TestData/compatibility/toc.yml", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "toc.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "items": [ + { + "href": "service.html", + "items": [ + { + "href": "service/createItem.html", + "name": "createItem", + "topicHref": "service/createItem.html", + "topicUid": "api.example.test/v1/Compatibility API/1.0/createItem" + }, + { + "href": "service/deleteItem.html", + "name": "deleteItem", + "topicHref": "service/deleteItem.html", + "topicUid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + }, + { + "href": "service/listItems.html", + "name": "listItems", + "topicHref": "service/listItems.html", + "topicUid": "api.example.test/v1/Compatibility API/1.0/listItems" + } + ], + "name": "Compatibility API", + "topicHref": "service.html" + } + ], + "meta": "Compatibility metadata" + } + }, + "html": { + "service.html": "

Compatibility API

A stable API.

Use the API guide.

", + "service/createItem.html": "

createItem

Create an item.

createItem

Request
POST /items?api-version
Parameters
NameTypeDefaultNotes
*bodyItem

The new item.

*api-version1.0

The API version.

Responses
Status CodeTypeDescriptionSamples
201Item

Created.

Mime type: application/json
{\n  "id": "one",\n  "name": "First"\n}

Definitions

Item

NameTypeNotes
id string

The item identifier.

name string

The display name.

state string
", + "service/deleteItem.html": "

deleteItem

Delete an item.

deleteItem

Request
DELETE /items/{id}
Parameters
NameTypeDefaultNotes
*id

Item ID.

Responses
Status CodeTypeDescriptionSamples
204

No content.

", + "service/listItems.html": "

listItems

List items.

listItems

Request
GET /items[?api-version&limit]
Parameters
NameTypeDefaultNotes
api-version2.0

An optional version.

limit0

Maximum number of items.

Responses
Status CodeTypeDescriptionSamples
200Item[]

The items.

Mime type: application/json
[\n  {\n    "id": "one",\n    "name": "First",\n    "$ref": "literal-example"\n  }\n]
Mime type: text/plain
"one"
defaultError

A request error.

Definitions

Item

NameTypeNotes
id string

The item identifier.

name string

The display name.

state string

Error

NameTypeNotes
message string

Error details.

" + }, + "xrefs": [ + { + "href": "service.html", + "name": "Compatibility API", + "uid": "api.example.test/v1/Compatibility API/1.0" + }, + { + "href": "service/createItem.html", + "name": "createItem", + "uid": "api.example.test/v1/Compatibility API/1.0/createItem" + }, + { + "href": "service/createItem.html#api_example_test_v1_Compatibility_API_1_0_createItem_operation", + "name": "createItem", + "uid": "api.example.test/v1/Compatibility API/1.0/createItem/operation" + }, + { + "href": "service/deleteItem.html", + "name": "deleteItem", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + }, + { + "href": "service/deleteItem.html#api_example_test_v1_Compatibility_API_1_0_deleteItem_operation", + "name": "deleteItem", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem/operation" + }, + { + "href": "service/listItems.html", + "name": "listItems", + "uid": "api.example.test/v1/Compatibility API/1.0/listItems" + }, + { + "href": "service/listItems.html#api_example_test_v1_Compatibility_API_1_0_listItems_operation", + "name": "listItems", + "uid": "api.example.test/v1/Compatibility API/1.0/listItems/operation" + } + ], + "manifest": [ + { + "output": { + ".html": { + "relative_path": "service.html" + } + }, + "source_relative_path": "TestData/compatibility/service.swagger.json", + "type": "RestApi" + }, + { + "output": { + ".html": { + "relative_path": "service/createItem.html" + } + }, + "source_relative_path": "TestData/compatibility/service.swagger.json", + "type": "RestApi" + }, + { + "output": { + ".html": { + "relative_path": "service/deleteItem.html" + } + }, + "source_relative_path": "TestData/compatibility/service.swagger.json", + "type": "RestApi" + }, + { + "output": { + ".html": { + "relative_path": "service/listItems.html" + } + }, + "source_relative_path": "TestData/compatibility/service.swagger.json", + "type": "RestApi" + }, + { + "output": { + ".html": { + "relative_path": "toc.html" + }, + ".json": { + "relative_path": "toc.json" + } + }, + "source_relative_path": "TestData/compatibility/toc.yml", + "type": "Toc" + } + ] +} \ No newline at end of file diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-True-operations-False-overwrite-False.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-True-operations-False-overwrite-False.json new file mode 100644 index 00000000000..dfe4c98f289 --- /dev/null +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-True-operations-False-overwrite-False.json @@ -0,0 +1,968 @@ +{ + "baselineCommit": "bd097d04a7b2c1eb7533b8f6e045764e20d15967", + "models": { + "service.html.view.json": { + "_disableContribution": true, + "_disableNextArticle": true, + "_disableSearch": true, + "_disableToc": true, + "_isSplittedByTag": true, + "_jsonPath": "service.swagger.json", + "_key": "TestData/compatibility/service.swagger.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "service.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "children": [ + { + "conceptual": "", + "deprecated": true, + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", + "operation": "DELETE", + "operationId": "deleteItem", + "parameters": [ + { + "description": "

Item ID.

\n", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "path": "/items/{id}", + "remarks": "", + "responses": [ + { + "description": "

No content.

\n", + "statusCode": "204" + } + ], + "security": [], + "source": null, + "sourceurl": "", + "summary": "

Delete an item.

\n", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + } + ], + "consumes": [ + "application/json" + ], + "definitions": [], + "description": "

Use the API guide.

\n", + "documentType": "RestApi", + "docurl": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0", + "meta": "Compatibility metadata", + "name": "Compatibility API", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "sourceurl": "", + "summary": "

A stable API.

\n", + "swagger": "2.0", + "tags": [], + "title": "Compatibility API", + "uid": "api.example.test/v1/Compatibility API/1.0", + "x-owner": { + "team": "documentation" + } + }, + "service.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_isSplittedByTag": true, + "_key": "TestData/compatibility/service.swagger.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "service.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "children": [ + { + "deprecated": true, + "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", + "operation": "delete", + "operationId": "deleteItem", + "parameters": [ + { + "description": "

Item ID.

\n", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "path": "/items/{id}", + "responses": [ + { + "description": "

No content.

\n", + "statusCode": "204" + } + ], + "security": [], + "source": null, + "summary": "

Delete an item.

\n", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + } + ], + "consumes": [ + "application/json" + ], + "description": "

Use the API guide.

\n", + "documentType": "RestApi", + "htmlId": "api_example_test_v1_Compatibility_API_1_0", + "meta": "Compatibility metadata", + "name": "Compatibility API", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "summary": "

A stable API.

\n", + "swagger": "2.0", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0", + "x-owner": { + "team": "documentation" + } + }, + "service/items.html.view.json": { + "_disableContribution": true, + "_disableNextArticle": true, + "_disableSearch": true, + "_disableToc": true, + "_isSplittedToTag": true, + "_jsonPath": "items.swagger.json", + "_key": "TestData/compatibility/service/items.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "../toc.html", + "_path": "service/items.html", + "_rel": "../", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "../toc.html", + "children": [ + { + "conceptual": "", + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", + "operation": "GET", + "operationId": "listItems", + "parameters": [ + { + "default": "2.0", + "description": "

An optional version.

\n", + "in": "query", + "name": "api-version", + "required": false, + "type": "string" + }, + { + "default": 0, + "description": "

Maximum number of items.

\n", + "in": "query", + "minimum": 0, + "name": "limit", + "type": "integer" + } + ], + "path": "/items[?api-version&limit]", + "remarks": "", + "responses": [ + { + "description": "

The items.

\n", + "examples": [ + { + "content": "[\n {\n \"id\": \"one\",\n \"name\": \"First\",\n \"$ref\": \"literal-example\"\n }\n]", + "mimeType": "application/json" + }, + { + "content": "\"one\"", + "mimeType": "text/plain" + } + ], + "headers": { + "X-Count": { + "description": "

Total count.

\n", + "type": "integer" + } + }, + "schema": { + "cType": "Item", + "cTypeId": "Item", + "cTypeIsArray": true, + "items": { + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + }, + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "type": "array" + }, + "statusCode": "200" + }, + { + "description": "

A request error.

\n", + "schema": { + "cType": "Error", + "cTypeId": "Error", + "properties": [ + { + "key": "message", + "value": { + "description": "

Error details.

\n", + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Error" + }, + "statusCode": "default", + "x-internal-ref-name": "Error" + } + ], + "source": null, + "sourceurl": "", + "summary": "

List items.

\n", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/listItems" + }, + { + "conceptual": "", + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", + "operation": "POST", + "operationId": "createItem", + "parameters": [ + { + "description": "

The new item.

\n", + "in": "body", + "name": "body", + "required": true, + "schema": { + "cType": "Item", + "cTypeId": "Item", + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "required": true, + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "description": null, + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Item" + } + }, + { + "default": "1.0", + "description": "

The API version.

\n", + "in": "query", + "name": "api-version", + "required": true, + "type": "string", + "x-internal-ref-name": "Version" + } + ], + "path": "/items?api-version", + "remarks": "", + "responses": [ + { + "description": "

Created.

\n", + "examples": [ + { + "content": "{\n \"id\": \"one\",\n \"name\": \"First\"\n}", + "mimeType": "application/json" + } + ], + "schema": { + "cType": "Item", + "cTypeId": "Item", + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "required": true, + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "description": null, + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "statusCode": "201" + } + ], + "source": null, + "sourceurl": "", + "summary": "

Create an item.

\n", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/createItem" + } + ], + "consumes": [ + "application/json" + ], + "definitions": [ + { + "cType": "Item", + "cTypeId": "Item", + "cTypeIsArray": true, + "items": { + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + }, + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "array", + "x-internal-ref-name": "Item" + }, + { + "cType": "Error", + "cTypeId": "Error", + "properties": [ + { + "key": "message", + "value": { + "description": "

Error details.

\n", + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Error" + } + ], + "description": "

Manage items.

\n", + "documentType": "RestApi", + "docurl": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_tag_items", + "meta": "Compatibility metadata", + "name": "items", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "sourceurl": "", + "swagger": "2.0", + "tags": [], + "title": "items", + "uid": "api.example.test/v1/Compatibility API/1.0/tag/items", + "x-owner": { + "team": "documentation" + } + }, + "service/items.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_isSplittedToTag": true, + "_key": "TestData/compatibility/service/items.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "../toc.html", + "_path": "service/items.html", + "_rel": "../", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "../toc.html", + "children": [ + { + "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", + "operation": "get", + "operationId": "listItems", + "parameters": [ + { + "default": "2.0", + "description": "

An optional version.

\n", + "in": "query", + "name": "api-version", + "required": false, + "type": "string" + }, + { + "default": 0, + "description": "

Maximum number of items.

\n", + "in": "query", + "minimum": 0, + "name": "limit", + "type": "integer" + } + ], + "path": "/items", + "responses": [ + { + "description": "

The items.

\n", + "examples": [ + { + "content": "[{\"id\":\"one\",\"name\":\"First\",\"$ref\":\"literal-example\"}]", + "mimeType": "application/json" + }, + { + "content": "\"one\"", + "mimeType": "text/plain" + } + ], + "headers": { + "X-Count": { + "description": "

Total count.

\n", + "type": "integer" + } + }, + "schema": { + "items": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + }, + "type": "array" + }, + "statusCode": "200" + }, + { + "description": "

A request error.

\n", + "schema": { + "properties": { + "message": { + "description": "

Error details.

\n", + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Error" + }, + "statusCode": "default", + "x-internal-ref-name": "Error" + } + ], + "source": null, + "summary": "

List items.

\n", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/listItems" + }, + { + "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", + "operation": "post", + "operationId": "createItem", + "parameters": [ + { + "description": "

The new item.

\n", + "in": "body", + "name": "body", + "required": true, + "schema": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + } + }, + { + "default": "1.0", + "description": "

The API version.

\n", + "in": "query", + "name": "api-version", + "required": true, + "type": "string", + "x-internal-ref-name": "Version" + } + ], + "path": "/items", + "responses": [ + { + "description": "

Created.

\n", + "examples": [ + { + "content": "{\"id\":\"one\",\"name\":\"First\"}", + "mimeType": "application/json" + } + ], + "schema": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + }, + "statusCode": "201" + } + ], + "source": null, + "summary": "

Create an item.

\n", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/createItem" + } + ], + "consumes": [ + "application/json" + ], + "description": "

Manage items.

\n", + "documentType": "RestApi", + "htmlId": "items-tag", + "meta": "Compatibility metadata", + "name": "items", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "swagger": "2.0", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/tag/items", + "x-owner": { + "team": "documentation" + } + }, + "toc.html.view.json": { + "_disableContribution": true, + "_disableSearch": true, + "_disableToc": true, + "_key": "TestData/compatibility/toc.yml", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "toc.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "items": [ + { + "href": "service.html", + "items": [ + { + "href": "service/items.html", + "items": [], + "leaf": true, + "level": 3, + "name": "items", + "tocHref": null, + "topicHref": "service/items.html", + "topicUid": "api.example.test/v1/Compatibility API/1.0/tag/items" + } + ], + "level": 2, + "name": "Compatibility API", + "tocHref": null, + "topicHref": "service.html" + } + ], + "leaf": false, + "level": 1, + "meta": "Compatibility metadata", + "name": null, + "title": "Table of Content", + "tocHref": null, + "topicHref": null + }, + "toc.json.view.json": { + "content": "{\"items\":[{\"name\":\"Compatibility API\",\"href\":\"service.html\",\"topicHref\":\"service.html\",\"items\":[{\"name\":\"items\",\"href\":\"service/items.html\",\"topicHref\":\"service/items.html\",\"topicUid\":\"api.example.test/v1/Compatibility API/1.0/tag/items\"}]}],\"meta\":\"Compatibility metadata\"}" + }, + "toc.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_key": "TestData/compatibility/toc.yml", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "toc.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "items": [ + { + "href": "service.html", + "items": [ + { + "href": "service/items.html", + "name": "items", + "topicHref": "service/items.html", + "topicUid": "api.example.test/v1/Compatibility API/1.0/tag/items" + } + ], + "name": "Compatibility API", + "topicHref": "service.html" + } + ], + "meta": "Compatibility metadata" + } + }, + "html": { + "service.html": "

Compatibility API

A stable API.

Use the API guide.

deleteItem

Delete an item.

Request
DELETE /items/{id}
Parameters
NameTypeDefaultNotes
*id

Item ID.

Responses
Status CodeTypeDescriptionSamples
204

No content.

", + "service/items.html": "

items

Manage items.

listItems

List items.

Request
GET /items[?api-version&limit]
Parameters
NameTypeDefaultNotes
api-version2.0

An optional version.

limit0

Maximum number of items.

Responses
Status CodeTypeDescriptionSamples
200Item[]

The items.

Mime type: application/json
[\n  {\n    "id": "one",\n    "name": "First",\n    "$ref": "literal-example"\n  }\n]
Mime type: text/plain
"one"
defaultError

A request error.

createItem

Create an item.

Request
POST /items?api-version
Parameters
NameTypeDefaultNotes
*bodyItem

The new item.

*api-version1.0

The API version.

Responses
Status CodeTypeDescriptionSamples
201Item

Created.

Mime type: application/json
{\n  "id": "one",\n  "name": "First"\n}

Definitions

Item

Manage items.

NameTypeNotes
id string

The item identifier.

name string

The display name.

state string

Error

Manage items.

NameTypeNotes
message string

Error details.

" + }, + "xrefs": [ + { + "href": "service.html", + "name": "Compatibility API", + "uid": "api.example.test/v1/Compatibility API/1.0" + }, + { + "href": "service/items.html#api_example_test_v1_Compatibility_API_1_0_createItem", + "name": "createItem", + "uid": "api.example.test/v1/Compatibility API/1.0/createItem" + }, + { + "href": "service.html#api_example_test_v1_Compatibility_API_1_0_deleteItem", + "name": "deleteItem", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + }, + { + "href": "service/items.html#api_example_test_v1_Compatibility_API_1_0_listItems", + "name": "listItems", + "uid": "api.example.test/v1/Compatibility API/1.0/listItems" + }, + { + "href": "service/items.html", + "name": "items", + "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" + } + ], + "manifest": [ + { + "output": { + ".html": { + "relative_path": "service.html" + } + }, + "source_relative_path": "TestData/compatibility/service.swagger.json", + "type": "RestApi" + }, + { + "output": { + ".html": { + "relative_path": "service/items.html" + } + }, + "source_relative_path": "TestData/compatibility/service.swagger.json", + "type": "RestApi" + }, + { + "output": { + ".html": { + "relative_path": "toc.html" + }, + ".json": { + "relative_path": "toc.json" + } + }, + "source_relative_path": "TestData/compatibility/toc.yml", + "type": "Toc" + } + ] +} \ No newline at end of file diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-True-operations-True-overwrite-False.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-True-operations-True-overwrite-False.json new file mode 100644 index 00000000000..fcebb4305e0 --- /dev/null +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-True-operations-True-overwrite-False.json @@ -0,0 +1,1425 @@ +{ + "baselineCommit": "bd097d04a7b2c1eb7533b8f6e045764e20d15967", + "models": { + "service.html.view.json": { + "_disableContribution": true, + "_disableNextArticle": true, + "_disableSearch": true, + "_disableToc": true, + "_isSplittedByOperation": true, + "_isSplittedByTag": true, + "_jsonPath": "service.swagger.json", + "_key": "TestData/compatibility/service.swagger.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "service.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "children": [], + "consumes": [ + "application/json" + ], + "definitions": [], + "description": "

Use the API guide.

\n", + "documentType": "RestApi", + "docurl": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0", + "meta": "Compatibility metadata", + "name": "Compatibility API", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "sourceurl": "", + "summary": "

A stable API.

\n", + "swagger": "2.0", + "tags": [], + "title": "Compatibility API", + "uid": "api.example.test/v1/Compatibility API/1.0", + "x-owner": { + "team": "documentation" + } + }, + "service.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_isSplittedByOperation": true, + "_isSplittedByTag": true, + "_key": "TestData/compatibility/service.swagger.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "service.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "children": [], + "consumes": [ + "application/json" + ], + "description": "

Use the API guide.

\n", + "documentType": "RestApi", + "htmlId": "api_example_test_v1_Compatibility_API_1_0", + "meta": "Compatibility metadata", + "name": "Compatibility API", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "summary": "

A stable API.

\n", + "swagger": "2.0", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0", + "x-owner": { + "team": "documentation" + } + }, + "service/deleteItem.html.view.json": { + "_disableContribution": true, + "_disableNextArticle": true, + "_disableSearch": true, + "_disableToc": true, + "_isSplittedByTag": true, + "_isSplittedToOperation": true, + "_jsonPath": "deleteItem.swagger.json", + "_key": "TestData/compatibility/service/deleteItem.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "../toc.html", + "_path": "service/deleteItem.html", + "_rel": "../", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "../toc.html", + "children": [ + { + "conceptual": "", + "deprecated": true, + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem_operation", + "operation": "DELETE", + "operationId": "deleteItem", + "parameters": [ + { + "description": "

Item ID.

\n", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "path": "/items/{id}", + "remarks": "", + "responses": [ + { + "description": "

No content.

\n", + "statusCode": "204" + } + ], + "security": [], + "source": null, + "sourceurl": "", + "summary": "", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem/operation" + } + ], + "consumes": [ + "application/json" + ], + "definitions": [], + "deprecated": true, + "documentType": "RestApi", + "docurl": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", + "meta": "Compatibility metadata", + "name": "deleteItem", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "source": null, + "sourceurl": "", + "summary": "

Delete an item.

\n", + "swagger": "2.0", + "tags": [], + "title": "deleteItem", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem", + "x-owner": { + "team": "documentation" + } + }, + "service/deleteItem.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_isSplittedByTag": true, + "_isSplittedToOperation": true, + "_key": "TestData/compatibility/service/deleteItem.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "../toc.html", + "_path": "service/deleteItem.html", + "_rel": "../", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "../toc.html", + "children": [ + { + "deprecated": true, + "operation": "delete", + "operationId": "deleteItem", + "parameters": [ + { + "description": "

Item ID.

\n", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "path": "/items/{id}", + "responses": [ + { + "description": "

No content.

\n", + "statusCode": "204" + } + ], + "security": [], + "source": null, + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem/operation" + } + ], + "consumes": [ + "application/json" + ], + "deprecated": true, + "documentType": "RestApi", + "meta": "Compatibility metadata", + "name": "deleteItem", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "source": null, + "summary": "

Delete an item.

\n", + "swagger": "2.0", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem", + "x-owner": { + "team": "documentation" + } + }, + "service/items.html.view.json": { + "_disableContribution": true, + "_disableNextArticle": true, + "_disableSearch": true, + "_disableToc": true, + "_isSplittedByOperation": true, + "_isSplittedToTag": true, + "_jsonPath": "items.swagger.json", + "_key": "TestData/compatibility/service/items.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "../toc.html", + "_path": "service/items.html", + "_rel": "../", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "../toc.html", + "children": [], + "consumes": [ + "application/json" + ], + "definitions": [], + "description": "

Manage items.

\n", + "documentType": "RestApi", + "docurl": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_tag_items", + "meta": "Compatibility metadata", + "name": "items", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "sourceurl": "", + "swagger": "2.0", + "tags": [], + "title": "items", + "uid": "api.example.test/v1/Compatibility API/1.0/tag/items", + "x-owner": { + "team": "documentation" + } + }, + "service/items.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_isSplittedByOperation": true, + "_isSplittedToTag": true, + "_key": "TestData/compatibility/service/items.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "../toc.html", + "_path": "service/items.html", + "_rel": "../", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "../toc.html", + "children": [], + "consumes": [ + "application/json" + ], + "description": "

Manage items.

\n", + "documentType": "RestApi", + "htmlId": "items-tag", + "meta": "Compatibility metadata", + "name": "items", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "swagger": "2.0", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/tag/items", + "x-owner": { + "team": "documentation" + } + }, + "service/items/createItem.html.view.json": { + "_disableContribution": true, + "_disableNextArticle": true, + "_disableSearch": true, + "_disableToc": true, + "_isSplittedToOperation": true, + "_isSplittedToTag": true, + "_jsonPath": "createItem.swagger.json", + "_key": "TestData/compatibility/service/items/createItem.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "../../toc.html", + "_path": "service/items/createItem.html", + "_rel": "../../", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "../../toc.html", + "children": [ + { + "conceptual": "", + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem_operation", + "operation": "POST", + "operationId": "createItem", + "parameters": [ + { + "description": "

The new item.

\n", + "in": "body", + "name": "body", + "required": true, + "schema": { + "cType": "Item", + "cTypeId": "Item", + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "required": true, + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "description": null, + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Item" + } + }, + { + "default": "1.0", + "description": "

The API version.

\n", + "in": "query", + "name": "api-version", + "required": true, + "type": "string", + "x-internal-ref-name": "Version" + } + ], + "path": "/items?api-version", + "remarks": "", + "responses": [ + { + "description": "

Created.

\n", + "examples": [ + { + "content": "{\n \"id\": \"one\",\n \"name\": \"First\"\n}", + "mimeType": "application/json" + } + ], + "schema": { + "cType": "Item", + "cTypeId": "Item", + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "required": true, + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "description": null, + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "statusCode": "201" + } + ], + "source": null, + "sourceurl": "", + "summary": "", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/createItem/operation" + } + ], + "consumes": [ + "application/json" + ], + "definitions": [ + { + "cType": "Item", + "cTypeId": "Item", + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "required": true, + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "description": null, + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Item" + } + ], + "documentType": "RestApi", + "docurl": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", + "meta": "Compatibility metadata", + "name": "createItem", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "source": null, + "sourceurl": "", + "summary": "

Create an item.

\n", + "swagger": "2.0", + "tags": [], + "title": "createItem", + "uid": "api.example.test/v1/Compatibility API/1.0/createItem", + "x-owner": { + "team": "documentation" + } + }, + "service/items/createItem.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_isSplittedToOperation": true, + "_isSplittedToTag": true, + "_key": "TestData/compatibility/service/items/createItem.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "../../toc.html", + "_path": "service/items/createItem.html", + "_rel": "../../", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "../../toc.html", + "children": [ + { + "operation": "post", + "operationId": "createItem", + "parameters": [ + { + "description": "

The new item.

\n", + "in": "body", + "name": "body", + "required": true, + "schema": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + } + }, + { + "default": "1.0", + "description": "

The API version.

\n", + "in": "query", + "name": "api-version", + "required": true, + "type": "string", + "x-internal-ref-name": "Version" + } + ], + "path": "/items", + "responses": [ + { + "description": "

Created.

\n", + "examples": [ + { + "content": "{\"id\":\"one\",\"name\":\"First\"}", + "mimeType": "application/json" + } + ], + "schema": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + }, + "statusCode": "201" + } + ], + "source": null, + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/createItem/operation" + } + ], + "consumes": [ + "application/json" + ], + "documentType": "RestApi", + "meta": "Compatibility metadata", + "name": "createItem", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "source": null, + "summary": "

Create an item.

\n", + "swagger": "2.0", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/createItem", + "x-owner": { + "team": "documentation" + } + }, + "service/items/listItems.html.view.json": { + "_disableContribution": true, + "_disableNextArticle": true, + "_disableSearch": true, + "_disableToc": true, + "_isSplittedToOperation": true, + "_isSplittedToTag": true, + "_jsonPath": "listItems.swagger.json", + "_key": "TestData/compatibility/service/items/listItems.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "../../toc.html", + "_path": "service/items/listItems.html", + "_rel": "../../", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "../../toc.html", + "children": [ + { + "conceptual": "", + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems_operation", + "operation": "GET", + "operationId": "listItems", + "parameters": [ + { + "default": "2.0", + "description": "

An optional version.

\n", + "in": "query", + "name": "api-version", + "required": false, + "type": "string" + }, + { + "default": 0, + "description": "

Maximum number of items.

\n", + "in": "query", + "minimum": 0, + "name": "limit", + "type": "integer" + } + ], + "path": "/items[?api-version&limit]", + "remarks": "", + "responses": [ + { + "description": "

The items.

\n", + "examples": [ + { + "content": "[\n {\n \"id\": \"one\",\n \"name\": \"First\",\n \"$ref\": \"literal-example\"\n }\n]", + "mimeType": "application/json" + }, + { + "content": "\"one\"", + "mimeType": "text/plain" + } + ], + "headers": { + "X-Count": { + "description": "

Total count.

\n", + "type": "integer" + } + }, + "schema": { + "cType": "Item", + "cTypeId": "Item", + "cTypeIsArray": true, + "items": { + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + }, + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "type": "array" + }, + "statusCode": "200" + }, + { + "description": "

A request error.

\n", + "schema": { + "cType": "Error", + "cTypeId": "Error", + "properties": [ + { + "key": "message", + "value": { + "description": "

Error details.

\n", + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Error" + }, + "statusCode": "default", + "x-internal-ref-name": "Error" + } + ], + "source": null, + "sourceurl": "", + "summary": "", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/listItems/operation" + } + ], + "consumes": [ + "application/json" + ], + "definitions": [ + { + "cType": "Item", + "cTypeId": "Item", + "cTypeIsArray": true, + "items": { + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + }, + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "array", + "x-internal-ref-name": "Item" + }, + { + "cType": "Error", + "cTypeId": "Error", + "properties": [ + { + "key": "message", + "value": { + "description": "

Error details.

\n", + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Error" + } + ], + "documentType": "RestApi", + "docurl": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", + "meta": "Compatibility metadata", + "name": "listItems", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "source": null, + "sourceurl": "", + "summary": "

List items.

\n", + "swagger": "2.0", + "tags": [], + "title": "listItems", + "uid": "api.example.test/v1/Compatibility API/1.0/listItems", + "x-owner": { + "team": "documentation" + } + }, + "service/items/listItems.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_isSplittedToOperation": true, + "_isSplittedToTag": true, + "_key": "TestData/compatibility/service/items/listItems.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "../../toc.html", + "_path": "service/items/listItems.html", + "_rel": "../../", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "../../toc.html", + "children": [ + { + "operation": "get", + "operationId": "listItems", + "parameters": [ + { + "default": "2.0", + "description": "

An optional version.

\n", + "in": "query", + "name": "api-version", + "required": false, + "type": "string" + }, + { + "default": 0, + "description": "

Maximum number of items.

\n", + "in": "query", + "minimum": 0, + "name": "limit", + "type": "integer" + } + ], + "path": "/items", + "responses": [ + { + "description": "

The items.

\n", + "examples": [ + { + "content": "[{\"id\":\"one\",\"name\":\"First\",\"$ref\":\"literal-example\"}]", + "mimeType": "application/json" + }, + { + "content": "\"one\"", + "mimeType": "text/plain" + } + ], + "headers": { + "X-Count": { + "description": "

Total count.

\n", + "type": "integer" + } + }, + "schema": { + "items": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + }, + "type": "array" + }, + "statusCode": "200" + }, + { + "description": "

A request error.

\n", + "schema": { + "properties": { + "message": { + "description": "

Error details.

\n", + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Error" + }, + "statusCode": "default", + "x-internal-ref-name": "Error" + } + ], + "source": null, + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/listItems/operation" + } + ], + "consumes": [ + "application/json" + ], + "documentType": "RestApi", + "meta": "Compatibility metadata", + "name": "listItems", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "source": null, + "summary": "

List items.

\n", + "swagger": "2.0", + "tags": [], + "uid": "api.example.test/v1/Compatibility API/1.0/listItems", + "x-owner": { + "team": "documentation" + } + }, + "toc.html.view.json": { + "_disableContribution": true, + "_disableSearch": true, + "_disableToc": true, + "_key": "TestData/compatibility/toc.yml", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "toc.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "items": [ + { + "href": "service.html", + "items": [ + { + "href": "service/items.html", + "items": [ + { + "href": "service/items/createItem.html", + "items": [], + "leaf": true, + "level": 4, + "name": "createItem", + "tocHref": null, + "topicHref": "service/items/createItem.html", + "topicUid": "api.example.test/v1/Compatibility API/1.0/createItem" + }, + { + "href": "service/items/listItems.html", + "items": [], + "leaf": true, + "level": 4, + "name": "listItems", + "tocHref": null, + "topicHref": "service/items/listItems.html", + "topicUid": "api.example.test/v1/Compatibility API/1.0/listItems" + } + ], + "level": 3, + "name": "items", + "tocHref": null, + "topicHref": "service/items.html", + "topicUid": "api.example.test/v1/Compatibility API/1.0/tag/items" + }, + { + "href": "service/deleteItem.html", + "items": [], + "leaf": true, + "level": 3, + "name": "deleteItem", + "tocHref": null, + "topicHref": "service/deleteItem.html", + "topicUid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + } + ], + "level": 2, + "name": "Compatibility API", + "tocHref": null, + "topicHref": "service.html" + } + ], + "leaf": false, + "level": 1, + "meta": "Compatibility metadata", + "name": null, + "title": "Table of Content", + "tocHref": null, + "topicHref": null + }, + "toc.json.view.json": { + "content": "{\"items\":[{\"name\":\"Compatibility API\",\"href\":\"service.html\",\"topicHref\":\"service.html\",\"items\":[{\"name\":\"items\",\"href\":\"service/items.html\",\"topicHref\":\"service/items.html\",\"topicUid\":\"api.example.test/v1/Compatibility API/1.0/tag/items\",\"items\":[{\"name\":\"createItem\",\"href\":\"service/items/createItem.html\",\"topicHref\":\"service/items/createItem.html\",\"topicUid\":\"api.example.test/v1/Compatibility API/1.0/createItem\"},{\"name\":\"listItems\",\"href\":\"service/items/listItems.html\",\"topicHref\":\"service/items/listItems.html\",\"topicUid\":\"api.example.test/v1/Compatibility API/1.0/listItems\"}]},{\"name\":\"deleteItem\",\"href\":\"service/deleteItem.html\",\"topicHref\":\"service/deleteItem.html\",\"topicUid\":\"api.example.test/v1/Compatibility API/1.0/deleteItem\"}]}],\"meta\":\"Compatibility metadata\"}" + }, + "toc.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_key": "TestData/compatibility/toc.yml", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "toc.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "items": [ + { + "href": "service.html", + "items": [ + { + "href": "service/items.html", + "items": [ + { + "href": "service/items/createItem.html", + "name": "createItem", + "topicHref": "service/items/createItem.html", + "topicUid": "api.example.test/v1/Compatibility API/1.0/createItem" + }, + { + "href": "service/items/listItems.html", + "name": "listItems", + "topicHref": "service/items/listItems.html", + "topicUid": "api.example.test/v1/Compatibility API/1.0/listItems" + } + ], + "name": "items", + "topicHref": "service/items.html", + "topicUid": "api.example.test/v1/Compatibility API/1.0/tag/items" + }, + { + "href": "service/deleteItem.html", + "name": "deleteItem", + "topicHref": "service/deleteItem.html", + "topicUid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + } + ], + "name": "Compatibility API", + "topicHref": "service.html" + } + ], + "meta": "Compatibility metadata" + } + }, + "html": { + "service.html": "

Compatibility API

A stable API.

Use the API guide.

", + "service/deleteItem.html": "

deleteItem

Delete an item.

deleteItem

Request
DELETE /items/{id}
Parameters
NameTypeDefaultNotes
*id

Item ID.

Responses
Status CodeTypeDescriptionSamples
204

No content.

", + "service/items.html": "

items

Manage items.

", + "service/items/createItem.html": "

createItem

Create an item.

createItem

Request
POST /items?api-version
Parameters
NameTypeDefaultNotes
*bodyItem

The new item.

*api-version1.0

The API version.

Responses
Status CodeTypeDescriptionSamples
201Item

Created.

Mime type: application/json
{\n  "id": "one",\n  "name": "First"\n}

Definitions

Item

NameTypeNotes
id string

The item identifier.

name string

The display name.

state string
", + "service/items/listItems.html": "

listItems

List items.

listItems

Request
GET /items[?api-version&limit]
Parameters
NameTypeDefaultNotes
api-version2.0

An optional version.

limit0

Maximum number of items.

Responses
Status CodeTypeDescriptionSamples
200Item[]

The items.

Mime type: application/json
[\n  {\n    "id": "one",\n    "name": "First",\n    "$ref": "literal-example"\n  }\n]
Mime type: text/plain
"one"
defaultError

A request error.

Definitions

Item

NameTypeNotes
id string

The item identifier.

name string

The display name.

state string

Error

NameTypeNotes
message string

Error details.

" + }, + "xrefs": [ + { + "href": "service.html", + "name": "Compatibility API", + "uid": "api.example.test/v1/Compatibility API/1.0" + }, + { + "href": "service/items/createItem.html", + "name": "createItem", + "uid": "api.example.test/v1/Compatibility API/1.0/createItem" + }, + { + "href": "service/items/createItem.html#api_example_test_v1_Compatibility_API_1_0_createItem_operation", + "name": "createItem", + "uid": "api.example.test/v1/Compatibility API/1.0/createItem/operation" + }, + { + "href": "service/deleteItem.html", + "name": "deleteItem", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + }, + { + "href": "service/deleteItem.html#api_example_test_v1_Compatibility_API_1_0_deleteItem_operation", + "name": "deleteItem", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem/operation" + }, + { + "href": "service/items/listItems.html", + "name": "listItems", + "uid": "api.example.test/v1/Compatibility API/1.0/listItems" + }, + { + "href": "service/items/listItems.html#api_example_test_v1_Compatibility_API_1_0_listItems_operation", + "name": "listItems", + "uid": "api.example.test/v1/Compatibility API/1.0/listItems/operation" + }, + { + "href": "service/items.html", + "name": "items", + "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" + } + ], + "manifest": [ + { + "output": { + ".html": { + "relative_path": "service.html" + } + }, + "source_relative_path": "TestData/compatibility/service.swagger.json", + "type": "RestApi" + }, + { + "output": { + ".html": { + "relative_path": "service/deleteItem.html" + } + }, + "source_relative_path": "TestData/compatibility/service.swagger.json", + "type": "RestApi" + }, + { + "output": { + ".html": { + "relative_path": "service/items.html" + } + }, + "source_relative_path": "TestData/compatibility/service.swagger.json", + "type": "RestApi" + }, + { + "output": { + ".html": { + "relative_path": "service/items/createItem.html" + } + }, + "source_relative_path": "TestData/compatibility/service.swagger.json", + "type": "RestApi" + }, + { + "output": { + ".html": { + "relative_path": "service/items/listItems.html" + } + }, + "source_relative_path": "TestData/compatibility/service.swagger.json", + "type": "RestApi" + }, + { + "output": { + ".html": { + "relative_path": "toc.html" + }, + ".json": { + "relative_path": "toc.json" + } + }, + "source_relative_path": "TestData/compatibility/toc.yml", + "type": "Toc" + } + ] +} \ No newline at end of file diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/modern-tags-False-operations-False-overwrite-False.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/modern-tags-False-operations-False-overwrite-False.json new file mode 100644 index 00000000000..23cb950aec8 --- /dev/null +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/modern-tags-False-operations-False-overwrite-False.json @@ -0,0 +1,859 @@ +{ + "baselineCommit": "bd097d04a7b2c1eb7533b8f6e045764e20d15967", + "models": { + "service.html.view.json": { + "_disableContribution": true, + "_disableNextArticle": true, + "_disableSearch": true, + "_disableToc": true, + "_jsonPath": "service.swagger.json", + "_key": "TestData/compatibility/service.swagger.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "service.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "children": [ + { + "conceptual": "", + "deprecated": true, + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", + "operation": "DELETE", + "operationId": "deleteItem", + "parameters": [ + { + "description": "

Item ID.

\n", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "path": "/items/{id}", + "remarks": "", + "responses": [ + { + "description": "

No content.

\n", + "statusCode": "204" + } + ], + "security": [], + "source": null, + "sourceurl": "", + "summary": "

Delete an item.

\n", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + } + ], + "consumes": [ + "application/json" + ], + "definitions": [ + { + "cType": "Item", + "cTypeId": "Item", + "cTypeIsArray": true, + "items": { + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + }, + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "array", + "x-internal-ref-name": "Item" + }, + { + "cType": "Error", + "cTypeId": "Error", + "properties": [ + { + "key": "message", + "value": { + "description": "

Error details.

\n", + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Error" + } + ], + "description": "

Use the API guide.

\n", + "documentType": "RestApi", + "docurl": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0", + "isTagLayout": true, + "meta": "Compatibility metadata", + "name": "Compatibility API", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "sourceurl": "", + "summary": "

A stable API.

\n", + "swagger": "2.0", + "tags": [ + { + "children": [ + { + "conceptual": "", + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", + "includedInTags": true, + "operation": "GET", + "operationId": "listItems", + "parameters": [ + { + "default": "2.0", + "description": "

An optional version.

\n", + "in": "query", + "name": "api-version", + "required": false, + "type": "string" + }, + { + "default": 0, + "description": "

Maximum number of items.

\n", + "in": "query", + "minimum": 0, + "name": "limit", + "type": "integer" + } + ], + "path": "/items[?api-version&limit]", + "remarks": "", + "responses": [ + { + "description": "

The items.

\n", + "examples": [ + { + "content": "[\n {\n \"id\": \"one\",\n \"name\": \"First\",\n \"$ref\": \"literal-example\"\n }\n]", + "mimeType": "application/json" + }, + { + "content": "\"one\"", + "mimeType": "text/plain" + } + ], + "headers": { + "X-Count": { + "description": "

Total count.

\n", + "type": "integer" + } + }, + "schema": { + "cType": "Item", + "cTypeId": "Item", + "cTypeIsArray": true, + "items": { + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + }, + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "type": "array" + }, + "statusCode": "200" + }, + { + "description": "

A request error.

\n", + "schema": { + "cType": "Error", + "cTypeId": "Error", + "properties": [ + { + "key": "message", + "value": { + "description": "

Error details.

\n", + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Error" + }, + "statusCode": "default", + "x-internal-ref-name": "Error" + } + ], + "source": null, + "sourceurl": "", + "summary": "

List items.

\n", + "tags": [ + "items" + ], + "uid": "api.example.test/v1/Compatibility API/1.0/listItems" + }, + { + "conceptual": "", + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", + "includedInTags": true, + "operation": "POST", + "operationId": "createItem", + "parameters": [ + { + "description": "

The new item.

\n", + "in": "body", + "name": "body", + "required": true, + "schema": { + "cType": "Item", + "cTypeId": "Item", + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "required": true, + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "description": null, + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Item" + } + }, + { + "default": "1.0", + "description": "

The API version.

\n", + "in": "query", + "name": "api-version", + "required": true, + "type": "string", + "x-internal-ref-name": "Version" + } + ], + "path": "/items?api-version", + "remarks": "", + "responses": [ + { + "description": "

Created.

\n", + "examples": [ + { + "content": "{\n \"id\": \"one\",\n \"name\": \"First\"\n}", + "mimeType": "application/json" + } + ], + "schema": { + "cType": "Item", + "cTypeId": "Item", + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "required": true, + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "description": null, + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "statusCode": "201" + } + ], + "source": null, + "sourceurl": "", + "summary": "

Create an item.

\n", + "tags": [ + "items" + ], + "uid": "api.example.test/v1/Compatibility API/1.0/createItem" + } + ], + "conceptual": "", + "description": "

Manage items.

\n", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_tag_items", + "name": "items", + "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" + } + ], + "title": "Compatibility API", + "uid": "api.example.test/v1/Compatibility API/1.0", + "x-owner": { + "team": "documentation" + } + }, + "service.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_key": "TestData/compatibility/service.swagger.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "service.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "children": [ + { + "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", + "operation": "get", + "operationId": "listItems", + "parameters": [ + { + "default": "2.0", + "description": "

An optional version.

\n", + "in": "query", + "name": "api-version", + "required": false, + "type": "string" + }, + { + "default": 0, + "description": "

Maximum number of items.

\n", + "in": "query", + "minimum": 0, + "name": "limit", + "type": "integer" + } + ], + "path": "/items", + "responses": [ + { + "description": "

The items.

\n", + "examples": [ + { + "content": "[{\"id\":\"one\",\"name\":\"First\",\"$ref\":\"literal-example\"}]", + "mimeType": "application/json" + }, + { + "content": "\"one\"", + "mimeType": "text/plain" + } + ], + "headers": { + "X-Count": { + "description": "

Total count.

\n", + "type": "integer" + } + }, + "schema": { + "items": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + }, + "type": "array" + }, + "statusCode": "200" + }, + { + "description": "

A request error.

\n", + "schema": { + "properties": { + "message": { + "description": "

Error details.

\n", + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Error" + }, + "statusCode": "default", + "x-internal-ref-name": "Error" + } + ], + "source": null, + "summary": "

List items.

\n", + "tags": [ + "items" + ], + "uid": "api.example.test/v1/Compatibility API/1.0/listItems" + }, + { + "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", + "operation": "post", + "operationId": "createItem", + "parameters": [ + { + "description": "

The new item.

\n", + "in": "body", + "name": "body", + "required": true, + "schema": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + } + }, + { + "default": "1.0", + "description": "

The API version.

\n", + "in": "query", + "name": "api-version", + "required": true, + "type": "string", + "x-internal-ref-name": "Version" + } + ], + "path": "/items", + "responses": [ + { + "description": "

Created.

\n", + "examples": [ + { + "content": "{\"id\":\"one\",\"name\":\"First\"}", + "mimeType": "application/json" + } + ], + "schema": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + }, + "statusCode": "201" + } + ], + "source": null, + "summary": "

Create an item.

\n", + "tags": [ + "items" + ], + "uid": "api.example.test/v1/Compatibility API/1.0/createItem" + }, + { + "deprecated": true, + "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", + "operation": "delete", + "operationId": "deleteItem", + "parameters": [ + { + "description": "

Item ID.

\n", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "path": "/items/{id}", + "responses": [ + { + "description": "

No content.

\n", + "statusCode": "204" + } + ], + "security": [], + "source": null, + "summary": "

Delete an item.

\n", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + } + ], + "consumes": [ + "application/json" + ], + "description": "

Use the API guide.

\n", + "documentType": "RestApi", + "htmlId": "api_example_test_v1_Compatibility_API_1_0", + "meta": "Compatibility metadata", + "name": "Compatibility API", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "summary": "

A stable API.

\n", + "swagger": "2.0", + "tags": [ + { + "description": "

Manage items.

\n", + "htmlId": "items-tag", + "name": "items", + "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" + } + ], + "uid": "api.example.test/v1/Compatibility API/1.0", + "x-owner": { + "team": "documentation" + } + }, + "toc.html.view.json": { + "_disableContribution": true, + "_disableSearch": true, + "_disableToc": true, + "_key": "TestData/compatibility/toc.yml", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "toc.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "items": [ + { + "href": "service.html", + "items": [], + "leaf": true, + "level": 2, + "name": "Compatibility API", + "tocHref": null, + "topicHref": "service.html" + } + ], + "leaf": false, + "level": 1, + "meta": "Compatibility metadata", + "name": null, + "title": "Table of Content", + "tocHref": null, + "topicHref": null + }, + "toc.json.view.json": { + "content": "{\"items\":[{\"name\":\"Compatibility API\",\"href\":\"service.html\",\"topicHref\":\"service.html\"}],\"meta\":\"Compatibility metadata\"}" + }, + "toc.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_key": "TestData/compatibility/toc.yml", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "toc.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "items": [ + { + "href": "service.html", + "name": "Compatibility API", + "topicHref": "service.html" + } + ], + "meta": "Compatibility metadata" + } + }, + "html": { + "service.html": "

Compatibility API

A stable API.

Use the API guide.

items

Manage items.

listItems

List items.

Request
GET /items[?api-version&limit]
Parameters
NameTypeDefaultNotes
api-version2.0

An optional version.

limit0

Maximum number of items.

Responses
Status CodeTypeDescriptionSamples
200Item[]

The items.

Mime type: application/json
[\n  {\n    "id": "one",\n    "name": "First",\n    "$ref": "literal-example"\n  }\n]
Mime type: text/plain
"one"
defaultError

A request error.

createItem

Create an item.

Request
POST /items?api-version
Parameters
NameTypeDefaultNotes
*bodyItem

The new item.

*api-version1.0

The API version.

Responses
Status CodeTypeDescriptionSamples
201Item

Created.

Mime type: application/json
{\n  "id": "one",\n  "name": "First"\n}

Other APIs

deleteItem

Delete an item.

Request
DELETE /items/{id}
Parameters
NameTypeDefaultNotes
*id

Item ID.

Responses
Status CodeTypeDescriptionSamples
204

No content.

Definitions

Item

Use the API guide.

NameTypeNotes
id string

The item identifier.

name string

The display name.

state string

Error

Use the API guide.

NameTypeNotes
message string

Error details.

" + }, + "xrefs": [ + { + "href": "service.html", + "name": "Compatibility API", + "uid": "api.example.test/v1/Compatibility API/1.0" + }, + { + "href": "service.html#api_example_test_v1_Compatibility_API_1_0_createItem", + "name": "createItem", + "uid": "api.example.test/v1/Compatibility API/1.0/createItem" + }, + { + "href": "service.html#api_example_test_v1_Compatibility_API_1_0_deleteItem", + "name": "deleteItem", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + }, + { + "href": "service.html#api_example_test_v1_Compatibility_API_1_0_listItems", + "name": "listItems", + "uid": "api.example.test/v1/Compatibility API/1.0/listItems" + }, + { + "href": "service.html#api_example_test_v1_Compatibility_API_1_0_tag_items", + "name": "items", + "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" + } + ], + "manifest": [ + { + "output": { + ".html": { + "relative_path": "service.html" + } + }, + "source_relative_path": "TestData/compatibility/service.swagger.json", + "type": "RestApi" + }, + { + "output": { + ".html": { + "relative_path": "toc.html" + }, + ".json": { + "relative_path": "toc.json" + } + }, + "source_relative_path": "TestData/compatibility/toc.yml", + "type": "Toc" + } + ] +} \ No newline at end of file diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/statictoc-tags-False-operations-False-overwrite-False.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/statictoc-tags-False-operations-False-overwrite-False.json new file mode 100644 index 00000000000..44ae437d1e3 --- /dev/null +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/statictoc-tags-False-operations-False-overwrite-False.json @@ -0,0 +1,912 @@ +{ + "baselineCommit": "bd097d04a7b2c1eb7533b8f6e045764e20d15967", + "models": { + "service.html.view.json": { + "_disableContribution": true, + "_disableSearch": true, + "_disableToc": true, + "_jsonPath": "service.swagger.json", + "_key": "TestData/compatibility/service.swagger.json", + "_nav": { + "_disableContribution": true, + "_disableSearch": true, + "_key": "TestData/compatibility/toc.yml", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "toc.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "items": [ + { + "active": true, + "href": "service.html", + "items": [], + "leaf": true, + "level": 2, + "name": "Compatibility API", + "topicHref": "service.html" + } + ], + "leaf": false, + "level": 1, + "meta": "Compatibility metadata" + }, + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "service.html", + "_rel": "", + "_toc": { + "_disableContribution": true, + "_disableSearch": true, + "_key": "TestData/compatibility/toc.yml", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "toc.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "items": [ + { + "active": true, + "href": "service.html", + "items": [], + "leaf": true, + "level": 2, + "name": "Compatibility API", + "topicHref": "service.html" + } + ], + "leaf": false, + "level": 1, + "meta": "Compatibility metadata" + }, + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "children": [ + { + "conceptual": "", + "deprecated": true, + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", + "operation": "DELETE", + "operationId": "deleteItem", + "parameters": [ + { + "description": "

Item ID.

\n", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "path": "/items/{id}", + "remarks": "", + "responses": [ + { + "description": "

No content.

\n", + "statusCode": "204" + } + ], + "security": [], + "source": null, + "sourceurl": "", + "summary": "

Delete an item.

\n", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + } + ], + "consumes": [ + "application/json" + ], + "definitions": [ + { + "cType": "Item", + "cTypeId": "Item", + "cTypeIsArray": true, + "items": { + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + }, + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "array", + "x-internal-ref-name": "Item" + }, + { + "cType": "Error", + "cTypeId": "Error", + "properties": [ + { + "key": "message", + "value": { + "description": "

Error details.

\n", + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Error" + } + ], + "description": "

Use the API guide.

\n", + "documentType": "RestApi", + "docurl": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0", + "isTagLayout": true, + "meta": "Compatibility metadata", + "name": "Compatibility API", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "sourceurl": "", + "summary": "

A stable API.

\n", + "swagger": "2.0", + "tags": [ + { + "children": [ + { + "conceptual": "", + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", + "includedInTags": true, + "operation": "GET", + "operationId": "listItems", + "parameters": [ + { + "default": "2.0", + "description": "

An optional version.

\n", + "in": "query", + "name": "api-version", + "required": false, + "type": "string" + }, + { + "default": 0, + "description": "

Maximum number of items.

\n", + "in": "query", + "minimum": 0, + "name": "limit", + "type": "integer" + } + ], + "path": "/items[?api-version&limit]", + "remarks": "", + "responses": [ + { + "description": "

The items.

\n", + "examples": [ + { + "content": "[\n {\n \"id\": \"one\",\n \"name\": \"First\",\n \"$ref\": \"literal-example\"\n }\n]", + "mimeType": "application/json" + }, + { + "content": "\"one\"", + "mimeType": "text/plain" + } + ], + "headers": { + "X-Count": { + "description": "

Total count.

\n", + "type": "integer" + } + }, + "schema": { + "cType": "Item", + "cTypeId": "Item", + "cTypeIsArray": true, + "items": { + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + }, + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "type": "array" + }, + "statusCode": "200" + }, + { + "description": "

A request error.

\n", + "schema": { + "cType": "Error", + "cTypeId": "Error", + "properties": [ + { + "key": "message", + "value": { + "description": "

Error details.

\n", + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Error" + }, + "statusCode": "default", + "x-internal-ref-name": "Error" + } + ], + "source": null, + "sourceurl": "", + "summary": "

List items.

\n", + "tags": [ + "items" + ], + "uid": "api.example.test/v1/Compatibility API/1.0/listItems" + }, + { + "conceptual": "", + "description": "", + "docurl": "", + "footer": "", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", + "includedInTags": true, + "operation": "POST", + "operationId": "createItem", + "parameters": [ + { + "description": "

The new item.

\n", + "in": "body", + "name": "body", + "required": true, + "schema": { + "cType": "Item", + "cTypeId": "Item", + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "required": true, + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "description": null, + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Item" + } + }, + { + "default": "1.0", + "description": "

The API version.

\n", + "in": "query", + "name": "api-version", + "required": true, + "type": "string", + "x-internal-ref-name": "Version" + } + ], + "path": "/items?api-version", + "remarks": "", + "responses": [ + { + "description": "

Created.

\n", + "examples": [ + { + "content": "{\n \"id\": \"one\",\n \"name\": \"First\"\n}", + "mimeType": "application/json" + } + ], + "schema": { + "cType": "Item", + "cTypeId": "Item", + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "properties": [ + { + "key": "id", + "value": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + { + "key": "name", + "value": { + "description": "

The display name.

\n", + "required": true, + "type": "string" + } + }, + { + "key": "state", + "value": { + "default": "active", + "description": null, + "enum": [ + "active", + "archived" + ], + "type": "string" + } + } + ], + "type": "object", + "x-internal-ref-name": "Item" + }, + "statusCode": "201" + } + ], + "source": null, + "sourceurl": "", + "summary": "

Create an item.

\n", + "tags": [ + "items" + ], + "uid": "api.example.test/v1/Compatibility API/1.0/createItem" + } + ], + "conceptual": "", + "description": "

Manage items.

\n", + "htmlId": "api_example_test_v1_Compatibility_API_1_0_tag_items", + "name": "items", + "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" + } + ], + "title": "Compatibility API", + "uid": "api.example.test/v1/Compatibility API/1.0", + "x-owner": { + "team": "documentation" + } + }, + "service.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_key": "TestData/compatibility/service.swagger.json", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "service.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "children": [ + { + "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", + "operation": "get", + "operationId": "listItems", + "parameters": [ + { + "default": "2.0", + "description": "

An optional version.

\n", + "in": "query", + "name": "api-version", + "required": false, + "type": "string" + }, + { + "default": 0, + "description": "

Maximum number of items.

\n", + "in": "query", + "minimum": 0, + "name": "limit", + "type": "integer" + } + ], + "path": "/items", + "responses": [ + { + "description": "

The items.

\n", + "examples": [ + { + "content": "[{\"id\":\"one\",\"name\":\"First\",\"$ref\":\"literal-example\"}]", + "mimeType": "application/json" + }, + { + "content": "\"one\"", + "mimeType": "text/plain" + } + ], + "headers": { + "X-Count": { + "description": "

Total count.

\n", + "type": "integer" + } + }, + "schema": { + "items": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + }, + "type": "array" + }, + "statusCode": "200" + }, + { + "description": "

A request error.

\n", + "schema": { + "properties": { + "message": { + "description": "

Error details.

\n", + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Error" + }, + "statusCode": "default", + "x-internal-ref-name": "Error" + } + ], + "source": null, + "summary": "

List items.

\n", + "tags": [ + "items" + ], + "uid": "api.example.test/v1/Compatibility API/1.0/listItems" + }, + { + "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", + "operation": "post", + "operationId": "createItem", + "parameters": [ + { + "description": "

The new item.

\n", + "in": "body", + "name": "body", + "required": true, + "schema": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + } + }, + { + "default": "1.0", + "description": "

The API version.

\n", + "in": "query", + "name": "api-version", + "required": true, + "type": "string", + "x-internal-ref-name": "Version" + } + ], + "path": "/items", + "responses": [ + { + "description": "

Created.

\n", + "examples": [ + { + "content": "{\"id\":\"one\",\"name\":\"First\"}", + "mimeType": "application/json" + } + ], + "schema": { + "allOf": [ + { + "properties": { + "id": { + "description": "

The item identifier.

\n", + "readOnly": true, + "type": "string" + } + }, + "type": "object", + "x-internal-ref-name": "Base" + }, + { + "properties": { + "name": { + "description": "

The display name.

\n", + "type": "string" + }, + "state": { + "default": "active", + "enum": [ + "active", + "archived" + ], + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ], + "example": { + "$ref": "literal-schema-example", + "id": "one", + "name": "First" + }, + "x-internal-ref-name": "Item" + }, + "statusCode": "201" + } + ], + "source": null, + "summary": "

Create an item.

\n", + "tags": [ + "items" + ], + "uid": "api.example.test/v1/Compatibility API/1.0/createItem" + }, + { + "deprecated": true, + "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", + "operation": "delete", + "operationId": "deleteItem", + "parameters": [ + { + "description": "

Item ID.

\n", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "path": "/items/{id}", + "responses": [ + { + "description": "

No content.

\n", + "statusCode": "204" + } + ], + "security": [], + "source": null, + "summary": "

Delete an item.

\n", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + } + ], + "consumes": [ + "application/json" + ], + "description": "

Use the API guide.

\n", + "documentType": "RestApi", + "htmlId": "api_example_test_v1_Compatibility_API_1_0", + "meta": "Compatibility metadata", + "name": "Compatibility API", + "produces": [ + "application/json", + "text/plain" + ], + "schemes": [ + "https" + ], + "security": [ + { + "apiKey": [] + } + ], + "securityDefinitions": { + "apiKey": { + "description": "

Your API key.

\n", + "in": "header", + "name": "X-Api-Key", + "type": "apiKey" + } + }, + "summary": "

A stable API.

\n", + "swagger": "2.0", + "tags": [ + { + "description": "

Manage items.

\n", + "htmlId": "items-tag", + "name": "items", + "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" + } + ], + "uid": "api.example.test/v1/Compatibility API/1.0", + "x-owner": { + "team": "documentation" + } + }, + "toc.html.view.json": { + "_disableContribution": true, + "_disableSearch": true, + "_disableToc": true, + "_key": "TestData/compatibility/toc.yml", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "toc.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "items": [ + { + "href": "service.html", + "items": [], + "leaf": true, + "level": 2, + "name": "Compatibility API", + "tocHref": null, + "topicHref": "service.html" + } + ], + "leaf": false, + "level": 1, + "meta": "Compatibility metadata", + "name": null, + "title": "Table of Content", + "tocHref": null, + "topicHref": null + }, + "toc.json.view.json": { + "content": "{\"items\":[{\"name\":\"Compatibility API\",\"href\":\"service.html\",\"topicHref\":\"service.html\"}],\"meta\":\"Compatibility metadata\"}" + }, + "toc.raw.json": { + "_disableContribution": true, + "_disableSearch": true, + "_key": "TestData/compatibility/toc.yml", + "_navKey": "~/TestData/compatibility/toc.yml", + "_navPath": "toc.html", + "_navRel": "toc.html", + "_path": "toc.html", + "_rel": "", + "_tocKey": "~/TestData/compatibility/toc.yml", + "_tocPath": "toc.html", + "_tocRel": "toc.html", + "items": [ + { + "href": "service.html", + "name": "Compatibility API", + "topicHref": "service.html" + } + ], + "meta": "Compatibility metadata" + } + }, + "html": { + "service.html": "

Compatibility API

A stable API.

Use the API guide.

items

Manage items.

listItems

List items.

Request
GET /items[?api-version&limit]
Parameters
NameTypeDefaultNotes
api-version2.0

An optional version.

limit0

Maximum number of items.

Responses
Status CodeTypeDescriptionSamples
200Item[]

The items.

Mime type: application/json
[\n  {\n    "id": "one",\n    "name": "First",\n    "$ref": "literal-example"\n  }\n]
Mime type: text/plain
"one"
defaultError

A request error.

createItem

Create an item.

Request
POST /items?api-version
Parameters
NameTypeDefaultNotes
*bodyItem

The new item.

*api-version1.0

The API version.

Responses
Status CodeTypeDescriptionSamples
201Item

Created.

Mime type: application/json
{\n  "id": "one",\n  "name": "First"\n}

Other APIs

deleteItem

Delete an item.

Request
DELETE /items/{id}
Parameters
NameTypeDefaultNotes
*id

Item ID.

Responses
Status CodeTypeDescriptionSamples
204

No content.

Definitions

Item

Use the API guide.

NameTypeNotes
id string

The item identifier.

name string

The display name.

state string

Error

Use the API guide.

NameTypeNotes
message string

Error details.

" + }, + "xrefs": [ + { + "href": "service.html", + "name": "Compatibility API", + "uid": "api.example.test/v1/Compatibility API/1.0" + }, + { + "href": "service.html#api_example_test_v1_Compatibility_API_1_0_createItem", + "name": "createItem", + "uid": "api.example.test/v1/Compatibility API/1.0/createItem" + }, + { + "href": "service.html#api_example_test_v1_Compatibility_API_1_0_deleteItem", + "name": "deleteItem", + "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" + }, + { + "href": "service.html#api_example_test_v1_Compatibility_API_1_0_listItems", + "name": "listItems", + "uid": "api.example.test/v1/Compatibility API/1.0/listItems" + }, + { + "href": "service.html#api_example_test_v1_Compatibility_API_1_0_tag_items", + "name": "items", + "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" + } + ], + "manifest": [ + { + "output": { + ".html": { + "relative_path": "service.html" + } + }, + "source_relative_path": "TestData/compatibility/service.swagger.json", + "type": "RestApi" + }, + { + "output": { + ".html": { + "relative_path": "toc.html" + }, + ".json": { + "relative_path": "toc.json" + } + }, + "source_relative_path": "TestData/compatibility/toc.yml", + "type": "Toc" + } + ] +} \ No newline at end of file diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/overwrite.md b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/overwrite.md new file mode 100644 index 00000000000..539fb189c62 --- /dev/null +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/overwrite.md @@ -0,0 +1,29 @@ +--- +uid: api.example.test/v1/Compatibility API/1.0 +summary: Updated **API** summary. +--- + +Document-level conceptual content. + +--- +uid: api.example.test/v1/Compatibility API/1.0/tag/items +description: Updated **items** tag. +--- + +Tag-level conceptual content. + +--- +uid: api.example.test/v1/Compatibility API/1.0/createItem +summary: Updated **create** summary. +parameters: + - name: body + description: Updated **body** description. + schema: + allOf: + - null + - properties: + name: + description: Updated name description. +--- + +Operation-level conceptual content. diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/service.swagger.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/service.swagger.json new file mode 100644 index 00000000000..71085e24392 --- /dev/null +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/service.swagger.json @@ -0,0 +1,139 @@ +{ + "swagger": "2.0", + "info": { + "title": "Compatibility API", + "version": "1.0", + "description": "API information." + }, + "host": "api.example.test", + "basePath": "/v1", + "schemes": ["https"], + "consumes": ["application/json"], + "produces": ["application/json", "text/plain"], + "summary": "A **stable** API.", + "description": "Use the [API guide](https://example.test/guide).", + "x-owner": { "team": "documentation" }, + "securityDefinitions": { + "apiKey": { + "type": "apiKey", + "name": "X-Api-Key", + "in": "header", + "description": "Your **API key**." + } + }, + "security": [{ "apiKey": [] }], + "tags": [ + { "name": "items", "description": "Manage **items**.", "x-bookmark-id": "items-tag" } + ], + "paths": { + "/items": { + "parameters": [{ "$ref": "#/parameters/Version" }], + "get": { + "operationId": "listItems", + "tags": ["items"], + "summary": "List **items**.", + "parameters": [ + { + "name": "api-version", + "in": "query", + "type": "string", + "required": false, + "default": "2.0", + "description": "An optional version." + }, + { + "name": "limit", + "in": "query", + "type": "integer", + "default": 0, + "minimum": 0, + "description": "Maximum number of items." + } + ], + "responses": { + "200": { + "description": "The **items**.", + "schema": { "type": "array", "items": { "$ref": "#/definitions/Item" } }, + "headers": { "X-Count": { "type": "integer", "description": "Total count." } }, + "examples": { + "application/json": [{ "id": "one", "name": "First", "$ref": "literal-example" }], + "text/plain": "one" + } + }, + "default": { "$ref": "#/responses/Error" } + } + }, + "post": { + "operationId": "createItem", + "tags": ["items"], + "summary": "Create an item.", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "description": "The **new** item.", + "schema": { "$ref": "#/definitions/Item" } + } + ], + "responses": { + "201": { + "description": "Created.", + "schema": { "$ref": "#/definitions/Item" }, + "examples": { "application/json": { "id": "one", "name": "First" } } + } + } + } + }, + "/items/{id}": { + "delete": { + "operationId": "deleteItem", + "summary": "Delete an item.", + "deprecated": true, + "security": [], + "parameters": [ + { "name": "id", "in": "path", "type": "string", "required": true, "description": "Item ID." } + ], + "responses": { "204": { "description": "No content." } } + } + } + }, + "parameters": { + "Version": { + "name": "api-version", + "in": "query", + "type": "string", + "required": true, + "default": "1.0", + "description": "The **API version**." + } + }, + "responses": { + "Error": { + "description": "A request error.", + "schema": { "$ref": "components.json#/definitions/Error" } + } + }, + "definitions": { + "Base": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "The item **identifier**.", "readOnly": true } + } + }, + "Item": { + "allOf": [ + { "$ref": "#/definitions/Base" }, + { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "description": "The **display name**." }, + "state": { "type": "string", "enum": ["active", "archived"], "default": "active" } + } + } + ], + "example": { "id": "one", "name": "First", "$ref": "literal-schema-example" } + } + } +} diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/toc.yml b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/toc.yml new file mode 100644 index 00000000000..f83f96c13d4 --- /dev/null +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/toc.yml @@ -0,0 +1,2 @@ +- name: Compatibility API + href: service.swagger.json From 17eacad684733b5b3bcee26dbf56df70f8dd17e5 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Sat, 19 Sep 2026 18:30:47 +1000 Subject: [PATCH 02/16] test: simplify Swagger output compatibility assertions Replace seven full output snapshots with focused model, DOM, xref, page and TOC assertions while retaining real template builds and existing scenarios. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../SwaggerOutputCompatibilityTest.cs | 321 ++-- ...alse-operations-False-overwrite-False.json | 859 ---------- ...False-operations-False-overwrite-True.json | 863 ---------- ...False-operations-True-overwrite-False.json | 1277 --------------- ...True-operations-False-overwrite-False.json | 968 ----------- ...-True-operations-True-overwrite-False.json | 1425 ----------------- ...alse-operations-False-overwrite-False.json | 859 ---------- ...alse-operations-False-overwrite-False.json | 912 ----------- 8 files changed, 215 insertions(+), 7269 deletions(-) delete mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-False-overwrite-False.json delete mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-False-overwrite-True.json delete mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-True-overwrite-False.json delete mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-True-operations-False-overwrite-False.json delete mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-True-operations-True-overwrite-False.json delete mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/modern-tags-False-operations-False-overwrite-False.json delete mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/statictoc-tags-False-operations-False-overwrite-False.json diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs b/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs index 17a6fb5ace7..7cc9d361a05 100644 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs @@ -3,13 +3,10 @@ using System.Collections.Immutable; using System.Reflection; -using System.Text; -using System.Text.RegularExpressions; using Docfx.Build.Engine; using Docfx.Build.OperationLevelRestApi; using Docfx.Build.TagLevelRestApi; using Docfx.Common; -using Docfx.DataContracts.Common; using Docfx.Plugins; using Docfx.Tests.Common; using HtmlAgilityPack; @@ -22,9 +19,9 @@ namespace Docfx.Build.RestApi.WithPlugins.Tests; [Trait("Category", "SwaggerCompatibility")] public class SwaggerOutputCompatibilityTest : TestBase { - // Expected outputs were recorded with unchanged production code at this commit. - private const string BaselineCommit = "bd097d04a7b2c1eb7533b8f6e045764e20d15967"; private const string InputDirectory = "TestData/compatibility"; + 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("trace")] @@ -159,129 +156,241 @@ public void PreservesSwaggerDocumentation(string template, bool splitTags, bool } Assert.Empty(listener.Items); - var actual = CaptureOutput(output); - var name = $"{template}-tags-{splitTags}-operations-{splitOperations}-overwrite-{overwrite}"; - var expectedPath = Path.Combine(InputDirectory, "expected", name + ".json"); - var expected = File.Exists(expectedPath) ? JObject.Parse(File.ReadAllText(expectedPath)) : null; - if (expected == null || !JToken.DeepEquals(expected, actual)) + string[] pages = (splitTags, splitOperations) switch { - // Never update a baseline from a candidate implementation automatically. - var actualDirectory = Path.Combine(AppContext.BaseDirectory, "TestResults", "SwaggerCompatibility"); - Directory.CreateDirectory(actualDirectory); - var actualPath = Path.Combine(actualDirectory, name + ".actual.json"); - File.WriteAllText(actualPath, actual.ToString()); - Assert.Fail($"Swagger output differs from baseline {BaselineCommit}. Expected: {expectedPath}. Actual: {actualPath}"); - } - } + (false, false) => ["service"], + (true, false) => ["service", "service/items"], + (false, true) => ["service", "service/createItem", "service/deleteItem", "service/listItems"], + (true, true) => ["service", "service/deleteItem", "service/items", "service/items/createItem", "service/items/listItems"] + }; + Assert.Equal( + pages.Select(page => page + ".html").Append("toc.html").Order(StringComparer.Ordinal), + Directory.GetFiles(output, "*.html", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(output, path).Replace('\\', '/')).Order(StringComparer.Ordinal)); + var manifest = ReadModel(output, "manifest.json")["files"]; + Assert.Equal(pages.Length + 1, manifest.Count()); + Assert.Equal(pages.Select(page => page + ".html"), + manifest.Where(file => (string)file["type"] == "RestApi") + .Select(file => (string)file["output"][".html"]["relative_path"]).Order(StringComparer.Ordinal)); + var tocOutput = Assert.Single(manifest, file => (string)file["type"] == "Toc")["output"]; + Assert.Equal("toc.html", (string)tocOutput[".html"]["relative_path"]); + Assert.Equal("toc.json", (string)tocOutput[".json"]["relative_path"]); - private static IEnumerable GetAssemblies(bool splitTags, bool splitOperations) - { - yield return typeof(RestApiDocumentProcessor).Assembly; - if (splitTags) + var raw = pages.ToDictionary(page => page, page => ReadModel(output, page + ".raw.json")); + var views = pages.ToDictionary(page => page, page => ReadModel(output, page + ".html.view.json")); + var articles = pages.ToDictionary(page => page, + page => ReadHtml(output, page + ".html").SelectSingleNode("//article")); + foreach (var page in pages) { - yield return typeof(SplitRestApiToTagLevel).Assembly; + Assert.NotNull(articles[page]); + Assert.Equal((string)raw[page]["uid"], (string)views[page]["uid"]); + Assert.Equal((string)views[page]["uid"], articles[page].SelectSingleNode(".//h1").GetAttributeValue("data-uid", null)); + Assert.Equal((string)views[page]["htmlId"], articles[page].SelectSingleNode(".//h1").Id); + var tocRel = string.Concat(Enumerable.Repeat("../", page.Count(character => character == '/'))) + "toc.html"; + Assert.Equal(tocRel, (string)raw[page]["_tocRel"]); + Assert.Equal(tocRel, (string)views[page]["_tocRel"]); } - if (splitOperations) - { - yield return typeof(SplitRestApiToOperationLevel).Assembly; - } - } + var root = raw["service"]; + Assert.Equal(RootUid, (string)root["uid"]); + Assert.Equal(RootHtmlId, (string)root["htmlId"]); + Assert.Equal(RootHtmlId, (string)views["service"]["htmlId"]); + Assert.Equal("Compatibility API", (string)root["name"]); + Assert.Equal(File.ReadAllText(Path.Combine(InputDirectory, "service.swagger.json")), (string)root["_raw"]); + Assert.NotNull(articles["service"].SelectSingleNode($".//p/strong[text()='{(overwrite ? "API" : "stable")}']")); + Assert.NotNull(articles["service"].SelectSingleNode(".//a[@href='https://example.test/guide']")); - private static JObject CaptureOutput(string output) - { - var models = new JObject(); - var html = new JObject(); - foreach (var path in Directory.GetFiles(output, "*", SearchOption.AllDirectories).Order(StringComparer.Ordinal)) + var xrefs = YamlUtility.Deserialize(Path.Combine(output, XRefArchive.MajorFileName)) + .References.ToDictionary(reference => reference.Uid, reference => reference.Href); + var expectedXrefs = new Dictionary { [RootUid] = "service.html" }; + var operations = raw.Values.SelectMany(model => model["children"]) + .ToDictionary(operation => (string)operation["operationId"]); + var viewOperations = views.Values.SelectMany(model => + model["children"].Concat(model["tags"].SelectMany(tag => tag["children"]))) + .ToDictionary(operation => (string)operation["operationId"]); + Assert.Equal(["createItem", "deleteItem", "listItems"], operations.Keys.Order(StringComparer.Ordinal)); + Assert.Equal(operations.Keys.Order(StringComparer.Ordinal), viewOperations.Keys.Order(StringComparer.Ordinal)); + foreach (var (id, method, path) in new[] + { + ("listItems", "GET", "/items[?api-version&limit]"), + ("createItem", "POST", "/items?api-version"), + ("deleteItem", "DELETE", "/items/{id}") + }) { - var relative = Path.GetRelativePath(output, path).Replace('\\', '/'); - if (relative.EndsWith(".raw.json", StringComparison.Ordinal) || relative.EndsWith(".view.json", StringComparison.Ordinal)) + var page = splitTags && id != "deleteItem" ? "service/items" : "service"; + if (splitOperations) { - var model = JObject.Parse(File.ReadAllText(path)); - if (model["_raw"]?.Type == JTokenType.String) - { - Assert.Equal(File.ReadAllText($"{InputDirectory}/service.swagger.json"), (string)model["_raw"]); - } - model.Remove("_raw"); - model.Remove("__global"); - model.Remove(Constants.PropertyName.SystemKeys); - models[relative] = Canonicalize(model); + page += "/" + id; + Assert.Equal(RootUid + "/" + id, (string)raw[page]["uid"]); + Assert.Equal(RootHtmlId + "_" + id, (string)views[page]["htmlId"]); + Assert.Same(operations[id], Assert.Single(raw[page]["children"])); + Assert.Null((string)operations[id]["htmlId"]); } - else if (relative.EndsWith(".html", StringComparison.Ordinal)) + else { - var document = new HtmlDocument(); - document.Load(path); - var article = document.DocumentNode.SelectSingleNode("//article"); - if (article != null) - { - html[relative] = CanonicalHtml(article); - } + Assert.Contains(operations[id], raw[page]["children"]); + Assert.Equal(RootHtmlId + "_" + id, (string)operations[id]["htmlId"]); + } + + var uid = RootUid + "/" + id; + var operationUid = uid + (splitOperations ? "/operation" : ""); + var htmlId = RootHtmlId + "_" + id + (splitOperations ? "_operation" : ""); + Assert.Equal(operationUid, (string)operations[id]["uid"]); + Assert.Equal(operationUid, (string)viewOperations[id]["uid"]); + Assert.Equal(htmlId, (string)viewOperations[id]["htmlId"]); + Assert.Equal(method, (string)viewOperations[id]["operation"]); + Assert.Equal(path, (string)viewOperations[id]["path"]); + var heading = articles[page].SelectSingleNode($".//h3[@id='{htmlId}']"); + Assert.NotNull(heading); + Assert.Equal(operationUid, heading.GetAttributeValue("data-uid", null)); + expectedXrefs[uid] = page + ".html" + (splitOperations ? "" : "#" + htmlId); + if (splitOperations) + { + expectedXrefs[operationUid] = page + ".html#" + htmlId; } } - Assert.NotEmpty(models); - Assert.NotEmpty(html); - var xrefs = YamlUtility.Deserialize(Path.Combine(output, XRefArchive.MajorFileName)); - var manifest = JObject.Parse(File.ReadAllText(Path.Combine(output, "manifest.json"))); - return new JObject + if (splitTags || !splitOperations) { - ["baselineCommit"] = BaselineCommit, - ["models"] = models, - ["html"] = html, - ["xrefs"] = Canonicalize(JArray.FromObject(xrefs.References.OrderBy(r => r.Uid, StringComparer.Ordinal))), - ["manifest"] = Canonicalize(new JArray(((JArray)manifest["files"]) - .OrderBy(f => (string)f["source_relative_path"], StringComparer.Ordinal) - .ThenBy(f => (string)f["output"]?[".html"]?["relative_path"], StringComparer.Ordinal))) - }; - } + var tag = splitTags ? raw["service/items"] : Assert.Single(root["tags"]); + var tagPage = splitTags ? "service/items" : "service"; + Assert.Equal(RootUid + "/tag/items", (string)tag["uid"]); + Assert.Equal("items-tag", (string)tag["htmlId"]); + var tagId = RootHtmlId + "_tag_items"; + Assert.NotNull(articles[tagPage].SelectSingleNode($".//*[@id='{tagId}']")); + Assert.NotNull(articles[tagPage].SelectSingleNode( + $".//p[strong[text()='items'] and contains(., '{(overwrite ? "Updated" : "Manage")}')]")); + expectedXrefs[RootUid + "/tag/items"] = tagPage + ".html" + (splitTags ? "" : "#" + tagId); + } + if (splitTags || splitOperations) + { + Assert.Empty(root["tags"]); + } + Assert.Equal(expectedXrefs.OrderBy(pair => pair.Key, StringComparer.Ordinal), + xrefs.OrderBy(pair => pair.Key, StringComparer.Ordinal)); - private static JToken Canonicalize(JToken token) - { - return token switch + var tocRoot = Assert.Single(ReadModel(output, "toc.raw.json")["items"]); + Assert.Equal("Compatibility API", (string)tocRoot["name"]); + Assert.Equal("service.html", (string)tocRoot["href"]); + Assert.Equal("service.html", (string)tocRoot["topicHref"]); + var tocChildren = tocRoot["items"]?.ToArray() ?? []; + string[] tocNames = (splitTags, splitOperations) switch { - JObject obj => new JObject(obj.Properties().OrderBy(p => p.Name, StringComparer.Ordinal) - .Select(p => new JProperty(p.Name, Canonicalize(p.Value)))), - JArray array => new JArray(array.Select(Canonicalize)), - JValue { Type: JTokenType.String } value => new JValue(((string)value).Replace("\r\n", "\n")), - _ => token.DeepClone() + (false, false) => [], + (true, false) => ["items"], + (false, true) => ["createItem", "deleteItem", "listItems"], + (true, true) => ["items", "deleteItem"] }; - } + Assert.Equal(tocNames, tocChildren.Select(item => (string)item["name"])); + foreach (var item in tocChildren) + { + string[] nestedNames = splitTags && splitOperations && (string)item["name"] == "items" + ? ["createItem", "listItems"] : []; + Assert.Equal(nestedNames, (item["items"]?.ToArray() ?? []).Select(child => (string)child["name"])); + } + var tocHtml = ReadHtml(output, "toc.html"); + Assert.NotNull(tocHtml.SelectSingleNode("//a[@href='service.html']")); + foreach (var item in tocChildren.Concat(tocChildren.SelectMany(item => item["items"]?.ToArray() ?? []))) + { + var name = (string)item["name"]; + var uid = RootUid + (name == "items" ? "/tag/" : "/") + name; + Assert.Equal(uid, (string)item["topicUid"]); + Assert.Equal(expectedXrefs[uid], (string)item["href"]); + Assert.Equal(expectedXrefs[uid], (string)item["topicHref"]); + Assert.NotNull(tocHtml.SelectSingleNode($"//a[@href='{expectedXrefs[uid]}']")); + } - private static string CanonicalHtml(HtmlNode node) - { - var result = new StringBuilder(); - Append(node, false); - return result.ToString(); + var list = operations["listItems"]; + var create = operations["createItem"]; + Assert.Equal(["api-version", "limit"], list["parameters"].Select(parameter => (string)parameter["name"])); + Assert.Equal("2.0", (string)list["parameters"][0]["default"]); + Assert.False((bool)list["parameters"][0]["required"]); + Assert.Equal(0, (int)list["parameters"][1]["default"]); + Assert.Equal(["body", "api-version"], create["parameters"].Select(parameter => (string)parameter["name"])); + Assert.Equal("1.0", (string)create["parameters"][1]["default"]); + Assert.True((bool)create["parameters"][1]["required"]); + var body = create["parameters"][0]; + Assert.Equal("body", (string)body["in"]); + Assert.True((bool)body["required"]); + var schema = body["schema"]; + Assert.Equal("Item", (string)schema["x-internal-ref-name"]); + Assert.Equal("Base", (string)schema["allOf"][0]["x-internal-ref-name"]); + Assert.True((bool)schema["allOf"][0]["properties"]["id"]["readOnly"]); + Assert.Equal("name", (string)Assert.Single(schema["allOf"][1]["required"])); + Assert.Equal("string", (string)schema["allOf"][1]["properties"]["name"]["type"]); + Assert.Equal("literal-schema-example", (string)schema["example"]["$ref"]); - void Append(HtmlNode current, bool preserveWhitespace) + Assert.Equal(["200", "default"], list["responses"].Select(response => (string)response["statusCode"])); + var response = list["responses"][0]; + Assert.Equal("array", (string)response["schema"]["type"]); + Assert.Equal("Item", (string)response["schema"]["items"]["x-internal-ref-name"]); + Assert.Equal("Base", (string)response["schema"]["items"]["allOf"][0]["x-internal-ref-name"]); + Assert.Equal("literal-schema-example", (string)response["schema"]["items"]["example"]["$ref"]); + Assert.Equal("integer", (string)response["headers"]["X-Count"]["type"]); + Assert.Equal("Error", (string)list["responses"][1]["schema"]["x-internal-ref-name"]); + Assert.Equal("string", (string)list["responses"][1]["schema"]["properties"]["message"]["type"]); + Assert.Equal(["application/json", "text/plain"], response["examples"].Select(example => (string)example["mimeType"])); + var example = Assert.Single(JArray.Parse((string)response["examples"][0]["content"])); + Assert.Equal("one", (string)example["id"]); + Assert.Equal("literal-example", (string)example["$ref"]); + Assert.Equal("\"one\"", (string)response["examples"][1]["content"]); + 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 listPage = splitTags ? "service/items" : "service"; + if (splitOperations) { - if (current.NodeType == HtmlNodeType.Comment) - { - return; - } - if (current is HtmlTextNode text) - { - if (preserveWhitespace) - { - result.Append(text.Text.Replace("\r\n", "\n")); - } - else if (!string.IsNullOrWhiteSpace(text.Text)) - { - result.Append(Regex.Replace(text.Text, @"\s+", " ")); - } - return; - } + listPage += "/listItems"; + } + var exampleCode = Assert.Single(articles[listPage].SelectNodes(".//pre/code"), + node => node.InnerText.Contains("literal-example", StringComparison.Ordinal)); + var renderedExample = Assert.Single(JArray.Parse(HtmlEntity.DeEntitize(exampleCode.InnerText))); + Assert.Equal("literal-example", (string)renderedExample["$ref"]); + Assert.NotNull(articles[listPage].SelectSingleNode(".//p[strong[text()='items'] and contains(., 'List')]")); - result.Append('<').Append(current.Name); - foreach (var attribute in current.Attributes.OrderBy(a => a.Name, StringComparer.Ordinal)) - { - result.Append(' ').Append(attribute.Name).Append("=\"").Append(attribute.Value).Append('"'); - } - result.Append('>'); - foreach (var child in current.ChildNodes) + if (overwrite) + { + 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" }) { - Append(child, preserveWhitespace || current.Name == "pre"); + Assert.NotNull(articles["service"].SelectSingleNode($".//p[text()='{level}-level conceptual content.']")); } - result.Append("'); + Assert.NotNull(articles["service"].SelectSingleNode(".//p[strong[text()='items'] and contains(., 'Updated')]")); + Assert.NotNull(articles["service"].SelectSingleNode(".//p[strong[text()='create'] and contains(., 'Updated')]")); + Assert.NotNull(articles["service"].SelectSingleNode(".//p[strong[text()='body'] and contains(., 'Updated')]")); + } + else + { + Assert.NotNull(HtmlNode.CreateNode((string)viewName["description"]).SelectSingleNode("strong[text()='display name']")); + } + } + + private static IEnumerable GetAssemblies(bool splitTags, bool splitOperations) + { + yield return typeof(RestApiDocumentProcessor).Assembly; + if (splitTags) + { + yield return typeof(SplitRestApiToTagLevel).Assembly; } + if (splitOperations) + { + yield return typeof(SplitRestApiToOperationLevel).Assembly; + } + } + + private static JObject ReadModel(string output, string path) => + JObject.Parse(File.ReadAllText(Path.Combine(output, path.Replace('/', Path.DirectorySeparatorChar)))); + + private static HtmlNode ReadHtml(string output, string path) + { + var document = new HtmlDocument(); + document.Load(Path.Combine(output, path.Replace('/', Path.DirectorySeparatorChar))); + return document.DocumentNode; } } diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-False-overwrite-False.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-False-overwrite-False.json deleted file mode 100644 index 6d061561681..00000000000 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-False-overwrite-False.json +++ /dev/null @@ -1,859 +0,0 @@ -{ - "baselineCommit": "bd097d04a7b2c1eb7533b8f6e045764e20d15967", - "models": { - "service.html.view.json": { - "_disableContribution": true, - "_disableNextArticle": true, - "_disableSearch": true, - "_disableToc": true, - "_jsonPath": "service.swagger.json", - "_key": "TestData/compatibility/service.swagger.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "service.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "children": [ - { - "conceptual": "", - "deprecated": true, - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", - "operation": "DELETE", - "operationId": "deleteItem", - "parameters": [ - { - "description": "

Item ID.

\n", - "in": "path", - "name": "id", - "required": true, - "type": "string" - } - ], - "path": "/items/{id}", - "remarks": "", - "responses": [ - { - "description": "

No content.

\n", - "statusCode": "204" - } - ], - "security": [], - "source": null, - "sourceurl": "", - "summary": "

Delete an item.

\n", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - } - ], - "consumes": [ - "application/json" - ], - "definitions": [ - { - "cType": "Item", - "cTypeId": "Item", - "cTypeIsArray": true, - "items": { - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - }, - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "array", - "x-internal-ref-name": "Item" - }, - { - "cType": "Error", - "cTypeId": "Error", - "properties": [ - { - "key": "message", - "value": { - "description": "

Error details.

\n", - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Error" - } - ], - "description": "

Use the API guide.

\n", - "documentType": "RestApi", - "docurl": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0", - "isTagLayout": true, - "meta": "Compatibility metadata", - "name": "Compatibility API", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "sourceurl": "", - "summary": "

A stable API.

\n", - "swagger": "2.0", - "tags": [ - { - "children": [ - { - "conceptual": "", - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", - "includedInTags": true, - "operation": "GET", - "operationId": "listItems", - "parameters": [ - { - "default": "2.0", - "description": "

An optional version.

\n", - "in": "query", - "name": "api-version", - "required": false, - "type": "string" - }, - { - "default": 0, - "description": "

Maximum number of items.

\n", - "in": "query", - "minimum": 0, - "name": "limit", - "type": "integer" - } - ], - "path": "/items[?api-version&limit]", - "remarks": "", - "responses": [ - { - "description": "

The items.

\n", - "examples": [ - { - "content": "[\n {\n \"id\": \"one\",\n \"name\": \"First\",\n \"$ref\": \"literal-example\"\n }\n]", - "mimeType": "application/json" - }, - { - "content": "\"one\"", - "mimeType": "text/plain" - } - ], - "headers": { - "X-Count": { - "description": "

Total count.

\n", - "type": "integer" - } - }, - "schema": { - "cType": "Item", - "cTypeId": "Item", - "cTypeIsArray": true, - "items": { - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - }, - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "type": "array" - }, - "statusCode": "200" - }, - { - "description": "

A request error.

\n", - "schema": { - "cType": "Error", - "cTypeId": "Error", - "properties": [ - { - "key": "message", - "value": { - "description": "

Error details.

\n", - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Error" - }, - "statusCode": "default", - "x-internal-ref-name": "Error" - } - ], - "source": null, - "sourceurl": "", - "summary": "

List items.

\n", - "tags": [ - "items" - ], - "uid": "api.example.test/v1/Compatibility API/1.0/listItems" - }, - { - "conceptual": "", - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", - "includedInTags": true, - "operation": "POST", - "operationId": "createItem", - "parameters": [ - { - "description": "

The new item.

\n", - "in": "body", - "name": "body", - "required": true, - "schema": { - "cType": "Item", - "cTypeId": "Item", - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "required": true, - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "description": null, - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Item" - } - }, - { - "default": "1.0", - "description": "

The API version.

\n", - "in": "query", - "name": "api-version", - "required": true, - "type": "string", - "x-internal-ref-name": "Version" - } - ], - "path": "/items?api-version", - "remarks": "", - "responses": [ - { - "description": "

Created.

\n", - "examples": [ - { - "content": "{\n \"id\": \"one\",\n \"name\": \"First\"\n}", - "mimeType": "application/json" - } - ], - "schema": { - "cType": "Item", - "cTypeId": "Item", - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "required": true, - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "description": null, - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "statusCode": "201" - } - ], - "source": null, - "sourceurl": "", - "summary": "

Create an item.

\n", - "tags": [ - "items" - ], - "uid": "api.example.test/v1/Compatibility API/1.0/createItem" - } - ], - "conceptual": "", - "description": "

Manage items.

\n", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_tag_items", - "name": "items", - "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" - } - ], - "title": "Compatibility API", - "uid": "api.example.test/v1/Compatibility API/1.0", - "x-owner": { - "team": "documentation" - } - }, - "service.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_key": "TestData/compatibility/service.swagger.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "service.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "children": [ - { - "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", - "operation": "get", - "operationId": "listItems", - "parameters": [ - { - "default": "2.0", - "description": "

An optional version.

\n", - "in": "query", - "name": "api-version", - "required": false, - "type": "string" - }, - { - "default": 0, - "description": "

Maximum number of items.

\n", - "in": "query", - "minimum": 0, - "name": "limit", - "type": "integer" - } - ], - "path": "/items", - "responses": [ - { - "description": "

The items.

\n", - "examples": [ - { - "content": "[{\"id\":\"one\",\"name\":\"First\",\"$ref\":\"literal-example\"}]", - "mimeType": "application/json" - }, - { - "content": "\"one\"", - "mimeType": "text/plain" - } - ], - "headers": { - "X-Count": { - "description": "

Total count.

\n", - "type": "integer" - } - }, - "schema": { - "items": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - }, - "type": "array" - }, - "statusCode": "200" - }, - { - "description": "

A request error.

\n", - "schema": { - "properties": { - "message": { - "description": "

Error details.

\n", - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Error" - }, - "statusCode": "default", - "x-internal-ref-name": "Error" - } - ], - "source": null, - "summary": "

List items.

\n", - "tags": [ - "items" - ], - "uid": "api.example.test/v1/Compatibility API/1.0/listItems" - }, - { - "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", - "operation": "post", - "operationId": "createItem", - "parameters": [ - { - "description": "

The new item.

\n", - "in": "body", - "name": "body", - "required": true, - "schema": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - } - }, - { - "default": "1.0", - "description": "

The API version.

\n", - "in": "query", - "name": "api-version", - "required": true, - "type": "string", - "x-internal-ref-name": "Version" - } - ], - "path": "/items", - "responses": [ - { - "description": "

Created.

\n", - "examples": [ - { - "content": "{\"id\":\"one\",\"name\":\"First\"}", - "mimeType": "application/json" - } - ], - "schema": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - }, - "statusCode": "201" - } - ], - "source": null, - "summary": "

Create an item.

\n", - "tags": [ - "items" - ], - "uid": "api.example.test/v1/Compatibility API/1.0/createItem" - }, - { - "deprecated": true, - "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", - "operation": "delete", - "operationId": "deleteItem", - "parameters": [ - { - "description": "

Item ID.

\n", - "in": "path", - "name": "id", - "required": true, - "type": "string" - } - ], - "path": "/items/{id}", - "responses": [ - { - "description": "

No content.

\n", - "statusCode": "204" - } - ], - "security": [], - "source": null, - "summary": "

Delete an item.

\n", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - } - ], - "consumes": [ - "application/json" - ], - "description": "

Use the API guide.

\n", - "documentType": "RestApi", - "htmlId": "api_example_test_v1_Compatibility_API_1_0", - "meta": "Compatibility metadata", - "name": "Compatibility API", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "summary": "

A stable API.

\n", - "swagger": "2.0", - "tags": [ - { - "description": "

Manage items.

\n", - "htmlId": "items-tag", - "name": "items", - "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" - } - ], - "uid": "api.example.test/v1/Compatibility API/1.0", - "x-owner": { - "team": "documentation" - } - }, - "toc.html.view.json": { - "_disableContribution": true, - "_disableSearch": true, - "_disableToc": true, - "_key": "TestData/compatibility/toc.yml", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "toc.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "items": [ - { - "href": "service.html", - "items": [], - "leaf": true, - "level": 2, - "name": "Compatibility API", - "tocHref": null, - "topicHref": "service.html" - } - ], - "leaf": false, - "level": 1, - "meta": "Compatibility metadata", - "name": null, - "title": "Table of Content", - "tocHref": null, - "topicHref": null - }, - "toc.json.view.json": { - "content": "{\"items\":[{\"name\":\"Compatibility API\",\"href\":\"service.html\",\"topicHref\":\"service.html\"}],\"meta\":\"Compatibility metadata\"}" - }, - "toc.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_key": "TestData/compatibility/toc.yml", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "toc.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "items": [ - { - "href": "service.html", - "name": "Compatibility API", - "topicHref": "service.html" - } - ], - "meta": "Compatibility metadata" - } - }, - "html": { - "service.html": "

Compatibility API

A stable API.

Use the API guide.

items

Manage items.

listItems

List items.

Request
GET /items[?api-version&limit]
Parameters
NameTypeDefaultNotes
api-version2.0

An optional version.

limit0

Maximum number of items.

Responses
Status CodeTypeDescriptionSamples
200Item[]

The items.

Mime type: application/json
[\n  {\n    "id": "one",\n    "name": "First",\n    "$ref": "literal-example"\n  }\n]
Mime type: text/plain
"one"
defaultError

A request error.

createItem

Create an item.

Request
POST /items?api-version
Parameters
NameTypeDefaultNotes
*bodyItem

The new item.

*api-version1.0

The API version.

Responses
Status CodeTypeDescriptionSamples
201Item

Created.

Mime type: application/json
{\n  "id": "one",\n  "name": "First"\n}

Other APIs

deleteItem

Delete an item.

Request
DELETE /items/{id}
Parameters
NameTypeDefaultNotes
*id

Item ID.

Responses
Status CodeTypeDescriptionSamples
204

No content.

Definitions

Item

Use the API guide.

NameTypeNotes
id string

The item identifier.

name string

The display name.

state string

Error

Use the API guide.

NameTypeNotes
message string

Error details.

" - }, - "xrefs": [ - { - "href": "service.html", - "name": "Compatibility API", - "uid": "api.example.test/v1/Compatibility API/1.0" - }, - { - "href": "service.html#api_example_test_v1_Compatibility_API_1_0_createItem", - "name": "createItem", - "uid": "api.example.test/v1/Compatibility API/1.0/createItem" - }, - { - "href": "service.html#api_example_test_v1_Compatibility_API_1_0_deleteItem", - "name": "deleteItem", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - }, - { - "href": "service.html#api_example_test_v1_Compatibility_API_1_0_listItems", - "name": "listItems", - "uid": "api.example.test/v1/Compatibility API/1.0/listItems" - }, - { - "href": "service.html#api_example_test_v1_Compatibility_API_1_0_tag_items", - "name": "items", - "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" - } - ], - "manifest": [ - { - "output": { - ".html": { - "relative_path": "service.html" - } - }, - "source_relative_path": "TestData/compatibility/service.swagger.json", - "type": "RestApi" - }, - { - "output": { - ".html": { - "relative_path": "toc.html" - }, - ".json": { - "relative_path": "toc.json" - } - }, - "source_relative_path": "TestData/compatibility/toc.yml", - "type": "Toc" - } - ] -} \ No newline at end of file diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-False-overwrite-True.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-False-overwrite-True.json deleted file mode 100644 index 59ea83492cd..00000000000 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-False-overwrite-True.json +++ /dev/null @@ -1,863 +0,0 @@ -{ - "baselineCommit": "bd097d04a7b2c1eb7533b8f6e045764e20d15967", - "models": { - "service.html.view.json": { - "_disableContribution": true, - "_disableNextArticle": true, - "_disableSearch": true, - "_disableToc": true, - "_jsonPath": "service.swagger.json", - "_key": "TestData/compatibility/service.swagger.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "service.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "children": [ - { - "conceptual": "", - "deprecated": true, - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", - "operation": "DELETE", - "operationId": "deleteItem", - "parameters": [ - { - "description": "

Item ID.

\n", - "in": "path", - "name": "id", - "required": true, - "type": "string" - } - ], - "path": "/items/{id}", - "remarks": "", - "responses": [ - { - "description": "

No content.

\n", - "statusCode": "204" - } - ], - "security": [], - "source": null, - "sourceurl": "", - "summary": "

Delete an item.

\n", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - } - ], - "conceptual": "\n

Document-level conceptual content.

\n", - "consumes": [ - "application/json" - ], - "definitions": [ - { - "cType": "Item", - "cTypeId": "Item", - "cTypeIsArray": true, - "items": { - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - }, - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "array", - "x-internal-ref-name": "Item" - }, - { - "cType": "Error", - "cTypeId": "Error", - "properties": [ - { - "key": "message", - "value": { - "description": "

Error details.

\n", - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Error" - } - ], - "description": "

Use the API guide.

\n", - "documentType": "RestApi", - "docurl": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0", - "isTagLayout": true, - "meta": "Compatibility metadata", - "name": "Compatibility API", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "sourceurl": "", - "summary": "

Updated API summary.

\n", - "swagger": "2.0", - "tags": [ - { - "children": [ - { - "conceptual": "", - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", - "includedInTags": true, - "operation": "GET", - "operationId": "listItems", - "parameters": [ - { - "default": "2.0", - "description": "

An optional version.

\n", - "in": "query", - "name": "api-version", - "required": false, - "type": "string" - }, - { - "default": 0, - "description": "

Maximum number of items.

\n", - "in": "query", - "minimum": 0, - "name": "limit", - "type": "integer" - } - ], - "path": "/items[?api-version&limit]", - "remarks": "", - "responses": [ - { - "description": "

The items.

\n", - "examples": [ - { - "content": "[\n {\n \"id\": \"one\",\n \"name\": \"First\",\n \"$ref\": \"literal-example\"\n }\n]", - "mimeType": "application/json" - }, - { - "content": "\"one\"", - "mimeType": "text/plain" - } - ], - "headers": { - "X-Count": { - "description": "

Total count.

\n", - "type": "integer" - } - }, - "schema": { - "cType": "Item", - "cTypeId": "Item", - "cTypeIsArray": true, - "items": { - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - }, - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "type": "array" - }, - "statusCode": "200" - }, - { - "description": "

A request error.

\n", - "schema": { - "cType": "Error", - "cTypeId": "Error", - "properties": [ - { - "key": "message", - "value": { - "description": "

Error details.

\n", - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Error" - }, - "statusCode": "default", - "x-internal-ref-name": "Error" - } - ], - "source": null, - "sourceurl": "", - "summary": "

List items.

\n", - "tags": [ - "items" - ], - "uid": "api.example.test/v1/Compatibility API/1.0/listItems" - }, - { - "conceptual": "\n

Operation-level conceptual content.

\n", - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", - "includedInTags": true, - "operation": "POST", - "operationId": "createItem", - "parameters": [ - { - "description": "

Updated body description.

\n", - "in": "body", - "name": "body", - "required": true, - "schema": { - "cType": "Item", - "cTypeId": "Item", - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "Updated name description.", - "required": true, - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "description": null, - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Item" - } - }, - { - "default": "1.0", - "description": "

The API version.

\n", - "in": "query", - "name": "api-version", - "required": true, - "type": "string", - "x-internal-ref-name": "Version" - } - ], - "path": "/items?api-version", - "remarks": "", - "responses": [ - { - "description": "

Created.

\n", - "examples": [ - { - "content": "{\n \"id\": \"one\",\n \"name\": \"First\"\n}", - "mimeType": "application/json" - } - ], - "schema": { - "cType": "Item", - "cTypeId": "Item", - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "required": true, - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "description": null, - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "statusCode": "201" - } - ], - "source": null, - "sourceurl": "", - "summary": "

Updated create summary.

\n", - "tags": [ - "items" - ], - "uid": "api.example.test/v1/Compatibility API/1.0/createItem" - } - ], - "conceptual": "

Tag-level conceptual content.

\n", - "description": "

Updated items tag.

\n", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_tag_items", - "name": "items", - "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" - } - ], - "title": "Compatibility API", - "uid": "api.example.test/v1/Compatibility API/1.0", - "x-owner": { - "team": "documentation" - } - }, - "service.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_key": "TestData/compatibility/service.swagger.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "service.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "children": [ - { - "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", - "operation": "get", - "operationId": "listItems", - "parameters": [ - { - "default": "2.0", - "description": "

An optional version.

\n", - "in": "query", - "name": "api-version", - "required": false, - "type": "string" - }, - { - "default": 0, - "description": "

Maximum number of items.

\n", - "in": "query", - "minimum": 0, - "name": "limit", - "type": "integer" - } - ], - "path": "/items", - "responses": [ - { - "description": "

The items.

\n", - "examples": [ - { - "content": "[{\"id\":\"one\",\"name\":\"First\",\"$ref\":\"literal-example\"}]", - "mimeType": "application/json" - }, - { - "content": "\"one\"", - "mimeType": "text/plain" - } - ], - "headers": { - "X-Count": { - "description": "

Total count.

\n", - "type": "integer" - } - }, - "schema": { - "items": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - }, - "type": "array" - }, - "statusCode": "200" - }, - { - "description": "

A request error.

\n", - "schema": { - "properties": { - "message": { - "description": "

Error details.

\n", - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Error" - }, - "statusCode": "default", - "x-internal-ref-name": "Error" - } - ], - "source": null, - "summary": "

List items.

\n", - "tags": [ - "items" - ], - "uid": "api.example.test/v1/Compatibility API/1.0/listItems" - }, - { - "conceptual": "\n

Operation-level conceptual content.

\n", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", - "operation": "post", - "operationId": "createItem", - "parameters": [ - { - "description": "

Updated body description.

\n", - "in": "body", - "name": "body", - "required": true, - "schema": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "Updated name description.", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - } - }, - { - "default": "1.0", - "description": "

The API version.

\n", - "in": "query", - "name": "api-version", - "required": true, - "type": "string", - "x-internal-ref-name": "Version" - } - ], - "path": "/items", - "responses": [ - { - "description": "

Created.

\n", - "examples": [ - { - "content": "{\"id\":\"one\",\"name\":\"First\"}", - "mimeType": "application/json" - } - ], - "schema": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - }, - "statusCode": "201" - } - ], - "source": null, - "summary": "

Updated create summary.

\n", - "tags": [ - "items" - ], - "uid": "api.example.test/v1/Compatibility API/1.0/createItem" - }, - { - "deprecated": true, - "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", - "operation": "delete", - "operationId": "deleteItem", - "parameters": [ - { - "description": "

Item ID.

\n", - "in": "path", - "name": "id", - "required": true, - "type": "string" - } - ], - "path": "/items/{id}", - "responses": [ - { - "description": "

No content.

\n", - "statusCode": "204" - } - ], - "security": [], - "source": null, - "summary": "

Delete an item.

\n", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - } - ], - "conceptual": "\n

Document-level conceptual content.

\n", - "consumes": [ - "application/json" - ], - "description": "

Use the API guide.

\n", - "documentType": "RestApi", - "htmlId": "api_example_test_v1_Compatibility_API_1_0", - "meta": "Compatibility metadata", - "name": "Compatibility API", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "summary": "

Updated API summary.

\n", - "swagger": "2.0", - "tags": [ - { - "conceptual": "

Tag-level conceptual content.

\n", - "description": "

Updated items tag.

\n", - "htmlId": "items-tag", - "name": "items", - "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" - } - ], - "uid": "api.example.test/v1/Compatibility API/1.0", - "x-owner": { - "team": "documentation" - } - }, - "toc.html.view.json": { - "_disableContribution": true, - "_disableSearch": true, - "_disableToc": true, - "_key": "TestData/compatibility/toc.yml", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "toc.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "items": [ - { - "href": "service.html", - "items": [], - "leaf": true, - "level": 2, - "name": "Compatibility API", - "tocHref": null, - "topicHref": "service.html" - } - ], - "leaf": false, - "level": 1, - "meta": "Compatibility metadata", - "name": null, - "title": "Table of Content", - "tocHref": null, - "topicHref": null - }, - "toc.json.view.json": { - "content": "{\"items\":[{\"name\":\"Compatibility API\",\"href\":\"service.html\",\"topicHref\":\"service.html\"}],\"meta\":\"Compatibility metadata\"}" - }, - "toc.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_key": "TestData/compatibility/toc.yml", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "toc.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "items": [ - { - "href": "service.html", - "name": "Compatibility API", - "topicHref": "service.html" - } - ], - "meta": "Compatibility metadata" - } - }, - "html": { - "service.html": "

Compatibility API

Updated API summary.

Use the API guide.

Document-level conceptual content.

items

Updated items tag.

Tag-level conceptual content.

listItems

List items.

Request
GET /items[?api-version&limit]
Parameters
NameTypeDefaultNotes
api-version2.0

An optional version.

limit0

Maximum number of items.

Responses
Status CodeTypeDescriptionSamples
200Item[]

The items.

Mime type: application/json
[\n  {\n    "id": "one",\n    "name": "First",\n    "$ref": "literal-example"\n  }\n]
Mime type: text/plain
"one"
defaultError

A request error.

createItem

Updated create summary.

Operation-level conceptual content.

Request
POST /items?api-version
Parameters
NameTypeDefaultNotes
*bodyItem

Updated body description.

*api-version1.0

The API version.

Responses
Status CodeTypeDescriptionSamples
201Item

Created.

Mime type: application/json
{\n  "id": "one",\n  "name": "First"\n}

Other APIs

deleteItem

Delete an item.

Request
DELETE /items/{id}
Parameters
NameTypeDefaultNotes
*id

Item ID.

Responses
Status CodeTypeDescriptionSamples
204

No content.

Definitions

Item

Use the API guide.

NameTypeNotes
id string

The item identifier.

name string

The display name.

state string

Error

Use the API guide.

NameTypeNotes
message string

Error details.

" - }, - "xrefs": [ - { - "href": "service.html", - "name": "Compatibility API", - "uid": "api.example.test/v1/Compatibility API/1.0" - }, - { - "href": "service.html#api_example_test_v1_Compatibility_API_1_0_createItem", - "name": "createItem", - "uid": "api.example.test/v1/Compatibility API/1.0/createItem" - }, - { - "href": "service.html#api_example_test_v1_Compatibility_API_1_0_deleteItem", - "name": "deleteItem", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - }, - { - "href": "service.html#api_example_test_v1_Compatibility_API_1_0_listItems", - "name": "listItems", - "uid": "api.example.test/v1/Compatibility API/1.0/listItems" - }, - { - "href": "service.html#api_example_test_v1_Compatibility_API_1_0_tag_items", - "name": "items", - "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" - } - ], - "manifest": [ - { - "output": { - ".html": { - "relative_path": "service.html" - } - }, - "source_relative_path": "TestData/compatibility/service.swagger.json", - "type": "RestApi" - }, - { - "output": { - ".html": { - "relative_path": "toc.html" - }, - ".json": { - "relative_path": "toc.json" - } - }, - "source_relative_path": "TestData/compatibility/toc.yml", - "type": "Toc" - } - ] -} \ No newline at end of file diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-True-overwrite-False.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-True-overwrite-False.json deleted file mode 100644 index a0ebe461b50..00000000000 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-False-operations-True-overwrite-False.json +++ /dev/null @@ -1,1277 +0,0 @@ -{ - "baselineCommit": "bd097d04a7b2c1eb7533b8f6e045764e20d15967", - "models": { - "service.html.view.json": { - "_disableContribution": true, - "_disableNextArticle": true, - "_disableSearch": true, - "_disableToc": true, - "_isSplittedByOperation": true, - "_jsonPath": "service.swagger.json", - "_key": "TestData/compatibility/service.swagger.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "service.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "children": [], - "consumes": [ - "application/json" - ], - "definitions": [], - "description": "

Use the API guide.

\n", - "documentType": "RestApi", - "docurl": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0", - "meta": "Compatibility metadata", - "name": "Compatibility API", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "sourceurl": "", - "summary": "

A stable API.

\n", - "swagger": "2.0", - "tags": [], - "title": "Compatibility API", - "uid": "api.example.test/v1/Compatibility API/1.0", - "x-owner": { - "team": "documentation" - } - }, - "service.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_isSplittedByOperation": true, - "_key": "TestData/compatibility/service.swagger.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "service.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "children": [], - "consumes": [ - "application/json" - ], - "description": "

Use the API guide.

\n", - "documentType": "RestApi", - "htmlId": "api_example_test_v1_Compatibility_API_1_0", - "meta": "Compatibility metadata", - "name": "Compatibility API", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "summary": "

A stable API.

\n", - "swagger": "2.0", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0", - "x-owner": { - "team": "documentation" - } - }, - "service/createItem.html.view.json": { - "_disableContribution": true, - "_disableNextArticle": true, - "_disableSearch": true, - "_disableToc": true, - "_isSplittedToOperation": true, - "_jsonPath": "createItem.swagger.json", - "_key": "TestData/compatibility/service/createItem.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "../toc.html", - "_path": "service/createItem.html", - "_rel": "../", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "../toc.html", - "children": [ - { - "conceptual": "", - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem_operation", - "operation": "POST", - "operationId": "createItem", - "parameters": [ - { - "description": "

The new item.

\n", - "in": "body", - "name": "body", - "required": true, - "schema": { - "cType": "Item", - "cTypeId": "Item", - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "required": true, - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "description": null, - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Item" - } - }, - { - "default": "1.0", - "description": "

The API version.

\n", - "in": "query", - "name": "api-version", - "required": true, - "type": "string", - "x-internal-ref-name": "Version" - } - ], - "path": "/items?api-version", - "remarks": "", - "responses": [ - { - "description": "

Created.

\n", - "examples": [ - { - "content": "{\n \"id\": \"one\",\n \"name\": \"First\"\n}", - "mimeType": "application/json" - } - ], - "schema": { - "cType": "Item", - "cTypeId": "Item", - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "required": true, - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "description": null, - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "statusCode": "201" - } - ], - "source": null, - "sourceurl": "", - "summary": "", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/createItem/operation" - } - ], - "consumes": [ - "application/json" - ], - "definitions": [ - { - "cType": "Item", - "cTypeId": "Item", - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "required": true, - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "description": null, - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Item" - } - ], - "documentType": "RestApi", - "docurl": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", - "meta": "Compatibility metadata", - "name": "createItem", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "source": null, - "sourceurl": "", - "summary": "

Create an item.

\n", - "swagger": "2.0", - "tags": [], - "title": "createItem", - "uid": "api.example.test/v1/Compatibility API/1.0/createItem", - "x-owner": { - "team": "documentation" - } - }, - "service/createItem.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_isSplittedToOperation": true, - "_key": "TestData/compatibility/service/createItem.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "../toc.html", - "_path": "service/createItem.html", - "_rel": "../", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "../toc.html", - "children": [ - { - "operation": "post", - "operationId": "createItem", - "parameters": [ - { - "description": "

The new item.

\n", - "in": "body", - "name": "body", - "required": true, - "schema": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - } - }, - { - "default": "1.0", - "description": "

The API version.

\n", - "in": "query", - "name": "api-version", - "required": true, - "type": "string", - "x-internal-ref-name": "Version" - } - ], - "path": "/items", - "responses": [ - { - "description": "

Created.

\n", - "examples": [ - { - "content": "{\"id\":\"one\",\"name\":\"First\"}", - "mimeType": "application/json" - } - ], - "schema": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - }, - "statusCode": "201" - } - ], - "source": null, - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/createItem/operation" - } - ], - "consumes": [ - "application/json" - ], - "documentType": "RestApi", - "meta": "Compatibility metadata", - "name": "createItem", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "source": null, - "summary": "

Create an item.

\n", - "swagger": "2.0", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/createItem", - "x-owner": { - "team": "documentation" - } - }, - "service/deleteItem.html.view.json": { - "_disableContribution": true, - "_disableNextArticle": true, - "_disableSearch": true, - "_disableToc": true, - "_isSplittedToOperation": true, - "_jsonPath": "deleteItem.swagger.json", - "_key": "TestData/compatibility/service/deleteItem.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "../toc.html", - "_path": "service/deleteItem.html", - "_rel": "../", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "../toc.html", - "children": [ - { - "conceptual": "", - "deprecated": true, - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem_operation", - "operation": "DELETE", - "operationId": "deleteItem", - "parameters": [ - { - "description": "

Item ID.

\n", - "in": "path", - "name": "id", - "required": true, - "type": "string" - } - ], - "path": "/items/{id}", - "remarks": "", - "responses": [ - { - "description": "

No content.

\n", - "statusCode": "204" - } - ], - "security": [], - "source": null, - "sourceurl": "", - "summary": "", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem/operation" - } - ], - "consumes": [ - "application/json" - ], - "definitions": [], - "deprecated": true, - "documentType": "RestApi", - "docurl": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", - "meta": "Compatibility metadata", - "name": "deleteItem", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "source": null, - "sourceurl": "", - "summary": "

Delete an item.

\n", - "swagger": "2.0", - "tags": [], - "title": "deleteItem", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem", - "x-owner": { - "team": "documentation" - } - }, - "service/deleteItem.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_isSplittedToOperation": true, - "_key": "TestData/compatibility/service/deleteItem.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "../toc.html", - "_path": "service/deleteItem.html", - "_rel": "../", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "../toc.html", - "children": [ - { - "deprecated": true, - "operation": "delete", - "operationId": "deleteItem", - "parameters": [ - { - "description": "

Item ID.

\n", - "in": "path", - "name": "id", - "required": true, - "type": "string" - } - ], - "path": "/items/{id}", - "responses": [ - { - "description": "

No content.

\n", - "statusCode": "204" - } - ], - "security": [], - "source": null, - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem/operation" - } - ], - "consumes": [ - "application/json" - ], - "deprecated": true, - "documentType": "RestApi", - "meta": "Compatibility metadata", - "name": "deleteItem", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "source": null, - "summary": "

Delete an item.

\n", - "swagger": "2.0", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem", - "x-owner": { - "team": "documentation" - } - }, - "service/listItems.html.view.json": { - "_disableContribution": true, - "_disableNextArticle": true, - "_disableSearch": true, - "_disableToc": true, - "_isSplittedToOperation": true, - "_jsonPath": "listItems.swagger.json", - "_key": "TestData/compatibility/service/listItems.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "../toc.html", - "_path": "service/listItems.html", - "_rel": "../", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "../toc.html", - "children": [ - { - "conceptual": "", - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems_operation", - "operation": "GET", - "operationId": "listItems", - "parameters": [ - { - "default": "2.0", - "description": "

An optional version.

\n", - "in": "query", - "name": "api-version", - "required": false, - "type": "string" - }, - { - "default": 0, - "description": "

Maximum number of items.

\n", - "in": "query", - "minimum": 0, - "name": "limit", - "type": "integer" - } - ], - "path": "/items[?api-version&limit]", - "remarks": "", - "responses": [ - { - "description": "

The items.

\n", - "examples": [ - { - "content": "[\n {\n \"id\": \"one\",\n \"name\": \"First\",\n \"$ref\": \"literal-example\"\n }\n]", - "mimeType": "application/json" - }, - { - "content": "\"one\"", - "mimeType": "text/plain" - } - ], - "headers": { - "X-Count": { - "description": "

Total count.

\n", - "type": "integer" - } - }, - "schema": { - "cType": "Item", - "cTypeId": "Item", - "cTypeIsArray": true, - "items": { - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - }, - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "type": "array" - }, - "statusCode": "200" - }, - { - "description": "

A request error.

\n", - "schema": { - "cType": "Error", - "cTypeId": "Error", - "properties": [ - { - "key": "message", - "value": { - "description": "

Error details.

\n", - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Error" - }, - "statusCode": "default", - "x-internal-ref-name": "Error" - } - ], - "source": null, - "sourceurl": "", - "summary": "", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/listItems/operation" - } - ], - "consumes": [ - "application/json" - ], - "definitions": [ - { - "cType": "Item", - "cTypeId": "Item", - "cTypeIsArray": true, - "items": { - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - }, - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "array", - "x-internal-ref-name": "Item" - }, - { - "cType": "Error", - "cTypeId": "Error", - "properties": [ - { - "key": "message", - "value": { - "description": "

Error details.

\n", - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Error" - } - ], - "documentType": "RestApi", - "docurl": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", - "meta": "Compatibility metadata", - "name": "listItems", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "source": null, - "sourceurl": "", - "summary": "

List items.

\n", - "swagger": "2.0", - "tags": [], - "title": "listItems", - "uid": "api.example.test/v1/Compatibility API/1.0/listItems", - "x-owner": { - "team": "documentation" - } - }, - "service/listItems.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_isSplittedToOperation": true, - "_key": "TestData/compatibility/service/listItems.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "../toc.html", - "_path": "service/listItems.html", - "_rel": "../", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "../toc.html", - "children": [ - { - "operation": "get", - "operationId": "listItems", - "parameters": [ - { - "default": "2.0", - "description": "

An optional version.

\n", - "in": "query", - "name": "api-version", - "required": false, - "type": "string" - }, - { - "default": 0, - "description": "

Maximum number of items.

\n", - "in": "query", - "minimum": 0, - "name": "limit", - "type": "integer" - } - ], - "path": "/items", - "responses": [ - { - "description": "

The items.

\n", - "examples": [ - { - "content": "[{\"id\":\"one\",\"name\":\"First\",\"$ref\":\"literal-example\"}]", - "mimeType": "application/json" - }, - { - "content": "\"one\"", - "mimeType": "text/plain" - } - ], - "headers": { - "X-Count": { - "description": "

Total count.

\n", - "type": "integer" - } - }, - "schema": { - "items": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - }, - "type": "array" - }, - "statusCode": "200" - }, - { - "description": "

A request error.

\n", - "schema": { - "properties": { - "message": { - "description": "

Error details.

\n", - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Error" - }, - "statusCode": "default", - "x-internal-ref-name": "Error" - } - ], - "source": null, - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/listItems/operation" - } - ], - "consumes": [ - "application/json" - ], - "documentType": "RestApi", - "meta": "Compatibility metadata", - "name": "listItems", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "source": null, - "summary": "

List items.

\n", - "swagger": "2.0", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/listItems", - "x-owner": { - "team": "documentation" - } - }, - "toc.html.view.json": { - "_disableContribution": true, - "_disableSearch": true, - "_disableToc": true, - "_key": "TestData/compatibility/toc.yml", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "toc.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "items": [ - { - "href": "service.html", - "items": [ - { - "href": "service/createItem.html", - "items": [], - "leaf": true, - "level": 3, - "name": "createItem", - "tocHref": null, - "topicHref": "service/createItem.html", - "topicUid": "api.example.test/v1/Compatibility API/1.0/createItem" - }, - { - "href": "service/deleteItem.html", - "items": [], - "leaf": true, - "level": 3, - "name": "deleteItem", - "tocHref": null, - "topicHref": "service/deleteItem.html", - "topicUid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - }, - { - "href": "service/listItems.html", - "items": [], - "leaf": true, - "level": 3, - "name": "listItems", - "tocHref": null, - "topicHref": "service/listItems.html", - "topicUid": "api.example.test/v1/Compatibility API/1.0/listItems" - } - ], - "level": 2, - "name": "Compatibility API", - "tocHref": null, - "topicHref": "service.html" - } - ], - "leaf": false, - "level": 1, - "meta": "Compatibility metadata", - "name": null, - "title": "Table of Content", - "tocHref": null, - "topicHref": null - }, - "toc.json.view.json": { - "content": "{\"items\":[{\"name\":\"Compatibility API\",\"href\":\"service.html\",\"topicHref\":\"service.html\",\"items\":[{\"name\":\"createItem\",\"href\":\"service/createItem.html\",\"topicHref\":\"service/createItem.html\",\"topicUid\":\"api.example.test/v1/Compatibility API/1.0/createItem\"},{\"name\":\"deleteItem\",\"href\":\"service/deleteItem.html\",\"topicHref\":\"service/deleteItem.html\",\"topicUid\":\"api.example.test/v1/Compatibility API/1.0/deleteItem\"},{\"name\":\"listItems\",\"href\":\"service/listItems.html\",\"topicHref\":\"service/listItems.html\",\"topicUid\":\"api.example.test/v1/Compatibility API/1.0/listItems\"}]}],\"meta\":\"Compatibility metadata\"}" - }, - "toc.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_key": "TestData/compatibility/toc.yml", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "toc.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "items": [ - { - "href": "service.html", - "items": [ - { - "href": "service/createItem.html", - "name": "createItem", - "topicHref": "service/createItem.html", - "topicUid": "api.example.test/v1/Compatibility API/1.0/createItem" - }, - { - "href": "service/deleteItem.html", - "name": "deleteItem", - "topicHref": "service/deleteItem.html", - "topicUid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - }, - { - "href": "service/listItems.html", - "name": "listItems", - "topicHref": "service/listItems.html", - "topicUid": "api.example.test/v1/Compatibility API/1.0/listItems" - } - ], - "name": "Compatibility API", - "topicHref": "service.html" - } - ], - "meta": "Compatibility metadata" - } - }, - "html": { - "service.html": "

Compatibility API

A stable API.

Use the API guide.

", - "service/createItem.html": "

createItem

Create an item.

createItem

Request
POST /items?api-version
Parameters
NameTypeDefaultNotes
*bodyItem

The new item.

*api-version1.0

The API version.

Responses
Status CodeTypeDescriptionSamples
201Item

Created.

Mime type: application/json
{\n  "id": "one",\n  "name": "First"\n}

Definitions

Item

NameTypeNotes
id string

The item identifier.

name string

The display name.

state string
", - "service/deleteItem.html": "

deleteItem

Delete an item.

deleteItem

Request
DELETE /items/{id}
Parameters
NameTypeDefaultNotes
*id

Item ID.

Responses
Status CodeTypeDescriptionSamples
204

No content.

", - "service/listItems.html": "

listItems

List items.

listItems

Request
GET /items[?api-version&limit]
Parameters
NameTypeDefaultNotes
api-version2.0

An optional version.

limit0

Maximum number of items.

Responses
Status CodeTypeDescriptionSamples
200Item[]

The items.

Mime type: application/json
[\n  {\n    "id": "one",\n    "name": "First",\n    "$ref": "literal-example"\n  }\n]
Mime type: text/plain
"one"
defaultError

A request error.

Definitions

Item

NameTypeNotes
id string

The item identifier.

name string

The display name.

state string

Error

NameTypeNotes
message string

Error details.

" - }, - "xrefs": [ - { - "href": "service.html", - "name": "Compatibility API", - "uid": "api.example.test/v1/Compatibility API/1.0" - }, - { - "href": "service/createItem.html", - "name": "createItem", - "uid": "api.example.test/v1/Compatibility API/1.0/createItem" - }, - { - "href": "service/createItem.html#api_example_test_v1_Compatibility_API_1_0_createItem_operation", - "name": "createItem", - "uid": "api.example.test/v1/Compatibility API/1.0/createItem/operation" - }, - { - "href": "service/deleteItem.html", - "name": "deleteItem", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - }, - { - "href": "service/deleteItem.html#api_example_test_v1_Compatibility_API_1_0_deleteItem_operation", - "name": "deleteItem", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem/operation" - }, - { - "href": "service/listItems.html", - "name": "listItems", - "uid": "api.example.test/v1/Compatibility API/1.0/listItems" - }, - { - "href": "service/listItems.html#api_example_test_v1_Compatibility_API_1_0_listItems_operation", - "name": "listItems", - "uid": "api.example.test/v1/Compatibility API/1.0/listItems/operation" - } - ], - "manifest": [ - { - "output": { - ".html": { - "relative_path": "service.html" - } - }, - "source_relative_path": "TestData/compatibility/service.swagger.json", - "type": "RestApi" - }, - { - "output": { - ".html": { - "relative_path": "service/createItem.html" - } - }, - "source_relative_path": "TestData/compatibility/service.swagger.json", - "type": "RestApi" - }, - { - "output": { - ".html": { - "relative_path": "service/deleteItem.html" - } - }, - "source_relative_path": "TestData/compatibility/service.swagger.json", - "type": "RestApi" - }, - { - "output": { - ".html": { - "relative_path": "service/listItems.html" - } - }, - "source_relative_path": "TestData/compatibility/service.swagger.json", - "type": "RestApi" - }, - { - "output": { - ".html": { - "relative_path": "toc.html" - }, - ".json": { - "relative_path": "toc.json" - } - }, - "source_relative_path": "TestData/compatibility/toc.yml", - "type": "Toc" - } - ] -} \ No newline at end of file diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-True-operations-False-overwrite-False.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-True-operations-False-overwrite-False.json deleted file mode 100644 index dfe4c98f289..00000000000 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-True-operations-False-overwrite-False.json +++ /dev/null @@ -1,968 +0,0 @@ -{ - "baselineCommit": "bd097d04a7b2c1eb7533b8f6e045764e20d15967", - "models": { - "service.html.view.json": { - "_disableContribution": true, - "_disableNextArticle": true, - "_disableSearch": true, - "_disableToc": true, - "_isSplittedByTag": true, - "_jsonPath": "service.swagger.json", - "_key": "TestData/compatibility/service.swagger.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "service.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "children": [ - { - "conceptual": "", - "deprecated": true, - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", - "operation": "DELETE", - "operationId": "deleteItem", - "parameters": [ - { - "description": "

Item ID.

\n", - "in": "path", - "name": "id", - "required": true, - "type": "string" - } - ], - "path": "/items/{id}", - "remarks": "", - "responses": [ - { - "description": "

No content.

\n", - "statusCode": "204" - } - ], - "security": [], - "source": null, - "sourceurl": "", - "summary": "

Delete an item.

\n", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - } - ], - "consumes": [ - "application/json" - ], - "definitions": [], - "description": "

Use the API guide.

\n", - "documentType": "RestApi", - "docurl": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0", - "meta": "Compatibility metadata", - "name": "Compatibility API", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "sourceurl": "", - "summary": "

A stable API.

\n", - "swagger": "2.0", - "tags": [], - "title": "Compatibility API", - "uid": "api.example.test/v1/Compatibility API/1.0", - "x-owner": { - "team": "documentation" - } - }, - "service.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_isSplittedByTag": true, - "_key": "TestData/compatibility/service.swagger.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "service.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "children": [ - { - "deprecated": true, - "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", - "operation": "delete", - "operationId": "deleteItem", - "parameters": [ - { - "description": "

Item ID.

\n", - "in": "path", - "name": "id", - "required": true, - "type": "string" - } - ], - "path": "/items/{id}", - "responses": [ - { - "description": "

No content.

\n", - "statusCode": "204" - } - ], - "security": [], - "source": null, - "summary": "

Delete an item.

\n", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - } - ], - "consumes": [ - "application/json" - ], - "description": "

Use the API guide.

\n", - "documentType": "RestApi", - "htmlId": "api_example_test_v1_Compatibility_API_1_0", - "meta": "Compatibility metadata", - "name": "Compatibility API", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "summary": "

A stable API.

\n", - "swagger": "2.0", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0", - "x-owner": { - "team": "documentation" - } - }, - "service/items.html.view.json": { - "_disableContribution": true, - "_disableNextArticle": true, - "_disableSearch": true, - "_disableToc": true, - "_isSplittedToTag": true, - "_jsonPath": "items.swagger.json", - "_key": "TestData/compatibility/service/items.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "../toc.html", - "_path": "service/items.html", - "_rel": "../", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "../toc.html", - "children": [ - { - "conceptual": "", - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", - "operation": "GET", - "operationId": "listItems", - "parameters": [ - { - "default": "2.0", - "description": "

An optional version.

\n", - "in": "query", - "name": "api-version", - "required": false, - "type": "string" - }, - { - "default": 0, - "description": "

Maximum number of items.

\n", - "in": "query", - "minimum": 0, - "name": "limit", - "type": "integer" - } - ], - "path": "/items[?api-version&limit]", - "remarks": "", - "responses": [ - { - "description": "

The items.

\n", - "examples": [ - { - "content": "[\n {\n \"id\": \"one\",\n \"name\": \"First\",\n \"$ref\": \"literal-example\"\n }\n]", - "mimeType": "application/json" - }, - { - "content": "\"one\"", - "mimeType": "text/plain" - } - ], - "headers": { - "X-Count": { - "description": "

Total count.

\n", - "type": "integer" - } - }, - "schema": { - "cType": "Item", - "cTypeId": "Item", - "cTypeIsArray": true, - "items": { - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - }, - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "type": "array" - }, - "statusCode": "200" - }, - { - "description": "

A request error.

\n", - "schema": { - "cType": "Error", - "cTypeId": "Error", - "properties": [ - { - "key": "message", - "value": { - "description": "

Error details.

\n", - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Error" - }, - "statusCode": "default", - "x-internal-ref-name": "Error" - } - ], - "source": null, - "sourceurl": "", - "summary": "

List items.

\n", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/listItems" - }, - { - "conceptual": "", - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", - "operation": "POST", - "operationId": "createItem", - "parameters": [ - { - "description": "

The new item.

\n", - "in": "body", - "name": "body", - "required": true, - "schema": { - "cType": "Item", - "cTypeId": "Item", - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "required": true, - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "description": null, - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Item" - } - }, - { - "default": "1.0", - "description": "

The API version.

\n", - "in": "query", - "name": "api-version", - "required": true, - "type": "string", - "x-internal-ref-name": "Version" - } - ], - "path": "/items?api-version", - "remarks": "", - "responses": [ - { - "description": "

Created.

\n", - "examples": [ - { - "content": "{\n \"id\": \"one\",\n \"name\": \"First\"\n}", - "mimeType": "application/json" - } - ], - "schema": { - "cType": "Item", - "cTypeId": "Item", - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "required": true, - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "description": null, - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "statusCode": "201" - } - ], - "source": null, - "sourceurl": "", - "summary": "

Create an item.

\n", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/createItem" - } - ], - "consumes": [ - "application/json" - ], - "definitions": [ - { - "cType": "Item", - "cTypeId": "Item", - "cTypeIsArray": true, - "items": { - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - }, - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "array", - "x-internal-ref-name": "Item" - }, - { - "cType": "Error", - "cTypeId": "Error", - "properties": [ - { - "key": "message", - "value": { - "description": "

Error details.

\n", - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Error" - } - ], - "description": "

Manage items.

\n", - "documentType": "RestApi", - "docurl": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_tag_items", - "meta": "Compatibility metadata", - "name": "items", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "sourceurl": "", - "swagger": "2.0", - "tags": [], - "title": "items", - "uid": "api.example.test/v1/Compatibility API/1.0/tag/items", - "x-owner": { - "team": "documentation" - } - }, - "service/items.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_isSplittedToTag": true, - "_key": "TestData/compatibility/service/items.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "../toc.html", - "_path": "service/items.html", - "_rel": "../", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "../toc.html", - "children": [ - { - "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", - "operation": "get", - "operationId": "listItems", - "parameters": [ - { - "default": "2.0", - "description": "

An optional version.

\n", - "in": "query", - "name": "api-version", - "required": false, - "type": "string" - }, - { - "default": 0, - "description": "

Maximum number of items.

\n", - "in": "query", - "minimum": 0, - "name": "limit", - "type": "integer" - } - ], - "path": "/items", - "responses": [ - { - "description": "

The items.

\n", - "examples": [ - { - "content": "[{\"id\":\"one\",\"name\":\"First\",\"$ref\":\"literal-example\"}]", - "mimeType": "application/json" - }, - { - "content": "\"one\"", - "mimeType": "text/plain" - } - ], - "headers": { - "X-Count": { - "description": "

Total count.

\n", - "type": "integer" - } - }, - "schema": { - "items": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - }, - "type": "array" - }, - "statusCode": "200" - }, - { - "description": "

A request error.

\n", - "schema": { - "properties": { - "message": { - "description": "

Error details.

\n", - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Error" - }, - "statusCode": "default", - "x-internal-ref-name": "Error" - } - ], - "source": null, - "summary": "

List items.

\n", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/listItems" - }, - { - "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", - "operation": "post", - "operationId": "createItem", - "parameters": [ - { - "description": "

The new item.

\n", - "in": "body", - "name": "body", - "required": true, - "schema": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - } - }, - { - "default": "1.0", - "description": "

The API version.

\n", - "in": "query", - "name": "api-version", - "required": true, - "type": "string", - "x-internal-ref-name": "Version" - } - ], - "path": "/items", - "responses": [ - { - "description": "

Created.

\n", - "examples": [ - { - "content": "{\"id\":\"one\",\"name\":\"First\"}", - "mimeType": "application/json" - } - ], - "schema": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - }, - "statusCode": "201" - } - ], - "source": null, - "summary": "

Create an item.

\n", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/createItem" - } - ], - "consumes": [ - "application/json" - ], - "description": "

Manage items.

\n", - "documentType": "RestApi", - "htmlId": "items-tag", - "meta": "Compatibility metadata", - "name": "items", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "swagger": "2.0", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/tag/items", - "x-owner": { - "team": "documentation" - } - }, - "toc.html.view.json": { - "_disableContribution": true, - "_disableSearch": true, - "_disableToc": true, - "_key": "TestData/compatibility/toc.yml", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "toc.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "items": [ - { - "href": "service.html", - "items": [ - { - "href": "service/items.html", - "items": [], - "leaf": true, - "level": 3, - "name": "items", - "tocHref": null, - "topicHref": "service/items.html", - "topicUid": "api.example.test/v1/Compatibility API/1.0/tag/items" - } - ], - "level": 2, - "name": "Compatibility API", - "tocHref": null, - "topicHref": "service.html" - } - ], - "leaf": false, - "level": 1, - "meta": "Compatibility metadata", - "name": null, - "title": "Table of Content", - "tocHref": null, - "topicHref": null - }, - "toc.json.view.json": { - "content": "{\"items\":[{\"name\":\"Compatibility API\",\"href\":\"service.html\",\"topicHref\":\"service.html\",\"items\":[{\"name\":\"items\",\"href\":\"service/items.html\",\"topicHref\":\"service/items.html\",\"topicUid\":\"api.example.test/v1/Compatibility API/1.0/tag/items\"}]}],\"meta\":\"Compatibility metadata\"}" - }, - "toc.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_key": "TestData/compatibility/toc.yml", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "toc.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "items": [ - { - "href": "service.html", - "items": [ - { - "href": "service/items.html", - "name": "items", - "topicHref": "service/items.html", - "topicUid": "api.example.test/v1/Compatibility API/1.0/tag/items" - } - ], - "name": "Compatibility API", - "topicHref": "service.html" - } - ], - "meta": "Compatibility metadata" - } - }, - "html": { - "service.html": "

Compatibility API

A stable API.

Use the API guide.

deleteItem

Delete an item.

Request
DELETE /items/{id}
Parameters
NameTypeDefaultNotes
*id

Item ID.

Responses
Status CodeTypeDescriptionSamples
204

No content.

", - "service/items.html": "

items

Manage items.

listItems

List items.

Request
GET /items[?api-version&limit]
Parameters
NameTypeDefaultNotes
api-version2.0

An optional version.

limit0

Maximum number of items.

Responses
Status CodeTypeDescriptionSamples
200Item[]

The items.

Mime type: application/json
[\n  {\n    "id": "one",\n    "name": "First",\n    "$ref": "literal-example"\n  }\n]
Mime type: text/plain
"one"
defaultError

A request error.

createItem

Create an item.

Request
POST /items?api-version
Parameters
NameTypeDefaultNotes
*bodyItem

The new item.

*api-version1.0

The API version.

Responses
Status CodeTypeDescriptionSamples
201Item

Created.

Mime type: application/json
{\n  "id": "one",\n  "name": "First"\n}

Definitions

Item

Manage items.

NameTypeNotes
id string

The item identifier.

name string

The display name.

state string

Error

Manage items.

NameTypeNotes
message string

Error details.

" - }, - "xrefs": [ - { - "href": "service.html", - "name": "Compatibility API", - "uid": "api.example.test/v1/Compatibility API/1.0" - }, - { - "href": "service/items.html#api_example_test_v1_Compatibility_API_1_0_createItem", - "name": "createItem", - "uid": "api.example.test/v1/Compatibility API/1.0/createItem" - }, - { - "href": "service.html#api_example_test_v1_Compatibility_API_1_0_deleteItem", - "name": "deleteItem", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - }, - { - "href": "service/items.html#api_example_test_v1_Compatibility_API_1_0_listItems", - "name": "listItems", - "uid": "api.example.test/v1/Compatibility API/1.0/listItems" - }, - { - "href": "service/items.html", - "name": "items", - "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" - } - ], - "manifest": [ - { - "output": { - ".html": { - "relative_path": "service.html" - } - }, - "source_relative_path": "TestData/compatibility/service.swagger.json", - "type": "RestApi" - }, - { - "output": { - ".html": { - "relative_path": "service/items.html" - } - }, - "source_relative_path": "TestData/compatibility/service.swagger.json", - "type": "RestApi" - }, - { - "output": { - ".html": { - "relative_path": "toc.html" - }, - ".json": { - "relative_path": "toc.json" - } - }, - "source_relative_path": "TestData/compatibility/toc.yml", - "type": "Toc" - } - ] -} \ No newline at end of file diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-True-operations-True-overwrite-False.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-True-operations-True-overwrite-False.json deleted file mode 100644 index fcebb4305e0..00000000000 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/default-tags-True-operations-True-overwrite-False.json +++ /dev/null @@ -1,1425 +0,0 @@ -{ - "baselineCommit": "bd097d04a7b2c1eb7533b8f6e045764e20d15967", - "models": { - "service.html.view.json": { - "_disableContribution": true, - "_disableNextArticle": true, - "_disableSearch": true, - "_disableToc": true, - "_isSplittedByOperation": true, - "_isSplittedByTag": true, - "_jsonPath": "service.swagger.json", - "_key": "TestData/compatibility/service.swagger.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "service.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "children": [], - "consumes": [ - "application/json" - ], - "definitions": [], - "description": "

Use the API guide.

\n", - "documentType": "RestApi", - "docurl": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0", - "meta": "Compatibility metadata", - "name": "Compatibility API", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "sourceurl": "", - "summary": "

A stable API.

\n", - "swagger": "2.0", - "tags": [], - "title": "Compatibility API", - "uid": "api.example.test/v1/Compatibility API/1.0", - "x-owner": { - "team": "documentation" - } - }, - "service.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_isSplittedByOperation": true, - "_isSplittedByTag": true, - "_key": "TestData/compatibility/service.swagger.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "service.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "children": [], - "consumes": [ - "application/json" - ], - "description": "

Use the API guide.

\n", - "documentType": "RestApi", - "htmlId": "api_example_test_v1_Compatibility_API_1_0", - "meta": "Compatibility metadata", - "name": "Compatibility API", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "summary": "

A stable API.

\n", - "swagger": "2.0", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0", - "x-owner": { - "team": "documentation" - } - }, - "service/deleteItem.html.view.json": { - "_disableContribution": true, - "_disableNextArticle": true, - "_disableSearch": true, - "_disableToc": true, - "_isSplittedByTag": true, - "_isSplittedToOperation": true, - "_jsonPath": "deleteItem.swagger.json", - "_key": "TestData/compatibility/service/deleteItem.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "../toc.html", - "_path": "service/deleteItem.html", - "_rel": "../", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "../toc.html", - "children": [ - { - "conceptual": "", - "deprecated": true, - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem_operation", - "operation": "DELETE", - "operationId": "deleteItem", - "parameters": [ - { - "description": "

Item ID.

\n", - "in": "path", - "name": "id", - "required": true, - "type": "string" - } - ], - "path": "/items/{id}", - "remarks": "", - "responses": [ - { - "description": "

No content.

\n", - "statusCode": "204" - } - ], - "security": [], - "source": null, - "sourceurl": "", - "summary": "", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem/operation" - } - ], - "consumes": [ - "application/json" - ], - "definitions": [], - "deprecated": true, - "documentType": "RestApi", - "docurl": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", - "meta": "Compatibility metadata", - "name": "deleteItem", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "source": null, - "sourceurl": "", - "summary": "

Delete an item.

\n", - "swagger": "2.0", - "tags": [], - "title": "deleteItem", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem", - "x-owner": { - "team": "documentation" - } - }, - "service/deleteItem.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_isSplittedByTag": true, - "_isSplittedToOperation": true, - "_key": "TestData/compatibility/service/deleteItem.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "../toc.html", - "_path": "service/deleteItem.html", - "_rel": "../", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "../toc.html", - "children": [ - { - "deprecated": true, - "operation": "delete", - "operationId": "deleteItem", - "parameters": [ - { - "description": "

Item ID.

\n", - "in": "path", - "name": "id", - "required": true, - "type": "string" - } - ], - "path": "/items/{id}", - "responses": [ - { - "description": "

No content.

\n", - "statusCode": "204" - } - ], - "security": [], - "source": null, - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem/operation" - } - ], - "consumes": [ - "application/json" - ], - "deprecated": true, - "documentType": "RestApi", - "meta": "Compatibility metadata", - "name": "deleteItem", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "source": null, - "summary": "

Delete an item.

\n", - "swagger": "2.0", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem", - "x-owner": { - "team": "documentation" - } - }, - "service/items.html.view.json": { - "_disableContribution": true, - "_disableNextArticle": true, - "_disableSearch": true, - "_disableToc": true, - "_isSplittedByOperation": true, - "_isSplittedToTag": true, - "_jsonPath": "items.swagger.json", - "_key": "TestData/compatibility/service/items.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "../toc.html", - "_path": "service/items.html", - "_rel": "../", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "../toc.html", - "children": [], - "consumes": [ - "application/json" - ], - "definitions": [], - "description": "

Manage items.

\n", - "documentType": "RestApi", - "docurl": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_tag_items", - "meta": "Compatibility metadata", - "name": "items", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "sourceurl": "", - "swagger": "2.0", - "tags": [], - "title": "items", - "uid": "api.example.test/v1/Compatibility API/1.0/tag/items", - "x-owner": { - "team": "documentation" - } - }, - "service/items.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_isSplittedByOperation": true, - "_isSplittedToTag": true, - "_key": "TestData/compatibility/service/items.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "../toc.html", - "_path": "service/items.html", - "_rel": "../", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "../toc.html", - "children": [], - "consumes": [ - "application/json" - ], - "description": "

Manage items.

\n", - "documentType": "RestApi", - "htmlId": "items-tag", - "meta": "Compatibility metadata", - "name": "items", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "swagger": "2.0", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/tag/items", - "x-owner": { - "team": "documentation" - } - }, - "service/items/createItem.html.view.json": { - "_disableContribution": true, - "_disableNextArticle": true, - "_disableSearch": true, - "_disableToc": true, - "_isSplittedToOperation": true, - "_isSplittedToTag": true, - "_jsonPath": "createItem.swagger.json", - "_key": "TestData/compatibility/service/items/createItem.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "../../toc.html", - "_path": "service/items/createItem.html", - "_rel": "../../", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "../../toc.html", - "children": [ - { - "conceptual": "", - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem_operation", - "operation": "POST", - "operationId": "createItem", - "parameters": [ - { - "description": "

The new item.

\n", - "in": "body", - "name": "body", - "required": true, - "schema": { - "cType": "Item", - "cTypeId": "Item", - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "required": true, - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "description": null, - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Item" - } - }, - { - "default": "1.0", - "description": "

The API version.

\n", - "in": "query", - "name": "api-version", - "required": true, - "type": "string", - "x-internal-ref-name": "Version" - } - ], - "path": "/items?api-version", - "remarks": "", - "responses": [ - { - "description": "

Created.

\n", - "examples": [ - { - "content": "{\n \"id\": \"one\",\n \"name\": \"First\"\n}", - "mimeType": "application/json" - } - ], - "schema": { - "cType": "Item", - "cTypeId": "Item", - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "required": true, - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "description": null, - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "statusCode": "201" - } - ], - "source": null, - "sourceurl": "", - "summary": "", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/createItem/operation" - } - ], - "consumes": [ - "application/json" - ], - "definitions": [ - { - "cType": "Item", - "cTypeId": "Item", - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "required": true, - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "description": null, - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Item" - } - ], - "documentType": "RestApi", - "docurl": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", - "meta": "Compatibility metadata", - "name": "createItem", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "source": null, - "sourceurl": "", - "summary": "

Create an item.

\n", - "swagger": "2.0", - "tags": [], - "title": "createItem", - "uid": "api.example.test/v1/Compatibility API/1.0/createItem", - "x-owner": { - "team": "documentation" - } - }, - "service/items/createItem.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_isSplittedToOperation": true, - "_isSplittedToTag": true, - "_key": "TestData/compatibility/service/items/createItem.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "../../toc.html", - "_path": "service/items/createItem.html", - "_rel": "../../", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "../../toc.html", - "children": [ - { - "operation": "post", - "operationId": "createItem", - "parameters": [ - { - "description": "

The new item.

\n", - "in": "body", - "name": "body", - "required": true, - "schema": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - } - }, - { - "default": "1.0", - "description": "

The API version.

\n", - "in": "query", - "name": "api-version", - "required": true, - "type": "string", - "x-internal-ref-name": "Version" - } - ], - "path": "/items", - "responses": [ - { - "description": "

Created.

\n", - "examples": [ - { - "content": "{\"id\":\"one\",\"name\":\"First\"}", - "mimeType": "application/json" - } - ], - "schema": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - }, - "statusCode": "201" - } - ], - "source": null, - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/createItem/operation" - } - ], - "consumes": [ - "application/json" - ], - "documentType": "RestApi", - "meta": "Compatibility metadata", - "name": "createItem", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "source": null, - "summary": "

Create an item.

\n", - "swagger": "2.0", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/createItem", - "x-owner": { - "team": "documentation" - } - }, - "service/items/listItems.html.view.json": { - "_disableContribution": true, - "_disableNextArticle": true, - "_disableSearch": true, - "_disableToc": true, - "_isSplittedToOperation": true, - "_isSplittedToTag": true, - "_jsonPath": "listItems.swagger.json", - "_key": "TestData/compatibility/service/items/listItems.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "../../toc.html", - "_path": "service/items/listItems.html", - "_rel": "../../", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "../../toc.html", - "children": [ - { - "conceptual": "", - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems_operation", - "operation": "GET", - "operationId": "listItems", - "parameters": [ - { - "default": "2.0", - "description": "

An optional version.

\n", - "in": "query", - "name": "api-version", - "required": false, - "type": "string" - }, - { - "default": 0, - "description": "

Maximum number of items.

\n", - "in": "query", - "minimum": 0, - "name": "limit", - "type": "integer" - } - ], - "path": "/items[?api-version&limit]", - "remarks": "", - "responses": [ - { - "description": "

The items.

\n", - "examples": [ - { - "content": "[\n {\n \"id\": \"one\",\n \"name\": \"First\",\n \"$ref\": \"literal-example\"\n }\n]", - "mimeType": "application/json" - }, - { - "content": "\"one\"", - "mimeType": "text/plain" - } - ], - "headers": { - "X-Count": { - "description": "

Total count.

\n", - "type": "integer" - } - }, - "schema": { - "cType": "Item", - "cTypeId": "Item", - "cTypeIsArray": true, - "items": { - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - }, - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "type": "array" - }, - "statusCode": "200" - }, - { - "description": "

A request error.

\n", - "schema": { - "cType": "Error", - "cTypeId": "Error", - "properties": [ - { - "key": "message", - "value": { - "description": "

Error details.

\n", - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Error" - }, - "statusCode": "default", - "x-internal-ref-name": "Error" - } - ], - "source": null, - "sourceurl": "", - "summary": "", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/listItems/operation" - } - ], - "consumes": [ - "application/json" - ], - "definitions": [ - { - "cType": "Item", - "cTypeId": "Item", - "cTypeIsArray": true, - "items": { - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - }, - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "array", - "x-internal-ref-name": "Item" - }, - { - "cType": "Error", - "cTypeId": "Error", - "properties": [ - { - "key": "message", - "value": { - "description": "

Error details.

\n", - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Error" - } - ], - "documentType": "RestApi", - "docurl": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", - "meta": "Compatibility metadata", - "name": "listItems", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "source": null, - "sourceurl": "", - "summary": "

List items.

\n", - "swagger": "2.0", - "tags": [], - "title": "listItems", - "uid": "api.example.test/v1/Compatibility API/1.0/listItems", - "x-owner": { - "team": "documentation" - } - }, - "service/items/listItems.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_isSplittedToOperation": true, - "_isSplittedToTag": true, - "_key": "TestData/compatibility/service/items/listItems.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "../../toc.html", - "_path": "service/items/listItems.html", - "_rel": "../../", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "../../toc.html", - "children": [ - { - "operation": "get", - "operationId": "listItems", - "parameters": [ - { - "default": "2.0", - "description": "

An optional version.

\n", - "in": "query", - "name": "api-version", - "required": false, - "type": "string" - }, - { - "default": 0, - "description": "

Maximum number of items.

\n", - "in": "query", - "minimum": 0, - "name": "limit", - "type": "integer" - } - ], - "path": "/items", - "responses": [ - { - "description": "

The items.

\n", - "examples": [ - { - "content": "[{\"id\":\"one\",\"name\":\"First\",\"$ref\":\"literal-example\"}]", - "mimeType": "application/json" - }, - { - "content": "\"one\"", - "mimeType": "text/plain" - } - ], - "headers": { - "X-Count": { - "description": "

Total count.

\n", - "type": "integer" - } - }, - "schema": { - "items": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - }, - "type": "array" - }, - "statusCode": "200" - }, - { - "description": "

A request error.

\n", - "schema": { - "properties": { - "message": { - "description": "

Error details.

\n", - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Error" - }, - "statusCode": "default", - "x-internal-ref-name": "Error" - } - ], - "source": null, - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/listItems/operation" - } - ], - "consumes": [ - "application/json" - ], - "documentType": "RestApi", - "meta": "Compatibility metadata", - "name": "listItems", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "source": null, - "summary": "

List items.

\n", - "swagger": "2.0", - "tags": [], - "uid": "api.example.test/v1/Compatibility API/1.0/listItems", - "x-owner": { - "team": "documentation" - } - }, - "toc.html.view.json": { - "_disableContribution": true, - "_disableSearch": true, - "_disableToc": true, - "_key": "TestData/compatibility/toc.yml", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "toc.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "items": [ - { - "href": "service.html", - "items": [ - { - "href": "service/items.html", - "items": [ - { - "href": "service/items/createItem.html", - "items": [], - "leaf": true, - "level": 4, - "name": "createItem", - "tocHref": null, - "topicHref": "service/items/createItem.html", - "topicUid": "api.example.test/v1/Compatibility API/1.0/createItem" - }, - { - "href": "service/items/listItems.html", - "items": [], - "leaf": true, - "level": 4, - "name": "listItems", - "tocHref": null, - "topicHref": "service/items/listItems.html", - "topicUid": "api.example.test/v1/Compatibility API/1.0/listItems" - } - ], - "level": 3, - "name": "items", - "tocHref": null, - "topicHref": "service/items.html", - "topicUid": "api.example.test/v1/Compatibility API/1.0/tag/items" - }, - { - "href": "service/deleteItem.html", - "items": [], - "leaf": true, - "level": 3, - "name": "deleteItem", - "tocHref": null, - "topicHref": "service/deleteItem.html", - "topicUid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - } - ], - "level": 2, - "name": "Compatibility API", - "tocHref": null, - "topicHref": "service.html" - } - ], - "leaf": false, - "level": 1, - "meta": "Compatibility metadata", - "name": null, - "title": "Table of Content", - "tocHref": null, - "topicHref": null - }, - "toc.json.view.json": { - "content": "{\"items\":[{\"name\":\"Compatibility API\",\"href\":\"service.html\",\"topicHref\":\"service.html\",\"items\":[{\"name\":\"items\",\"href\":\"service/items.html\",\"topicHref\":\"service/items.html\",\"topicUid\":\"api.example.test/v1/Compatibility API/1.0/tag/items\",\"items\":[{\"name\":\"createItem\",\"href\":\"service/items/createItem.html\",\"topicHref\":\"service/items/createItem.html\",\"topicUid\":\"api.example.test/v1/Compatibility API/1.0/createItem\"},{\"name\":\"listItems\",\"href\":\"service/items/listItems.html\",\"topicHref\":\"service/items/listItems.html\",\"topicUid\":\"api.example.test/v1/Compatibility API/1.0/listItems\"}]},{\"name\":\"deleteItem\",\"href\":\"service/deleteItem.html\",\"topicHref\":\"service/deleteItem.html\",\"topicUid\":\"api.example.test/v1/Compatibility API/1.0/deleteItem\"}]}],\"meta\":\"Compatibility metadata\"}" - }, - "toc.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_key": "TestData/compatibility/toc.yml", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "toc.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "items": [ - { - "href": "service.html", - "items": [ - { - "href": "service/items.html", - "items": [ - { - "href": "service/items/createItem.html", - "name": "createItem", - "topicHref": "service/items/createItem.html", - "topicUid": "api.example.test/v1/Compatibility API/1.0/createItem" - }, - { - "href": "service/items/listItems.html", - "name": "listItems", - "topicHref": "service/items/listItems.html", - "topicUid": "api.example.test/v1/Compatibility API/1.0/listItems" - } - ], - "name": "items", - "topicHref": "service/items.html", - "topicUid": "api.example.test/v1/Compatibility API/1.0/tag/items" - }, - { - "href": "service/deleteItem.html", - "name": "deleteItem", - "topicHref": "service/deleteItem.html", - "topicUid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - } - ], - "name": "Compatibility API", - "topicHref": "service.html" - } - ], - "meta": "Compatibility metadata" - } - }, - "html": { - "service.html": "

Compatibility API

A stable API.

Use the API guide.

", - "service/deleteItem.html": "

deleteItem

Delete an item.

deleteItem

Request
DELETE /items/{id}
Parameters
NameTypeDefaultNotes
*id

Item ID.

Responses
Status CodeTypeDescriptionSamples
204

No content.

", - "service/items.html": "

items

Manage items.

", - "service/items/createItem.html": "

createItem

Create an item.

createItem

Request
POST /items?api-version
Parameters
NameTypeDefaultNotes
*bodyItem

The new item.

*api-version1.0

The API version.

Responses
Status CodeTypeDescriptionSamples
201Item

Created.

Mime type: application/json
{\n  "id": "one",\n  "name": "First"\n}

Definitions

Item

NameTypeNotes
id string

The item identifier.

name string

The display name.

state string
", - "service/items/listItems.html": "

listItems

List items.

listItems

Request
GET /items[?api-version&limit]
Parameters
NameTypeDefaultNotes
api-version2.0

An optional version.

limit0

Maximum number of items.

Responses
Status CodeTypeDescriptionSamples
200Item[]

The items.

Mime type: application/json
[\n  {\n    "id": "one",\n    "name": "First",\n    "$ref": "literal-example"\n  }\n]
Mime type: text/plain
"one"
defaultError

A request error.

Definitions

Item

NameTypeNotes
id string

The item identifier.

name string

The display name.

state string

Error

NameTypeNotes
message string

Error details.

" - }, - "xrefs": [ - { - "href": "service.html", - "name": "Compatibility API", - "uid": "api.example.test/v1/Compatibility API/1.0" - }, - { - "href": "service/items/createItem.html", - "name": "createItem", - "uid": "api.example.test/v1/Compatibility API/1.0/createItem" - }, - { - "href": "service/items/createItem.html#api_example_test_v1_Compatibility_API_1_0_createItem_operation", - "name": "createItem", - "uid": "api.example.test/v1/Compatibility API/1.0/createItem/operation" - }, - { - "href": "service/deleteItem.html", - "name": "deleteItem", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - }, - { - "href": "service/deleteItem.html#api_example_test_v1_Compatibility_API_1_0_deleteItem_operation", - "name": "deleteItem", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem/operation" - }, - { - "href": "service/items/listItems.html", - "name": "listItems", - "uid": "api.example.test/v1/Compatibility API/1.0/listItems" - }, - { - "href": "service/items/listItems.html#api_example_test_v1_Compatibility_API_1_0_listItems_operation", - "name": "listItems", - "uid": "api.example.test/v1/Compatibility API/1.0/listItems/operation" - }, - { - "href": "service/items.html", - "name": "items", - "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" - } - ], - "manifest": [ - { - "output": { - ".html": { - "relative_path": "service.html" - } - }, - "source_relative_path": "TestData/compatibility/service.swagger.json", - "type": "RestApi" - }, - { - "output": { - ".html": { - "relative_path": "service/deleteItem.html" - } - }, - "source_relative_path": "TestData/compatibility/service.swagger.json", - "type": "RestApi" - }, - { - "output": { - ".html": { - "relative_path": "service/items.html" - } - }, - "source_relative_path": "TestData/compatibility/service.swagger.json", - "type": "RestApi" - }, - { - "output": { - ".html": { - "relative_path": "service/items/createItem.html" - } - }, - "source_relative_path": "TestData/compatibility/service.swagger.json", - "type": "RestApi" - }, - { - "output": { - ".html": { - "relative_path": "service/items/listItems.html" - } - }, - "source_relative_path": "TestData/compatibility/service.swagger.json", - "type": "RestApi" - }, - { - "output": { - ".html": { - "relative_path": "toc.html" - }, - ".json": { - "relative_path": "toc.json" - } - }, - "source_relative_path": "TestData/compatibility/toc.yml", - "type": "Toc" - } - ] -} \ No newline at end of file diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/modern-tags-False-operations-False-overwrite-False.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/modern-tags-False-operations-False-overwrite-False.json deleted file mode 100644 index 23cb950aec8..00000000000 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/modern-tags-False-operations-False-overwrite-False.json +++ /dev/null @@ -1,859 +0,0 @@ -{ - "baselineCommit": "bd097d04a7b2c1eb7533b8f6e045764e20d15967", - "models": { - "service.html.view.json": { - "_disableContribution": true, - "_disableNextArticle": true, - "_disableSearch": true, - "_disableToc": true, - "_jsonPath": "service.swagger.json", - "_key": "TestData/compatibility/service.swagger.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "service.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "children": [ - { - "conceptual": "", - "deprecated": true, - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", - "operation": "DELETE", - "operationId": "deleteItem", - "parameters": [ - { - "description": "

Item ID.

\n", - "in": "path", - "name": "id", - "required": true, - "type": "string" - } - ], - "path": "/items/{id}", - "remarks": "", - "responses": [ - { - "description": "

No content.

\n", - "statusCode": "204" - } - ], - "security": [], - "source": null, - "sourceurl": "", - "summary": "

Delete an item.

\n", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - } - ], - "consumes": [ - "application/json" - ], - "definitions": [ - { - "cType": "Item", - "cTypeId": "Item", - "cTypeIsArray": true, - "items": { - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - }, - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "array", - "x-internal-ref-name": "Item" - }, - { - "cType": "Error", - "cTypeId": "Error", - "properties": [ - { - "key": "message", - "value": { - "description": "

Error details.

\n", - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Error" - } - ], - "description": "

Use the API guide.

\n", - "documentType": "RestApi", - "docurl": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0", - "isTagLayout": true, - "meta": "Compatibility metadata", - "name": "Compatibility API", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "sourceurl": "", - "summary": "

A stable API.

\n", - "swagger": "2.0", - "tags": [ - { - "children": [ - { - "conceptual": "", - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", - "includedInTags": true, - "operation": "GET", - "operationId": "listItems", - "parameters": [ - { - "default": "2.0", - "description": "

An optional version.

\n", - "in": "query", - "name": "api-version", - "required": false, - "type": "string" - }, - { - "default": 0, - "description": "

Maximum number of items.

\n", - "in": "query", - "minimum": 0, - "name": "limit", - "type": "integer" - } - ], - "path": "/items[?api-version&limit]", - "remarks": "", - "responses": [ - { - "description": "

The items.

\n", - "examples": [ - { - "content": "[\n {\n \"id\": \"one\",\n \"name\": \"First\",\n \"$ref\": \"literal-example\"\n }\n]", - "mimeType": "application/json" - }, - { - "content": "\"one\"", - "mimeType": "text/plain" - } - ], - "headers": { - "X-Count": { - "description": "

Total count.

\n", - "type": "integer" - } - }, - "schema": { - "cType": "Item", - "cTypeId": "Item", - "cTypeIsArray": true, - "items": { - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - }, - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "type": "array" - }, - "statusCode": "200" - }, - { - "description": "

A request error.

\n", - "schema": { - "cType": "Error", - "cTypeId": "Error", - "properties": [ - { - "key": "message", - "value": { - "description": "

Error details.

\n", - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Error" - }, - "statusCode": "default", - "x-internal-ref-name": "Error" - } - ], - "source": null, - "sourceurl": "", - "summary": "

List items.

\n", - "tags": [ - "items" - ], - "uid": "api.example.test/v1/Compatibility API/1.0/listItems" - }, - { - "conceptual": "", - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", - "includedInTags": true, - "operation": "POST", - "operationId": "createItem", - "parameters": [ - { - "description": "

The new item.

\n", - "in": "body", - "name": "body", - "required": true, - "schema": { - "cType": "Item", - "cTypeId": "Item", - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "required": true, - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "description": null, - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Item" - } - }, - { - "default": "1.0", - "description": "

The API version.

\n", - "in": "query", - "name": "api-version", - "required": true, - "type": "string", - "x-internal-ref-name": "Version" - } - ], - "path": "/items?api-version", - "remarks": "", - "responses": [ - { - "description": "

Created.

\n", - "examples": [ - { - "content": "{\n \"id\": \"one\",\n \"name\": \"First\"\n}", - "mimeType": "application/json" - } - ], - "schema": { - "cType": "Item", - "cTypeId": "Item", - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "required": true, - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "description": null, - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "statusCode": "201" - } - ], - "source": null, - "sourceurl": "", - "summary": "

Create an item.

\n", - "tags": [ - "items" - ], - "uid": "api.example.test/v1/Compatibility API/1.0/createItem" - } - ], - "conceptual": "", - "description": "

Manage items.

\n", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_tag_items", - "name": "items", - "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" - } - ], - "title": "Compatibility API", - "uid": "api.example.test/v1/Compatibility API/1.0", - "x-owner": { - "team": "documentation" - } - }, - "service.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_key": "TestData/compatibility/service.swagger.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "service.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "children": [ - { - "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", - "operation": "get", - "operationId": "listItems", - "parameters": [ - { - "default": "2.0", - "description": "

An optional version.

\n", - "in": "query", - "name": "api-version", - "required": false, - "type": "string" - }, - { - "default": 0, - "description": "

Maximum number of items.

\n", - "in": "query", - "minimum": 0, - "name": "limit", - "type": "integer" - } - ], - "path": "/items", - "responses": [ - { - "description": "

The items.

\n", - "examples": [ - { - "content": "[{\"id\":\"one\",\"name\":\"First\",\"$ref\":\"literal-example\"}]", - "mimeType": "application/json" - }, - { - "content": "\"one\"", - "mimeType": "text/plain" - } - ], - "headers": { - "X-Count": { - "description": "

Total count.

\n", - "type": "integer" - } - }, - "schema": { - "items": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - }, - "type": "array" - }, - "statusCode": "200" - }, - { - "description": "

A request error.

\n", - "schema": { - "properties": { - "message": { - "description": "

Error details.

\n", - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Error" - }, - "statusCode": "default", - "x-internal-ref-name": "Error" - } - ], - "source": null, - "summary": "

List items.

\n", - "tags": [ - "items" - ], - "uid": "api.example.test/v1/Compatibility API/1.0/listItems" - }, - { - "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", - "operation": "post", - "operationId": "createItem", - "parameters": [ - { - "description": "

The new item.

\n", - "in": "body", - "name": "body", - "required": true, - "schema": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - } - }, - { - "default": "1.0", - "description": "

The API version.

\n", - "in": "query", - "name": "api-version", - "required": true, - "type": "string", - "x-internal-ref-name": "Version" - } - ], - "path": "/items", - "responses": [ - { - "description": "

Created.

\n", - "examples": [ - { - "content": "{\"id\":\"one\",\"name\":\"First\"}", - "mimeType": "application/json" - } - ], - "schema": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - }, - "statusCode": "201" - } - ], - "source": null, - "summary": "

Create an item.

\n", - "tags": [ - "items" - ], - "uid": "api.example.test/v1/Compatibility API/1.0/createItem" - }, - { - "deprecated": true, - "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", - "operation": "delete", - "operationId": "deleteItem", - "parameters": [ - { - "description": "

Item ID.

\n", - "in": "path", - "name": "id", - "required": true, - "type": "string" - } - ], - "path": "/items/{id}", - "responses": [ - { - "description": "

No content.

\n", - "statusCode": "204" - } - ], - "security": [], - "source": null, - "summary": "

Delete an item.

\n", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - } - ], - "consumes": [ - "application/json" - ], - "description": "

Use the API guide.

\n", - "documentType": "RestApi", - "htmlId": "api_example_test_v1_Compatibility_API_1_0", - "meta": "Compatibility metadata", - "name": "Compatibility API", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "summary": "

A stable API.

\n", - "swagger": "2.0", - "tags": [ - { - "description": "

Manage items.

\n", - "htmlId": "items-tag", - "name": "items", - "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" - } - ], - "uid": "api.example.test/v1/Compatibility API/1.0", - "x-owner": { - "team": "documentation" - } - }, - "toc.html.view.json": { - "_disableContribution": true, - "_disableSearch": true, - "_disableToc": true, - "_key": "TestData/compatibility/toc.yml", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "toc.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "items": [ - { - "href": "service.html", - "items": [], - "leaf": true, - "level": 2, - "name": "Compatibility API", - "tocHref": null, - "topicHref": "service.html" - } - ], - "leaf": false, - "level": 1, - "meta": "Compatibility metadata", - "name": null, - "title": "Table of Content", - "tocHref": null, - "topicHref": null - }, - "toc.json.view.json": { - "content": "{\"items\":[{\"name\":\"Compatibility API\",\"href\":\"service.html\",\"topicHref\":\"service.html\"}],\"meta\":\"Compatibility metadata\"}" - }, - "toc.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_key": "TestData/compatibility/toc.yml", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "toc.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "items": [ - { - "href": "service.html", - "name": "Compatibility API", - "topicHref": "service.html" - } - ], - "meta": "Compatibility metadata" - } - }, - "html": { - "service.html": "

Compatibility API

A stable API.

Use the API guide.

items

Manage items.

listItems

List items.

Request
GET /items[?api-version&limit]
Parameters
NameTypeDefaultNotes
api-version2.0

An optional version.

limit0

Maximum number of items.

Responses
Status CodeTypeDescriptionSamples
200Item[]

The items.

Mime type: application/json
[\n  {\n    "id": "one",\n    "name": "First",\n    "$ref": "literal-example"\n  }\n]
Mime type: text/plain
"one"
defaultError

A request error.

createItem

Create an item.

Request
POST /items?api-version
Parameters
NameTypeDefaultNotes
*bodyItem

The new item.

*api-version1.0

The API version.

Responses
Status CodeTypeDescriptionSamples
201Item

Created.

Mime type: application/json
{\n  "id": "one",\n  "name": "First"\n}

Other APIs

deleteItem

Delete an item.

Request
DELETE /items/{id}
Parameters
NameTypeDefaultNotes
*id

Item ID.

Responses
Status CodeTypeDescriptionSamples
204

No content.

Definitions

Item

Use the API guide.

NameTypeNotes
id string

The item identifier.

name string

The display name.

state string

Error

Use the API guide.

NameTypeNotes
message string

Error details.

" - }, - "xrefs": [ - { - "href": "service.html", - "name": "Compatibility API", - "uid": "api.example.test/v1/Compatibility API/1.0" - }, - { - "href": "service.html#api_example_test_v1_Compatibility_API_1_0_createItem", - "name": "createItem", - "uid": "api.example.test/v1/Compatibility API/1.0/createItem" - }, - { - "href": "service.html#api_example_test_v1_Compatibility_API_1_0_deleteItem", - "name": "deleteItem", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - }, - { - "href": "service.html#api_example_test_v1_Compatibility_API_1_0_listItems", - "name": "listItems", - "uid": "api.example.test/v1/Compatibility API/1.0/listItems" - }, - { - "href": "service.html#api_example_test_v1_Compatibility_API_1_0_tag_items", - "name": "items", - "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" - } - ], - "manifest": [ - { - "output": { - ".html": { - "relative_path": "service.html" - } - }, - "source_relative_path": "TestData/compatibility/service.swagger.json", - "type": "RestApi" - }, - { - "output": { - ".html": { - "relative_path": "toc.html" - }, - ".json": { - "relative_path": "toc.json" - } - }, - "source_relative_path": "TestData/compatibility/toc.yml", - "type": "Toc" - } - ] -} \ No newline at end of file diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/statictoc-tags-False-operations-False-overwrite-False.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/statictoc-tags-False-operations-False-overwrite-False.json deleted file mode 100644 index 44ae437d1e3..00000000000 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/compatibility/expected/statictoc-tags-False-operations-False-overwrite-False.json +++ /dev/null @@ -1,912 +0,0 @@ -{ - "baselineCommit": "bd097d04a7b2c1eb7533b8f6e045764e20d15967", - "models": { - "service.html.view.json": { - "_disableContribution": true, - "_disableSearch": true, - "_disableToc": true, - "_jsonPath": "service.swagger.json", - "_key": "TestData/compatibility/service.swagger.json", - "_nav": { - "_disableContribution": true, - "_disableSearch": true, - "_key": "TestData/compatibility/toc.yml", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "toc.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "items": [ - { - "active": true, - "href": "service.html", - "items": [], - "leaf": true, - "level": 2, - "name": "Compatibility API", - "topicHref": "service.html" - } - ], - "leaf": false, - "level": 1, - "meta": "Compatibility metadata" - }, - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "service.html", - "_rel": "", - "_toc": { - "_disableContribution": true, - "_disableSearch": true, - "_key": "TestData/compatibility/toc.yml", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "toc.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "items": [ - { - "active": true, - "href": "service.html", - "items": [], - "leaf": true, - "level": 2, - "name": "Compatibility API", - "topicHref": "service.html" - } - ], - "leaf": false, - "level": 1, - "meta": "Compatibility metadata" - }, - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "children": [ - { - "conceptual": "", - "deprecated": true, - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", - "operation": "DELETE", - "operationId": "deleteItem", - "parameters": [ - { - "description": "

Item ID.

\n", - "in": "path", - "name": "id", - "required": true, - "type": "string" - } - ], - "path": "/items/{id}", - "remarks": "", - "responses": [ - { - "description": "

No content.

\n", - "statusCode": "204" - } - ], - "security": [], - "source": null, - "sourceurl": "", - "summary": "

Delete an item.

\n", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - } - ], - "consumes": [ - "application/json" - ], - "definitions": [ - { - "cType": "Item", - "cTypeId": "Item", - "cTypeIsArray": true, - "items": { - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - }, - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "array", - "x-internal-ref-name": "Item" - }, - { - "cType": "Error", - "cTypeId": "Error", - "properties": [ - { - "key": "message", - "value": { - "description": "

Error details.

\n", - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Error" - } - ], - "description": "

Use the API guide.

\n", - "documentType": "RestApi", - "docurl": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0", - "isTagLayout": true, - "meta": "Compatibility metadata", - "name": "Compatibility API", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "sourceurl": "", - "summary": "

A stable API.

\n", - "swagger": "2.0", - "tags": [ - { - "children": [ - { - "conceptual": "", - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", - "includedInTags": true, - "operation": "GET", - "operationId": "listItems", - "parameters": [ - { - "default": "2.0", - "description": "

An optional version.

\n", - "in": "query", - "name": "api-version", - "required": false, - "type": "string" - }, - { - "default": 0, - "description": "

Maximum number of items.

\n", - "in": "query", - "minimum": 0, - "name": "limit", - "type": "integer" - } - ], - "path": "/items[?api-version&limit]", - "remarks": "", - "responses": [ - { - "description": "

The items.

\n", - "examples": [ - { - "content": "[\n {\n \"id\": \"one\",\n \"name\": \"First\",\n \"$ref\": \"literal-example\"\n }\n]", - "mimeType": "application/json" - }, - { - "content": "\"one\"", - "mimeType": "text/plain" - } - ], - "headers": { - "X-Count": { - "description": "

Total count.

\n", - "type": "integer" - } - }, - "schema": { - "cType": "Item", - "cTypeId": "Item", - "cTypeIsArray": true, - "items": { - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - }, - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "type": "array" - }, - "statusCode": "200" - }, - { - "description": "

A request error.

\n", - "schema": { - "cType": "Error", - "cTypeId": "Error", - "properties": [ - { - "key": "message", - "value": { - "description": "

Error details.

\n", - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Error" - }, - "statusCode": "default", - "x-internal-ref-name": "Error" - } - ], - "source": null, - "sourceurl": "", - "summary": "

List items.

\n", - "tags": [ - "items" - ], - "uid": "api.example.test/v1/Compatibility API/1.0/listItems" - }, - { - "conceptual": "", - "description": "", - "docurl": "", - "footer": "", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", - "includedInTags": true, - "operation": "POST", - "operationId": "createItem", - "parameters": [ - { - "description": "

The new item.

\n", - "in": "body", - "name": "body", - "required": true, - "schema": { - "cType": "Item", - "cTypeId": "Item", - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "required": true, - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "description": null, - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Item" - } - }, - { - "default": "1.0", - "description": "

The API version.

\n", - "in": "query", - "name": "api-version", - "required": true, - "type": "string", - "x-internal-ref-name": "Version" - } - ], - "path": "/items?api-version", - "remarks": "", - "responses": [ - { - "description": "

Created.

\n", - "examples": [ - { - "content": "{\n \"id\": \"one\",\n \"name\": \"First\"\n}", - "mimeType": "application/json" - } - ], - "schema": { - "cType": "Item", - "cTypeId": "Item", - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "properties": [ - { - "key": "id", - "value": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - { - "key": "name", - "value": { - "description": "

The display name.

\n", - "required": true, - "type": "string" - } - }, - { - "key": "state", - "value": { - "default": "active", - "description": null, - "enum": [ - "active", - "archived" - ], - "type": "string" - } - } - ], - "type": "object", - "x-internal-ref-name": "Item" - }, - "statusCode": "201" - } - ], - "source": null, - "sourceurl": "", - "summary": "

Create an item.

\n", - "tags": [ - "items" - ], - "uid": "api.example.test/v1/Compatibility API/1.0/createItem" - } - ], - "conceptual": "", - "description": "

Manage items.

\n", - "htmlId": "api_example_test_v1_Compatibility_API_1_0_tag_items", - "name": "items", - "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" - } - ], - "title": "Compatibility API", - "uid": "api.example.test/v1/Compatibility API/1.0", - "x-owner": { - "team": "documentation" - } - }, - "service.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_key": "TestData/compatibility/service.swagger.json", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "service.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "children": [ - { - "htmlId": "api_example_test_v1_Compatibility_API_1_0_listItems", - "operation": "get", - "operationId": "listItems", - "parameters": [ - { - "default": "2.0", - "description": "

An optional version.

\n", - "in": "query", - "name": "api-version", - "required": false, - "type": "string" - }, - { - "default": 0, - "description": "

Maximum number of items.

\n", - "in": "query", - "minimum": 0, - "name": "limit", - "type": "integer" - } - ], - "path": "/items", - "responses": [ - { - "description": "

The items.

\n", - "examples": [ - { - "content": "[{\"id\":\"one\",\"name\":\"First\",\"$ref\":\"literal-example\"}]", - "mimeType": "application/json" - }, - { - "content": "\"one\"", - "mimeType": "text/plain" - } - ], - "headers": { - "X-Count": { - "description": "

Total count.

\n", - "type": "integer" - } - }, - "schema": { - "items": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - }, - "type": "array" - }, - "statusCode": "200" - }, - { - "description": "

A request error.

\n", - "schema": { - "properties": { - "message": { - "description": "

Error details.

\n", - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Error" - }, - "statusCode": "default", - "x-internal-ref-name": "Error" - } - ], - "source": null, - "summary": "

List items.

\n", - "tags": [ - "items" - ], - "uid": "api.example.test/v1/Compatibility API/1.0/listItems" - }, - { - "htmlId": "api_example_test_v1_Compatibility_API_1_0_createItem", - "operation": "post", - "operationId": "createItem", - "parameters": [ - { - "description": "

The new item.

\n", - "in": "body", - "name": "body", - "required": true, - "schema": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - } - }, - { - "default": "1.0", - "description": "

The API version.

\n", - "in": "query", - "name": "api-version", - "required": true, - "type": "string", - "x-internal-ref-name": "Version" - } - ], - "path": "/items", - "responses": [ - { - "description": "

Created.

\n", - "examples": [ - { - "content": "{\"id\":\"one\",\"name\":\"First\"}", - "mimeType": "application/json" - } - ], - "schema": { - "allOf": [ - { - "properties": { - "id": { - "description": "

The item identifier.

\n", - "readOnly": true, - "type": "string" - } - }, - "type": "object", - "x-internal-ref-name": "Base" - }, - { - "properties": { - "name": { - "description": "

The display name.

\n", - "type": "string" - }, - "state": { - "default": "active", - "enum": [ - "active", - "archived" - ], - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - ], - "example": { - "$ref": "literal-schema-example", - "id": "one", - "name": "First" - }, - "x-internal-ref-name": "Item" - }, - "statusCode": "201" - } - ], - "source": null, - "summary": "

Create an item.

\n", - "tags": [ - "items" - ], - "uid": "api.example.test/v1/Compatibility API/1.0/createItem" - }, - { - "deprecated": true, - "htmlId": "api_example_test_v1_Compatibility_API_1_0_deleteItem", - "operation": "delete", - "operationId": "deleteItem", - "parameters": [ - { - "description": "

Item ID.

\n", - "in": "path", - "name": "id", - "required": true, - "type": "string" - } - ], - "path": "/items/{id}", - "responses": [ - { - "description": "

No content.

\n", - "statusCode": "204" - } - ], - "security": [], - "source": null, - "summary": "

Delete an item.

\n", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - } - ], - "consumes": [ - "application/json" - ], - "description": "

Use the API guide.

\n", - "documentType": "RestApi", - "htmlId": "api_example_test_v1_Compatibility_API_1_0", - "meta": "Compatibility metadata", - "name": "Compatibility API", - "produces": [ - "application/json", - "text/plain" - ], - "schemes": [ - "https" - ], - "security": [ - { - "apiKey": [] - } - ], - "securityDefinitions": { - "apiKey": { - "description": "

Your API key.

\n", - "in": "header", - "name": "X-Api-Key", - "type": "apiKey" - } - }, - "summary": "

A stable API.

\n", - "swagger": "2.0", - "tags": [ - { - "description": "

Manage items.

\n", - "htmlId": "items-tag", - "name": "items", - "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" - } - ], - "uid": "api.example.test/v1/Compatibility API/1.0", - "x-owner": { - "team": "documentation" - } - }, - "toc.html.view.json": { - "_disableContribution": true, - "_disableSearch": true, - "_disableToc": true, - "_key": "TestData/compatibility/toc.yml", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "toc.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "items": [ - { - "href": "service.html", - "items": [], - "leaf": true, - "level": 2, - "name": "Compatibility API", - "tocHref": null, - "topicHref": "service.html" - } - ], - "leaf": false, - "level": 1, - "meta": "Compatibility metadata", - "name": null, - "title": "Table of Content", - "tocHref": null, - "topicHref": null - }, - "toc.json.view.json": { - "content": "{\"items\":[{\"name\":\"Compatibility API\",\"href\":\"service.html\",\"topicHref\":\"service.html\"}],\"meta\":\"Compatibility metadata\"}" - }, - "toc.raw.json": { - "_disableContribution": true, - "_disableSearch": true, - "_key": "TestData/compatibility/toc.yml", - "_navKey": "~/TestData/compatibility/toc.yml", - "_navPath": "toc.html", - "_navRel": "toc.html", - "_path": "toc.html", - "_rel": "", - "_tocKey": "~/TestData/compatibility/toc.yml", - "_tocPath": "toc.html", - "_tocRel": "toc.html", - "items": [ - { - "href": "service.html", - "name": "Compatibility API", - "topicHref": "service.html" - } - ], - "meta": "Compatibility metadata" - } - }, - "html": { - "service.html": "

Compatibility API

A stable API.

Use the API guide.

items

Manage items.

listItems

List items.

Request
GET /items[?api-version&limit]
Parameters
NameTypeDefaultNotes
api-version2.0

An optional version.

limit0

Maximum number of items.

Responses
Status CodeTypeDescriptionSamples
200Item[]

The items.

Mime type: application/json
[\n  {\n    "id": "one",\n    "name": "First",\n    "$ref": "literal-example"\n  }\n]
Mime type: text/plain
"one"
defaultError

A request error.

createItem

Create an item.

Request
POST /items?api-version
Parameters
NameTypeDefaultNotes
*bodyItem

The new item.

*api-version1.0

The API version.

Responses
Status CodeTypeDescriptionSamples
201Item

Created.

Mime type: application/json
{\n  "id": "one",\n  "name": "First"\n}

Other APIs

deleteItem

Delete an item.

Request
DELETE /items/{id}
Parameters
NameTypeDefaultNotes
*id

Item ID.

Responses
Status CodeTypeDescriptionSamples
204

No content.

Definitions

Item

Use the API guide.

NameTypeNotes
id string

The item identifier.

name string

The display name.

state string

Error

Use the API guide.

NameTypeNotes
message string

Error details.

" - }, - "xrefs": [ - { - "href": "service.html", - "name": "Compatibility API", - "uid": "api.example.test/v1/Compatibility API/1.0" - }, - { - "href": "service.html#api_example_test_v1_Compatibility_API_1_0_createItem", - "name": "createItem", - "uid": "api.example.test/v1/Compatibility API/1.0/createItem" - }, - { - "href": "service.html#api_example_test_v1_Compatibility_API_1_0_deleteItem", - "name": "deleteItem", - "uid": "api.example.test/v1/Compatibility API/1.0/deleteItem" - }, - { - "href": "service.html#api_example_test_v1_Compatibility_API_1_0_listItems", - "name": "listItems", - "uid": "api.example.test/v1/Compatibility API/1.0/listItems" - }, - { - "href": "service.html#api_example_test_v1_Compatibility_API_1_0_tag_items", - "name": "items", - "uid": "api.example.test/v1/Compatibility API/1.0/tag/items" - } - ], - "manifest": [ - { - "output": { - ".html": { - "relative_path": "service.html" - } - }, - "source_relative_path": "TestData/compatibility/service.swagger.json", - "type": "RestApi" - }, - { - "output": { - ".html": { - "relative_path": "toc.html" - }, - ".json": { - "relative_path": "toc.json" - } - }, - "source_relative_path": "TestData/compatibility/toc.yml", - "type": "Toc" - } - ] -} \ No newline at end of file From d8449369576f3e688eaca10ccc1126d479a2ebe2 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Sat, 19 Sep 2026 19:20:07 +1000 Subject: [PATCH 03/16] test: avoid unnecessary Swagger fixture IO and builds Exercise pure converter contracts with in-memory models, retain file-backed parser coverage, and separate suffix recognition from representative naming builds. Skip unused reference fixtures and file setup for classification paths that do not read content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../SwaggerCompatibilityTest.cs | 57 +++++++++++-------- .../SwaggerDocumentCompatibilityTest.cs | 41 +++++++------ 2 files changed, 58 insertions(+), 40 deletions(-) diff --git a/test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs b/test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs index a2f1f9de0ab..2bd7f0b87c5 100644 --- a/test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs +++ b/test/Docfx.Build.RestApi.Tests/SwaggerCompatibilityTest.cs @@ -32,7 +32,7 @@ public void AllSevenSwaggerMethodsPreserveDocumentOrderAndOperationIdentity() } path["x-path-extension"] = new JObject { ["enabled"] = false }; - var model = Convert($$""" + var model = ConvertInMemory($$""" "host": "api.example.com", "basePath": "/v1/", "paths": { "/items/{id}": {{path}} } @@ -53,15 +53,12 @@ public void AllSevenSwaggerMethodsPreserveDocumentOrderAndOperationIdentity() [Fact] public void ParameterMergeUsesBothNameAndLocationAndKeepsOperationThenInheritedOrder() { - var model = Convert(""" - "parameters": { - "QueryId": { "name": "id", "in": "query", "type": "string", "description": "inherited query" } - }, + var model = ConvertInMemory(""" "paths": { "/items/{id}": { "parameters": [ { "name": "id", "in": "path", "type": "string", "required": true, "description": "inherited path" }, - { "$ref": "#/parameters/QueryId" }, + { "name": "id", "in": "query", "type": "string", "description": "inherited query" }, { "name": "token", "in": "header", "type": "string", "description": "inherited header" }, { "name": "ID", "in": "query", "type": "string", "description": "case-sensitive name" } ], @@ -94,7 +91,7 @@ public void ParameterMergeUsesBothNameAndLocationAndKeepsOperationThenInheritedO [InlineData("\"parameters\": null,")] public void AbsentEmptyAndNullOperationParametersKeepInheritedParameters(string operationParameters) { - var model = Convert($$""" + var model = ConvertInMemory($$""" "paths": { "/items": { "parameters": [ @@ -126,7 +123,7 @@ public void PrimitiveConstraintsAndFalsyDefaultsSurviveParsingAndConversion() ] """; - var swagger = Parse($$""" + var swagger = ParseFile($$""" "paths": { "/items": { "get": { "operationId": "get", "parameters": {{parameters}} } } } """); AssertJson(parameters, GetOperation(swagger, "/items", "get")["parameters"]); @@ -158,7 +155,7 @@ public void BodyFormDataAndFileParametersKeepTheirDistinctMetadata() ] """; - var model = Convert($$""" + var model = ConvertInMemory($$""" "paths": { "/body": { "post": { "operationId": "body", "parameters": {{body}}, "consumes": ["application/json"] } }, "/upload": { "post": { "operationId": "upload", "parameters": {{form}}, "consumes": ["multipart/form-data"] } } @@ -175,7 +172,7 @@ public void BodyFormDataAndFileParametersKeepTheirDistinctMetadata() [Fact] public void SecurityMediaTypesAndExtensionsStayAtTheirDeclaredLevel() { - var swagger = Parse(""" + var model = ConvertInMemory(""" "schemes": ["https", "http"], "consumes": ["application/json"], "produces": ["application/json", "text/plain"], @@ -203,8 +200,6 @@ public void SecurityMediaTypesAndExtensionsStayAtTheirDeclaredLevel() } } """); - var model = SwaggerModelConverter.FromSwaggerModel(swagger); - AssertJson("""["https", "http"]""", JToken.FromObject(model.Metadata["schemes"])); AssertJson("""["application/json"]""", JToken.FromObject(model.Metadata["consumes"])); AssertJson("""["application/json", "text/plain"]""", JToken.FromObject(model.Metadata["produces"])); @@ -250,13 +245,14 @@ public void SecurityMediaTypesAndExtensionsStayAtTheirDeclaredLevel() [InlineData("#/definitions/~01", "~1", "~01")] public void EscapedReferenceNamesResolveButKeepEscapesInInternalName(string reference, string definition, string marker) { - var model = Convert($$""" + var swagger = ParseFile($$""" "definitions": { "{{definition}}": { "type": "string", "description": "Escaped name" } }, "paths": { "/items": { "get": { "responses": { "200": { "description": "OK", "schema": { "$ref": "{{reference}}" } } } } } } """); + var model = SwaggerModelConverter.FromSwaggerModel(swagger); var schema = Assert.IsType(Assert.Single(Assert.Single(model.Children).Responses).Metadata["schema"]); AssertJson($$""" { "type": "string", "description": "Escaped name", "x-internal-ref-name": "{{marker}}" } @@ -271,14 +267,17 @@ public void ReferenceKindsPreserveTheirLegacySiblingPrecedence(string kind, stri { var folder = GetRandomFolder(); const string target = """{ "type": "string", "description": "Target description", "x-target": true }"""; - CreateFile("target.json", kind == "direct" ? target : $$"""{ "definitions": { "Value": {{target}} } }""", folder); + if (kind != "internal") + { + CreateFile("target.json", kind == "direct" ? target : $$"""{ "definitions": { "Value": {{target}} } }""", folder); + } var reference = kind switch { "internal" => "#/definitions/Value", "embedded" => "target.json#/definitions/Value", _ => "target.json" }; - var swagger = Parse($$""" + var swagger = ParseFile($$""" "definitions": { "Value": {{target}} }, "paths": { "/items": { "get": { "responses": { "200": { "description": "OK", @@ -329,7 +328,7 @@ public void LiteralExamplesPreserveReferencesDatesAndFalsyValues() { "date": "2024-01-02T03:04:05.120+02:30", "$ref": "not-a-reference", "nested": [{ "$ref": 17 }], "false": false, "zero": 0, "empty": "", "null": null } """; - var swagger = Parse($$""" + var swagger = ParseFile($$""" "x-ms-examples": {{literal}}, "definitions": { "Item": { @@ -370,7 +369,7 @@ public void LiteralExamplesPreserveReferencesDatesAndFalsyValues() public void LeadingReferenceInResponseExampleSurvivesParsingButFailsConversion() { const string literal = """{"$ref":"not-a-reference","date":"2024-01-02T03:04:05.120+02:30"}"""; - var swagger = Parse($$""" + var swagger = ParseFile($$""" "paths": { "/items": { "get": { "responses": { "200": { "description": "OK", "examples": { "application/json": {{literal}} } } } } } } @@ -388,7 +387,7 @@ public void LeadingReferenceInResponseExampleSurvivesParsingButFailsConversion() [Fact] public void InlineSchemaExamplesAreResolvedUnlikeDefinitionExamples() { - var swagger = Parse(""" + var swagger = ParseFile(""" "definitions": { "Value": { "type": "string" } }, "paths": { "/items": { "post": { "parameters": [ { "name": "body", "in": "body", @@ -407,7 +406,7 @@ public void InlineSchemaExamplesAreResolvedUnlikeDefinitionExamples() [InlineData("[]", "Array")] public void NonStringReferencesFailWithTheirTokenTypeAndLocation(string value, string tokenType) { - var exception = Assert.Throws(() => Parse($$""" + var exception = Assert.Throws(() => ParseFile($$""" "definitions": { "Bad": { "$ref": {{value}} } } """)); @@ -419,7 +418,7 @@ public void NonStringReferencesFailWithTheirTokenTypeAndLocation(string value, s [Fact] public void NullReferenceFailsInReferenceFormatter() { - var exception = Assert.Throws(() => Parse(""" + var exception = Assert.Throws(() => ParseFile(""" "definitions": { "Bad": { "$ref": null } } """)); @@ -434,7 +433,7 @@ public void NullReferenceFailsInReferenceFormatter() [InlineData("file.json#/definitions/Value#extra", typeof(InvalidOperationException), "Reference path 'file.json#/definitions/Value#extra' should contain only one '#' character.")] public void InvalidReferencePathsHaveSpecificFailureContracts(string reference, Type exceptionType, string message) { - var exception = Assert.Throws(exceptionType, () => Parse($$""" + var exception = Assert.Throws(exceptionType, () => ParseFile($$""" "definitions": { "Bad": { "$ref": "{{reference}}" } } """)); @@ -480,7 +479,7 @@ public void MalformedJsonReportsReaderPathAndSourcePosition() [InlineData("1")] public void NonObjectOperationsFailAtConversionRatherThanParsing(string value) { - var swagger = Parse($$""" + var swagger = ParseFile($$""" "paths": { "/items": { "get": {{value}} } } """); var exception = Assert.Throws(() => SwaggerModelConverter.FromSwaggerModel(swagger)); @@ -488,7 +487,7 @@ public void NonObjectOperationsFailAtConversionRatherThanParsing(string value) Assert.Equal("Value of get should be JObject", exception.Message); } - private SwaggerModel Parse(string members, string folder = null) + private SwaggerModel ParseFile(string members, string folder = null) { var file = CreateFile("swagger.json", $$""" { @@ -500,7 +499,17 @@ private SwaggerModel Parse(string members, string folder = null) return SwaggerJsonParser.Parse(file); } - private RestApiRootItemViewModel Convert(string members) => SwaggerModelConverter.FromSwaggerModel(Parse(members)); + private static RestApiRootItemViewModel ConvertInMemory(string members) + { + var swagger = JsonConvert.DeserializeObject($$""" + { + "swagger": "2.0", + "info": { "title": "Compatibility", "version": "1" }, + {{members}} + } + """); + return SwaggerModelConverter.FromSwaggerModel(swagger); + } private static JObject GetOperation(SwaggerModel swagger, string path, string method) => Assert.IsType(swagger.Paths[path].Metadata[method]); diff --git a/test/Docfx.Build.RestApi.Tests/SwaggerDocumentCompatibilityTest.cs b/test/Docfx.Build.RestApi.Tests/SwaggerDocumentCompatibilityTest.cs index 9567f65f8a4..b24e162fd34 100644 --- a/test/Docfx.Build.RestApi.Tests/SwaggerDocumentCompatibilityTest.cs +++ b/test/Docfx.Build.RestApi.Tests/SwaggerDocumentCompatibilityTest.cs @@ -39,19 +39,27 @@ public class SwaggerDocumentCompatibilityTest : TestBase [InlineData("_swagger.json")] [InlineData(".swagger.json")] [InlineData(".swagger2.json")] + public void LegacySuffixesAreRecognized(string suffix) + { + var input = GetRandomFolder(); + var fileName = Path.Combine("api", "a.b" + suffix); + CreateFile(fileName, Document, input); + var file = new FileAndType(Path.GetFullPath(input), fileName, DocumentType.Article); + + Assert.Equal(ProcessingPriority.Normal, new RestApiDocumentProcessor().GetProcessingPriority(file)); + } + + [Theory] [InlineData(".JSON")] [InlineData("_SWAGGER2.JSON")] [InlineData("_Swagger.Json")] [InlineData(".SWAGGER.JSON")] [InlineData(".Swagger2.Json")] - public void LegacySuffixesAreRecognizedAndBuildToTheSameFilenameAndUids(string suffix) + public void LegacySuffixesBuildCaseInsensitivelyToTheSameFilenameAndUids(string suffix) { var input = GetRandomFolder(); var fileName = Path.Combine("api", "a.b" + suffix); CreateFile(fileName, Document, input); - var file = new FileAndType(Path.GetFullPath(input), fileName, DocumentType.Article); - Assert.Equal(ProcessingPriority.Normal, new RestApiDocumentProcessor().GetProcessingPriority(file)); - var (output, diagnostics) = Build(input, fileName); Assert.Empty(diagnostics); @@ -81,7 +89,6 @@ public void LegacySuffixesAreRecognizedAndBuildToTheSameFilenameAndUids(string s } [Theory] - [InlineData("api.json", DocumentType.Article, ProcessingPriority.Normal)] [InlineData("api.md", DocumentType.Article, ProcessingPriority.NotSupported)] [InlineData("api.yaml", DocumentType.Article, ProcessingPriority.NotSupported)] [InlineData("api.md", DocumentType.Overwrite, ProcessingPriority.Normal)] @@ -90,11 +97,9 @@ public void LegacySuffixesAreRecognizedAndBuildToTheSameFilenameAndUids(string s [InlineData("api_swagger2.json", DocumentType.Overwrite, ProcessingPriority.NotSupported)] [InlineData("api.json", DocumentType.Resource, ProcessingPriority.NotSupported)] [InlineData("api.md", DocumentType.Resource, ProcessingPriority.NotSupported)] - public void ClassificationDependsOnDocumentTypeAndExtension(string fileName, DocumentType type, ProcessingPriority expected) + public void ClassificationByDocumentTypeAndExtensionDoesNotRequireAFile(string fileName, DocumentType type, ProcessingPriority expected) { - var folder = GetRandomFolder(); - CreateFile(fileName, type == DocumentType.Overwrite ? "Overwrite content is not inspected during recognition." : Document, folder); - var file = new FileAndType(Path.GetFullPath(folder), fileName, type); + var file = new FileAndType(Path.GetFullPath(Path.GetRandomFileName()), fileName, type); Assert.Equal(expected, new RestApiDocumentProcessor().GetProcessingPriority(file)); } @@ -120,7 +125,8 @@ public void OrdinaryAndMalformedJsonAreNotRecognizedAsSwagger(string content) [Fact] public void MissingJsonFileIsNotRecognizedAsSwagger() { - var file = new FileAndType(Path.GetFullPath(GetRandomFolder()), "missing.json", DocumentType.Article); + var file = new FileAndType(Directory.GetCurrentDirectory(), Path.GetRandomFileName() + ".json", DocumentType.Article); + Assert.False(File.Exists(file.FullPath)); Assert.Equal(ProcessingPriority.NotSupported, new RestApiDocumentProcessor().GetProcessingPriority(file)); } @@ -165,12 +171,15 @@ public void InvalidSwaggerReportsInvalidInputFileAndDoesNotExportARawModel(strin "$ref in target.json is not supported in external reference currently."), _ => throw new ArgumentOutOfRangeException(nameof(failure)) }; - CreateFile(Path.Combine("api", "target.json"), """ - { - "definitions": { "Value": { "type": "string" } }, - "properties": { "nested": { "$ref": "#/definitions/Value" } } - } - """, input); + if (failure is "missing-external-fragment" or "nested-direct-external-reference") + { + CreateFile(Path.Combine("api", "target.json"), """ + { + "definitions": { "Value": { "type": "string" } }, + "properties": { "nested": { "$ref": "#/definitions/Value" } } + } + """, input); + } CreateFile(invalidName, content, input); var validName = Path.Combine("api", "good.json"); CreateFile(validName, Document, input); From ea6da65026de0bc55a5ebf97e4135c0a1e6ed8c6 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Sat, 19 Sep 2026 21:19:31 +1000 Subject: [PATCH 04/16] Add OpenAPI.NET-backed REST documentation Use the maintained OpenAPI reader and typed models for OpenAPI 3.0 and 3.1 core JSON/YAML documentation while retaining the legacy Swagger 2.0 compatibility path. Load local document graphs through SDK workspaces and report known SDK fidelity limits explicitly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 2 + docs/docs/rest-api-docs.md | 84 ++- .../BuildRestApiDocument.cs | 38 +- .../Docfx.Build.RestApi.csproj | 4 + .../OpenApiDocumentReader.cs | 404 +++++++++++++ .../OpenApiModelConverter.cs | 414 +++++++++++++ .../RestApiDocumentProcessor.cs | 49 +- .../RestApiModelConverter.cs | 31 + .../SwaggerModelConverter.cs | 43 +- templates/common/RestApi.common.js | 133 ++++- .../default/partials/rest.child.tmpl.partial | 38 +- .../partials/rest.definition.tmpl.partial | 6 + .../partials/rest.examples.tmpl.partial | 18 + .../partials/rest.media-schema.tmpl.partial | 5 + .../default/partials/rest.schema.tmpl.partial | 43 ++ templates/modern/src/rest.test.ts | 327 +++++++++++ .../OpenApiDocumentReaderTest.cs | 438 ++++++++++++++ .../OpenApiOutputTest.cs | 551 ++++++++++++++++++ .../TestData/openapi/components.json | 32 + .../TestData/openapi/service.json | 110 ++++ 20 files changed, 2705 insertions(+), 65 deletions(-) create mode 100644 src/Docfx.Build.RestApi/OpenApiDocumentReader.cs create mode 100644 src/Docfx.Build.RestApi/OpenApiModelConverter.cs create mode 100644 src/Docfx.Build.RestApi/RestApiModelConverter.cs create mode 100644 templates/default/partials/rest.examples.tmpl.partial create mode 100644 templates/default/partials/rest.media-schema.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/OpenApiDocumentReaderTest.cs create mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs create mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/openapi/components.json create mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/openapi/service.json diff --git a/Directory.Packages.props b/Directory.Packages.props index 7cc416579ca..1e308ea6819 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,8 @@ + + diff --git a/docs/docs/rest-api-docs.md b/docs/docs/rest-api-docs.md index 02c8afc79e3..b2c963385ef 100644 --- a/docs/docs/rest-api-docs.md +++ b/docs/docs/rest-api-docs.md @@ -1,6 +1,9 @@ # REST API docs -Docfx generates REST API documentation from [Swagger 2.0](http://swagger.io/specification/) files. +Docfx generates REST API documentation from Swagger 2.0 JSON and OpenAPI 3.0 JSON or YAML files, +with **OpenAPI 3.1 core support and documented SDK limitations**. +OpenAPI documents are read using [OpenAPI.NET](https://github.com/microsoft/OpenAPI.NET). +Swagger 2.0 continues to use the existing compatibility reader. To add REST API docs, include the swagger JSON file to the `build` config in `docfx.json`: @@ -16,6 +19,85 @@ To add REST API docs, include the swagger JSON file to the `build` config in `do Each swagger file produces one output HTML file. +## OpenAPI 3 documents + +Include the entry documents in `build.content`, for example: + +```json +{ + "build": { + "content": [{ + "files": ["api/service.yaml", "api/other.json"] + }] + } +} +``` + +Both `.yaml` and `.yml` are supported. Referenced OpenAPI documents can mix JSON and YAML and +do not need to be listed as separate entry documents. References are loaded through +Docfx's file abstraction; HTTP/HTTPS and network-share references are not fetched. +An invalid document or unresolved reference produces an input error, not a fallback +to the Swagger reader. Only OpenAPI 3.0 and 3.1 are supported, even if the installed +library can read newer versions. + +Operations reuse the existing Markdown, overwrite, cross-reference, tag and +operation splitting pipeline. Parameters, request bodies and response content +include their media types, schemas and examples. Example payloads are literal data, +not Markdown or documents whose `$ref` properties should be resolved. + +Operation servers override path servers, which override document servers. Server +variables use their declared defaults; an omitted server defaults to `/`. +The root UID follows the existing authority/base-path/title/version convention +using the first document server. Operation UIDs append the operation ID. If an +operation has no ID, Docfx generates a stable, filename-safe ID from its HTTP method +and path. Explicit IDs must be unique. Tags used by operations need not be declared +at document level. + +Schema documentation preserves alternatives and intersections rather than merging +`allOf`/`anyOf`/`oneOf` properties into a single object. OpenAPI 3.1 inline boolean schemas, +type unions and recursive references are displayed without expanding cycles. Schema +reference siblings are shown as an intersection with the target, not an override. +The SDK can normalize disjoint primitive alternatives into equivalent type unions. +This is documentation generation, not full JSON Schema validation or full OpenAPI +conformance. Callbacks, webhooks, security configuration and response links do not +have dedicated rendered UI. The original input remains available in the raw model. + +### Known OpenAPI.NET 3.10.2 limitations + +This integration pins `Microsoft.OpenApi` and `Microsoft.OpenApi.YamlReader` to +**3.10.2**. It deliberately reports errors instead of generating misleading documentation +for the following valid inputs: + +- Boolean schemas in `components.schemas`, `properties`, `patternProperties`, + `$defs` or `dependentSchemas`, and boolean branches in `allOf`, `anyOf` or `oneOf`, + produce `UnsupportedBooleanSchema`. The SDK's + [`JsonNodeHelper.CreateMap/CreateList`](https://github.com/microsoft/OpenAPI.NET/blob/v3.10.2/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs) + only pass JSON objects to the schema reader, dropping these boolean values. + Docfx checks root and external sources before reading; it does not rewrite schemas. + Boolean example payloads and extension data are unaffected. +- Standalone external schema/component fragments without an OpenAPI document envelope + are not yet supported. `UnsupportedExternalFragment` identifies this integration limit, + not an invalid OpenAPI specification. Keep referenced definitions in a complete + OpenAPI 3.0/3.1 component document for this version of the integration. +- Dynamic schema references (`$dynamicRef`) produce `UnsupportedOpenApiSchema`; + they are not replaced with ordinary references. +- The SDK's [OpenAPI 3.0 primitive-union folding](https://github.com/microsoft/OpenAPI.NET/blob/v3.10.2/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs#L423-L529) + can lose exclusivity for type-only `oneOf` branches with duplicate types or + overlapping `integer`/`number` types, and can discard branch examples. + These known lossy forms produce `UnsupportedOpenApiComposition`. Disjoint + type-only alternatives and constrained alternatives remain supported. + +The SDK's automatic +[`OpenApiWorkspaceLoader`](https://github.com/microsoft/OpenAPI.NET/blob/v3.10.2/src/Microsoft.OpenApi/Reader/Services/OpenApiWorkspaceLoader.cs) +reuses the entry document's format for external documents and loads recursively before +joining workspaces. Docfx therefore loads local documents once, detects each file's +format, and registers them with SDK workspaces before resolving references. This +supports mixed-format and cyclic document graphs without adding a separate JSON +Pointer or schema resolver. + +The typed SDK model is not a lossless JSON Schema representation (for example, explicit +null defaults and some `const` forms). Use the preserved original source for exact +schema syntax. This integration does not advertise OpenAPI 3.2 support. ## Organize REST APIs using Tags diff --git a/src/Docfx.Build.RestApi/BuildRestApiDocument.cs b/src/Docfx.Build.RestApi/BuildRestApiDocument.cs index 8e2b79feafb..3b63adbc2d4 100644 --- a/src/Docfx.Build.RestApi/BuildRestApiDocument.cs +++ b/src/Docfx.Build.RestApi/BuildRestApiDocument.cs @@ -41,6 +41,7 @@ protected override void BuildArticle(IHostService host, FileModel model) public static RestApiItemViewModelBase BuildItem(IHostService host, RestApiItemViewModelBase item, FileModel model, Func filter = null) { + var preserveLiteralData = item.Metadata.GetValueOrDefault("_preserveLiteralData") is true; item.Summary = Markup(host, item.Summary, model, filter); item.Description = Markup(host, item.Description, model, filter); if (model.Type != DocumentType.Overwrite) @@ -52,22 +53,32 @@ 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()) + foreach (var jToken in GetMarkupTokens(rootModel.Metadata, preserveLiteralData)) { - MarkupRecursive(jToken, host, model, filter); + MarkupRecursive(jToken, host, model, filter, preserveLiteralData); } } var childModel = item as RestApiChildItemViewModel; + if (childModel != null && preserveLiteralData) + { + foreach (var key in new[] { "requestBody", "servers" }) + { + if (childModel.Metadata.GetValueOrDefault(key) is JToken value) + { + MarkupRecursive(value, host, model, filter, preserveLiteralData); + } + } + } if (childModel?.Parameters != null) { foreach (var param in childModel.Parameters) { param.Description = Markup(host, param.Description, model, filter); - foreach (var jToken in param.Metadata.Values.OfType()) + foreach (var jToken in GetMarkupTokens(param.Metadata, preserveLiteralData)) { - MarkupRecursive(jToken, host, model, filter); + MarkupRecursive(jToken, host, model, filter, preserveLiteralData); } } } @@ -77,22 +88,26 @@ public static RestApiItemViewModelBase BuildItem(IHostService host, RestApiItemV { response.Description = Markup(host, response.Description, model, filter); - foreach (var jToken in response.Metadata.Values.OfType()) + foreach (var jToken in GetMarkupTokens(response.Metadata, preserveLiteralData)) { - MarkupRecursive(jToken, host, model, filter); + MarkupRecursive(jToken, host, model, filter, preserveLiteralData); } } } return item; } - private static void MarkupRecursive(JToken jToken, IHostService host, FileModel model, Func filter = null) + private static IEnumerable GetMarkupTokens(Dictionary metadata, bool preserveLiteralData) => + metadata.Where(pair => !preserveLiteralData || !pair.Key.StartsWith("x-", StringComparison.Ordinal)) + .Select(pair => pair.Value).OfType(); + + private static void MarkupRecursive(JToken jToken, IHostService host, FileModel model, Func filter = null, bool preserveLiteralData = false) { if (jToken is JArray jArray) { foreach (var item in jArray) { - MarkupRecursive(item, host, model, filter); + MarkupRecursive(item, host, model, filter, preserveLiteralData); } } @@ -100,6 +115,11 @@ private static void MarkupRecursive(JToken jToken, IHostService host, FileModel { foreach (var pair in jObject) { + if (preserveLiteralData && (pair.Key.StartsWith("x-", StringComparison.Ordinal) || + (jObject.ContainsKey("type") && pair.Key is "example" or "examples" or "enum" or "default" or "const"))) + { + continue; + } if (MarkupKeys.Contains(pair.Key) && pair.Value != null) { if (pair.Value is JValue { Type: JTokenType.String } jValue) @@ -107,7 +127,7 @@ private static void MarkupRecursive(JToken jToken, IHostService host, FileModel jObject[pair.Key] = Markup(host, (string)jValue, model, filter); } } - MarkupRecursive(jObject[pair.Key], host, model, filter); + MarkupRecursive(jObject[pair.Key], host, model, filter, preserveLiteralData); } } } diff --git a/src/Docfx.Build.RestApi/Docfx.Build.RestApi.csproj b/src/Docfx.Build.RestApi/Docfx.Build.RestApi.csproj index 7703e8c6c3b..37bbd0ba140 100644 --- a/src/Docfx.Build.RestApi/Docfx.Build.RestApi.csproj +++ b/src/Docfx.Build.RestApi/Docfx.Build.RestApi.csproj @@ -1,4 +1,8 @@ + + + + diff --git a/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs b/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs new file mode 100644 index 00000000000..a7aebeb01c3 --- /dev/null +++ b/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs @@ -0,0 +1,404 @@ +// 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; +using Docfx.Common; +using Docfx.DataContracts.RestApi; +using Docfx.Exceptions; +using Docfx.Plugins; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; + +namespace Docfx.Build.RestApi; + +internal static class OpenApiDocumentReader +{ + internal static bool IsOpenApiFile(string path) + { + try + { + return GetVersion(EnvironmentContext.FileAbstractLayer.ReadAllText(path)) != null; + } + catch (FileNotFoundException ex) + { + Logger.LogVerbose($"Could not find OpenAPI file '{path}': {ex.Message}"); + } + catch (DirectoryNotFoundException ex) + { + Logger.LogVerbose($"Could not find OpenAPI file '{path}': {ex.Message}"); + } + catch (YamlException ex) + { + Logger.LogVerbose($"Could not read OpenAPI version in '{path}': {ex.Message}"); + } + return false; + } + + internal static RestApiRootItemViewModel Read(string path) + { + var format = Path.GetExtension(path).Equals(".json", StringComparison.OrdinalIgnoreCase) ? "json" : "yaml"; + var model = Parse(EnvironmentContext.FileAbstractLayer.ReadAllText(path), format, new Uri(Path.GetFullPath(path))); + return model; + } + + internal static RestApiRootItemViewModel Parse(string raw, string format, Uri baseUrl = null) + { + try + { + var version = GetVersion(raw); + if (!System.Version.TryParse(version, out var parsed) || parsed.Major != 3 || parsed.Minor is not (0 or 1)) + { + throw new DocfxException($"OpenAPI version '{version}' is not supported. Use OpenAPI 3.0 or 3.1."); + } + var document = LoadDocuments(raw, format, baseUrl ?? new Uri(Path.GetFullPath("openapi.json"))); + var model = new OpenApiModelConverter(document.BaseUri).Convert(document, raw, version); + model.Metadata["rawExtension"] = format == "json" ? ".json" : ".yaml"; + return model; + } + catch (Exception ex) when (ex is IOException or YamlException or System.Text.Json.JsonException or OpenApiException or InvalidOperationException) + { + throw new DocfxException($"Unable to read OpenAPI document: {ex.Message}", ex); + } + } + + private static OpenApiDocument LoadDocuments(string raw, string format, Uri root) + { + var loader = new LocalStreamLoader(); + var documents = new Dictionary(); + var references = new Dictionary>(); + var pending = new Queue(); + var scheduled = new HashSet { root }; + pending.Enqueue(root); + while (pending.TryDequeue(out var location)) + { + var source = raw; + var sourceFormat = format; + if (location != root) + { + using var input = loader.LoadAsync(root, location).GetAwaiter().GetResult(); + using var reader = new StreamReader(input); + source = reader.ReadToEnd(); + sourceFormat = Path.GetExtension(location.LocalPath).Equals(".json", StringComparison.OrdinalIgnoreCase) ? "json" : "yaml"; + } + var version = GetVersion(source); + if (!System.Version.TryParse(version, out var parsed) || parsed.Major != 3 || parsed.Minor is not (0 or 1)) + { + throw new DocfxException($"UnsupportedExternalFragment: '{location.LocalPath}' is not a complete OpenAPI 3.0 or 3.1 document. " + + "Standalone schema/component fragments are valid OpenAPI references, but are not supported by this reader integration."); + } + CheckSchemaReaderLimitations(source, location, parsed.Minor == 0); + var settings = new OpenApiReaderSettings + { + BaseUrl = location, + LoadExternalRefs = false, + CustomExternalLoader = loader + }; + settings.AddYamlReader(); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(source)); + var result = Task.Run(() => OpenApiDocument.LoadAsync(stream, sourceFormat, settings)).GetAwaiter().GetResult(); + if (result.Diagnostic.Errors.Count > 0) + { + throw new DocfxException($"Invalid OpenAPI document '{location.LocalPath}': " + + string.Join("; ", result.Diagnostic.Errors.Select(e => e.ToString()))); + } + foreach (var warning in result.Diagnostic.Warnings) + { + Logger.LogWarning($"OpenAPI '{location.LocalPath}': {warning}"); + } + var document = result.Document ?? throw new DocfxException($"The OpenAPI reader did not produce a document for '{location.LocalPath}'."); + if (document.Components?.Schemas?.Any(pair => pair.Value == null) == true) + { + throw new DocfxException($"UnsupportedBooleanSchema: OpenAPI.NET could not read a component schema in '{location.LocalPath}'."); + } + documents.Add(location, document); + if (document.Webhooks is { Count: > 0 } || document.Security is { Count: > 0 } || + document.Components?.SecuritySchemes is { Count: > 0 } || + document.Paths?.Values.Any(path => path.Operations?.Values.Any(operation => + operation.Callbacks is { Count: > 0 } || operation.Security is { Count: > 0 } || + operation.Responses?.Values.Any(response => response.Links is { Count: > 0 }) == true) == true) == true) + { + Logger.LogWarning($"OpenAPI '{location.LocalPath}': callbacks, webhooks, security configuration and response links do not have dedicated documentation UI."); + } + var collector = new ReferenceCollector(); + new OpenApiWalker(collector).Walk(document); + references.Add(location, collector.References); + foreach (var (_, reference) in collector.References) + { + if (reference.ExternalResource is { } external) + { + var target = LocalStreamLoader.Resolve(location, new Uri(external, UriKind.RelativeOrAbsolute)); + if (scheduled.Add(target)) + { + pending.Enqueue(target); + } + } + } + } + + // The SDK aliases external names globally within a workspace. Each host needs its own + // aliases so two documents can both refer to "common.yaml" in different directories. + foreach (var (location, document) in documents) + { + document.Workspace = new OpenApiWorkspace(); + foreach (var other in documents.Values) + { + document.Workspace.RegisterComponents(other); + } + foreach (var (_, reference) in references[location]) + { + if (reference.ExternalResource is { } external) + { + document.Workspace.AddDocumentId(external, new Uri(location, external)); + } + } + } + foreach (var (location, holders) in references) + { + foreach (var (holder, reference) in holders) + { + if (holder.UnresolvedReference) + { + throw new DocfxException($"Could not resolve OpenAPI reference '{reference.ReferenceV3}' in '{location.LocalPath}'."); + } + } + } + return documents[root]; + } + + private static void CheckSchemaReaderLimitations(string source, Uri location, bool openApi30) + { + var yaml = new YamlStream(); + yaml.Load(new StringReader(source)); + var root = yaml.Documents[0].RootNode; + if (root is YamlMappingNode document && + document.Children.TryGetValue(new YamlScalarNode("components"), out var components) && + components is YamlMappingNode componentMap && + componentMap.Children.TryGetValue(new YamlScalarNode("schemas"), out var schemas)) + { + CheckMap(schemas, "#/components/schemas"); + } + VisitDocument(root, "#"); + + void VisitDocument(YamlNode node, string path) + { + if (node is YamlSequenceNode sequence) + { + for (var i = 0; i < sequence.Children.Count; i++) + { + VisitDocument(sequence.Children[i], path + "/" + i); + } + } + if (node is not YamlMappingNode mapping) + { + return; + } + foreach (var (key, value) in mapping.Children) + { + var name = ((YamlScalarNode)key).Value; + if (name.StartsWith("x-", StringComparison.Ordinal) || name is "example" or "examples" or "default" or "enum" or "const" or "value" or "schemas") + { + continue; + } + if (name == "schema") + { + CheckSchema(value, path + "/schema"); + } + else if (name == "$ref") + { + CheckReference(value, path); + } + else + { + VisitDocument(value, path + "/" + name); + } + } + } + + void CheckMap(YamlNode node, string path) + { + if (node is YamlMappingNode map) + { + foreach (var (key, value) in map.Children) + { + RejectBoolean(value, path + "/" + key); + CheckSchema(value, path + "/" + key); + } + } + } + + void CheckSchema(YamlNode node, string path) + { + if (node is not YamlMappingNode schema) + { + return; + } + foreach (var (key, value) in schema.Children) + { + var name = ((YamlScalarNode)key).Value; + switch (name) + { + case "$ref": + CheckReference(value, path); + break; + case "$dynamicRef": + throw new DocfxException($"UnsupportedOpenApiSchema: dynamic references at '{path}' in '{location.LocalPath}' are not supported."); + case "properties" or "patternProperties" or "$defs" or "dependentSchemas": + CheckMap(value, path + "/" + name); + break; + case "allOf" or "oneOf" or "anyOf": + if (value is YamlSequenceNode sequence) + { + CheckPrimitiveUnion(schema, sequence, name, path); + for (var i = 0; i < sequence.Children.Count; i++) + { + RejectBoolean(sequence.Children[i], path + "/" + name + "/" + i); + CheckSchema(sequence.Children[i], path + "/" + name + "/" + i); + } + } + break; + case "items" or "not" or "additionalProperties" or "unevaluatedProperties" or "contains" or "propertyNames" or "if" or "then" or "else" or "contentSchema": + CheckSchema(value, path + "/" + name); + break; + } + } + } + + void RejectBoolean(YamlNode node, string path) + { + // OpenAPI.NET 3.10.2 JsonNodeHelper.CreateMap/CreateList drop non-object schemas. + // Do not rewrite them: fail before the SDK can silently change their meaning. + if (node is YamlScalarNode { Style: ScalarStyle.Plain, Value: { } value } && + (value.Equals("true", StringComparison.OrdinalIgnoreCase) || value.Equals("false", StringComparison.OrdinalIgnoreCase))) + { + throw new DocfxException($"UnsupportedBooleanSchema: OpenAPI.NET 3.10.2 cannot preserve the boolean schema at '{path}' in '{location.LocalPath}'."); + } + } + + void CheckReference(YamlNode node, string path) + { + if (node is YamlScalarNode { Value: { } value } && !value.Contains('#')) + { + throw new DocfxException($"UnsupportedExternalFragment: reference '{value}' at '{path}' in '{location.LocalPath}' " + + "requires a complete OpenAPI component document and a fragment identifier."); + } + } + + void CheckPrimitiveUnion(YamlMappingNode schema, YamlSequenceNode sequence, string kind, string path) + { + if (!openApi30 || kind == "allOf" || schema.Children.ContainsKey(new YamlScalarNode("type")) || sequence.Children.Count == 0) + { + return; + } + var types = new List(); + var hasExamples = false; + foreach (var node in sequence.Children) + { + if (node is not YamlMappingNode branch || + branch.Children.Keys.Any(key => ((YamlScalarNode)key).Value is not ("type" or "example" or "examples")) || + !branch.Children.TryGetValue(new YamlScalarNode("type"), out var type) || + type is not YamlScalarNode { Value: "string" or "integer" or "number" or "boolean" or "object" or "array" or "null" } scalar) + { + return; + } + types.Add(scalar.Value); + hasExamples |= branch.Children.Count > 1; + } + if (hasExamples || (kind == "oneOf" && (types.Distinct().Count() != types.Count || + (types.Contains("integer") && types.Contains("number"))))) + { + throw new DocfxException($"UnsupportedOpenApiComposition: OpenAPI.NET 3.10.2 would lose exclusive alternatives or branch examples " + + $"in '{kind}' at '{path}' in '{location.LocalPath}'."); + } + } + } + + private sealed class ReferenceCollector : OpenApiVisitorBase + { + internal List<(IOpenApiReferenceHolder Holder, BaseOpenApiReference Reference)> References { get; } = []; + private readonly HashSet _visitedReferences = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _visitedSchemas = new(ReferenceEqualityComparer.Instance); + + public override void Visit(IOpenApiReferenceHolder holder) + { + // Operation tags in OpenAPI 3.0/3.1 are names, not required references to root tags. + if (holder is OpenApiTagReference) + { + return; + } + if (!_visitedReferences.Add(holder)) + { + return; + } + BaseOpenApiReference reference = holder switch + { + IOpenApiReferenceHolder schema => schema.Reference, + IOpenApiReferenceHolder summarized => summarized.Reference, + IOpenApiReferenceHolder described => described.Reference, + IOpenApiReferenceHolder basic => basic.Reference, + _ => throw new DocfxException($"Unsupported OpenAPI reference holder '{holder.GetType().Name}'.") + }; + References.Add((holder, reference)); + if (holder is OpenApiSchemaReference schemaReference) + { + WalkSchema(OpenApiModelConverter.GetReferenceSiblings(schemaReference)); + } + } + + public override void Visit(IOpenApiSchema schema) + { + if (!_visitedSchemas.Add(schema)) + { + return; + } + foreach (var child in (schema.Definitions?.Values ?? Enumerable.Empty()) + .Concat(schema.PatternProperties?.Values ?? Enumerable.Empty())) + { + WalkSchema(child); + } + if (schema is IOpenApiSchemaMissingProperties extra) + { + foreach (var child in new[] { extra.If, extra.Then, extra.Else, extra.Contains, extra.ContentSchema, extra.PropertyNames, extra.UnevaluatedPropertiesSchema } + .Concat(extra.DependentSchemas?.Values ?? Enumerable.Empty()).Where(s => s != null)) + { + WalkSchema(child); + } + } + } + + private void WalkSchema(IOpenApiSchema schema) => new OpenApiWalker(this).Walk(new OpenApiDocument + { + Components = new OpenApiComponents { Schemas = new Dictionary { ["schema"] = schema } } + }); + } + + private static string GetVersion(string raw) + { + var yaml = new YamlStream(); + yaml.Load(new StringReader(raw)); + return yaml.Documents.Count == 1 && + yaml.Documents[0].RootNode is YamlMappingNode root && + root.Children.TryGetValue(new YamlScalarNode("openapi"), out var node) && + node is YamlScalarNode version ? version.Value : null; + } + + private sealed class LocalStreamLoader : IStreamLoader + { + public Task LoadAsync(Uri baseUrl, Uri uri, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(EnvironmentContext.FileAbstractLayer.OpenRead(Resolve(baseUrl, uri).LocalPath)); + } + + internal static Uri Resolve(Uri baseUrl, Uri uri) + { + uri = uri.IsAbsoluteUri ? uri : new Uri(baseUrl, uri); + if (!uri.IsAbsoluteUri || !uri.IsFile || uri.IsUnc || !string.IsNullOrEmpty(uri.Host)) + { + throw new DocfxException($"Only local file references are supported in OpenAPI documents: '{uri}'."); + } + return new Uri(Path.GetFullPath(uri.LocalPath)); + } + } +} diff --git a/src/Docfx.Build.RestApi/OpenApiModelConverter.cs b/src/Docfx.Build.RestApi/OpenApiModelConverter.cs new file mode 100644 index 00000000000..678d0875664 --- /dev/null +++ b/src/Docfx.Build.RestApi/OpenApiModelConverter.cs @@ -0,0 +1,414 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using Docfx.DataContracts.RestApi; +using Docfx.Exceptions; +using Microsoft.OpenApi; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +using static Docfx.Build.RestApi.RestApiModelConverter; + +namespace Docfx.Build.RestApi; + +internal sealed class OpenApiModelConverter(Uri documentUri) +{ + internal RestApiRootItemViewModel Convert(OpenApiDocument document, string raw, string version) + { + var servers = Servers(document.Servers); + var server = (string)servers[0]["url"]; + var absolute = Uri.TryCreate(server, UriKind.Absolute, out var uri) && !uri.IsFile; + var uid = GenerateUid(absolute ? uri.Authority : null, (absolute ? uri.AbsolutePath : server).Trim('/'), + document.Info.Title, document.Info.Version); + var model = new RestApiRootItemViewModel + { + Uid = uid, + HtmlId = GetHtmlId(uid), + Name = document.Info.Title, + Description = document.Info.Description, + Summary = document.Info.Summary, + Raw = raw, + Metadata = Extensions(document.Extensions), + Children = [], + Tags = [] + }; + model.Metadata["openapi"] = version; + model.Metadata["_preserveLiteralData"] = true; + model.Metadata["servers"] = servers; + model.Metadata["info"] = Serialize(document.Info); + if (document.ExternalDocs != null) + { + model.Metadata["externalDocs"] = Serialize(document.ExternalDocs); + } + var schemas = new JObject(); + foreach (var (name, schema) in document.Components?.Schemas?.AsEnumerable() ?? []) + { + schemas[name] = Schema(schema); + } + model.Metadata["schemas"] = schemas; + foreach (var tag in document.Tags?.AsEnumerable() ?? []) + { + AddTag(tag.Name, tag.Description, Extensions(tag.Extensions)); + } + + var operationIds = new HashSet(StringComparer.Ordinal); + foreach (var (path, pathItem) in document.Paths ?? []) + { + foreach (var (method, operation) in pathItem.Operations ?? []) + { + var methodName = method.ToString().ToLowerInvariant(); + var id = string.IsNullOrEmpty(operation.OperationId) + ? methodName + "_" + System.Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(path))).ToLowerInvariant() + : operation.OperationId; + if (!operationIds.Add(id)) + { + throw new DocfxException($"OpenAPI operation ID '{id}' is not unique."); + } + var operationUid = GenerateUid(uid, id); + var effectiveServers = Servers(operation.Servers is { Count: > 0 } ? operation.Servers : + pathItem.Servers is { Count: > 0 } ? pathItem.Servers : document.Servers); + var parameters = MergeParameters(operation.Parameters, pathItem.Parameters, + (left, right) => left.Name == right.Name && left.In == right.In); + var child = new RestApiChildItemViewModel + { + Uid = operationUid, + HtmlId = GetHtmlId(operationUid), + OperationId = id, + OperationName = methodName, + Path = path, + Summary = operation.Summary, + Description = operation.Description, + Tags = operation.Tags?.Select(t => t.Name).ToList() ?? [], + Parameters = parameters?.Select(Parameter).ToList() ?? [], + Responses = operation.Responses?.Select(pair => Response(pair.Key, pair.Value)).ToList() ?? [], + Metadata = Extensions(operation.Extensions) + }; + child.Metadata["servers"] = effectiveServers; + child.Metadata["_preserveLiteralData"] = true; + child.Metadata["requestUrl"] = ((string)effectiveServers[0]["url"]).TrimEnd('/') + "/" + path.TrimStart('/'); + if (operation.RequestBody is { } body) + { + child.Metadata["requestBody"] = new JObject + { + ["description"] = body.Description, + ["required"] = body.Required, + ["content"] = Content(body.Content) + }; + } + foreach (var name in child.Tags) + { + if (!model.Tags.Any(t => t.Name == name)) + { + AddTag(name, null, []); + } + } + model.Children.Add(child); + } + } + + return model; + + void AddTag(string name, string description, Dictionary metadata) + { + if (model.Tags.Any(tag => tag.Name == name)) + { + return; + } + model.Tags.Add(new RestApiTagViewModel + { + Name = name, + Description = description, + Uid = GenerateUid(uid, "tag", name), + HtmlId = metadata.TryGetValue("x-bookmark-id", out var bookmark) ? bookmark?.ToString() : GetHtmlId(name), + Metadata = metadata + }); + } + } + + private static JArray Servers(IList servers) + { + if (servers == null || servers.Count == 0) + { + return new JArray(new JObject { ["url"] = "/" }); + } + return new JArray(servers.Select(server => + { + var url = server.Url; + foreach (var (name, variable) in server.Variables?.AsEnumerable() ?? []) + { + if (variable.Default == null) + { + throw new DocfxException($"OpenAPI server variable '{name}' requires a default."); + } + url = url.Replace("{" + name + "}", variable.Default, StringComparison.Ordinal); + } + if (url.Contains('{') || url.Contains('}')) + { + throw new DocfxException($"OpenAPI server URL '{server.Url}' contains a variable without a default."); + } + return new JObject { ["url"] = url, ["description"] = server.Description }; + })); + } + + private RestApiParameterViewModel Parameter(IOpenApiParameter parameter) + { + var schema = Schema(parameter.Schema); + var metadata = Extensions(parameter.Extensions); + metadata["in"] = parameter.In?.ToString().ToLowerInvariant(); + metadata["required"] = parameter.Required; + metadata["schema"] = schema; + metadata["style"] = parameter.Style?.ToString(); + metadata["explode"] = parameter.Explode; + if (parameter.Schema?.Default != null) + { + metadata["default"] = Literal(parameter.Schema.Default); + } + if (parameter.Content is { Count: > 0 }) + { + metadata["content"] = Content(parameter.Content); + } + return new RestApiParameterViewModel + { + Name = parameter.Name, + Description = parameter.Description, + Metadata = metadata + }; + } + + private RestApiResponseViewModel Response(string status, IOpenApiResponse response) + { + var metadata = Extensions(response.Extensions); + var content = Content(response.Content); + metadata["content"] = content; + return new RestApiResponseViewModel + { + HttpStatusCode = status, + Description = response.Description, + Metadata = metadata, + Examples = content.SelectMany(media => media["examples"]).Select(example => new RestApiResponseExampleViewModel + { + MimeType = (string)example["mimeType"], + Content = (string)example["content"] + }).ToList() + }; + } + + private JArray Content(IDictionary content) => + new(content?.Select(pair => new JObject + { + ["mimeType"] = pair.Key, + ["schema"] = Schema(pair.Value.Schema), + ["examples"] = Examples(pair.Key, pair.Value) + }) ?? []); + + private static JArray Examples(string mimeType, IOpenApiMediaType media) + { + var result = new JArray(); + if (media.Example != null) + { + result.Add(new JObject { ["mimeType"] = mimeType, ["content"] = Literal(media.Example) }); + } + foreach (var (name, example) in media.Examples?.AsEnumerable() ?? []) + { + result.Add(new JObject + { + ["name"] = name, + ["mimeType"] = mimeType, + ["content"] = example.Value == null ? null : Literal(example.Value), + ["externalValue"] = example.ExternalValue + }); + } + return result; + } + + private static string Literal(JsonNode value) => value?.ToJsonString(new() { WriteIndented = true }); + + private static JToken Serialize(IOpenApiSerializable value) + { + using var text = new StringWriter(); + value.SerializeAsV31(new OpenApiJsonWriter(text)); + return JToken.Parse(text.ToString()); + } + + private static Dictionary Extensions(IDictionary extensions) + { + var result = new Dictionary(); + foreach (var (name, extension) in extensions?.AsEnumerable() ?? []) + { + using var text = new StringWriter(); + extension.Write(new OpenApiJsonWriter(text), OpenApiSpecVersion.OpenApi3_1); + var token = JToken.Parse(text.ToString()); + result[name] = token is JValue value ? value.Value : token; + } + return result; + } + + private JObject Schema(IOpenApiSchema schema, HashSet ancestors = null) + { + if (schema == null) + { + return null; + } + ancestors ??= new(ReferenceEqualityComparer.Instance); + if (schema is OpenApiSchemaReference reference) + { + var target = reference.Target ?? throw new DocfxException($"Could not resolve OpenAPI schema reference '{reference.Reference?.Id}'."); + if (ancestors.Contains(target) || !ancestors.Add(reference)) + { + if (target is OpenApiSchemaReference) + { + throw new DocfxException($"Cyclic OpenAPI schema alias '{reference.Reference?.Id}' has no concrete schema."); + } + return new JObject { ["type"] = "recursive reference", ["x-internal-loop-ref-name"] = ReferenceName(reference) }; + } + try + { + var targetModel = Schema(target, ancestors); + var siblings = GetReferenceSiblings(reference); + using var siblingText = new StringWriter(); + siblings.SerializeAsV31(new OpenApiJsonWriter(siblingText)); + var result = JObject.Parse(siblingText.ToString()).Count == 0 ? targetModel : new JObject + { + ["type"] = "all of", + ["description"] = reference.Reference.Description ?? target.Description, + ["composition"] = new JArray(new JObject + { + ["kind"] = "All of", + ["schemas"] = new JArray(targetModel, Schema(siblings, ancestors)) + }) + }; + result["x-internal-ref-name"] ??= ReferenceName(reference); + return result; + } + finally + { + ancestors.Remove(reference); + } + } + if (!ancestors.Add(schema)) + { + return new JObject + { + ["type"] = "recursive reference", + ["x-internal-loop-ref-name"] = schema.Title ?? "schema" + }; + } + + try + { + using var text = new StringWriter(); + schema.SerializeAsV31(new OpenApiJsonWriter(text)); + var serialized = JToken.Parse(text.ToString()); + if (serialized is JObject { Count: 1 } && serialized["not"] is JObject { Count: 0 }) + { + return new JObject { ["type"] = "no value" }; + } + + var result = JObject.FromObject(Extensions(schema.Extensions)); + result["type"] = schema.Type?.ToString().ToLowerInvariant().Replace(", ", " | ") ?? + (serialized is JObject { Count: 0 } ? "any value" : "any type"); + result["format"] = schema.Format; + result["description"] = schema.Description; + if (schema.Properties is { Count: > 0 }) + { + result["properties"] = new JObject(schema.Properties.Select(pair => + { + var property = Schema(pair.Value, ancestors); + if (schema.Required?.Contains(pair.Key) == true) + { + property["required"] = true; + } + return new JProperty(pair.Key, property); + })); + } + if (schema.Items != null) + { + result["items"] = Schema(schema.Items, ancestors); + } + var composition = new JArray(); + AddComposition("All of", schema.AllOf); + AddComposition("One of", schema.OneOf); + AddComposition("Any of", schema.AnyOf); + if (schema.Not != null) + { + AddComposition("Not", [schema.Not]); + } + if (composition.Count > 0) + { + result["composition"] = composition; + } + var constraints = new JArray(); + foreach (var property in ((JObject)serialized).Properties()) + { + if (!property.Name.StartsWith("x-", StringComparison.Ordinal) && property.Name is not + ("type" or "format" or "description" or "properties" or "items" or "allOf" or "oneOf" or "anyOf" or "not" or + "additionalProperties" or "enum" or "example" or "examples")) + { + constraints.Add(new JObject { ["name"] = property.Name, ["value"] = property.Value.ToString(Formatting.None) }); + } + } + if (schema.AdditionalProperties != null) + { + composition.Add(new JObject { ["kind"] = "Additional properties", ["schemas"] = new JArray(Schema(schema.AdditionalProperties, ancestors)) }); + result["composition"] = composition; + } + else if (!schema.AdditionalPropertiesAllowed) + { + constraints.Add(new JObject { ["name"] = "additionalProperties", ["value"] = "false" }); + } + if (constraints.Count > 0) + { + result["constraints"] = constraints; + } + if (schema.Enum is { Count: > 0 }) + { + result["enum"] = new JArray(schema.Enum.Select(value => value == null ? JValue.CreateNull() : JToken.Parse(value.ToJsonString()))); + } + if (schema.Examples is { Count: > 0 }) + { + result["examples"] = new JArray(schema.Examples.Select(example => new JObject { ["content"] = Literal(example) })); + } +#pragma warning disable CS0618 // OpenAPI 3.0's singular schema example is still read into this SDK property. + else if (schema.Example != null) + { + result["examples"] = new JArray(new JObject { ["content"] = Literal(schema.Example) }); + } +#pragma warning restore CS0618 + return result; + + void AddComposition(string kind, IList schemas) + { + if (schemas is { Count: > 0 }) + { + composition.Add(new JObject { ["kind"] = kind, ["schemas"] = new JArray(schemas.Select(s => Schema(s, ancestors))) }); + } + } + } + finally + { + ancestors.Remove(schema); + } + } + + internal static OpenApiSchema GetReferenceSiblings(OpenApiSchemaReference reference) + { + var detached = new OpenApiSchemaReference(reference.Reference.Id) + { + Reference = new JsonSchemaReference(reference.Reference) { HostDocument = null } + }; + var siblings = (OpenApiSchema)detached.CopyReferenceAsTargetElementWithOverrides(new OpenApiSchema()); + siblings.Description = reference.Reference.Description; + return siblings; + } + + private string ReferenceName(OpenApiSchemaReference reference) + { + var host = reference.Reference.HostDocument?.BaseUri ?? documentUri; + var target = reference.Reference.ExternalResource is { } external ? new Uri(host, external) : host; + return target == documentUri ? reference.Reference.Id : + documentUri.MakeRelativeUri(target) + "#" + reference.Reference.Id; + } +} diff --git a/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs b/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs index 72bc22a9968..dfe5ca5d484 100644 --- a/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs +++ b/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs @@ -34,6 +34,8 @@ public class RestApiDocumentProcessor : ReferenceDocumentProcessorBase ".swagger.json", ".swagger2.json", ".json", + ".yaml", + ".yml", ]; protected static readonly string[] SystemKeys = [ @@ -66,6 +68,19 @@ public class RestApiDocumentProcessor : ReferenceDocumentProcessorBase "externalDocs" ]; + private static readonly string[] OpenApiSystemKeys = [ + .. SystemKeys, + "openapi", + "servers", + "components", + "schemas", + "requestBody", + "requestUrl", + "rawExtension", + "jsonSchemaDialect", + "webhooks" + ]; + [ImportMany(nameof(RestApiDocumentProcessor))] public override IEnumerable BuildSteps { get; set; } @@ -122,20 +137,34 @@ public override SaveResult Save(FileModel model) protected override FileModel LoadArticle(FileAndType file, ImmutableDictionary metadata) { var filePath = Path.Combine(file.BaseDir, file.File); - var swagger = SwaggerJsonParser.Parse(filePath); - swagger.Metadata[DocumentTypeKey] = RestApiDocumentType; - swagger.Raw = EnvironmentContext.FileAbstractLayer.ReadAllText(filePath); - CheckOperationId(swagger, file.File); + RestApiRootItemViewModel vm; + var isOpenApi = !(filePath.EndsWith(".json", StringComparison.OrdinalIgnoreCase) && IsSwaggerFile(filePath)) && + OpenApiDocumentReader.IsOpenApiFile(filePath); + if (isOpenApi) + { + vm = OpenApiDocumentReader.Read(filePath); + } + else + { + var swagger = SwaggerJsonParser.Parse(filePath); + swagger.Raw = EnvironmentContext.FileAbstractLayer.ReadAllText(filePath); + CheckOperationId(swagger, file.File); + vm = SwaggerModelConverter.FromSwaggerModel(swagger); + } + vm.Metadata[DocumentTypeKey] = RestApiDocumentType; var repoInfo = GitUtility.TryGetFileDetail(filePath); if (repoInfo != null) { - swagger.Metadata["source"] = new SourceDetail { Remote = repoInfo }; + vm.Metadata["source"] = new SourceDetail { Remote = repoInfo }; } - swagger.Metadata = MergeMetadata(swagger.Metadata, metadata); - var vm = SwaggerModelConverter.FromSwaggerModel(swagger); - vm.Metadata[Constants.PropertyName.SystemKeys] = SystemKeys; + vm.Metadata = MergeMetadata(vm.Metadata, metadata); + foreach (var child in vm.Children) + { + child.Metadata[Constants.PropertyName.Source] = vm.Metadata.GetValueOrDefault(Constants.PropertyName.Source); + } + vm.Metadata[Constants.PropertyName.SystemKeys] = isOpenApi ? OpenApiSystemKeys : SystemKeys; var displayLocalPath = PathUtility.MakeRelativePath(EnvironmentContext.BaseDirectory, file.FullPath); return new FileModel(file, vm) @@ -193,7 +222,9 @@ private static IEnumerable GetXRefInfo(RestApiRootItemViewModel rootIt private static bool IsSupportedFile(string filePath) { - return SupportedFileEndings.Any(s => IsSupportedFileEnding(filePath, s)) && IsSwaggerFile(filePath); + return SupportedFileEndings.Any(s => IsSupportedFileEnding(filePath, s)) && + ((filePath.EndsWith(".json", StringComparison.OrdinalIgnoreCase) && IsSwaggerFile(filePath)) || + OpenApiDocumentReader.IsOpenApiFile(filePath)); } private static bool IsSupportedFileEnding(string filePath, string fileEnding) diff --git a/src/Docfx.Build.RestApi/RestApiModelConverter.cs b/src/Docfx.Build.RestApi/RestApiModelConverter.cs new file mode 100644 index 00000000000..ce693ffa130 --- /dev/null +++ b/src/Docfx.Build.RestApi/RestApiModelConverter.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.RegularExpressions; + +namespace Docfx.Build.RestApi; + +internal static partial class RestApiModelConverter +{ + [GeneratedRegex(@"\W")] + private static partial Regex HtmlEncodeRegex(); + + internal static string GetHtmlId(string id) => string.IsNullOrEmpty(id) ? null : HtmlEncodeRegex().Replace(id, "_"); + + internal static string GenerateUid(params string[] segments) => + string.Join('/', segments.Where(s => !string.IsNullOrEmpty(s)).Select(s => s.Trim('/'))); + + internal static IEnumerable MergeParameters(IList operationParameters, IList pathParameters, Func equals) + { + if (pathParameters == null || pathParameters.Count == 0) + { + return operationParameters; + } + if (operationParameters == null || operationParameters.Count == 0) + { + return pathParameters; + } + + return operationParameters.Union(pathParameters.Where(p => !operationParameters.Any(o => equals(p, o)))).ToList(); + } +} diff --git a/src/Docfx.Build.RestApi/SwaggerModelConverter.cs b/src/Docfx.Build.RestApi/SwaggerModelConverter.cs index 9a83e692a9b..6e02c45793c 100644 --- a/src/Docfx.Build.RestApi/SwaggerModelConverter.cs +++ b/src/Docfx.Build.RestApi/SwaggerModelConverter.cs @@ -1,8 +1,6 @@ // 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.RegularExpressions; - using Docfx.Build.RestApi.Swagger; using Docfx.Common; using Docfx.DataContracts.Common; @@ -10,6 +8,8 @@ using Newtonsoft.Json.Linq; +using static Docfx.Build.RestApi.RestApiModelConverter; + namespace Docfx.Build.RestApi; public static partial class SwaggerModelConverter @@ -106,23 +106,9 @@ public static RestApiRootItemViewModel FromSwaggerModel(SwaggerModel swagger) #region Private methods - [GeneratedRegex(@"\W")] - private static partial Regex HtmlEncodeRegex(); - private const string TagText = "tag"; private static readonly string[] OperationNames = ["get", "put", "post", "delete", "options", "head", "patch"]; - /// - /// TODO: merge with the one in XrefDetails - /// - /// - /// - private static string GetHtmlId(string id) - { - if (string.IsNullOrEmpty(id)) return null; - return HtmlEncodeRegex().Replace(id, "_"); - } - private static string GetUid(SwaggerModel swagger) { return GenerateUid(swagger.Host, swagger.BasePath, swagger.Info.Title, swagger.Info.Version); @@ -138,16 +124,6 @@ private static string GetUidForTag(string parentUid, TagItemObject tag) return GenerateUid(parentUid, TagText, tag.Name); } - /// - /// UID is joined by '/', if segment ends with '/', use that one instead - /// - /// The segments to generate UID - /// - private static string GenerateUid(params string[] segments) - { - return string.Join('/', segments.Where(s => !string.IsNullOrEmpty(s)).Select(s => s.Trim('/'))); - } - /// /// Merge operation's parameters with path's parameters. /// @@ -156,20 +132,7 @@ private static string GenerateUid(params string[] segments) /// private static IEnumerable GetParametersForOperation(List operationParameters, List pathParameters) { - if (pathParameters == null || pathParameters.Count == 0) - { - return operationParameters; - } - if (operationParameters == null || operationParameters.Count == 0) - { - return pathParameters; - } - - // Path parameters can be overridden at the operation level. - var uniquePathParams = pathParameters.Where( - p => !operationParameters.Any(o => IsParameterEquals(p, o))).ToList(); - - return operationParameters.Union(uniquePathParams).ToList(); + return MergeParameters(operationParameters, pathParameters, IsParameterEquals); } /// diff --git a/templates/common/RestApi.common.js b/templates/common/RestApi.common.js index cdb07596e22..df0566bed95 100644 --- a/templates/common/RestApi.common.js +++ b/templates/common/RestApi.common.js @@ -3,8 +3,29 @@ var common = require('./common.js'); exports.transform = function (model) { + var schemas = Object.create(null); + Object.keys(model.schemas || {}).forEach(function (name) { + schemas[name] = model.schemas[name]; + }); + Object.keys(schemas).forEach(function (name) { collectSchemas(schemas[name]); }); + (model.children || []).forEach(function (child) { + if (!(model._preserveLiteralData || child._preserveLiteralData || + model.schemas || child.servers || child.requestUrl || child.requestBody || + (child.parameters || []).some(function (parameter) { return parameter.content; }) || + (child.responses || []).some(function (response) { return response.content; }))) return; + child._hasSchemaDetails = true; + (child.parameters || []).forEach(function (parameter) { + collectSchemas(parameter.schema); + (parameter.content || []).forEach(function (media) { collectSchemas(media.schema); }); + }); + (child.responses || []).forEach(function (response) { + collectSchemas(response.schema); + (response.content || []).forEach(function (media) { collectSchemas(media.schema); }); + }); + ((child.requestBody || {}).content || []).forEach(function (media) { collectSchemas(media.schema); }); + }); var _fileNameWithoutExt = common.path.getFileNameWithoutExtension(model._path); - model._jsonPath = _fileNameWithoutExt + ".swagger.json"; + model._jsonPath = _fileNameWithoutExt + ".swagger" + (model.rawExtension === ".yaml" ? ".yaml" : ".json"); model.title = model.title || model.name; model.docurl = model.docurl || common.getImproveTheDocHref(model, model._gitContribute, model._gitUrlPattern); model.sourceurl = model.sourceurl || common.getViewSourceHref(model, null, model._gitUrlPattern); @@ -16,7 +37,9 @@ exports.transform = function (model) { if (child.operation) { child.operation = child.operation.toUpperCase(); } - child.path = appendQueryParamsToPath(child.path, child.parameters); + if (!child._hasSchemaDetails) { + child.path = appendQueryParamsToPath(child.path, child.parameters); + } child.sourceurl = child.sourceurl || common.getViewSourceHref(child, null, model._gitUrlPattern); child.conceptual = child.conceptual || ''; // set to empty incase mustache looks up child.summary = child.summary || ''; // set to empty incase mustache looks up @@ -26,8 +49,26 @@ exports.transform = function (model) { child.htmlId = common.getHtmlId(child.uid); formatExample(child.responses); - resolveAllOf(child); - transformReference(child); + if (child._hasSchemaDetails) { + (child.servers || []).forEach(function (server) { server.description = server.description || null; }); + (child.parameters || []).forEach(function (parameter) { + parameter.hasContent = parameter.content !== undefined && parameter.content !== null; + transformContent(parameter.content); + parameter.schemaDetails = schemaDetails(parameter.schema); + }); + if (child.requestBody) { + child.requestBody.description = child.requestBody.description || null; + transformContent(child.requestBody.content); + } + (child.responses || []).forEach(function (response) { + response.hasContent = response.content !== undefined && response.content !== null; + transformContent(response.content); + response.schemaDetails = schemaDetails(response.schema); + }); + } else { + resolveAllOf(child); + transformReference(child); + } }; if (!model.tags || model.tags.length === 0) { var childTags = []; @@ -85,6 +126,7 @@ exports.transform = function (model) { if (model.tags) { model.tags.forEach(function(tag) { (tag.children || []).forEach(function(child) { + if (child._hasSchemaDetails) return; (child.parameters || []).forEach(function(parameter) { addComplexTypeMetadata(parameter.schema, model.definitions); }); (child.responses || []).forEach(function(response) { addComplexTypeMetadata(response.schema, model.definitions); }); }); @@ -92,13 +134,96 @@ exports.transform = function (model) { } if (model.children) { model.children.forEach(function(child) { + if (child._hasSchemaDetails) return; (child.parameters || []).forEach(function(parameter) { addComplexTypeMetadata(parameter.schema, model.definitions); }); (child.responses || []).forEach(function(response) { addComplexTypeMetadata(response.schema, model.definitions); }); }); } + Object.keys(schemas).forEach(function (name) { + var details = schemaDetails(schemas[name]); + details.id = schemaId(name); + details.name = name; + if (details.referenceName === name) { + details.referenceName = null; + details.referenceId = null; + } + model.definitions.push({ schemaDetails: details }); + }); return model; + function schemaId(name) { + return "schema-" + name.replace(/[^a-zA-Z0-9-]/g, function (character) { + return "_" + character.charCodeAt(0).toString(16) + "_"; + }); + } + + function collectSchemas(schema) { + if (!schema) return; + var name = schema['x-internal-ref-name']; + if (name && !schemas[name]) schemas[name] = schema; + Object.keys(schema.properties || {}).forEach(function (key) { collectSchemas(schema.properties[key]); }); + collectSchemas(schema.items); + (schema.composition || []).forEach(function (composition) { + (composition.schemas || []).forEach(collectSchemas); + }); + } + + function schemaDetails(schema) { + if (!schema) return null; + var name = schema['x-internal-loop-ref-name'] || schema['x-internal-ref-name']; + // Explicit empty fields prevent recursive Mustache partials from looking up an ancestor's schema. + return { + type: schema.type || null, + format: schema.format || null, + description: schema.description || null, + referenceName: name || null, + referenceId: name && schemas[name] ? schemaId(name) : null, + 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.composition || []).map(function (composition) { + return { kind: composition.kind, schemas: (composition.schemas || []).map(schemaDetails) }; + }), + constraints: schema.constraints || [], + enum: (schema.enum || []).map(function (value) { return { value: JSON.stringify(value) }; }), + exampleDetails: exampleDetails(schema.examples) + }; + } + + function exampleDetails(examples) { + return (examples || []).map(function (example) { + var externalValue = example.externalValue || null; + return { + name: example.name || null, + mimeType: example.mimeType || null, + content: typeof example.content === "string" ? example.content : null, + hasContent: typeof example.content === "string", + externalValue: externalValue, + externalHref: externalValue && /^https?:\/\/[^\s\\]+$/i.test(externalValue) ? externalValue : null + }; + }); + } + + function transformContent(content) { + (content || []).forEach(function (media) { + media.schemaDetails = schemaDetails(media.schema); + media.examples = media.examples || []; + media.examples.forEach(function (example) { + example.name = example.name || null; + example.mimeType = example.mimeType || media.mimeType; + }); + }); + formatExample(content); + (content || []).forEach(function (media) { media.exampleDetails = exampleDetails(media.examples); }); + } + function getChildrenByTag(children, tag) { if (!children) return; return children.filter(function (child) { diff --git a/templates/default/partials/rest.child.tmpl.partial b/templates/default/partials/rest.child.tmpl.partial index a64d6b7dd8b..9542de2aeb5 100644 --- a/templates/default/partials/rest.child.tmpl.partial +++ b/templates/default/partials/rest.child.tmpl.partial @@ -23,8 +23,16 @@ {{/conceptual}}
Request
-
{{operation}} {{path}}
+
{{operation}} {{#requestUrl}}{{requestUrl}}{{/requestUrl}}{{^requestUrl}}{{path}}{{/requestUrl}}
+{{#servers.0}} +
Servers
+
    + {{#servers}} +
  • {{url}}{{#description}}
    {{{description}}}
    {{/description}}
  • + {{/servers}} +
+{{/servers.0}} {{#parameters.0}}
Parameters
@@ -42,6 +50,10 @@ - + {{/parameters}} {{#parameters.0}}
{{#required}}*{{/required}}{{name}} + {{#content}}{{>partials/rest.media-schema}}{{/content}} + {{^hasContent}} + {{#schemaDetails}}{{>partials/rest.schema}}{{/schemaDetails}} + {{^schemaDetails}} {{^schema.cType}} {{schema.type}} {{#schema.format}} @@ -52,15 +64,28 @@ {{#schema.cType}} {{{schema.cType}}}{{#schema.cTypeIsArray}}[]{{/schema.cTypeIsArray}} {{/schema.cType}} + {{/schemaDetails}} + {{/hasContent}} {{default}}{{{description}}}{{{description}}}{{#content}}{{>partials/rest.examples}}{{/content}}
{{/parameters.0}} +{{#requestBody}} +
Request Body
+
+

{{#required}}Required{{/required}}{{^required}}Optional{{/required}}

+ {{#description}}
{{{description}}}
{{/description}} + {{#content}} + {{>partials/rest.media-schema}} + {{>partials/rest.examples}} + {{/content}} +
+{{/requestBody}} {{#responses.0}}
Responses
@@ -79,6 +104,10 @@ {{statusCode}} + {{#content}}{{>partials/rest.media-schema}}{{/content}} + {{^hasContent}} + {{#schemaDetails}}{{>partials/rest.schema}}{{/schemaDetails}} + {{^schemaDetails}} {{^schema.cType}} {{schema.type}} {{/schema.cType}} @@ -86,15 +115,20 @@ {{#schema.cType}} {{{schema.cType}}}{{#schema.cTypeIsArray}}[]{{/schema.cTypeIsArray}} {{/schema.cType}} + {{/schemaDetails}} + {{/hasContent}} {{{description}}} + {{#content}}{{>partials/rest.examples}}{{/content}} + {{^hasContent}} {{#examples}}
Mime type: {{mimeType}}
{{content}}
{{/examples}} + {{/hasContent}} {{/responses}} diff --git a/templates/default/partials/rest.definition.tmpl.partial b/templates/default/partials/rest.definition.tmpl.partial index 83cc77ef00b..97ab63ff6de 100644 --- a/templates/default/partials/rest.definition.tmpl.partial +++ b/templates/default/partials/rest.definition.tmpl.partial @@ -1,5 +1,10 @@ {{!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}} +{{^schemaDetails}}

{{{cType}}}

{{#description}}
{{{description}}}
@@ -43,3 +48,4 @@ {{.}}
{{/enum}} {{/enum.0}} +{{/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..f34e5426549 --- /dev/null +++ b/templates/default/partials/rest.examples.tmpl.partial @@ -0,0 +1,18 @@ +{{!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}} +{{#name}}
{{name}}
{{/name}} +{{#hasContent}} +
{{content}}
+{{/hasContent}} +{{#externalValue}} +

External example: + {{#externalHref}}{{externalValue}}{{/externalHref}} + {{^externalHref}}{{externalValue}}{{/externalHref}} +

+{{/externalValue}} +{{/exampleDetails}} diff --git a/templates/default/partials/rest.media-schema.tmpl.partial b/templates/default/partials/rest.media-schema.tmpl.partial new file mode 100644 index 00000000000..32e7c589800 --- /dev/null +++ b/templates/default/partials/rest.media-schema.tmpl.partial @@ -0,0 +1,5 @@ +{{!Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license.}} +
+
Mime type: {{mimeType}}
+ {{#schemaDetails}}{{>partials/rest.schema}}{{/schemaDetails}} +
diff --git a/templates/default/partials/rest.schema.tmpl.partial b/templates/default/partials/rest.schema.tmpl.partial new file mode 100644 index 00000000000..82723091e50 --- /dev/null +++ b/templates/default/partials/rest.schema.tmpl.partial @@ -0,0 +1,43 @@ +{{!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}} + {{#constraints.0}} +
+ {{#constraints}}
{{name}}
{{value}}
{{/constraints}} +
+ {{/constraints.0}} + {{#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..5a1f462b9a8 --- /dev/null +++ b/templates/modern/src/rest.test.ts @@ -0,0 +1,327 @@ +// 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 raw filename hints preserve JSON compatibility and identify original YAML', () => { + const legacy = rest.transform({ uid: 'legacy', _path: 'legacy.html' }) + assert.equal(legacy._jsonPath, 'legacy.swagger.json') + + const json = rest.transform({ uid: 'json', _path: 'openapi.html', rawExtension: '.json', _raw: '{"openapi":"3.1.0"}' }) + assert.equal(json._jsonPath, 'openapi.swagger.json') + assert.equal(json._raw, '{"openapi":"3.1.0"}') + + const yaml = rest.transform({ uid: 'yaml', _path: 'openapi.html', rawExtension: '.yaml', _raw: 'openapi: 3.1.0\n' }) + assert.equal(yaml._jsonPath, 'openapi.swagger.yaml') + assert.equal(yaml._raw, 'openapi: 3.1.0\n') +}) + +test('REST preserves legacy parameter paths, allOf flattening, and definitions', () => { + const model = rest.transform({ + uid: 'legacy', _path: 'legacy.json', + children: [{ + uid: 'get', operation: 'get', path: '/items', + parameters: [ + { name: 'filter', in: 'query', required: true, schema: { type: 'string' } }, + { name: 'limit', in: 'query', schema: { type: 'integer' } } + ], + responses: [{ + schema: { + 'x-internal-ref-name': 'Item', + allOf: [{ properties: { id: { type: 'integer' } } }, { properties: { name: { type: 'string' } } }] + }, + examples: [{ mimeType: 'application/json', content: '{"id":1}' }] + }] + }] + }) + const child = model.children[0] + assert.equal(child.operation, 'GET') + assert.equal(child.path, '/items?filter[&limit]') + assert.equal(child._hasSchemaDetails, undefined) + assert.equal(child.responses[0].examples[0].content, '{\n "id": 1\n}') + assert.equal(child.responses[0].schema.cTypeId, 'Item') + assert.deepEqual(child.responses[0].schema.properties.map(property => property.key), ['id', 'name']) + assert.equal(child.responses[0].schema.allOf, undefined) + assert.equal(model.definitions.length, 1) + assert.equal(model.definitions[0].schemaDetails, undefined) +}) + +test('REST prepares every request and response media schema and named example', () => { + const model = rest.transform({ + uid: 'media', _path: 'media.json', schemas: {}, + children: [{ + uid: 'post', operation: 'post', path: '/items', + requestUrl: 'https://api.example.test/v2/items', + servers: [{ url: 'https://api.example.test/v2' }], + parameters: [{ name: 'filter', in: 'query', schema: { type: 'string | null', format: 'uuid' } }], + requestBody: { + required: true, + content: [ + { mimeType: 'application/json', schema: { type: 'object' }, examples: [{ name: 'created', content: '{"id":1}' }] }, + { mimeType: 'text/plain', schema: { type: 'string' }, examples: [{ content: 'text request' }] } + ] + }, + responses: [{ + statusCode: '200', + content: [ + { mimeType: 'application/json', schema: { type: 'array', items: { type: 'integer' } }, examples: [{ content: '[1,2]' }] }, + { mimeType: 'text/plain', schema: { type: 'string' }, examples: [{ content: 'text response' }] }, + { mimeType: 'application/octet-stream' } + ], + examples: [{ mimeType: 'text/plain', content: 'flattened response' }] + }, { statusCode: '204', content: [], examples: [{ content: 'must not render' }] }] + }] + }) + const child = model.children[0] + assert.equal(child.path, '/items') + assert.equal(child.requestUrl, 'https://api.example.test/v2/items') + assert.equal(child.servers[0].description, null) + assert.equal(child.parameters[0].schemaDetails.type, 'string | null') + assert.equal(child.parameters[0].schemaDetails.format, 'uuid') + assert.equal(child.requestBody.description, null) + assert.deepEqual(child.requestBody.content.map(media => media.schemaDetails.type), ['object', 'string']) + assert.deepEqual(child.requestBody.content[0].examples[0], { + name: 'created', mimeType: 'application/json', content: '{\n "id": 1\n}' + }) + assert.equal(child.responses[0].content[0].schemaDetails.items.type, 'integer') + assert.equal(child.responses[0].content[0].examples[0].content, '[\n 1,\n 2\n]') + assert.equal(child.responses[0].content[1].examples[0].name, null) + assert.deepEqual(child.responses[0].content[2].examples, []) + assert.equal(child.responses[0].content[2].schemaDetails, null) + assert.equal(child.responses[0].hasContent, true) + assert.equal(child.responses[1].hasContent, true) + assert.equal(child.responses[0].examples[0].content, 'flattened response') +}) + +test('REST keeps nested composition, constraints, unions, boolean schemas, and false enum values', () => { + const schema = { + type: 'object', + required: ['value'], + properties: { + value: { + type: 'string | null', + description: '

A nullable value.

', + constraints: [{ name: 'minLength', value: '0' }], + enum: ['', null, false, 0] + }, + list: { + type: 'array', + required: true, + items: { + composition: [ + { kind: 'All of', schemas: [{ type: 'object', properties: { allowed: { type: 'any value' } } }] }, + { kind: 'One of', schemas: [{ type: 'string' }, { type: 'integer' }] }, + { kind: 'Any of', schemas: [{ type: 'boolean' }, { type: 'null' }] }, + { kind: 'Not', schemas: [{ type: 'no value' }] } + ] + } + } + } + } + const original = structuredClone(schema) + const model = rest.transform({ uid: 'nested', _path: 'nested.json', schemas: { Nested: schema } }) + const details = model.definitions[0].schemaDetails + assert.deepEqual(schema, original) + assert.equal(details.properties[0].required, true) + assert.equal(details.properties[1].required, true) + assert.deepEqual(details.properties[0].value.enum, [{ value: '""' }, { value: 'null' }, { value: 'false' }, { value: '0' }]) + assert.deepEqual(details.properties[0].value.constraints, [{ name: 'minLength', value: '0' }]) + assert.equal(details.properties[0].value.description, '

A nullable value.

') + const composition = details.properties[1].value.items.composition + assert.deepEqual(composition.map(item => item.kind), ['All of', 'One of', 'Any of', 'Not']) + assert.equal(composition[0].schemas[0].properties[0].value.type, 'any value') + assert.equal(composition[3].schemas[0].type, 'no value') + assert.deepEqual(composition[3].schemas[0].properties, []) + assert.equal(composition[3].schemas[0].items, null) + assert.deepEqual(composition[3].schemas[0].composition, []) +}) + +test('REST links recursive references and aliases without colliding schema anchors', () => { + const model = rest.transform({ + uid: 'references', _path: 'references.json', + schemas: { + 'Tree.Node': { type: 'object', properties: { next: { 'x-internal-loop-ref-name': 'Tree.Node' } } }, + Tree_Node: { type: 'any value' }, + Alias: { 'x-internal-ref-name': 'Tree.Node' } + }, + children: [{ + uid: 'read', path: '/tree', tags: ['Trees'], + requestUrl: '/tree', + responses: [{ + content: [{ + mimeType: 'application/json', + schema: { type: 'array', items: { 'x-internal-ref-name': 'Tree.Node' } } + }] + }] + }] + }) + const [tree, distinct, alias] = model.definitions.map(definition => definition.schemaDetails) + assert.notEqual(tree.id, distinct.id) + assert.equal(tree.properties[0].value.referenceId, tree.id) + assert.equal(alias.referenceId, tree.id) + assert.equal(model.tags[0].children[0].responses[0].content[0].schemaDetails.items.referenceId, tree.id) + assert.equal(model.definitions.length, 3) +}) + +test('REST adds inline reference definitions and leaves unresolved references as text', () => { + const model = rest.transform({ + uid: 'inline', _path: 'inline.json', + children: [{ + uid: 'read', path: '/inline', requestUrl: '/inline', + parameters: [{ + schema: { + type: 'object', 'x-internal-ref-name': 'Inline', + properties: { missing: { 'x-internal-loop-ref-name': 'Missing' } } + } + }] + }] + }) + const details = model.children[0].parameters[0].schemaDetails + assert.equal(details.referenceId, model.definitions[0].schemaDetails.id) + assert.equal(details.properties[0].value.referenceName, 'Missing') + assert.equal(details.properties[0].value.referenceId, null) +}) + +test('REST renders parameter content and keeps same-name external schema references distinct', () => { + const model = rest.transform({ + uid: 'parameters', _path: 'parameters.json', + children: [{ + uid: 'search', path: '/items', + parameters: [{ + name: 'filter', in: 'query', default: '{"active":true}', + content: [ + { + mimeType: 'application/json', + schema: { + type: 'object', 'x-internal-ref-name': 'models/first.yaml#Filter', + properties: { next: { 'x-internal-loop-ref-name': 'models/first.yaml#Filter' } } + }, + examples: [{ name: 'active', content: '{"active":true}' }] + }, + { + mimeType: 'text/plain', + schema: { type: 'string', 'x-internal-ref-name': 'models/second.yaml#Filter' }, + examples: [{ content: 'active' }] + } + ] + }] + }] + }) + const parameter = model.children[0].parameters[0] + assert.equal(parameter.hasContent, true) + assert.equal(parameter.schemaDetails, null) + assert.equal(parameter.default, '{"active":true}') + assert.equal(parameter.content[0].exampleDetails[0].content, '{\n "active": true\n}') + assert.equal(parameter.content[0].exampleDetails[0].name, 'active') + assert.equal(parameter.content[1].schemaDetails.type, 'string') + const [first, second] = model.definitions.map(definition => definition.schemaDetails) + assert.notEqual(first.id, second.id) + assert.equal(parameter.content[0].schemaDetails.referenceId, first.id) + assert.equal(parameter.content[1].schemaDetails.referenceId, second.id) + assert.equal(first.properties[0].value.referenceId, first.id) +}) + +test('REST renders schema examples without inheriting names, MIME types, or ancestor examples', () => { + const schema = { + type: 'object', + examples: [{ content: '{"state":"active"}' }, { content: '' }], + properties: { state: { type: 'string', enum: ['active', 'archived'], examples: [{ content: '"active"' }] } }, + items: { type: 'string' } + } + const original = structuredClone(schema) + const model = rest.transform({ uid: 'examples', _path: 'examples.json', schemas: { Example: schema } }) + const details = model.definitions[0].schemaDetails + assert.deepEqual(schema, original) + assert.deepEqual(details.exampleDetails[0], { + name: null, mimeType: null, content: '{"state":"active"}', + hasContent: true, externalValue: null, externalHref: null + }) + assert.equal(details.exampleDetails[1].hasContent, true) + assert.equal(details.exampleDetails[1].content, '') + assert.equal(details.properties[0].value.exampleDetails[0].content, '"active"') + assert.deepEqual(details.properties[0].value.enum, [{ value: '"active"' }, { value: '"archived"' }]) + assert.deepEqual(details.items.exampleDetails, []) +}) + +test('REST displays external example URLs without inventing content or linking executable schemes', () => { + const urls = ['https://example.test/sample.json', 'http://example.test/sample.json', 'samples/local.json', 'javascript:alert(1)'] + const model = rest.transform({ + uid: 'external-examples', _path: 'external-examples.json', + children: [{ + uid: 'read', path: '/items', + responses: [{ + content: [{ + mimeType: 'application/json', + examples: urls.map(externalValue => ({ name: 'external', externalValue, content: null })) + }] + }] + }] + }) + const examples = model.children[0].responses[0].content[0].exampleDetails + assert.deepEqual(examples.map(example => example.externalValue), urls) + assert.deepEqual(examples.map(example => example.externalHref), [...urls.slice(0, 2), null, null]) + assert.ok(examples.every(example => example.name === 'external' && example.content === null && !example.hasContent)) +}) + +for (const flagLocation of ['root', 'operation']) { + test(`REST preserves literal enum, examples, and extensions with the ${flagLocation} feature flag`, () => { + const literal = { + description: 'literal **description**, not markup', + allOf: [{ type: 'string' }, { properties: { literal: { type: 'integer' } } }], + $ref: '#/literal/value', + schema: { properties: { description: { type: 'string' } } }, + 'x-internal-ref-name': 'not-a-definition' + } + const original = structuredClone(literal) + const schema = { + type: 'object', + enum: [literal], + examples: [{ content: JSON.stringify(literal), 'x-literal': structuredClone(literal) }], + constraints: [{ name: 'const', value: JSON.stringify(literal) }], + properties: { value: { type: 'string' } }, + 'x-schema-shaped': structuredClone(literal) + } + const originalSchema = structuredClone(schema) + const operation = { + uid: 'read', path: '/literal', + _preserveLiteralData: flagLocation === 'operation', + parameters: [{ name: 'filter', in: 'query', required: true, schema }], + responses: [{ + schema: { type: 'object', enum: [structuredClone(literal)] }, + examples: [{ mimeType: 'text/plain', content: JSON.stringify(literal), 'x-literal': structuredClone(literal) }] + }], + 'x-operation': structuredClone(literal) + } + const model = rest.transform({ + uid: 'literal', _path: 'literal.json', + _preserveLiteralData: flagLocation === 'root', + 'x-root': structuredClone(literal), + children: [operation] + }) + assert.equal(operation._hasSchemaDetails, true) + assert.equal(operation.path, '/literal') + assert.deepEqual(schema, originalSchema) + assert.deepEqual(model['x-root'], original) + assert.deepEqual(operation['x-operation'], original) + assert.deepEqual(operation.responses[0].schema.enum[0], original) + assert.deepEqual(operation.responses[0].examples[0]['x-literal'], original) + assert.equal(operation.responses[0].examples[0].content, JSON.stringify(original)) + assert.deepEqual(operation.parameters[0].schemaDetails.enum, [{ value: JSON.stringify(original) }]) + assert.equal(operation.parameters[0].schemaDetails.exampleDetails[0].content, JSON.stringify(original)) + assert.deepEqual(model.definitions, []) + }) +} diff --git a/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs b/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs new file mode 100644 index 00000000000..42ad66b67be --- /dev/null +++ b/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs @@ -0,0 +1,438 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Docfx.DataContracts.RestApi; +using Docfx.Exceptions; +using Docfx.Tests.Common; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Docfx.Build.RestApi.Tests; + +[Collection("docfx STA")] +public class OpenApiDocumentReaderTest : TestBase +{ + [Theory] + [InlineData("3.0.3")] + [InlineData("3.1.0")] + public void MapsTypedParametersBodiesResponsesAndLiteralExamples(string version) + { + var raw = $$""" + { + "openapi": "{{version}}", + "info": { "title": "Typed API", "version": "1", "description": "**API**" }, + "servers": [{ "url": "https://{host}/v1", "variables": { "host": { "default": "api.example.test" } } }], + "paths": { + "/items/{id}": { + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 10 } } + ], + "post": { + "operationId": "createItem", "tags": ["items"], "x-owner": "docs", + "parameters": [{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 0 } }], + "requestBody": { + "description": "**Body**", "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Item" } } } + }, + "responses": { + "200": { + "description": "**OK**", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Item" }, + "example": { "$ref": "this-is-payload.json", "description": "**literal**" } + }, + "text/plain": { "schema": { "type": "string" }, "example": "OK" } + } + } + } + } + } + }, + "components": { "schemas": { "Item": { + "type": "object", "required": ["name"], + "properties": { "name": { "type": "string" }, "next": { "$ref": "#/components/schemas/Item" } } + } } } + } + """; + var model = OpenApiDocumentReader.Parse(raw, "json"); + Assert.Equal(raw, model.Raw); + Assert.Equal("api.example.test/v1/Typed API/1", model.Uid); + Assert.Equal("**API**", model.Description); + var child = Assert.Single(model.Children); + Assert.Equal(model.Uid + "/createItem", child.Uid); + Assert.Equal("docs", child.Metadata["x-owner"]?.ToString()); + Assert.Equal("https://api.example.test/v1/items/{id}", child.Metadata["requestUrl"]); + Assert.Equal(["limit", "id"], child.Parameters.Select(p => p.Name)); + Assert.Equal("integer", ((JObject)child.Parameters[0].Metadata["schema"])["type"]); + Assert.Equal("0", child.Parameters[0].Metadata["default"]?.ToString()); + Assert.Equal(model.Uid + "/tag/items", Assert.Single(model.Tags).Uid); + var body = (JObject)child.Metadata["requestBody"]; + Assert.True((bool)body["required"]); + Assert.Equal("application/json", body["content"][0]["mimeType"]); + var schema = body["content"][0]["schema"]; + Assert.Equal("string", schema["properties"]["name"]["type"]); + Assert.NotNull(schema["properties"]["next"]["x-internal-loop-ref-name"]); + var response = Assert.Single(child.Responses); + var content = (JArray)response.Metadata["content"]; + Assert.Equal(["application/json", "text/plain"], content.Select(c => (string)c["mimeType"])); + var example = JObject.Parse((string)content[0]["examples"][0]["content"]); + Assert.Equal("this-is-payload.json", example["$ref"]); + Assert.Equal("**literal**", example["description"]); + Assert.Equal(2, response.Examples.Count); + } + + [Theory] + [InlineData("3.0.3")] + [InlineData("3.1.1")] + public void YamlUsesTheSameModelsAndDefaults(string version) + { + var model = OpenApiDocumentReader.Parse($$""" + openapi: {{version}} + info: + title: YAML API + version: '1' + paths: + /health: + get: + responses: + '204': + description: Healthy + """, "yaml"); + Assert.Equal("YAML API/1", model.Uid); + var operation = Assert.Single(model.Children); + Assert.StartsWith("get_", operation.OperationId); + Assert.Equal("/health", operation.Metadata["requestUrl"]); + Assert.Equal("204", Assert.Single(operation.Responses).HttpStatusCode); + } + + [Fact] + public void ServerPrecedenceAndGeneratedIdsAreStable() + { + static RestApiRootItemViewModel Read(string paths) => OpenApiDocumentReader.Parse($$""" + { + "openapi":"3.1.0", "info":{"title":"Servers","version":"1"}, + "servers":[{"url":"https://root.example.test/root"}], + "paths": { {{paths}} } + } + """, "json"); + const string paths = """ + "/path": { "servers":[{"url":"/path-base"}], + "get":{"responses":{"200":{"description":"OK"}}}, + "post":{"servers":[{"url":"https://override.example.test/{stage}","variables":{"stage":{"default":"v2"}}}], + "responses":{"201":{"description":"Created"}}} + }, + "/root": { "get":{"responses":{"200":{"description":"OK"}}} } + """; + var first = Read(paths); + var second = Read("\"/unrelated\": {\"get\":{\"responses\":{\"200\":{\"description\":\"OK\"}}}}," + paths); + Assert.Equal(["/path-base/path", "https://override.example.test/v2/path", "https://root.example.test/root/root"], + first.Children.Select(child => child.Metadata["requestUrl"])); + Assert.Equal(first.Children.Select(child => child.OperationId), second.Children.Skip(1).Select(child => child.OperationId)); + Assert.All(first.Children, child => Assert.DoesNotContain("/", child.OperationId)); + } + + [Fact] + public void BooleanUnionCompositionAndRefSiblingsAreNotFlattened() + { + var model = OpenApiDocumentReader.Parse(""" + { + "openapi":"3.1.0", "info":{"title":"Schemas","version":"1"}, + "paths":{"/boolean":{"get":{"responses":{"200":{"description":"OK","content":{ + "application/anything":{"schema":true}, + "application/nothing":{"schema":false} + }}}}}}, + "components":{"schemas":{ + "Nullable":{"type":["string","null"],"examples":[{"description":"**literal**"}]}, + "Base":{"type":"string","maxLength":10,"description":"base"}, + "Sibling":{"$ref":"#/components/schemas/Base","maxLength":5,"description":"sibling"}, + "Intersection":{"allOf":[{"type":"string"},{"type":"integer"}]}, + "Choice":{"oneOf":[{"type":"string"},{"type":"number"}]} + }} + } + """, "json"); + var schemas = (JObject)model.Metadata["schemas"]; + var content = (JArray)Assert.Single(Assert.Single(model.Children).Responses).Metadata["content"]; + Assert.Equal("any value", content[0]["schema"]["type"]); + Assert.Equal("no value", content[1]["schema"]["type"]); + Assert.Contains("string", (string)schemas["Nullable"]["type"]); + Assert.Contains("null", (string)schemas["Nullable"]["type"]); + Assert.Equal("sibling", schemas["Sibling"]["description"]); + var siblings = schemas["Sibling"]["composition"][0]["schemas"]; + Assert.Equal("10", siblings[0]["constraints"][0]["value"]); + Assert.Equal("5", siblings[1]["constraints"][0]["value"]); + Assert.Equal("All of", schemas["Intersection"]["composition"][0]["kind"]); + Assert.Equal(["string", "integer"], schemas["Intersection"]["composition"][0]["schemas"].Select(s => (string)s["type"])); + Assert.Equal("One of", schemas["Choice"]["composition"][0]["kind"]); + Assert.Null(schemas["Intersection"]["properties"]); + } + + [Theory] + [InlineData("true")] + [InlineData("false")] + public void RejectsBooleanSchemasTheSdkWouldDrop(string boolean) + { + foreach (var schema in new[] + { + boolean, + $$"""{ "properties": { "value": {{boolean}} } }""", + $$"""{ "patternProperties": { ".*": {{boolean}} } }""", + $$"""{ "$defs": { "value": {{boolean}} } }""", + $$"""{ "dependentSchemas": { "value": {{boolean}} } }""", + $$"""{ "allOf": [{{boolean}}] }""", + $$"""{ "anyOf": [{{boolean}}] }""", + $$"""{ "oneOf": [{{boolean}}] }""" + }) + { + var error = Assert.Throws(() => OpenApiDocumentReader.Parse( + """{"openapi":"3.1.0","info":{"title":"Boolean","version":"1"},"paths":{},"components":{"schemas":{"Value":SCHEMA}}}""" + .Replace("SCHEMA", schema), "json")); + Assert.Contains("UnsupportedBooleanSchema", error.Message); + Assert.Contains("#/components/schemas/Value", error.Message); + } + } + + [Theory] + [InlineData("true", "component")] + [InlineData("false", "component")] + [InlineData("true", "properties")] + [InlineData("false", "properties")] + [InlineData("true", "composition")] + [InlineData("false", "composition")] + public void RejectsBooleanLossInExternalDocuments(string boolean, string position) + { + var folder = GetRandomFolder(); + var entry = CreateFile("entry.json", """ + {"openapi":"3.1.0","info":{"title":"External","version":"1"},"paths":{}, + "components":{"schemas":{"Value":{"$ref":"external.yaml#/components/schemas/Value"}}}} + """, folder); + var schema = position switch + { + "component" => boolean, + "properties" => "{ properties: { value: " + boolean + " } }", + _ => "{ allOf: [" + boolean + "] }" + }; + CreateFile("external.yaml", $$""" + openapi: 3.1.0 + info: { title: External, version: '1' } + paths: {} + components: + schemas: + Value: {{schema}} + """, folder); + var error = Assert.Throws(() => OpenApiDocumentReader.Read(entry)); + Assert.Contains("UnsupportedBooleanSchema", error.Message); + Assert.Contains("external.yaml", error.Message); + } + + [Fact] + public void SchemaShapedLiteralExamplesAndExtensionsAreNotPreflighted() + { + var model = OpenApiDocumentReader.Parse(""" + { + "openapi":"3.1.0","info":{"title":"Data","version":"1"}, + "x-data":{"schema":{"allOf":[false]},"components":{"schemas":{"Value":true}}}, + "paths":{"/data":{"get":{"responses":{"200":{"description":"OK","content":{ + "application/json":{"schema":{"type":"object"},"example":{"schema":{"oneOf":[false]},"components":{"schemas":{"Value":true}}}} + }}}}}} + } + """, "json"); + Assert.NotNull(model.Metadata["x-data"]); + var example = Assert.Single(Assert.Single(Assert.Single(model.Children).Responses).Examples); + Assert.Contains("false", example.Content); + Assert.Contains("true", example.Content); + } + + [Fact] + public void PreservesSingularSchemaExamplesFromOpenApi30() + { + var model = OpenApiDocumentReader.Parse(""" + { + "openapi":"3.0.3","info":{"title":"Examples","version":"1"},"paths":{}, + "components":{"schemas":{"Value":{"type":"object","example":{"description":"**literal**","$ref":"payload"}}}} + } + """, "json"); + var schema = ((JObject)model.Metadata["schemas"])["Value"]; + var example = JObject.Parse((string)schema["examples"][0]["content"]); + Assert.Equal("**literal**", example["description"]); + Assert.Equal("payload", example["$ref"]); + } + + [Theory] + [InlineData("3.0.3")] + [InlineData("3.1.0")] + public void DoesNotTurnExclusiveOverlappingAlternativesIntoInclusiveUnions(string version) + { + foreach (var (schema, lossy) in new[] + { + ("""{"oneOf":[{"type":"integer"},{"type":"number"}]}""", true), + ("""{"oneOf":[{"type":"string"},{"type":"string"}]}""", true), + ("""{"oneOf":[{"type":"string","maxLength":2},{"type":"string","minLength":4}]}""", false) + }) + { + var raw = """ + {"openapi":"VERSION","info":{"title":"Exclusive","version":"1"},"paths":{}, + "components":{"schemas":{"Value":SCHEMA}}} + """.Replace("VERSION", version).Replace("SCHEMA", schema); + if (version == "3.0.3" && lossy) + { + var error = Assert.Throws(() => OpenApiDocumentReader.Parse(raw, "json")); + Assert.Contains("UnsupportedOpenApiComposition", error.Message); + continue; + } + var model = OpenApiDocumentReader.Parse(raw, "json"); + var value = ((JObject)model.Metadata["schemas"])["Value"]; + Assert.Equal("One of", value["composition"][0]["kind"]); + Assert.Equal(2, value["composition"][0]["schemas"].Count()); + } + } + + [Theory] + [InlineData("3.2.0")] + [InlineData("4.0.0")] + [InlineData("3.10.0")] + public void DoesNotAdvertiseUntestedVersions(string version) + { + var error = Assert.Throws(() => OpenApiDocumentReader.Parse( + """{"openapi":"VERSION","info":{"title":"Future","version":"1"},"paths":{}}""".Replace("VERSION", version), "json")); + Assert.Contains("3.0", error.Message); + Assert.Contains("3.1", error.Message); + } + + [Theory] + [InlineData("#/components/schemas/Missing")] + [InlineData("https://example.test/schema.json#/components/schemas/Item")] + [InlineData("file://server/share/schema.json")] + public void InvalidAndNetworkReferencesAreErrors(string reference) + { + var error = Assert.Throws(() => OpenApiDocumentReader.Parse(""" + { + "openapi":"3.1.0","info":{"title":"References","version":"1"}, + "paths":{},"components":{"schemas":{"Item":{"$ref":"REFERENCE"}}} + } + """.Replace("REFERENCE", reference), "json")); + Assert.NotEmpty(error.Message); + } + + [Fact] + public void ResolvesLocalMixedFormatDocumentsAndNestedReferences() + { + var folder = GetRandomFolder(); + var entry = CreateFile("entry.yaml", """ + openapi: 3.1.0 + info: { title: References, version: '1' } + paths: + /items: + get: + operationId: list + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: models/first.json#/components/schemas/Item + """, folder); + CreateFile("models/first.json", """ + { + "openapi":"3.1.0","info":{"title":"Models","version":"1"},"paths":{}, + "components":{"schemas":{"Item":{"type":"object","properties":{ + "name":{"$ref":"second.yaml#/components/schemas/Name"} + }}}} + } + """, folder); + CreateFile("models/second.yaml", """ + openapi: 3.1.0 + info: { title: Types, version: '1' } + paths: {} + components: + schemas: + Name: { type: string, description: From YAML } + """, folder); + var model = OpenApiDocumentReader.Read(entry); + var response = Assert.Single(Assert.Single(model.Children).Responses); + var schema = ((JArray)response.Metadata["content"])[0]["schema"]; + Assert.Equal("string", schema["properties"]["name"]["type"]); + Assert.Equal("From YAML", schema["properties"]["name"]["description"]); + } + + [Fact] + public void LoadsMixedFormatBidirectionalReferencesOnceWithoutExpandingCycles() + { + var folder = GetRandomFolder(); + var entry = CreateFile("a.json", """ + {"openapi":"3.1.0","info":{"title":"Cycle","version":"1"},"paths":{}, + "components":{"schemas":{"A":{"type":"object","properties":{"b":{"$ref":"b.yaml#/components/schemas/B"}}}}}} + """, folder); + CreateFile("b.yaml", """ + openapi: 3.1.0 + info: { title: Other, version: '1' } + paths: {} + components: + schemas: + B: + type: object + properties: + a: { $ref: 'a.json#/components/schemas/A' } + """, folder); + var model = OpenApiDocumentReader.Read(entry); + var schemas = (JObject)model.Metadata["schemas"]; + Assert.Equal("object", schemas["A"]["properties"]["b"]["type"]); + Assert.Equal("A", schemas["A"]["properties"]["b"]["properties"]["a"]["x-internal-loop-ref-name"]); + } + + [Fact] + public void SameRelativeFilenameInDifferentDirectoriesHasDistinctSdkIdentity() + { + var folder = GetRandomFolder(); + var entry = CreateFile("entry.json", """ + {"openapi":"3.1.0","info":{"title":"Identity","version":"1"},"paths":{}, + "components":{"schemas":{ + "A":{"$ref":"a/document.yaml#/components/schemas/Value"}, + "B":{"$ref":"b/document.yaml#/components/schemas/Value"} + }}} + """, folder); + foreach (var (directory, type) in new[] { ("a", "string"), ("b", "integer") }) + { + CreateFile($"{directory}/document.yaml", """ + openapi: 3.1.0 + info: { title: Reference, version: '1' } + paths: {} + components: + schemas: + Value: { $ref: 'common.json#/components/schemas/Value' } + """, folder); + CreateFile($"{directory}/common.json", """ + {"openapi":"3.1.0","info":{"title":"Common","version":"1"},"paths":{}, + "components":{"schemas":{"Value":{"type":"TYPE"}}}} + """.Replace("TYPE", type), folder); + } + var model = OpenApiDocumentReader.Read(entry); + var schemas = (JObject)model.Metadata["schemas"]; + Assert.Equal("string", schemas["A"]["type"]); + Assert.Equal("integer", schemas["B"]["type"]); + Assert.NotEqual((string)schemas["A"]["x-internal-ref-name"], (string)schemas["B"]["x-internal-ref-name"]); + } + + [Theory] + [InlineData("missing.yaml#/components/schemas/Value", false, "missing.yaml")] + [InlineData("external.yaml#/components/schemas/Missing", true, "Could not resolve")] + [InlineData("fragment.yaml", true, "UnsupportedExternalFragment")] + [InlineData("fragment.yaml#/components/schemas/Value", true, "UnsupportedExternalFragment")] + public void MissingTargetsAndStandaloneFragmentsNeverSucceed(string reference, bool createExternal, string diagnostic) + { + var folder = GetRandomFolder(); + var entry = CreateFile("entry.json", """ + {"openapi":"3.1.0","info":{"title":"Missing","version":"1"},"paths":{}, + "components":{"schemas":{"Value":{"$ref":"REFERENCE"}}}} + """.Replace("REFERENCE", reference), folder); + if (createExternal) + { + CreateFile("external.yaml", "openapi: 3.1.0\ninfo: { title: External, version: '1' }\npaths: {}\ncomponents: { schemas: {} }", folder); + CreateFile("fragment.yaml", "type: string", folder); + } + var error = Assert.Throws(() => OpenApiDocumentReader.Read(entry)); + Assert.Contains(diagnostic, error.Message); + } +} diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs b/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs new file mode 100644 index 00000000000..8d7871cc4a0 --- /dev/null +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs @@ -0,0 +1,551 @@ +// 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.Immutable; +using System.Reflection; +using Docfx.Build.Engine; +using Docfx.Build.OperationLevelRestApi; +using Docfx.Build.TagLevelRestApi; +using Docfx.Common; +using Docfx.Plugins; +using Docfx.Tests.Common; +using HtmlAgilityPack; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Docfx.Build.RestApi.WithPlugins.Tests; + +[Collection("docfx STA")] +public class OpenApiOutputTest : TestBase +{ + private const string RootUid = "api.example.test/v1/SDK API/1.0"; + private const string RootHtmlId = "api_example_test_v1_SDK_API_1_0"; + + [Theory] + [InlineData("default", "3.0.3", ".json", false, false, false)] + [InlineData("default", "3.0.3", ".yml", true, false, false)] + [InlineData("default", "3.1.0", ".yaml", false, true, false)] + [InlineData("default", "3.1.0", ".json", true, true, true)] + [InlineData("default", "3.0.3", ".yaml", false, false, true)] + [InlineData("statictoc", "3.1.0", ".yml", false, false, false)] + [InlineData("modern", "3.1.0", ".yaml", true, true, false)] + [InlineData("modern", "3.0.3", ".json", false, false, false)] + public void BuildsOpenApiDocumentation(string template, string version, string extension, + bool splitTags, bool splitOperations, bool overwrite) + { + var (input, files, original) = CreateInput(version, extension, overwrite); + var output = Build(input, files, template, splitTags, splitOperations); + var raw = Directory.GetFiles(output, "*.raw.json", SearchOption.AllDirectories) + .Where(path => Path.GetFileName(path) != "toc.raw.json") + .ToDictionary(path => Path.GetRelativePath(output, path).Replace('\\', '/')[..^9], + path => JObject.Parse(File.ReadAllText(path))); + var operations = raw.Values.SelectMany(model => model["children"]) + .ToDictionary(operation => (string)operation["operationId"]); + var generatedId = Assert.Single(operations.Keys, id => id != "createItem" && id != "inspectHealth"); + Assert.StartsWith("get_", generatedId); + Assert.True(generatedId.Length > "get_".Length); + Assert.Equal(3, operations.Count); + + string OperationPage(string id) + { + var page = splitTags ? "service/" + (id == "inspectHealth" ? "health" : "items") : "service"; + return splitOperations ? page + "/" + id : page; + } + + var pages = new[] { "service" } + .Concat(splitTags ? ["service/health", "service/items"] : []) + .Concat(operations.Keys.Select(OperationPage)).Distinct().Order(StringComparer.Ordinal).ToArray(); + Assert.Equal(pages, raw.Keys.Order(StringComparer.Ordinal)); + Assert.Equal(pages.Select(page => page + ".html").Append("toc.html").Order(StringComparer.Ordinal), + Directory.GetFiles(output, "*.html", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(output, path).Replace('\\', '/')).Order(StringComparer.Ordinal)); + var manifest = ReadModel(output, "manifest.json")["files"]; + Assert.Equal(pages.Length + 1, manifest.Count()); + Assert.Equal(pages.Select(page => page + ".html"), + manifest.Where(file => (string)file["type"] == "RestApi") + .Select(file => (string)file["output"][".html"]["relative_path"]).Order(StringComparer.Ordinal)); + Assert.Equal("toc.html", (string)Assert.Single(manifest, file => (string)file["type"] == "Toc") + ["output"][".html"]["relative_path"]); + + var views = pages.ToDictionary(page => page, page => ReadModel(output, page + ".html.view.json")); + var articles = pages.ToDictionary(page => page, + page => ReadHtml(output, page + ".html").SelectSingleNode("//article")); + foreach (var page in pages) + { + Assert.NotNull(articles[page]); + var heading = articles[page].SelectSingleNode(".//h1"); + Assert.Equal((string)raw[page]["uid"], (string)views[page]["uid"]); + Assert.Equal((string)views[page]["uid"], heading.GetAttributeValue("data-uid", null)); + Assert.Equal((string)views[page]["htmlId"], heading.Id); + var tocRel = string.Concat(Enumerable.Repeat("../", page.Count(character => character == '/'))) + "toc.html"; + Assert.Equal(tocRel, (string)raw[page]["_tocRel"]); + Assert.Equal(tocRel, (string)views[page]["_tocRel"]); + } + Assert.Equal(RootUid, (string)raw["service"]["uid"]); + Assert.Equal(RootHtmlId, (string)views["service"]["htmlId"]); + Assert.Equal("SDK API", (string)raw["service"]["name"]); + Assert.Equal(original, (string)raw["service"]["_raw"]); + var rawExtension = extension == ".json" ? ".json" : ".yaml"; + Assert.Equal(rawExtension, (string)raw["service"]["rawExtension"] ?? ".json"); + Assert.Equal("service.swagger" + rawExtension, (string)views["service"]["_jsonPath"]); + Assert.NotNull(articles["service"].SelectSingleNode(".//strong[text()='SDK']")); + Assert.NotNull(articles["service"].SelectSingleNode(".//a[@href='https://example.test/guide']")); + + var viewOperations = views.Values.SelectMany(model => + model["children"].Concat(model["tags"].SelectMany(tag => tag["children"]))) + .ToDictionary(operation => (string)operation["operationId"]); + Assert.Equal(operations.Keys.Order(StringComparer.Ordinal), viewOperations.Keys.Order(StringComparer.Ordinal)); + var expectedXrefs = new Dictionary { [RootUid] = "service.html" }; + foreach (var (id, operation) in operations) + { + var page = OperationPage(id); + var uid = RootUid + "/" + id; + var childUid = uid + (splitOperations ? "/operation" : ""); + var htmlId = RootHtmlId + "_" + id + (splitOperations ? "_operation" : ""); + Assert.Equal(childUid, (string)operation["uid"]); + Assert.Equal(childUid, (string)viewOperations[id]["uid"]); + Assert.Equal(htmlId, (string)viewOperations[id]["htmlId"]); + Assert.Equal(id == "createItem" ? "POST" : "GET", (string)viewOperations[id]["operation"]); + Assert.Equal(id == "inspectHealth" ? "/health" : "/items/{id}", (string)operation["path"]); + Assert.Equal((string)operation["path"], (string)viewOperations[id]["path"]); + var heading = articles[page].SelectSingleNode($".//h3[@id='{htmlId}']"); + Assert.NotNull(heading); + Assert.Equal(childUid, heading.GetAttributeValue("data-uid", null)); + expectedXrefs[uid] = page + ".html" + (splitOperations ? "" : "#" + htmlId); + if (splitOperations) + { + Assert.Equal(uid, (string)raw[page]["uid"]); + Assert.Same(operation, Assert.Single(raw[page]["children"])); + expectedXrefs[childUid] = page + ".html#" + htmlId; + } + } + foreach (var tagName in new[] { "health", "items" }) + { + if (!splitTags && splitOperations) + { + continue; + } + var page = splitTags ? "service/" + tagName : "service"; + var tag = splitTags ? raw[page] : Assert.Single(raw[page]["tags"], candidate => (string)candidate["name"] == tagName); + var uid = RootUid + "/tag/" + tagName; + var htmlId = RootHtmlId + "_tag_" + tagName; + Assert.Equal(uid, (string)tag["uid"]); + Assert.NotNull(articles[page].SelectSingleNode($".//*[@id='{htmlId}']")); + expectedXrefs[uid] = page + ".html" + (splitTags ? "" : "#" + htmlId); + } + var xrefs = YamlUtility.Deserialize(Path.Combine(output, XRefArchive.MajorFileName)) + .References.ToDictionary(reference => reference.Uid, reference => reference.Href); + Assert.Equal(expectedXrefs.OrderBy(pair => pair.Key, StringComparer.Ordinal), + xrefs.OrderBy(pair => pair.Key, StringComparer.Ordinal)); + Assert.Equal(expectedXrefs[RootUid + "/createItem"], + articles["service"].SelectSingleNode(".//a[text()='create an item']").GetAttributeValue("href", null)); + + var tocRoot = Assert.Single(ReadModel(output, "toc.raw.json")["items"]); + Assert.Equal("SDK API", (string)tocRoot["name"]); + Assert.Equal("service.html", (string)tocRoot["href"]); + Assert.Equal("service.html", (string)tocRoot["topicHref"]); + var tocItems = Descendants(tocRoot).ToDictionary(item => (string)item["topicUid"]); + Assert.Equal(pages.Where(page => page != "service").Select(page => (string)raw[page]["uid"]).Order(StringComparer.Ordinal), + tocItems.Keys.Order(StringComparer.Ordinal)); + var tocHtml = ReadHtml(output, "toc.html"); + Assert.NotNull(tocHtml.SelectSingleNode("//a[@href='service.html']")); + foreach (var (uid, item) in tocItems) + { + Assert.Equal(expectedXrefs[uid], (string)item["href"]); + Assert.Equal(expectedXrefs[uid], (string)item["topicHref"]); + Assert.NotNull(tocHtml.SelectSingleNode($"//a[@href='{expectedXrefs[uid]}']")); + } + if (splitTags && splitOperations) + { + Assert.Equal(["health", "items"], tocRoot["items"].Select(item => (string)item["name"])); + Assert.Equal(["inspectHealth"], tocItems[RootUid + "/tag/health"]["items"].Select(item => (string)item["name"])); + Assert.Equal(new[] { "createItem", generatedId }.Order(StringComparer.Ordinal), + tocItems[RootUid + "/tag/items"]["items"].Select(item => (string)item["name"])); + } + + Assert.Equal("https://api.example.test/v1/health", (string)operations["inspectHealth"]["requestUrl"]); + Assert.Equal("https://read.example.test/v2/items/{id}", (string)operations[generatedId]["requestUrl"]); + var create = operations["createItem"]; + var createArticle = articles[OperationPage("createItem")]; + var createText = HtmlEntity.DeEntitize(createArticle.InnerText); + Assert.Equal("https://west.write.example.test/v3/items/{id}", (string)create["requestUrl"]); + Assert.Equal("https://west.write.example.test/v3", (string)Assert.Single(create["servers"])["url"]); + AssertStrongText((string)create["servers"][0]["description"], "region"); + Assert.Contains("https://west.write.example.test/v3/items/{id}", createText); + Assert.Equal("https://read.example.test/v2", (string)Assert.Single(operations[generatedId]["servers"])["url"]); + Assert.Equal("https://api.example.test/v1", (string)operations["inspectHealth"]["servers"][0]["url"]); + + var parameters = create["parameters"].ToDictionary(parameter => (string)parameter["name"]); + Assert.Equal(["id", "limit"], parameters.Keys.Order(StringComparer.Ordinal)); + Assert.True((bool)parameters["id"]["required"]); + Assert.Equal("string", (string)parameters["id"]["schema"]["type"]); + Assert.Equal("integer", (string)parameters["limit"]["schema"]["type"]); + Assert.Equal("int32", (string)parameters["limit"]["schema"]["format"]); + Assert.Equal(7, (int)parameters["limit"]["default"]); + Assert.Equal(1, (int)operations[generatedId]["parameters"].Single(parameter => (string)parameter["name"] == "limit")["default"]); + Assert.Contains("string", createArticle.SelectSingleNode(".//tr[td//span[normalize-space(.)='*id']]").InnerText); + Assert.Contains("integer", createArticle.SelectSingleNode(".//tr[td//span[normalize-space(.)='limit']]").InnerText); + + var body = create["requestBody"]; + Assert.True((bool)body["required"]); + AssertStrongText((string)body["description"], "body"); + var bodyHtml = createArticle.SelectSingleNode(".//div[@class='request-body']"); + Assert.NotNull(bodyHtml); + Assert.Contains("Required", bodyHtml.InnerText); + var requestHtml = MediaSchemas(bodyHtml); + Assert.Equal(["application/json", "application/xml"], requestHtml.Keys.Order(StringComparer.Ordinal)); + Assert.Contains("xmlOnly", requestHtml["application/xml"].InnerText); + Assert.Contains("integer", requestHtml["application/xml"].InnerText); + var requestMedia = body["content"].ToDictionary(media => (string)media["mimeType"]); + Assert.Equal(["application/json", "application/xml"], requestMedia.Keys.Order(StringComparer.Ordinal)); + Assert.Equal("integer", (string)requestMedia["application/xml"]["schema"]["properties"]["xmlOnly"]["type"]); + var schema = requestMedia["application/json"]["schema"]; + Assert.True((bool)schema["properties"]["name"]["required"]); + Assert.Equal("string", (string)schema["properties"]["name"]["type"]); + AssertStrongText((string)schema["properties"]["name"]["description"], "display name"); + Assert.NotNull(schema["examples"]); + var schemaExample = JObject.Parse((string)Assert.Single(schema["examples"])["content"]); + Assert.Equal("literal-schema.json#/data", (string)schemaExample["$ref"]); + Assert.Equal("**literal schema**", (string)schemaExample["description"]); + Assert.Equal(["active", "archived"], schema["properties"]["state"]["enum"].Values()); + Assert.Equal(["\"active\"", "\"archived\""], requestHtml["application/json"] + .SelectNodes(".//tr[td/span[text()='state']]/td[2]/div/div[@class='schema-enum']/code") + .Select(node => HtmlEntity.DeEntitize(node.InnerText))); + Assert.Equal(new[] { "null", "string" }, + ((string)schema["properties"]["label"]["type"]).Split(" | ").Order(StringComparer.Ordinal)); + var viewMedia = viewOperations["createItem"]["requestBody"]["content"] + .Single(media => (string)media["mimeType"] == "application/json"); + Assert.True(JToken.DeepEquals(schema, viewMedia["schema"])); + var viewProperties = viewMedia["schemaDetails"]["properties"].ToDictionary(property => (string)property["key"]); + Assert.True((bool)viewProperties["name"]["required"]); + foreach (var (property, kind) in new[] { ("choice", "One of"), ("combined", "All of"), ("either", "Any of"), ("excluded", "Not") }) + { + var propertySchema = schema["properties"][property]; + var details = viewProperties[property]["value"]; + Assert.Empty(details["properties"]); + var propertyHtml = requestHtml["application/json"] + .SelectSingleNode($".//tr[td/span[@class='parametername' and text()='{property}']]/td[2]/div[@class='rest-schema']"); + Assert.NotNull(propertyHtml); + Assert.Null(propertyHtml.SelectSingleNode("./table[contains(@class, 'schema-properties')]")); + string[] unionTypes = property switch + { + "choice" => ["integer", "string"], + "either" => ["boolean", "number"], + _ => null + }; + // OpenAPI.NET folds these disjoint, type-only alternatives into equivalent type unions for 3.0. + if (unionTypes != null && propertySchema["composition"] == null) + { + Assert.Equal(unionTypes, ((string)propertySchema["type"]).Split(" | ").Order(StringComparer.Ordinal)); + Assert.Equal(unionTypes, ((string)details["type"]).Split(" | ").Order(StringComparer.Ordinal)); + Assert.Empty(details["composition"]); + Assert.Equal(unionTypes, propertyHtml.SelectSingleNode("./span[@class='schema-type']").InnerText + .Split(" | ").Order(StringComparer.Ordinal)); + Assert.Null(propertyHtml.SelectSingleNode("./div[@class='schema-composition']")); + } + else + { + var composition = Assert.Single(propertySchema["composition"]); + Assert.Equal(kind, (string)composition["kind"]); + Assert.Equal(property == "excluded" ? 1 : 2, composition["schemas"].Count()); + Assert.Equal(kind, (string)Assert.Single(details["composition"])["kind"]); + Assert.Contains((string)details["type"], new[] { null, "any type" }); + Assert.Contains(propertyHtml.SelectSingleNode("./span[@class='schema-type']")?.InnerText, new[] { null, "any type" }); + Assert.Equal(kind, propertyHtml.SelectSingleNode("./div[@class='schema-composition']/strong").InnerText); + if (unionTypes != null) + { + Assert.Equal(unionTypes, composition["schemas"].Select(branch => (string)branch["type"]).Order(StringComparer.Ordinal)); + Assert.Equal(unionTypes, propertyHtml + .SelectNodes("./div[@class='schema-composition']/ul/li/div/span[@class='schema-type']") + .Select(node => node.InnerText).Order(StringComparer.Ordinal)); + } + } + } + Assert.Contains("leftField", createText); + Assert.Contains("rightField", createText); + if (version == "3.1.0") + { + var booleanResponse = Assert.Single(operations["inspectHealth"]["responses"]); + Assert.Equal("200", (string)booleanResponse["statusCode"]); + var booleanMedia = booleanResponse["content"].ToDictionary(media => (string)media["mimeType"]); + Assert.Equal(["application/json", "text/plain"], booleanMedia.Keys.Order(StringComparer.Ordinal)); + Assert.Equal("any value", (string)booleanMedia["application/json"]["schema"]["type"]); + Assert.Equal("no value", (string)booleanMedia["text/plain"]["schema"]["type"]); + var booleanHtml = MediaSchemas(articles[OperationPage("inspectHealth")] + .SelectSingleNode(".//div[@class='responses']//tr[td/span[@class='status' and text()='200']]/td[2]")); + Assert.Equal("any value", booleanHtml["application/json"] + .SelectSingleNode("./div[@class='rest-schema']/span[@class='schema-type']").InnerText); + Assert.Equal("no value", booleanHtml["text/plain"] + .SelectSingleNode("./div[@class='rest-schema']/span[@class='schema-type']").InnerText); + } + AssertExample(Assert.Single(requestMedia["application/json"]["examples"]), "request", "literal-request.json#/data", "**literal request**", bodyHtml); + + var response = Assert.Single(create["responses"]); + Assert.Equal("201", (string)response["statusCode"]); + AssertStrongText((string)response["description"], "created"); + var responseHtml = createArticle.SelectSingleNode(".//div[@class='responses']//tr[td/span[@class='status' and text()='201']]"); + Assert.NotNull(responseHtml); + var responseSchemas = MediaSchemas(responseHtml.SelectSingleNode("./td[2]")); + Assert.Equal(["application/json", "text/plain"], responseSchemas.Keys.Order(StringComparer.Ordinal)); + Assert.Contains("receipt", responseSchemas["application/json"].InnerText); + Assert.Contains("string", responseSchemas["text/plain"].InnerText); + Assert.Contains("Plain response schema.", responseSchemas["text/plain"].InnerText); + var responseMedia = response["content"].ToDictionary(media => (string)media["mimeType"]); + Assert.Equal(["application/json", "text/plain"], responseMedia.Keys.Order(StringComparer.Ordinal)); + Assert.Equal("string", (string)responseMedia["application/json"]["schema"]["properties"]["receipt"]["type"]); + Assert.Equal("string", (string)responseMedia["text/plain"]["schema"]["type"]); + AssertExample(Assert.Single(responseMedia["application/json"]["examples"]), "response", "literal-response.json#/data", "**literal response**", + responseHtml.SelectSingleNode("./td[@class='sample-response']")); + Assert.Equal("plain-response", (string)JToken.Parse((string)Assert.Single(responseMedia["text/plain"]["examples"])["content"])); + foreach (var text in new[] { "application/json", "application/xml", "text/plain", "xmlOnly", "receipt", "Plain response schema.", "plain-response" }) + { + Assert.Contains(text, createText); + } + Assert.NotNull(createArticle.SelectSingleNode(".//strong[text()='body']")); + Assert.NotNull(createArticle.SelectSingleNode(".//strong[text()='display name']")); + + if (overwrite) + { + var tagArticle = articles[splitTags ? "service/items" : "service"]; + Assert.NotNull(articles["service"].SelectSingleNode(".//strong[text()='API']")); + Assert.NotNull(tagArticle.SelectSingleNode(".//p[strong[text()='items'] and contains(., 'Updated')]")); + Assert.NotNull(createArticle.SelectSingleNode(".//p[strong[text()='create'] and contains(., 'Updated')]")); + Assert.NotNull(articles["service"].SelectSingleNode(".//p[text()='Document-level conceptual content.']")); + Assert.NotNull(tagArticle.SelectSingleNode(".//p[text()='Tag-level conceptual content.']")); + Assert.NotNull(createArticle.SelectSingleNode(".//p[text()='Operation-level conceptual content.']")); + } + } + + [Fact] + public void GeneratedOperationUidIsStableAcrossJsonAndYaml() + { + string uid = null; + foreach (var extension in new[] { ".json", ".yaml" }) + { + var (input, files, _) = CreateInput("3.1.0", extension, false); + var output = Build(input, files, null, false, false); + var root = ReadModel(output, "service.raw.json"); + var operation = Assert.Single(root["children"], child => ((string)child["operationId"]).StartsWith("get_", StringComparison.Ordinal)); + Assert.Equal(RootUid, (string)root["uid"]); + Assert.Equal(RootUid + "/" + (string)operation["operationId"], (string)operation["uid"]); + if (uid != null) + { + Assert.Equal(uid, (string)operation["uid"]); + } + uid = (string)operation["uid"]; + } + } + + [Theory] + [InlineData("UnsupportedBooleanSchema")] + [InlineData("UnsupportedExternalFragment")] + public void RejectsUnsupportedOpenApiWithoutPublishing(string diagnostic) + { + var input = GetRandomFolder(); + var schema = diagnostic == "UnsupportedBooleanSchema" + ? """{"type": "object", "properties": {"value": false}}""" + : """{"$ref": "schema.yaml"}"""; + if (diagnostic == "UnsupportedExternalFragment") + { + CreateFile("schema.yaml", "type: object\nproperties:\n value:\n type: string\n", input); + } + var file = CreateFile("unsupported.json", $$""" + { + "openapi": "3.1.0", + "info": { "title": "Unsupported API", "version": "1.0" }, + "paths": {}, + "components": { "schemas": { "Value": {{schema}} } } + } + """, input); + var files = new FileCollection(Directory.GetCurrentDirectory()); + files.Add(DocumentType.Article, [file], input); + + var output = Build(input, files, "default", false, false, diagnostic); + + Assert.Empty(Directory.GetFiles(output, "*.raw.json", SearchOption.AllDirectories)); + Assert.Empty(Directory.GetFiles(output, "*.html", SearchOption.AllDirectories)); + } + + private (string Input, FileCollection Files, string Original) CreateInput(string version, string extension, bool overwrite) + { + var input = GetRandomFolder(); + var document = ReadModel(Path.Combine("TestData", "openapi"), "service.json"); + var components = ReadModel(Path.Combine("TestData", "openapi"), "components.json"); + document["openapi"] = components["openapi"] = version; + var externalExtension = extension == ".json" ? ".yaml" : ".json"; + foreach (var reference in document.Descendants().OfType().Where(property => property.Name == "$ref")) + { + reference.Value = ((string)reference.Value).Replace("components.json", "components" + externalExtension, StringComparison.Ordinal); + } + if (version == "3.1.0") + { + var properties = components["components"]["schemas"]["Item"]["properties"]; + properties["label"] = new JObject { ["type"] = new JArray("string", "null") }; + // SDK 3.10.2 drops booleans in schema maps and composition lists; the reader rejects those forms. + // Exercise supported inline media schemas, using full OpenAPI documents for external references. + document["paths"]["/health"]["get"]["responses"] = new JObject + { + ["200"] = new JObject + { + ["description"] = "Supported boolean schemas.", + ["content"] = new JObject + { + ["application/json"] = new JObject { ["schema"] = true }, + ["text/plain"] = new JObject { ["schema"] = false } + } + } + }; + } + CreateFile("components" + externalExtension, Serialize(components, externalExtension), input); + var original = Serialize(document, extension); + var service = CreateFile("service" + extension, original, input); + var toc = CreateFile("toc.yml", $"- name: SDK API\n href: service{extension}\n", input); + var files = new FileCollection(Directory.GetCurrentDirectory()); + files.Add(DocumentType.Article, [service, toc], input); + if (overwrite) + { + var file = CreateFile("overwrite.md", $$""" + --- + uid: {{RootUid}} + summary: Updated **API** summary. + --- + Document-level conceptual content. + + --- + uid: {{RootUid}}/tag/items + description: Updated **items** tag. + --- + Tag-level conceptual content. + + --- + uid: {{RootUid}}/createItem + summary: Updated **create** summary. + --- + Operation-level conceptual content. + """, input); + files.Add(DocumentType.Overwrite, [file], input); + } + return (input, files, original); + } + + private string Build(string input, FileCollection files, string template, bool splitTags, bool splitOperations, + string expectedDiagnostic = null) + { + var output = GetRandomFolder(); + var templates = new List { "common", "default" }; + if (template is not null and not "default") + { + templates.Add(template); + } + var parameters = new DocumentBuildParameters + { + Files = files, + OutputBaseDir = output, + ApplyTemplateSettings = new ApplyTemplateSettings(input, output) + { + TransformDocument = template != null, + RawModelExportSettings = { Export = true }, + ViewModelExportSettings = { Export = template != null } + }, + TemplateManager = new TemplateManager(templates, null, "templates"), + Metadata = new Dictionary + { + ["_disableContribution"] = true, + ["_disableSearch"] = true + }.ToImmutableDictionary() + }; + var gitFeaturesDisabled = EnvironmentContext.GitFeaturesDisabled; + using var listener = new TestListenerScope(); + try + { + EnvironmentContext.SetGitFeaturesDisabled(true); + using var builder = new DocumentBuilder(GetAssemblies(splitTags, splitOperations), []); + builder.Build(parameters); + } + finally + { + EnvironmentContext.SetGitFeaturesDisabled(gitFeaturesDisabled); + } + if (expectedDiagnostic == null) + { + Assert.True(!listener.Items.Any(), + string.Join(Environment.NewLine, listener.Items.Select(item => $"{item.LogLevel} {item.Code}: {item.Message}"))); + } + else + { + var diagnostic = Assert.Single(listener.Items, item => item.Code == "InvalidInputFile"); + Assert.Contains(expectedDiagnostic, diagnostic.Message); + } + return output; + } + + private static IEnumerable GetAssemblies(bool splitTags, bool splitOperations) + { + yield return typeof(RestApiDocumentProcessor).Assembly; + if (splitTags) + { + yield return typeof(SplitRestApiToTagLevel).Assembly; + } + if (splitOperations) + { + yield return typeof(SplitRestApiToOperationLevel).Assembly; + } + } + + private static void AssertExample(JToken example, string name, string reference, string description, HtmlNode article) + { + Assert.Equal(name, (string)example["name"]); + Assert.Equal("application/json", (string)example["mimeType"]); + var payload = JObject.Parse((string)example["content"]); + Assert.Equal(reference, (string)payload["$ref"]); + Assert.Equal(description, (string)payload["description"]); + var code = Assert.Single(article.SelectNodes(".//pre/code"), + node => HtmlEntity.DeEntitize(node.InnerText).Contains(reference, StringComparison.Ordinal)); + var rendered = JObject.Parse(HtmlEntity.DeEntitize(code.InnerText)); + Assert.True(JToken.DeepEquals(payload, rendered)); + Assert.Null(code.SelectSingleNode(".//strong")); + } + + private static IEnumerable Descendants(JToken item) => + (item["items"]?.ToArray() ?? []).SelectMany(child => new[] { child }.Concat(Descendants(child))); + + private static Dictionary MediaSchemas(HtmlNode node) => + node.SelectNodes(".//div[@class='media-schema']") + .ToDictionary(media => media.SelectSingleNode("./div/span[@class='mime']").InnerText); + + private static void AssertStrongText(string html, string text) + { + var document = new HtmlDocument(); + document.LoadHtml(html); + Assert.NotNull(document.DocumentNode.SelectSingleNode($".//strong[text()='{text}']")); + } + + private static string Serialize(JObject document, string extension) + { + if (extension == ".json") + { + return document.ToString(); + } + using var writer = new StringWriter(); + YamlUtility.Serialize(writer, ToYamlValue(document)); + return writer.ToString(); + } + + private static object ToYamlValue(JToken token) => token switch + { + JObject obj => obj.Properties().ToDictionary(property => property.Name, property => ToYamlValue(property.Value)), + JArray array => array.Select(ToYamlValue).ToArray(), + JValue value => value.Value, + _ => throw new InvalidOperationException($"Unexpected fixture value: {token.Type}") + }; + + private static JObject ReadModel(string output, string path) => + JObject.Parse(File.ReadAllText(Path.Combine(output, path.Replace('/', Path.DirectorySeparatorChar)))); + + private static HtmlNode ReadHtml(string output, string path) + { + var document = new HtmlDocument(); + document.Load(Path.Combine(output, path.Replace('/', Path.DirectorySeparatorChar))); + return document.DocumentNode; + } +} diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/openapi/components.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/openapi/components.json new file mode 100644 index 00000000000..09d19883321 --- /dev/null +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/openapi/components.json @@ -0,0 +1,32 @@ +{ + "openapi": "3.0.3", + "info": { "title": "Shared schemas", "version": "1.0" }, + "paths": {}, + "components": { + "schemas": { + "Item": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "description": "The **display name**." }, + "state": { "type": "string", "enum": ["active", "archived"] }, + "label": { "type": "string", "nullable": true }, + "choice": { "oneOf": [{ "type": "string" }, { "type": "integer" }] }, + "combined": { + "allOf": [ + { "type": "object", "properties": { "leftField": { "type": "boolean" } } }, + { "type": "object", "properties": { "rightField": { "type": "number" } } } + ] + }, + "either": { "anyOf": [{ "type": "boolean" }, { "type": "number" }] }, + "excluded": { "not": { "type": "integer" } } + }, + "example": { "name": "schema", "$ref": "literal-schema.json#/data", "description": "**literal schema**" } + }, + "Result": { + "type": "object", + "properties": { "receipt": { "type": "string", "description": "The **receipt**." } } + } + } + } +} diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/openapi/service.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/openapi/service.json new file mode 100644 index 00000000000..f5242b5035e --- /dev/null +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/openapi/service.json @@ -0,0 +1,110 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "SDK API", + "version": "1.0", + "description": "Use the **SDK** [guide](https://example.test/guide) and [create an item](xref:api.example.test/v1/SDK%20API/1.0/createItem)." + }, + "servers": [ + { + "url": "https://{host}/{basePath}", + "description": "The **root** server.", + "variables": { "host": { "default": "api.example.test" }, "basePath": { "default": "v1" } } + }, + { "url": "https://fallback.example.test/v1" } + ], + "tags": [{ "name": "items", "description": "Manage **items**." }], + "paths": { + "/items/{id}": { + "servers": [ + { + "url": "https://read.example.test/{version}", + "variables": { "version": { "default": "v2" } } + } + ], + "parameters": [ + { "$ref": "#/components/parameters/Id" }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "format": "int32", "default": 1 } } + ], + "get": { + "tags": ["items"], + "summary": "Read **items** without an explicit operation ID.", + "responses": { "204": { "description": "No content." } } + }, + "post": { + "operationId": "createItem", + "tags": ["items"], + "summary": "Create **items**.", + "servers": [ + { + "url": "https://{region}.write.example.test/{version}", + "description": "Write in this **region**.", + "variables": { "region": { "default": "west" }, "version": { "default": "v3" } } + } + ], + "parameters": [ + { "name": "limit", "in": "query", "description": "Maximum **count**.", "schema": { "type": "integer", "format": "int32", "default": 7 } } + ], + "requestBody": { "$ref": "#/components/requestBodies/Create" }, + "responses": { + "201": { "$ref": "#/components/responses/Created" } + } + } + }, + "/health": { + "get": { + "operationId": "inspectHealth", + "tags": ["health"], + "summary": "Inspect **health**.", + "responses": { "204": { "description": "Healthy." } } + } + } + }, + "components": { + "parameters": { + "Id": { "name": "id", "in": "path", "required": true, "description": "The item **identifier**.", "schema": { "type": "string" } } + }, + "requestBodies": { + "Create": { + "description": "The **body** to create.", + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "components.json#/components/schemas/Item" }, + "examples": { "request": { "$ref": "#/components/examples/Request" } } + }, + "application/xml": { + "schema": { + "type": "object", + "properties": { "xmlOnly": { "type": "integer", "description": "XML quantity." } } + } + } + } + } + }, + "responses": { + "Created": { + "description": "The **created** item.", + "content": { + "application/json": { + "schema": { "$ref": "components.json#/components/schemas/Result" }, + "examples": { + "response": { + "value": { "receipt": "one", "$ref": "literal-response.json#/data", "description": "**literal response**" } + } + } + }, + "text/plain": { + "schema": { "type": "string", "description": "Plain response schema." }, + "example": "plain-response" + } + } + } + }, + "examples": { + "Request": { + "value": { "name": "new", "$ref": "literal-request.json#/data", "description": "**literal request**" } + } + } + } +} From 85ca8ba3d79d41dcbe9fe323a140adebe922a188 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Mon, 21 Sep 2026 10:54:08 +1000 Subject: [PATCH 05/16] test: fix REST template test lint formatting Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: aaccfe95-0a86-417b-9883-a37fd53bd705 --- templates/modern/src/rest.test.ts | 65 ++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/templates/modern/src/rest.test.ts b/templates/modern/src/rest.test.ts index 5a1f462b9a8..98cea2c576b 100644 --- a/templates/modern/src/rest.test.ts +++ b/templates/modern/src/rest.test.ts @@ -32,9 +32,12 @@ test('REST raw filename hints preserve JSON compatibility and identify original test('REST preserves legacy parameter paths, allOf flattening, and definitions', () => { const model = rest.transform({ - uid: 'legacy', _path: 'legacy.json', + uid: 'legacy', + _path: 'legacy.json', children: [{ - uid: 'get', operation: 'get', path: '/items', + uid: 'get', + operation: 'get', + path: '/items', parameters: [ { name: 'filter', in: 'query', required: true, schema: { type: 'string' } }, { name: 'limit', in: 'query', schema: { type: 'integer' } } @@ -62,9 +65,13 @@ test('REST preserves legacy parameter paths, allOf flattening, and definitions', test('REST prepares every request and response media schema and named example', () => { const model = rest.transform({ - uid: 'media', _path: 'media.json', schemas: {}, + uid: 'media', + _path: 'media.json', + schemas: {}, children: [{ - uid: 'post', operation: 'post', path: '/items', + uid: 'post', + operation: 'post', + path: '/items', requestUrl: 'https://api.example.test/v2/items', servers: [{ url: 'https://api.example.test/v2' }], parameters: [{ name: 'filter', in: 'query', schema: { type: 'string | null', format: 'uuid' } }], @@ -152,14 +159,17 @@ test('REST keeps nested composition, constraints, unions, boolean schemas, and f test('REST links recursive references and aliases without colliding schema anchors', () => { const model = rest.transform({ - uid: 'references', _path: 'references.json', + uid: 'references', + _path: 'references.json', schemas: { 'Tree.Node': { type: 'object', properties: { next: { 'x-internal-loop-ref-name': 'Tree.Node' } } }, Tree_Node: { type: 'any value' }, Alias: { 'x-internal-ref-name': 'Tree.Node' } }, children: [{ - uid: 'read', path: '/tree', tags: ['Trees'], + uid: 'read', + path: '/tree', + tags: ['Trees'], requestUrl: '/tree', responses: [{ content: [{ @@ -179,12 +189,16 @@ test('REST links recursive references and aliases without colliding schema ancho test('REST adds inline reference definitions and leaves unresolved references as text', () => { const model = rest.transform({ - uid: 'inline', _path: 'inline.json', + uid: 'inline', + _path: 'inline.json', children: [{ - uid: 'read', path: '/inline', requestUrl: '/inline', + uid: 'read', + path: '/inline', + requestUrl: '/inline', parameters: [{ schema: { - type: 'object', 'x-internal-ref-name': 'Inline', + type: 'object', + 'x-internal-ref-name': 'Inline', properties: { missing: { 'x-internal-loop-ref-name': 'Missing' } } } }] @@ -198,16 +212,21 @@ test('REST adds inline reference definitions and leaves unresolved references as test('REST renders parameter content and keeps same-name external schema references distinct', () => { const model = rest.transform({ - uid: 'parameters', _path: 'parameters.json', + uid: 'parameters', + _path: 'parameters.json', children: [{ - uid: 'search', path: '/items', + uid: 'search', + path: '/items', parameters: [{ - name: 'filter', in: 'query', default: '{"active":true}', + name: 'filter', + in: 'query', + default: '{"active":true}', content: [ { mimeType: 'application/json', schema: { - type: 'object', 'x-internal-ref-name': 'models/first.yaml#Filter', + type: 'object', + 'x-internal-ref-name': 'models/first.yaml#Filter', properties: { next: { 'x-internal-loop-ref-name': 'models/first.yaml#Filter' } } }, examples: [{ name: 'active', content: '{"active":true}' }] @@ -247,8 +266,12 @@ test('REST renders schema examples without inheriting names, MIME types, or ance const details = model.definitions[0].schemaDetails assert.deepEqual(schema, original) assert.deepEqual(details.exampleDetails[0], { - name: null, mimeType: null, content: '{"state":"active"}', - hasContent: true, externalValue: null, externalHref: null + name: null, + mimeType: null, + content: '{"state":"active"}', + hasContent: true, + externalValue: null, + externalHref: null }) assert.equal(details.exampleDetails[1].hasContent, true) assert.equal(details.exampleDetails[1].content, '') @@ -260,9 +283,11 @@ test('REST renders schema examples without inheriting names, MIME types, or ance test('REST displays external example URLs without inventing content or linking executable schemes', () => { const urls = ['https://example.test/sample.json', 'http://example.test/sample.json', 'samples/local.json', 'javascript:alert(1)'] const model = rest.transform({ - uid: 'external-examples', _path: 'external-examples.json', + uid: 'external-examples', + _path: 'external-examples.json', children: [{ - uid: 'read', path: '/items', + uid: 'read', + path: '/items', responses: [{ content: [{ mimeType: 'application/json', @@ -297,7 +322,8 @@ for (const flagLocation of ['root', 'operation']) { } const originalSchema = structuredClone(schema) const operation = { - uid: 'read', path: '/literal', + uid: 'read', + path: '/literal', _preserveLiteralData: flagLocation === 'operation', parameters: [{ name: 'filter', in: 'query', required: true, schema }], responses: [{ @@ -307,7 +333,8 @@ for (const flagLocation of ['root', 'operation']) { 'x-operation': structuredClone(literal) } const model = rest.transform({ - uid: 'literal', _path: 'literal.json', + uid: 'literal', + _path: 'literal.json', _preserveLiteralData: flagLocation === 'root', 'x-root': structuredClone(literal), children: [operation] From 9659bf8ef68cdec5d1e8c4c5614414bf7ee86796 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Mon, 21 Sep 2026 23:48:13 +1000 Subject: [PATCH 06/16] docs: document unsupported OpenAPI features and fidelity gaps Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: aaccfe95-0a86-417b-9883-a37fd53bd705 --- docs/docs/openapi-unsupported-features.md | 170 ++++++++++++++++++++++ docs/docs/rest-api-docs.md | 41 +----- docs/docs/toc.yml | 1 + 3 files changed, 178 insertions(+), 34 deletions(-) create mode 100644 docs/docs/openapi-unsupported-features.md diff --git a/docs/docs/openapi-unsupported-features.md b/docs/docs/openapi-unsupported-features.md new file mode 100644 index 00000000000..1d227565b75 --- /dev/null +++ b/docs/docs/openapi-unsupported-features.md @@ -0,0 +1,170 @@ +# OpenAPI features not yet supported + +This page describes the unsupported features and known fidelity issues in the +[OpenAPI 3 REST documentation reader](rest-api-docs.md#openapi-3-documents), +which uses `Microsoft.OpenApi` and `Microsoft.OpenApi.YamlReader` **3.10.2**. +Swagger 2.0 JSON continues to use its existing reader and is not affected by these +OpenAPI 3 limitations. + +OpenAPI 3.0 and 3.1 JSON/YAML documents are accepted, but this is not full +OpenAPI or JSON Schema conformance. Parsing a document successfully does not mean +every feature is rendered. The list below describes known boundaries, not a +commitment to a particular release or an exhaustive conformance matrix. + +## Features that produce an error + +These cases produce an input error rather than falling back to the Swagger reader. +The affected document is not generated. + +| Feature | Current behavior | Reason or alternative | +| --- | --- | --- | +| Standalone external schema/component fragments | `UnsupportedExternalFragment` | This integration loads complete OpenAPI documents, not standalone fragments. Put shared components in a complete local document and reference its component path. | +| References without a fragment identifier | `UnsupportedExternalFragment` | Use a supported component reference such as `components.yaml#/components/schemas/Pet`. | +| HTTP/HTTPS or network-share references | Rejected; no network fetching | Use local referenced documents. Server URLs and ordinary documentation links are not restricted by this rule. | +| Dynamic schema references (`$dynamicRef`) | `UnsupportedOpenApiSchema` | Dynamic scope is not implemented. Ordinary `$ref`, including recursive references, is supported. | +| Boolean schemas in schema maps or composition arrays | `UnsupportedBooleanSchema` | The pinned SDK drops these values in certain positions. See [boolean schemas](#boolean-schemas). | +| Certain OpenAPI 3.0 primitive compositions | `UnsupportedOpenApiComposition` | The pinned SDK can lose exclusive alternatives or branch examples. See [primitive compositions](#primitive-compositions). | +| Object or array values of `const` | SDK reader error: `Expected scalar value` | These valid OpenAPI 3.1 schema values are not supported by the pinned SDK's scalar-based `const` reader. | +| OpenAPI 3.2 and other unsupported specification versions | Version error | Only OpenAPI 3.0 and 3.1 are enabled, even if the SDK can read newer versions. | + +### Standalone external fragments + +A file containing only a schema is a valid OpenAPI reference target, but is not +supported by this integration: + +```yaml +# schemas/Pet.yaml +type: object +properties: + name: + type: string +``` + +For the supported form, put the schema in a complete component document: + +```yaml +# components.yaml +openapi: 3.1.0 +info: + title: Shared components + version: '1.0' +paths: {} +components: + schemas: + Pet: + type: object + properties: + name: + type: string +``` + +Then use `$ref: './components.yaml#/components/schemas/Pet'`. +Local component documents can mix JSON and YAML, and references between them can +form cycles. This limitation is in the current Docfx integration; it does not mean +standalone fragments are invalid OpenAPI. + +### Boolean schemas + +The ordinary schema `type: boolean` is supported. A *boolean schema* is different: +`true` accepts any JSON value, while `false` accepts no value. OpenAPI 3.1 allows +both forms. + +Direct media-type schemas such as `schema: true` and `schema: false` are supported. +The following positions are rejected because the pinned SDK does not preserve them: + +- Entries in `components.schemas`, `properties`, `patternProperties`, `$defs` and + `dependentSchemas`. +- Branches in `allOf`, `anyOf` and `oneOf`. + +For example, removing the `false` branch below would change a schema that accepts +no values into one that accepts strings: + +```yaml +allOf: + - false + - type: string +``` + +The check applies to both entry documents and referenced documents. Boolean values +inside examples and extension data are not schemas and are not rejected by this check. + +### Primitive compositions + +In some OpenAPI 3.0 cases, the pinned SDK combines primitive alternatives into a +type union. This is not always equivalent to `oneOf`, which requires exactly one +matching branch: + +```yaml +oneOf: + - type: integer + - type: number +``` + +The value `3` matches both branches and must fail this `oneOf`. Displaying it as +`integer | number` would lose that restriction. Type-only `oneOf` branches with +duplicate types have a similar problem. The SDK can also discard examples attached +to primitive branches during this conversion. + +The reader rejects these known lossy OpenAPI 3.0 forms. Disjoint type-only +alternatives, such as `string` and `integer`, can become equivalent unions. +Constrained alternatives and OpenAPI 3.1 compositions are not blanket-rejected. + +## Features without dedicated documentation UI + +These features produce a warning when encountered. Supported parts of the document +can still be generated; treating warnings as errors can make the warning a build +failure. Their presence is not an indication that the following details are rendered. + +| Feature | Information not currently rendered | +| --- | --- | +| Callbacks | Callback operations, parameters and payloads associated with an API operation. | +| Webhooks | Top-level webhook operations and their requests/responses. | +| Security schemes and requirements | API key, HTTP/Bearer, OAuth2 and OpenID Connect configuration, operation requirements and scopes. | +| Response Link Objects | Relationships to subsequent operations and mappings from response values to their parameters. | + +Response Link Objects are not ordinary Markdown links or Docfx cross-references; +those continue to work. Missing security documentation does not disable or change +authentication in the API itself. + +## Known schema fidelity issues + +> [!WARNING] +> Numeric and boolean `const` values are not yet rejected or preserved correctly. +> They can be displayed as strings, changing the meaning of the constraint. +> Keeping the original input does not make that rendered constraint correct. + +The following behavior has been verified with the pinned SDK and Docfx model +conversion: + +| Input | Current result | +| --- | --- | +| `{"const": "ok"}` | Preserves the string `"ok"`. | +| `{"const": 42}` | Converts the number to the string `"42"`. | +| `{"const": true}` | Converts the boolean to the string `"True"`. | +| `{"const": {"status": "ok"}}` or `{"const": [1, 2]}` | Produces a reader error. | +| `{"const": null}` | Preserves the null constraint. | +| `{"type": ["string", "null"], "default": null}` | Preserves the explicit null default. | + +`default` is an annotation, whereas `const` requires an exact value. They are not +interchangeable. Do not change a numeric or boolean `const` to a string merely to +make it render; that would change the API contract. + +The entry document's original text is retained in the raw model for reference. +It does not repair lost types or constraints in the generated HTML, and does not +provide an archive of every external source document. + +## SDK implementation notes + +The following links identify the fixed SDK version behind the known behavior: + +- [`JsonNodeHelper.CreateMap/CreateList`](https://github.com/microsoft/OpenAPI.NET/blob/v3.10.2/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs) + only pass object nodes to schema readers in the affected map/list positions. +- The [OpenAPI 3.0 schema reader](https://github.com/microsoft/OpenAPI.NET/blob/v3.10.2/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs) + performs the primitive-alternative folding described above. +- The [OpenAPI 3.1 schema reader](https://github.com/microsoft/OpenAPI.NET/blob/v3.10.2/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs) + reads `const` through `GetScalarValue`, causing the non-string limitations. +- The automatic [`OpenApiWorkspaceLoader`](https://github.com/microsoft/OpenAPI.NET/blob/v3.10.2/src/Microsoft.OpenApi/Reader/Services/OpenApiWorkspaceLoader.cs) + reuses the entry format and loads recursively before joining workspaces. Docfx + instead loads complete local documents once, detects each format, and registers + them with SDK workspaces before resolving references. It does not add a separate + JSON Pointer or schema resolver. diff --git a/docs/docs/rest-api-docs.md b/docs/docs/rest-api-docs.md index b2c963385ef..a9c2c753b96 100644 --- a/docs/docs/rest-api-docs.md +++ b/docs/docs/rest-api-docs.md @@ -64,40 +64,13 @@ have dedicated rendered UI. The original input remains available in the raw mode ### Known OpenAPI.NET 3.10.2 limitations -This integration pins `Microsoft.OpenApi` and `Microsoft.OpenApi.YamlReader` to -**3.10.2**. It deliberately reports errors instead of generating misleading documentation -for the following valid inputs: - -- Boolean schemas in `components.schemas`, `properties`, `patternProperties`, - `$defs` or `dependentSchemas`, and boolean branches in `allOf`, `anyOf` or `oneOf`, - produce `UnsupportedBooleanSchema`. The SDK's - [`JsonNodeHelper.CreateMap/CreateList`](https://github.com/microsoft/OpenAPI.NET/blob/v3.10.2/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs) - only pass JSON objects to the schema reader, dropping these boolean values. - Docfx checks root and external sources before reading; it does not rewrite schemas. - Boolean example payloads and extension data are unaffected. -- Standalone external schema/component fragments without an OpenAPI document envelope - are not yet supported. `UnsupportedExternalFragment` identifies this integration limit, - not an invalid OpenAPI specification. Keep referenced definitions in a complete - OpenAPI 3.0/3.1 component document for this version of the integration. -- Dynamic schema references (`$dynamicRef`) produce `UnsupportedOpenApiSchema`; - they are not replaced with ordinary references. -- The SDK's [OpenAPI 3.0 primitive-union folding](https://github.com/microsoft/OpenAPI.NET/blob/v3.10.2/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs#L423-L529) - can lose exclusivity for type-only `oneOf` branches with duplicate types or - overlapping `integer`/`number` types, and can discard branch examples. - These known lossy forms produce `UnsupportedOpenApiComposition`. Disjoint - type-only alternatives and constrained alternatives remain supported. - -The SDK's automatic -[`OpenApiWorkspaceLoader`](https://github.com/microsoft/OpenAPI.NET/blob/v3.10.2/src/Microsoft.OpenApi/Reader/Services/OpenApiWorkspaceLoader.cs) -reuses the entry document's format for external documents and loads recursively before -joining workspaces. Docfx therefore loads local documents once, detects each file's -format, and registers them with SDK workspaces before resolving references. This -supports mixed-format and cyclic document graphs without adding a separate JSON -Pointer or schema resolver. - -The typed SDK model is not a lossless JSON Schema representation (for example, explicit -null defaults and some `const` forms). Use the preserved original source for exact -schema syntax. This integration does not advertise OpenAPI 3.2 support. +See [OpenAPI features not yet supported](openapi-unsupported-features.md) for the +current input errors, features without dedicated UI, examples and SDK source references. + +> [!WARNING] +> Numeric and boolean `const` values can currently be displayed as strings. +> This known fidelity issue is not fixed by preserving the original source. +> Explicit `default: null` and `const: null` are preserved. ## Organize REST APIs using Tags diff --git a/docs/docs/toc.yml b/docs/docs/toc.yml index 7f1b3a150c9..c4e0b61f930 100644 --- a/docs/docs/toc.yml +++ b/docs/docs/toc.yml @@ -9,6 +9,7 @@ - href: template.md - href: dotnet-api-docs.md - href: rest-api-docs.md +- href: openapi-unsupported-features.md - href: links-and-cross-references.md - href: pdf.md From af14c887a6bfa59e6c97027f97db46c04ec23eaa Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Thu, 24 Sep 2026 11:32:46 +1000 Subject: [PATCH 07/16] fix: reject lossy OpenAPI constants and YAML null values --- docs/docs/openapi-unsupported-features.md | 25 ++-- docs/docs/rest-api-docs.md | 8 +- .../OpenApiDocumentReader.cs | 39 +++++ .../OpenApiDocumentReaderTest.cs | 139 +++++++++++++++++- .../OpenApiOutputTest.cs | 34 ++++- 5 files changed, 223 insertions(+), 22 deletions(-) diff --git a/docs/docs/openapi-unsupported-features.md b/docs/docs/openapi-unsupported-features.md index 1d227565b75..f431d321aed 100644 --- a/docs/docs/openapi-unsupported-features.md +++ b/docs/docs/openapi-unsupported-features.md @@ -24,7 +24,8 @@ The affected document is not generated. | Dynamic schema references (`$dynamicRef`) | `UnsupportedOpenApiSchema` | Dynamic scope is not implemented. Ordinary `$ref`, including recursive references, is supported. | | Boolean schemas in schema maps or composition arrays | `UnsupportedBooleanSchema` | The pinned SDK drops these values in certain positions. See [boolean schemas](#boolean-schemas). | | Certain OpenAPI 3.0 primitive compositions | `UnsupportedOpenApiComposition` | The pinned SDK can lose exclusive alternatives or branch examples. See [primitive compositions](#primitive-compositions). | -| Object or array values of `const` | SDK reader error: `Expected scalar value` | These valid OpenAPI 3.1 schema values are not supported by the pinned SDK's scalar-based `const` reader. | +| Numeric, boolean, object or array values of `const` | `UnsupportedOpenApiConst` | The pinned SDK changes the types of numbers/booleans and rejects objects/arrays. Docfx rejects these values before conversion; string and null constants are supported. | +| Implicit YAML null in `const` or `default` | `UnsupportedOpenApiNullValue` | The pinned SDK reads an empty value as an empty string. Write `const: null` or `default: null` explicitly. | | OpenAPI 3.2 and other unsupported specification versions | Version error | Only OpenAPI 3.0 and 3.1 are enabled, even if the SDK can read newer versions. | ### Standalone external fragments @@ -126,12 +127,13 @@ Response Link Objects are not ordinary Markdown links or Docfx cross-references; those continue to work. Missing security documentation does not disable or change authentication in the API itself. -## Known schema fidelity issues +## Const values and null defaults -> [!WARNING] -> Numeric and boolean `const` values are not yet rejected or preserved correctly. -> They can be displayed as strings, changing the meaning of the constraint. -> Keeping the original input does not make that rendered constraint correct. +Docfx rejects `const` values that the pinned SDK cannot preserve, with a diagnostic +that identifies the source file and schema location. This applies to inline schemas, +component schemas, reference siblings and schemas in referenced local documents. +Values inside examples, defaults, enums and extension data are not schema constraints +and are not rejected by this check. The following behavior has been verified with the pinned SDK and Docfx model conversion: @@ -139,15 +141,18 @@ conversion: | Input | Current result | | --- | --- | | `{"const": "ok"}` | Preserves the string `"ok"`. | -| `{"const": 42}` | Converts the number to the string `"42"`. | -| `{"const": true}` | Converts the boolean to the string `"True"`. | -| `{"const": {"status": "ok"}}` or `{"const": [1, 2]}` | Produces a reader error. | +| `{"const": 42}` | Produces `UnsupportedOpenApiConst`; no page is generated. | +| `{"const": true}` | Produces `UnsupportedOpenApiConst`; no page is generated. | +| `{"const": {"status": "ok"}}` or `{"const": [1, 2]}` | Produces `UnsupportedOpenApiConst`; no page is generated. | | `{"const": null}` | Preserves the null constraint. | | `{"type": ["string", "null"], "default": null}` | Preserves the explicit null default. | `default` is an annotation, whereas `const` requires an exact value. They are not interchangeable. Do not change a numeric or boolean `const` to a string merely to -make it render; that would change the API contract. +make it render; that would change the API contract. Quoted YAML strings such as +`const: '42'` retain their string type, and `const: null` retains the null constraint. +An empty YAML value such as `const:` or `default:` produces +`UnsupportedOpenApiNullValue`; spell out `null` to preserve the intended value. The entry document's original text is retained in the raw model for reference. It does not repair lost types or constraints in the generated HTML, and does not diff --git a/docs/docs/rest-api-docs.md b/docs/docs/rest-api-docs.md index a9c2c753b96..cc038e32b21 100644 --- a/docs/docs/rest-api-docs.md +++ b/docs/docs/rest-api-docs.md @@ -67,10 +67,10 @@ have dedicated rendered UI. The original input remains available in the raw mode See [OpenAPI features not yet supported](openapi-unsupported-features.md) for the current input errors, features without dedicated UI, examples and SDK source references. -> [!WARNING] -> Numeric and boolean `const` values can currently be displayed as strings. -> This known fidelity issue is not fixed by preserving the original source. -> Explicit `default: null` and `const: null` are preserved. +Numeric, boolean, object and array `const` values produce `UnsupportedOpenApiConst` +because the pinned SDK cannot preserve them. The diagnostic identifies the source +file and schema location, and the affected document is not generated. String +constants, explicit `default: null` and `const: null` are preserved. ## Organize REST APIs using Tags diff --git a/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs b/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs index a7aebeb01c3..3f09c7460ad 100644 --- a/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs +++ b/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; using System.Text; using Docfx.Common; using Docfx.DataContracts.RestApi; @@ -209,6 +210,23 @@ void VisitDocument(YamlNode node, string path) { CheckReference(value, path); } + else if (value is YamlMappingNode entries && name is + ("paths" or "webhooks" or "responses" or "content" or "headers" or + "parameters" or "requestBodies" or "pathItems" or "callbacks")) + { + // Map keys are names, not object fields: a "default" response or + // a parameter named "schema" still contains a schema. Only Paths + // and Responses Objects allow extensions alongside these entries. + foreach (var (entryKey, entryValue) in entries.Children) + { + if (path != "#/components" && name is ("paths" or "responses") && + ((YamlScalarNode)entryKey).Value.StartsWith("x-", StringComparison.Ordinal)) + { + continue; + } + VisitDocument(entryValue, path + "/" + name + "/" + entryKey); + } + } else { VisitDocument(value, path + "/" + name); @@ -239,6 +257,12 @@ void CheckSchema(YamlNode node, string path) var name = ((YamlScalarNode)key).Value; switch (name) { + case "const" or "default" when value is YamlScalarNode { Style: ScalarStyle.Plain, Value: null or "" }: + throw new DocfxException($"UnsupportedOpenApiNullValue: OpenAPI.NET 3.10.2 reads the implicit YAML null at '{path}/{name}' in '{location.LocalPath}' as an empty string. " + + $"Write '{name}: null' explicitly to preserve its meaning."); + case "const" when !openApi30: + RejectLossyConst(value, path + "/const"); + break; case "$ref": CheckReference(value, path); break; @@ -265,6 +289,21 @@ void CheckSchema(YamlNode node, string path) } } + void RejectLossyConst(YamlNode node, string path) + { + // OpenAPI.NET 3.10.2 reads const with GetScalarValue, turning numbers and + // booleans into strings and rejecting objects/arrays. Quoted scalars and + // explicit null remain supported; never infer a constant's type from type. + if (node is YamlMappingNode or YamlSequenceNode || + node is YamlScalarNode { Style: ScalarStyle.Plain, Value: { } value } && + (bool.TryParse(value, out _) || + (value.Any(char.IsAsciiDigit) && double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _)))) + { + throw new DocfxException($"UnsupportedOpenApiConst: OpenAPI.NET 3.10.2 cannot preserve the const value at '{path}' in '{location.LocalPath}'. " + + "Only string and null const values are supported."); + } + } + void RejectBoolean(YamlNode node, string path) { // OpenAPI.NET 3.10.2 JsonNodeHelper.CreateMap/CreateList drop non-object schemas. diff --git a/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs b/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs index 42ad66b67be..2af45ba82e6 100644 --- a/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs +++ b/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs @@ -232,9 +232,10 @@ public void SchemaShapedLiteralExamplesAndExtensionsAreNotPreflighted() var model = OpenApiDocumentReader.Parse(""" { "openapi":"3.1.0","info":{"title":"Data","version":"1"}, - "x-data":{"schema":{"allOf":[false]},"components":{"schemas":{"Value":true}}}, - "paths":{"/data":{"get":{"responses":{"200":{"description":"OK","content":{ - "application/json":{"schema":{"type":"object"},"example":{"schema":{"oneOf":[false]},"components":{"schemas":{"Value":true}}}} + "x-data":{"schema":{"allOf":[false],"const":42},"components":{"schemas":{"Value":true}}}, + "paths":{"x-data":{"schema":{"const":42}},"/data":{"get":{"responses":{"200":{"description":"OK","content":{ + "application/json":{"schema":{"type":"object","default":{"const":42},"enum":[{"const":true}]}, + "example":{"schema":{"oneOf":[false],"const":42},"components":{"schemas":{"Value":true}}}} }}}}}} } """, "json"); @@ -242,6 +243,7 @@ public void SchemaShapedLiteralExamplesAndExtensionsAreNotPreflighted() var example = Assert.Single(Assert.Single(Assert.Single(model.Children).Responses).Examples); Assert.Contains("false", example.Content); Assert.Contains("true", example.Content); + Assert.Equal(42, (int)JObject.Parse(example.Content)["schema"]["const"]); } [Fact] @@ -259,6 +261,137 @@ public void PreservesSingularSchemaExamplesFromOpenApi30() Assert.Equal("payload", example["$ref"]); } + [Theory] + [InlineData("json")] + [InlineData("yaml")] + public void RejectsConstValuesThatTheSdkCannotPreserve(string format) + { + foreach (var value in new[] { "42", "-1", "1.5", "1e20", "1e100", "true", "false", "{}", "[]" }) + { + var error = Assert.Throws(() => OpenApiDocumentReader.Parse(""" + {"openapi":"3.1.0","info":{"title":"Constants","version":"1"},"paths":{}, + "components":{"schemas":{"Value":{"const":VALUE}}}} + """.Replace("VALUE", value), format)); + Assert.Contains("UnsupportedOpenApiConst", error.Message); + Assert.Contains("#/components/schemas/Value/const", error.Message); + } + } + + [Theory] + [InlineData("json")] + [InlineData("yaml")] + public void PreservesStringAndNullConstantsAndExplicitNullDefaults(string format) + { + foreach (var value in new[] { "\"ok\"", "\"42\"", "\"true\"", "\"null\"", "\"\"", "null" }) + { + var model = OpenApiDocumentReader.Parse(""" + {"openapi":"3.1.0","info":{"title":"Constants","version":"1"},"paths":{}, + "components":{"schemas":{"Value":{"const":VALUE,"default":null}}}} + """.Replace("VALUE", value), format); + var constraints = ((JObject)model.Metadata["schemas"])["Value"]["constraints"]; + Assert.Equal(value, (string)Assert.Single(constraints, item => (string)item["name"] == "const")["value"]); + Assert.Equal("null", (string)Assert.Single(constraints, item => (string)item["name"] == "default")["value"]); + } + } + + [Theory] + [InlineData("'42'", "\"42\"")] + [InlineData("'true'", "\"true\"")] + [InlineData("'null'", "\"null\"")] + [InlineData("plain text", "\"plain text\"")] + [InlineData("NaN", "\"NaN\"")] + [InlineData("Infinity", "\"Infinity\"")] + [InlineData("|-\n 42", "\"42\"")] + [InlineData("~", "null")] + public void PreservesYamlStringAndNullConstants(string value, string expected) + { + var model = OpenApiDocumentReader.Parse($$""" + openapi: 3.1.0 + info: {title: Constants, version: '1'} + paths: {} + components: + schemas: + Value: + const: {{value}} + """, "yaml"); + var constraints = ((JObject)model.Metadata["schemas"])["Value"]["constraints"]; + Assert.Equal(expected, (string)Assert.Single(constraints)["value"]); + } + + [Theory] + [InlineData("3.1.0", "const")] + [InlineData("3.1.0", "default")] + [InlineData("3.0.3", "default")] + public void RejectsImplicitYamlNullValuesThatTheSdkTurnsIntoEmptyStrings(string version, string keyword) + { + var error = Assert.Throws(() => OpenApiDocumentReader.Parse($$""" + openapi: {{version}} + info: {title: Null values, version: '1'} + paths: {} + components: + schemas: + Value: + {{keyword}}: + """, "yaml")); + Assert.Contains("UnsupportedOpenApiNullValue", error.Message); + Assert.Contains("#/components/schemas/Value/" + keyword, error.Message); + } + + [Theory] + [InlineData("default")] + [InlineData("schema")] + [InlineData("value")] + [InlineData("x-parameter")] + public void ChecksSchemasInNamedParameters(string name) + { + var error = Assert.Throws(() => OpenApiDocumentReader.Parse(""" + {"openapi":"3.1.0","info":{"title":"Constants","version":"1"},"paths":{}, + "components":{"parameters":{"NAME":{"name":"q","in":"query","schema":{"const":42}}}}} + """.Replace("NAME", name), "json")); + Assert.Contains("UnsupportedOpenApiConst", error.Message); + Assert.Contains("#/components/parameters/" + name + "/schema/const", error.Message); + } + + [Theory] + [InlineData("{properties: {value: {const: 42}}}", "/properties/value/const")] + [InlineData("{oneOf: [{const: true}]}", "/oneOf/0/const")] + [InlineData("{$ref: '#/components/schemas/Base', const: false}", "/const")] + public void RejectsConstLossInExternalSchemas(string schema, string path) + { + var folder = GetRandomFolder(); + var entry = CreateFile("entry.json", """ + {"openapi":"3.1.0","info":{"title":"Constants","version":"1"},"paths":{}, + "components":{"schemas":{"Value":{"$ref":"external.yaml#/components/schemas/Value"}}}} + """, folder); + CreateFile("external.yaml", $$""" + openapi: 3.1.0 + info: {title: Constants, version: '1'} + paths: {} + components: + schemas: + Base: {type: boolean} + Value: {{schema}} + """, folder); + var error = Assert.Throws(() => OpenApiDocumentReader.Read(entry)); + Assert.Contains("UnsupportedOpenApiConst", error.Message); + Assert.Contains("external.yaml", error.Message); + Assert.Contains("#/components/schemas/Value" + path, error.Message); + } + + [Theory] + [InlineData("200")] + [InlineData("default")] + public void RejectsConstLossInInlineResponseSchemas(string status) + { + var error = Assert.Throws(() => OpenApiDocumentReader.Parse(""" + {"openapi":"3.1.0","info":{"title":"Constants","version":"1"}, + "paths":{"/items":{"get":{"responses":{"STATUS":{"description":"OK", + "content":{"application/json":{"schema":{"const":42}}}}}}}}} + """.Replace("STATUS", status), "json")); + Assert.Contains("UnsupportedOpenApiConst", error.Message); + Assert.Contains("/responses/" + status + "/content/application/json/schema/const", error.Message); + } + [Theory] [InlineData("3.0.3")] [InlineData("3.1.0")] diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs b/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs index 8d7871cc4a0..aa65eaec73d 100644 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs @@ -213,6 +213,21 @@ string OperationPage(string id) .Select(node => HtmlEntity.DeEntitize(node.InnerText))); Assert.Equal(new[] { "null", "string" }, ((string)schema["properties"]["label"]["type"]).Split(" | ").Order(StringComparer.Ordinal)); + if (version == "3.1.0") + { + foreach (var (property, expected) in new[] { ("label", "\"42\""), ("nullValue", "null") }) + { + var constraints = schema["properties"][property]["constraints"]; + Assert.Equal(expected, (string)Assert.Single(constraints, item => (string)item["name"] == "const")["value"]); + Assert.Equal("null", (string)Assert.Single(constraints, item => (string)item["name"] == "default")["value"]); + var propertyHtml = requestHtml["application/json"] + .SelectSingleNode($".//tr[td/span[text()='{property}']]/td[2]/div[@class='rest-schema']"); + Assert.Equal(expected, HtmlEntity.DeEntitize(propertyHtml + .SelectSingleNode("./dl/dt[text()='const']/following-sibling::dd[1]").InnerText)); + Assert.Equal("null", propertyHtml + .SelectSingleNode("./dl/dt[text()='default']/following-sibling::dd[1]").InnerText); + } + } var viewMedia = viewOperations["createItem"]["requestBody"]["content"] .Single(media => (string)media["mimeType"] == "application/json"); Assert.True(JToken.DeepEquals(schema, viewMedia["schema"])); @@ -339,17 +354,24 @@ public void GeneratedOperationUidIsStableAcrossJsonAndYaml() [Theory] [InlineData("UnsupportedBooleanSchema")] [InlineData("UnsupportedExternalFragment")] + [InlineData("UnsupportedOpenApiConst")] + [InlineData("UnsupportedOpenApiNullValue")] public void RejectsUnsupportedOpenApiWithoutPublishing(string diagnostic) { var input = GetRandomFolder(); - var schema = diagnostic == "UnsupportedBooleanSchema" - ? """{"type": "object", "properties": {"value": false}}""" - : """{"$ref": "schema.yaml"}"""; + var schema = diagnostic switch + { + "UnsupportedBooleanSchema" => """{"type": "object", "properties": {"value": false}}""", + "UnsupportedOpenApiConst" => """{"const": 42}""", + "UnsupportedOpenApiNullValue" => "{const: }", + _ => """{"$ref": "schema.yaml"}""" + }; if (diagnostic == "UnsupportedExternalFragment") { CreateFile("schema.yaml", "type: object\nproperties:\n value:\n type: string\n", input); } - var file = CreateFile("unsupported.json", $$""" + var fileName = diagnostic == "UnsupportedOpenApiNullValue" ? "unsupported.yaml" : "unsupported.json"; + var file = CreateFile(fileName, $$""" { "openapi": "3.1.0", "info": { "title": "Unsupported API", "version": "1.0" }, @@ -380,7 +402,8 @@ public void RejectsUnsupportedOpenApiWithoutPublishing(string diagnostic) if (version == "3.1.0") { var properties = components["components"]["schemas"]["Item"]["properties"]; - properties["label"] = new JObject { ["type"] = new JArray("string", "null") }; + properties["label"] = new JObject { ["type"] = new JArray("string", "null"), ["const"] = "42", ["default"] = null }; + properties["nullValue"] = new JObject { ["const"] = null, ["default"] = null }; // SDK 3.10.2 drops booleans in schema maps and composition lists; the reader rejects those forms. // Exercise supported inline media schemas, using full OpenAPI documents for external references. document["paths"]["/health"]["get"]["responses"] = new JObject @@ -535,6 +558,7 @@ private static string Serialize(JObject document, string extension) { JObject obj => obj.Properties().ToDictionary(property => property.Name, property => ToYamlValue(property.Value)), JArray array => array.Select(ToYamlValue).ToArray(), + JValue { Type: JTokenType.Null } => new YamlDotNet.RepresentationModel.YamlScalarNode("null") { Style = YamlDotNet.Core.ScalarStyle.Plain }, JValue value => value.Value, _ => throw new InvalidOperationException($"Unexpected fixture value: {token.Type}") }; From a8f27b2f595b1e54dbd58f9c9c4d633bd0d16d06 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Thu, 24 Sep 2026 19:24:04 +1000 Subject: [PATCH 08/16] Add OpenAPI 3.0 example and fix nested schema rendering --- docs/docs/openapi-3-example.yml | 123 ++++++++++++++++++ docs/docs/rest-api-docs.md | 3 + docs/docs/toc.yml | 2 + templates/common/RestApi.common.js | 35 ++--- templates/modern/src/rest.test.ts | 26 ++-- .../OpenApiOutputTest.cs | 40 ++++++ 6 files changed, 199 insertions(+), 30 deletions(-) create mode 100644 docs/docs/openapi-3-example.yml diff --git a/docs/docs/openapi-3-example.yml b/docs/docs/openapi-3-example.yml new file mode 100644 index 00000000000..af52519354a --- /dev/null +++ b/docs/docs/openapi-3-example.yml @@ -0,0 +1,123 @@ +openapi: 3.0.3 +info: + title: Catalog API (OpenAPI 3.0) + version: '1.0' + description: | + This page is generated directly from an **OpenAPI 3.0.3** document. + Explore path and query parameters, a JSON request body, response examples, + and reusable schemas below. The server address is illustrative. + + See the [REST API guide](rest-api-docs.md#openapi-3-documents) to document your own API. +servers: + - url: https://api.example.com/v1 + description: Example catalog server. +tags: + - name: Products + description: Read and create products in a catalog. +paths: + /products/{id}: + get: + operationId: getProduct + tags: [Products] + summary: Get a product + description: Returns a product by its identifier. + parameters: + - name: id + in: path + required: true + description: The product identifier. + schema: + type: string + - name: includeArchived + in: query + description: Include products that are no longer available. + schema: + type: boolean + default: false + responses: + '200': + description: The requested product. + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + example: + id: notebook + name: Paper notebook + price: 12.5 + status: available + '404': + description: No product has this identifier. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: product_not_found + message: The product does not exist. + /products: + post: + operationId: createProduct + tags: [Products] + summary: Create a product + description: Creates a product from a JSON request body. + requestBody: + required: true + description: The name and price of the new product. + content: + application/json: + schema: + $ref: '#/components/schemas/NewProduct' + example: + name: Paper notebook + price: 12.5 + responses: + '201': + description: The product was created. + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + example: + id: notebook + name: Paper notebook + price: 12.5 + status: available +components: + schemas: + NewProduct: + type: object + required: [name, price] + properties: + name: + type: string + description: The **display name** of the product. + minLength: 1 + price: + type: number + format: double + description: Unit price in the catalog currency. + minimum: 0 + Product: + allOf: + - $ref: '#/components/schemas/NewProduct' + - type: object + required: [id, status] + properties: + id: + type: string + description: The product identifier. + status: + type: string + description: Current availability. + enum: [available, archived] + Error: + type: object + required: [code, message] + properties: + code: + type: string + description: A machine-readable error code. + message: + type: string + description: A description of the error. diff --git a/docs/docs/rest-api-docs.md b/docs/docs/rest-api-docs.md index cc038e32b21..88d665de2cc 100644 --- a/docs/docs/rest-api-docs.md +++ b/docs/docs/rest-api-docs.md @@ -21,6 +21,9 @@ Each swagger file produces one output HTML file. ## OpenAPI 3 documents +See the [OpenAPI 3.0 example](openapi-3-example.yml) for a generated API page with +parameters, a request body, response examples and reusable schemas. + Include the entry documents in `build.content`, for example: ```json diff --git a/docs/docs/toc.yml b/docs/docs/toc.yml index 5642db9a79e..80ed12778c2 100644 --- a/docs/docs/toc.yml +++ b/docs/docs/toc.yml @@ -10,6 +10,8 @@ - href: dotnet-api-docs.md - href: sdk-compatibility.md - href: rest-api-docs.md +- name: OpenAPI 3.0 example + href: openapi-3-example.yml - href: openapi-unsupported-features.md - href: links-and-cross-references.md - href: pdf.md diff --git a/templates/common/RestApi.common.js b/templates/common/RestApi.common.js index df0566bed95..bae9072b4bd 100644 --- a/templates/common/RestApi.common.js +++ b/templates/common/RestApi.common.js @@ -50,14 +50,14 @@ exports.transform = function (model) { formatExample(child.responses); if (child._hasSchemaDetails) { - (child.servers || []).forEach(function (server) { server.description = server.description || null; }); + (child.servers || []).forEach(function (server) { server.description = server.description || ''; }); (child.parameters || []).forEach(function (parameter) { parameter.hasContent = parameter.content !== undefined && parameter.content !== null; transformContent(parameter.content); parameter.schemaDetails = schemaDetails(parameter.schema); }); if (child.requestBody) { - child.requestBody.description = child.requestBody.description || null; + child.requestBody.description = child.requestBody.description || ''; transformContent(child.requestBody.content); } (child.responses || []).forEach(function (response) { @@ -144,8 +144,8 @@ exports.transform = function (model) { details.id = schemaId(name); details.name = name; if (details.referenceName === name) { - details.referenceName = null; - details.referenceId = null; + details.referenceName = ''; + details.referenceId = ''; } model.definitions.push({ schemaDetails: details }); }); @@ -170,15 +170,16 @@ exports.transform = function (model) { } function schemaDetails(schema) { - if (!schema) return null; + if (!schema) return false; var name = schema['x-internal-loop-ref-name'] || schema['x-internal-ref-name']; - // Explicit empty fields prevent recursive Mustache partials from looking up an ancestor's schema. + // Null fields fall through to ancestor scopes in Docfx's Mustache renderer. + // Empty strings and false keep missing fields local to this schema. return { - type: schema.type || null, - format: schema.format || null, - description: schema.description || null, - referenceName: name || null, - referenceId: name && schemas[name] ? schemaId(name) : null, + type: schema.type || '', + format: schema.format || '', + description: schema.description || '', + referenceName: name || '', + referenceId: name && schemas[name] ? schemaId(name) : '', properties: Object.keys(schema.properties || {}).map(function (key) { return { key: key, @@ -199,14 +200,14 @@ exports.transform = function (model) { function exampleDetails(examples) { return (examples || []).map(function (example) { - var externalValue = example.externalValue || null; + var externalValue = example.externalValue || ''; return { - name: example.name || null, - mimeType: example.mimeType || null, - content: typeof example.content === "string" ? example.content : null, + name: example.name || '', + mimeType: example.mimeType || '', + content: typeof example.content === "string" ? example.content : '', hasContent: typeof example.content === "string", externalValue: externalValue, - externalHref: externalValue && /^https?:\/\/[^\s\\]+$/i.test(externalValue) ? externalValue : null + externalHref: externalValue && /^https?:\/\/[^\s\\]+$/i.test(externalValue) ? externalValue : '' }; }); } @@ -216,7 +217,7 @@ exports.transform = function (model) { media.schemaDetails = schemaDetails(media.schema); media.examples = media.examples || []; media.examples.forEach(function (example) { - example.name = example.name || null; + example.name = example.name || ''; example.mimeType = example.mimeType || media.mimeType; }); }); diff --git a/templates/modern/src/rest.test.ts b/templates/modern/src/rest.test.ts index 98cea2c576b..6beb8829942 100644 --- a/templates/modern/src/rest.test.ts +++ b/templates/modern/src/rest.test.ts @@ -96,19 +96,19 @@ test('REST prepares every request and response media schema and named example', const child = model.children[0] assert.equal(child.path, '/items') assert.equal(child.requestUrl, 'https://api.example.test/v2/items') - assert.equal(child.servers[0].description, null) + assert.equal(child.servers[0].description, '') assert.equal(child.parameters[0].schemaDetails.type, 'string | null') assert.equal(child.parameters[0].schemaDetails.format, 'uuid') - assert.equal(child.requestBody.description, null) + assert.equal(child.requestBody.description, '') assert.deepEqual(child.requestBody.content.map(media => media.schemaDetails.type), ['object', 'string']) assert.deepEqual(child.requestBody.content[0].examples[0], { name: 'created', mimeType: 'application/json', content: '{\n "id": 1\n}' }) assert.equal(child.responses[0].content[0].schemaDetails.items.type, 'integer') assert.equal(child.responses[0].content[0].examples[0].content, '[\n 1,\n 2\n]') - assert.equal(child.responses[0].content[1].examples[0].name, null) + assert.equal(child.responses[0].content[1].examples[0].name, '') assert.deepEqual(child.responses[0].content[2].examples, []) - assert.equal(child.responses[0].content[2].schemaDetails, null) + assert.equal(child.responses[0].content[2].schemaDetails, false) assert.equal(child.responses[0].hasContent, true) assert.equal(child.responses[1].hasContent, true) assert.equal(child.responses[0].examples[0].content, 'flattened response') @@ -153,7 +153,7 @@ test('REST keeps nested composition, constraints, unions, boolean schemas, and f assert.equal(composition[0].schemas[0].properties[0].value.type, 'any value') assert.equal(composition[3].schemas[0].type, 'no value') assert.deepEqual(composition[3].schemas[0].properties, []) - assert.equal(composition[3].schemas[0].items, null) + assert.equal(composition[3].schemas[0].items, false) assert.deepEqual(composition[3].schemas[0].composition, []) }) @@ -207,7 +207,7 @@ test('REST adds inline reference definitions and leaves unresolved references as const details = model.children[0].parameters[0].schemaDetails assert.equal(details.referenceId, model.definitions[0].schemaDetails.id) assert.equal(details.properties[0].value.referenceName, 'Missing') - assert.equal(details.properties[0].value.referenceId, null) + assert.equal(details.properties[0].value.referenceId, '') }) test('REST renders parameter content and keeps same-name external schema references distinct', () => { @@ -242,7 +242,7 @@ test('REST renders parameter content and keeps same-name external schema referen }) const parameter = model.children[0].parameters[0] assert.equal(parameter.hasContent, true) - assert.equal(parameter.schemaDetails, null) + assert.equal(parameter.schemaDetails, false) assert.equal(parameter.default, '{"active":true}') assert.equal(parameter.content[0].exampleDetails[0].content, '{\n "active": true\n}') assert.equal(parameter.content[0].exampleDetails[0].name, 'active') @@ -266,12 +266,12 @@ test('REST renders schema examples without inheriting names, MIME types, or ance const details = model.definitions[0].schemaDetails assert.deepEqual(schema, original) assert.deepEqual(details.exampleDetails[0], { - name: null, - mimeType: null, + name: '', + mimeType: '', content: '{"state":"active"}', hasContent: true, - externalValue: null, - externalHref: null + externalValue: '', + externalHref: '' }) assert.equal(details.exampleDetails[1].hasContent, true) assert.equal(details.exampleDetails[1].content, '') @@ -298,8 +298,8 @@ test('REST displays external example URLs without inventing content or linking e }) const examples = model.children[0].responses[0].content[0].exampleDetails assert.deepEqual(examples.map(example => example.externalValue), urls) - assert.deepEqual(examples.map(example => example.externalHref), [...urls.slice(0, 2), null, null]) - assert.ok(examples.every(example => example.name === 'external' && example.content === null && !example.hasContent)) + assert.deepEqual(examples.map(example => example.externalHref), [...urls.slice(0, 2), '', '']) + assert.ok(examples.every(example => example.name === 'external' && example.content === '' && !example.hasContent)) }) for (const flagLocation of ['root', 'operation']) { diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs b/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs index aa65eaec73d..7678ca42fb9 100644 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs @@ -351,6 +351,46 @@ public void GeneratedOperationUidIsStableAcrossJsonAndYaml() } } + [Theory] + [InlineData("default")] + [InlineData("statictoc")] + [InlineData("modern")] + public void MissingSchemaAndExampleFieldsDoNotInheritParentValues(string template) + { + var input = GetRandomFolder(); + var file = CreateFile("nested.json", """ + { + "openapi":"3.0.3","info":{"title":"Parent API","version":"1","description":"API description."}, + "paths":{"/items":{"get":{"operationId":"read","tags":["Parent tag"], + "responses":{"200":{"description":"Response description.","content":{"application/json":{ + "schema":{"$ref":"#/components/schemas/Container"},"example":{"child":"value"} + }}}}}}}, + "components":{"schemas":{"Container":{ + "type":"object","format":"parent-format","description":"Parent schema description.", + "properties":{"child":{"type":"string"}} + }}} + } + """, input); + var files = new FileCollection(Directory.GetCurrentDirectory()); + files.Add(DocumentType.Article, [file], input); + + var output = Build(input, files, template, false, false); + var article = ReadHtml(output, "nested.html").SelectSingleNode("//article"); + var childSchemas = article.SelectNodes(".//tr[td/span[text()='child']]/td[2]/div[@class='rest-schema']"); + Assert.NotEmpty(childSchemas); + foreach (var child in childSchemas) + { + Assert.Equal("string", child.SelectSingleNode("./span[@class='schema-type']").InnerText); + Assert.Null(child.SelectSingleNode("./a[@class='typelink']")); + Assert.Null(child.SelectSingleNode("./span[@class='schema-format']")); + Assert.Null(child.SelectSingleNode("./div[@class='markdown description']")); + } + Assert.Null(article.SelectSingleNode(".//div[@class='example-name']")); + Assert.Contains("Parent schema description.", article.InnerText); + Assert.Contains("Response description.", article.InnerText); + Assert.Contains("value", Assert.Single(article.SelectNodes(".//pre/code"), code => code.InnerText.Contains("child")).InnerText); + } + [Theory] [InlineData("UnsupportedBooleanSchema")] [InlineData("UnsupportedExternalFragment")] From cc091c57e9f20672112b15f38ec866706c6c3709 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Thu, 24 Sep 2026 20:29:26 +1000 Subject: [PATCH 09/16] Support OpenAPI 3.2 and preserve schema values --- docs/docs/openapi-32-example.yml | 63 +++++ docs/docs/openapi-unsupported-features.md | 79 ++---- docs/docs/rest-api-docs.md | 25 +- docs/docs/toc.yml | 2 + .../OpenApiDocumentReader.cs | 192 +++++++++++--- .../OpenApiModelConverter.cs | 42 ++- templates/common/RestApi.common.js | 12 +- .../partials/rest.media-schema.tmpl.partial | 3 + .../OpenApiDocumentReaderTest.cs | 248 +++++++++++++++--- .../OpenApiOutputTest.cs | 108 ++++++-- 10 files changed, 585 insertions(+), 189 deletions(-) create mode 100644 docs/docs/openapi-32-example.yml diff --git a/docs/docs/openapi-32-example.yml b/docs/docs/openapi-32-example.yml new file mode 100644 index 00000000000..802ccc4bfab --- /dev/null +++ b/docs/docs/openapi-32-example.yml @@ -0,0 +1,63 @@ +openapi: 3.2.0 +info: + title: Event Stream API + version: '1.0' + description: | + This OpenAPI 3.2 example demonstrates **streaming responses**, the `QUERY` + method and an additional HTTP method. The Event schema includes typed + constants, a null default and boolean schemas. +servers: + - url: https://api.example.com/v1 +paths: + /events: + query: + operationId: queryEvents + summary: Query the event stream + description: Each line in the response is one Event, described under **Stream item**. + responses: + '200': + description: A stream of matching events. + content: + application/jsonl: + itemSchema: + $ref: '#/components/schemas/Event' + examples: + structured: + dataValue: + version: 1 + active: true + context: {source: catalog} + codes: [1, 2] + cursor: null + wire: + serializedValue: | + {"version":1,"active":true,"context":{"source":"catalog"},"codes":[1,2],"cursor":null} + additionalOperations: + COPY: + operationId: copyEvents + summary: Copy the current events + responses: + '204': + description: The events were copied. +components: + schemas: + Event: + type: object + properties: + version: + type: integer + const: 1 + description: The event format version, preserved as a number. + active: + type: boolean + const: true + context: + const: {source: catalog} + codes: + const: [1, 2] + cursor: + type: [string, 'null'] + default: + description: An omitted YAML default value means null. + metadata: true + forbidden: false diff --git a/docs/docs/openapi-unsupported-features.md b/docs/docs/openapi-unsupported-features.md index f431d321aed..dac08e660fb 100644 --- a/docs/docs/openapi-unsupported-features.md +++ b/docs/docs/openapi-unsupported-features.md @@ -6,7 +6,7 @@ which uses `Microsoft.OpenApi` and `Microsoft.OpenApi.YamlReader` **3.10.2**. Swagger 2.0 JSON continues to use its existing reader and is not affected by these OpenAPI 3 limitations. -OpenAPI 3.0 and 3.1 JSON/YAML documents are accepted, but this is not full +OpenAPI 3.0, 3.1 and 3.2 JSON/YAML documents are accepted, but this is not full OpenAPI or JSON Schema conformance. Parsing a document successfully does not mean every feature is rendered. The list below describes known boundaries, not a commitment to a particular release or an exhaustive conformance matrix. @@ -21,12 +21,9 @@ The affected document is not generated. | Standalone external schema/component fragments | `UnsupportedExternalFragment` | This integration loads complete OpenAPI documents, not standalone fragments. Put shared components in a complete local document and reference its component path. | | References without a fragment identifier | `UnsupportedExternalFragment` | Use a supported component reference such as `components.yaml#/components/schemas/Pet`. | | HTTP/HTTPS or network-share references | Rejected; no network fetching | Use local referenced documents. Server URLs and ordinary documentation links are not restricted by this rule. | +| Future specification versions | Version error | Only OpenAPI 3.0, 3.1 and 3.2 are enabled. | | Dynamic schema references (`$dynamicRef`) | `UnsupportedOpenApiSchema` | Dynamic scope is not implemented. Ordinary `$ref`, including recursive references, is supported. | -| Boolean schemas in schema maps or composition arrays | `UnsupportedBooleanSchema` | The pinned SDK drops these values in certain positions. See [boolean schemas](#boolean-schemas). | | Certain OpenAPI 3.0 primitive compositions | `UnsupportedOpenApiComposition` | The pinned SDK can lose exclusive alternatives or branch examples. See [primitive compositions](#primitive-compositions). | -| Numeric, boolean, object or array values of `const` | `UnsupportedOpenApiConst` | The pinned SDK changes the types of numbers/booleans and rejects objects/arrays. Docfx rejects these values before conversion; string and null constants are supported. | -| Implicit YAML null in `const` or `default` | `UnsupportedOpenApiNullValue` | The pinned SDK reads an empty value as an empty string. Write `const: null` or `default: null` explicitly. | -| OpenAPI 3.2 and other unsupported specification versions | Version error | Only OpenAPI 3.0 and 3.1 are enabled, even if the SDK can read newer versions. | ### Standalone external fragments @@ -64,31 +61,6 @@ Local component documents can mix JSON and YAML, and references between them can form cycles. This limitation is in the current Docfx integration; it does not mean standalone fragments are invalid OpenAPI. -### Boolean schemas - -The ordinary schema `type: boolean` is supported. A *boolean schema* is different: -`true` accepts any JSON value, while `false` accepts no value. OpenAPI 3.1 allows -both forms. - -Direct media-type schemas such as `schema: true` and `schema: false` are supported. -The following positions are rejected because the pinned SDK does not preserve them: - -- Entries in `components.schemas`, `properties`, `patternProperties`, `$defs` and - `dependentSchemas`. -- Branches in `allOf`, `anyOf` and `oneOf`. - -For example, removing the `false` branch below would change a schema that accepts -no values into one that accepts strings: - -```yaml -allOf: - - false - - type: string -``` - -The check applies to both entry documents and referenced documents. Boolean values -inside examples and extension data are not schemas and are not rejected by this check. - ### Primitive compositions In some OpenAPI 3.0 cases, the pinned SDK combines primitive alternatives into a @@ -108,7 +80,7 @@ to primitive branches during this conversion. The reader rejects these known lossy OpenAPI 3.0 forms. Disjoint type-only alternatives, such as `string` and `integer`, can become equivalent unions. -Constrained alternatives and OpenAPI 3.1 compositions are not blanket-rejected. +Constrained alternatives and OpenAPI 3.1/3.2 compositions are not blanket-rejected. ## Features without dedicated documentation UI @@ -121,53 +93,38 @@ failure. Their presence is not an indication that the following details are rend | Callbacks | Callback operations, parameters and payloads associated with an API operation. | | Webhooks | Top-level webhook operations and their requests/responses. | | Security schemes and requirements | API key, HTTP/Bearer, OAuth2 and OpenID Connect configuration, operation requirements and scopes. | +| Media-type encoding | `encoding`, `itemEncoding` and `prefixEncoding` details. | +| Tag summary, hierarchy and kind | Tags retain flat grouping; `summary`, `parent` and `kind` have no dedicated UI. | | Response Link Objects | Relationships to subsequent operations and mappings from response values to their parameters. | Response Link Objects are not ordinary Markdown links or Docfx cross-references; those continue to work. Missing security documentation does not disable or change authentication in the API itself. -## Const values and null defaults +## Supported schema values and OpenAPI 3.2 features -Docfx rejects `const` values that the pinned SDK cannot preserve, with a diagnostic -that identifies the source file and schema location. This applies to inline schemas, -component schemas, reference siblings and schemas in referenced local documents. -Values inside examples, defaults, enums and extension data are not schema constraints -and are not rejected by this check. +Typed `const` values, explicit and implicit YAML null defaults, and boolean schemas +are supported. Boolean schemas work in components, properties and composition arrays +as well as inline. Docfx preserves these values around known SDK reader limitations. +Values inside examples and extension data remain literal data. -The following behavior has been verified with the pinned SDK and Docfx model -conversion: - -| Input | Current result | -| --- | --- | -| `{"const": "ok"}` | Preserves the string `"ok"`. | -| `{"const": 42}` | Produces `UnsupportedOpenApiConst`; no page is generated. | -| `{"const": true}` | Produces `UnsupportedOpenApiConst`; no page is generated. | -| `{"const": {"status": "ok"}}` or `{"const": [1, 2]}` | Produces `UnsupportedOpenApiConst`; no page is generated. | -| `{"const": null}` | Preserves the null constraint. | -| `{"type": ["string", "null"], "default": null}` | Preserves the explicit null default. | - -`default` is an annotation, whereas `const` requires an exact value. They are not -interchangeable. Do not change a numeric or boolean `const` to a string merely to -make it render; that would change the API contract. Quoted YAML strings such as -`const: '42'` retain their string type, and `const: null` retains the null constraint. -An empty YAML value such as `const:` or `default:` produces -`UnsupportedOpenApiNullValue`; spell out `null` to preserve the intended value. - -The entry document's original text is retained in the raw model for reference. -It does not repair lost types or constraints in the generated HTML, and does not -provide an archive of every external source document. +OpenAPI 3.2 support includes `QUERY`, additional HTTP methods, reusable media types, +streaming `itemSchema`, and examples using `dataValue` or `serializedValue`. +See the [OpenAPI 3.2 example](openapi-32-example.yml) for generated output. ## SDK implementation notes The following links identify the fixed SDK version behind the known behavior: - [`JsonNodeHelper.CreateMap/CreateList`](https://github.com/microsoft/OpenAPI.NET/blob/v3.10.2/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs) - only pass object nodes to schema readers in the affected map/list positions. + only pass object nodes to schema readers in map/list positions. Docfx normalizes + boolean schemas to equivalent objects before parsing. - The [OpenAPI 3.0 schema reader](https://github.com/microsoft/OpenAPI.NET/blob/v3.10.2/src/Microsoft.OpenApi/Reader/V3/OpenApiSchemaDeserializer.cs) performs the primitive-alternative folding described above. - The [OpenAPI 3.1 schema reader](https://github.com/microsoft/OpenAPI.NET/blob/v3.10.2/src/Microsoft.OpenApi/Reader/V31/OpenApiSchemaDeserializer.cs) - reads `const` through `GetScalarValue`, causing the non-string limitations. + reads `const` through `GetScalarValue` and models it as a string. Docfx retains + each original JSON value separately during SDK parsing and reference resolution, + then restores it during model conversion. - The automatic [`OpenApiWorkspaceLoader`](https://github.com/microsoft/OpenAPI.NET/blob/v3.10.2/src/Microsoft.OpenApi/Reader/Services/OpenApiWorkspaceLoader.cs) reuses the entry format and loads recursively before joining workspaces. Docfx instead loads complete local documents once, detects each format, and registers diff --git a/docs/docs/rest-api-docs.md b/docs/docs/rest-api-docs.md index 88d665de2cc..ae592c703ff 100644 --- a/docs/docs/rest-api-docs.md +++ b/docs/docs/rest-api-docs.md @@ -1,7 +1,7 @@ # REST API docs -Docfx generates REST API documentation from Swagger 2.0 JSON and OpenAPI 3.0 JSON or YAML files, -with **OpenAPI 3.1 core support and documented SDK limitations**. +Docfx generates REST API documentation from Swagger 2.0 JSON and OpenAPI 3.0, 3.1 and 3.2 +JSON or YAML files, with documented feature limitations. OpenAPI documents are read using [OpenAPI.NET](https://github.com/microsoft/OpenAPI.NET). Swagger 2.0 continues to use the existing compatibility reader. @@ -23,6 +23,8 @@ Each swagger file produces one output HTML file. See the [OpenAPI 3.0 example](openapi-3-example.yml) for a generated API page with parameters, a request body, response examples and reusable schemas. +The [OpenAPI 3.2 example](openapi-32-example.yml) demonstrates streaming responses, +additional HTTP methods, typed constants, null defaults and boolean schemas. Include the entry documents in `build.content`, for example: @@ -40,8 +42,7 @@ Both `.yaml` and `.yml` are supported. Referenced OpenAPI documents can mix JSON do not need to be listed as separate entry documents. References are loaded through Docfx's file abstraction; HTTP/HTTPS and network-share references are not fetched. An invalid document or unresolved reference produces an input error, not a fallback -to the Swagger reader. Only OpenAPI 3.0 and 3.1 are supported, even if the installed -library can read newer versions. +to the Swagger reader. OpenAPI 3.0, 3.1 and 3.2 are supported. Operations reuse the existing Markdown, overwrite, cross-reference, tag and operation splitting pipeline. Parameters, request bodies and response content @@ -57,7 +58,7 @@ and path. Explicit IDs must be unique. Tags used by operations need not be decla at document level. Schema documentation preserves alternatives and intersections rather than merging -`allOf`/`anyOf`/`oneOf` properties into a single object. OpenAPI 3.1 inline boolean schemas, +`allOf`/`anyOf`/`oneOf` properties into a single object. OpenAPI 3.1/3.2 boolean schemas, type unions and recursive references are displayed without expanding cycles. Schema reference siblings are shown as an intersection with the target, not an override. The SDK can normalize disjoint primitive alternatives into equivalent type unions. @@ -65,16 +66,20 @@ This is documentation generation, not full JSON Schema validation or full OpenAP conformance. Callbacks, webhooks, security configuration and response links do not have dedicated rendered UI. The original input remains available in the raw model. +OpenAPI 3.1/3.2 `const` values retain their JSON types, including numbers, booleans, +objects, arrays and null. Explicit and empty YAML null defaults are supported. +Boolean schemas work inline, in components and properties, and in composition arrays: +`true` accepts any value, while `false` accepts no value. + +OpenAPI 3.2 `QUERY` and additional HTTP methods are rendered as operations. Streaming +media types display `itemSchema` under **Stream item**. Examples support structured +`dataValue` and literal `serializedValue`, as well as `value` and `externalValue`. + ### Known OpenAPI.NET 3.10.2 limitations See [OpenAPI features not yet supported](openapi-unsupported-features.md) for the current input errors, features without dedicated UI, examples and SDK source references. -Numeric, boolean, object and array `const` values produce `UnsupportedOpenApiConst` -because the pinned SDK cannot preserve them. The diagnostic identifies the source -file and schema location, and the affected document is not generated. String -constants, explicit `default: null` and `const: null` are preserved. - ## Organize REST APIs using Tags APIs can be organized using the [Tag Object](http://swagger.io/specification/#tagObject). An API can be associated with one or more tags. Untagged APIs are put in the _Other apis_ section. diff --git a/docs/docs/toc.yml b/docs/docs/toc.yml index 80ed12778c2..0f446419541 100644 --- a/docs/docs/toc.yml +++ b/docs/docs/toc.yml @@ -12,6 +12,8 @@ - href: rest-api-docs.md - name: OpenAPI 3.0 example href: openapi-3-example.yml +- name: OpenAPI 3.2 example + href: openapi-32-example.yml - href: openapi-unsupported-features.md - href: links-and-cross-references.md - href: pdf.md diff --git a/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs b/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs index 3f09c7460ad..18067c6142a 100644 --- a/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs +++ b/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs @@ -9,6 +9,7 @@ using Docfx.Plugins; using Microsoft.OpenApi; using Microsoft.OpenApi.Reader; +using Newtonsoft.Json; using YamlDotNet.Core; using YamlDotNet.RepresentationModel; @@ -49,12 +50,13 @@ internal static RestApiRootItemViewModel Parse(string raw, string format, Uri ba try { var version = GetVersion(raw); - if (!System.Version.TryParse(version, out var parsed) || parsed.Major != 3 || parsed.Minor is not (0 or 1)) + if (!System.Version.TryParse(version, out var parsed) || parsed.Major != 3 || parsed.Minor is not (0 or 1 or 2)) { - throw new DocfxException($"OpenAPI version '{version}' is not supported. Use OpenAPI 3.0 or 3.1."); + throw new DocfxException($"OpenAPI version '{version}' is not supported. Use OpenAPI 3.0, 3.1 or 3.2."); } - var document = LoadDocuments(raw, format, baseUrl ?? new Uri(Path.GetFullPath("openapi.json"))); - var model = new OpenApiModelConverter(document.BaseUri).Convert(document, raw, version); + var constants = new Dictionary(); + var document = LoadDocuments(raw, format, baseUrl ?? new Uri(Path.GetFullPath("openapi.json")), constants); + var model = new OpenApiModelConverter(document.BaseUri, constants).Convert(document, raw, version); model.Metadata["rawExtension"] = format == "json" ? ".json" : ".yaml"; return model; } @@ -64,7 +66,7 @@ internal static RestApiRootItemViewModel Parse(string raw, string format, Uri ba } } - private static OpenApiDocument LoadDocuments(string raw, string format, Uri root) + private static OpenApiDocument LoadDocuments(string raw, string format, Uri root, Dictionary constants) { var loader = new LocalStreamLoader(); var documents = new Dictionary(); @@ -84,12 +86,17 @@ private static OpenApiDocument LoadDocuments(string raw, string format, Uri root sourceFormat = Path.GetExtension(location.LocalPath).Equals(".json", StringComparison.OrdinalIgnoreCase) ? "json" : "yaml"; } var version = GetVersion(source); - if (!System.Version.TryParse(version, out var parsed) || parsed.Major != 3 || parsed.Minor is not (0 or 1)) + if (!System.Version.TryParse(version, out var parsed) || parsed.Major != 3 || parsed.Minor is not (0 or 1 or 2)) { - throw new DocfxException($"UnsupportedExternalFragment: '{location.LocalPath}' is not a complete OpenAPI 3.0 or 3.1 document. " + + throw new DocfxException($"UnsupportedExternalFragment: '{location.LocalPath}' is not a complete OpenAPI 3.0, 3.1 or 3.2 document. " + "Standalone schema/component fragments are valid OpenAPI references, but are not supported by this reader integration."); } - CheckSchemaReaderLimitations(source, location, parsed.Minor == 0); + if (sourceFormat == "json") + { + // Replacing a const value must not make malformed JSON appear valid. + using var json = System.Text.Json.JsonDocument.Parse(source); + } + source = PrepareSchemas(source, location, parsed.Minor == 0, constants); var settings = new OpenApiReaderSettings { BaseUrl = location, @@ -109,10 +116,6 @@ private static OpenApiDocument LoadDocuments(string raw, string format, Uri root Logger.LogWarning($"OpenAPI '{location.LocalPath}': {warning}"); } var document = result.Document ?? throw new DocfxException($"The OpenAPI reader did not produce a document for '{location.LocalPath}'."); - if (document.Components?.Schemas?.Any(pair => pair.Value == null) == true) - { - throw new DocfxException($"UnsupportedBooleanSchema: OpenAPI.NET could not read a component schema in '{location.LocalPath}'."); - } documents.Add(location, document); if (document.Webhooks is { Count: > 0 } || document.Security is { Count: > 0 } || document.Components?.SecuritySchemes is { Count: > 0 } || @@ -124,6 +127,10 @@ private static OpenApiDocument LoadDocuments(string raw, string format, Uri root } var collector = new ReferenceCollector(); new OpenApiWalker(collector).Walk(document); + if (collector.HasEncoding || document.Tags?.Any(tag => tag.Parent != null || tag.Kind != null || tag.Summary != null) == true) + { + Logger.LogWarning($"OpenAPI '{location.LocalPath}': media-type encoding and tag summary, hierarchy and kind do not have dedicated documentation UI."); + } references.Add(location, collector.References); foreach (var (_, reference) in collector.References) { @@ -168,10 +175,46 @@ private static OpenApiDocument LoadDocuments(string raw, string format, Uri root return documents[root]; } - private static void CheckSchemaReaderLimitations(string source, Uri location, bool openApi30) + private static string PrepareSchemas(string source, Uri location, bool openApi30, Dictionary constants) { + var replacements = new Dictionary(); var yaml = new YamlStream(); yaml.Load(new StringReader(source)); + // Resolve YAML aliases before editing source spans: an alias shares its + // node's original span, which may belong to an example rather than a schema. + if (yaml.Documents[0].AllNodes.Any(node => !node.Anchor.IsEmpty)) + { + foreach (var node in yaml.Documents[0].AllNodes) + { + node.Anchor = AnchorName.Empty; + } + using var expanded = new StringWriter(); + yaml.Save(expanded, assignAnchors: false); + source = expanded.ToString(); + yaml = new YamlStream(); + yaml.Load(new StringReader(source)); + } + // Representation-model collection End marks describe the opening token. + // Use parsing events to locate the end of a complete const object/array. + var collectionEnds = new Dictionary(); + var starts = new Stack(); + var parser = new Parser(new StringReader(source)); + while (parser.MoveNext()) + { + if (parser.Current is YamlDotNet.Core.Events.MappingStart or YamlDotNet.Core.Events.SequenceStart) + { + starts.Push((int)parser.Current.Start.Index); + } + else if (parser.Current is YamlDotNet.Core.Events.MappingEnd or YamlDotNet.Core.Events.SequenceEnd) + { + var end = (int)parser.Current.End.Index; + if (parser.Current.Start.Index == end && end < source.Length && source[end] is '}' or ']') + { + end++; + } + collectionEnds.Add(starts.Pop(), end); + } + } var root = yaml.Documents[0].RootNode; if (root is YamlMappingNode document && document.Children.TryGetValue(new YamlScalarNode("components"), out var components) && @@ -181,6 +224,25 @@ components is YamlMappingNode componentMap && CheckMap(schemas, "#/components/schemas"); } VisitDocument(root, "#"); + var prepared = new StringBuilder(source); + foreach (var (start, replacement) in replacements.OrderByDescending(pair => pair.Key)) + { + prepared.Remove(start, replacement.End - start).Insert(start, replacement.Value); + } + return prepared.ToString(); + + void Replace(YamlNode node, string value) + { + var start = (int)node.Start.Index; + var end = collectionEnds.GetValueOrDefault(start, (int)node.End.Index); + // Block collections/scalars can include the newline before the next field. + var trimmedEnd = end; + while (trimmedEnd > start && char.IsWhiteSpace(source[trimmedEnd - 1])) + { + trimmedEnd--; + } + replacements[start] = (end, value + source[trimmedEnd..end]); + } void VisitDocument(YamlNode node, string path) { @@ -198,13 +260,13 @@ void VisitDocument(YamlNode node, string path) foreach (var (key, value) in mapping.Children) { var name = ((YamlScalarNode)key).Value; - if (name.StartsWith("x-", StringComparison.Ordinal) || name is "example" or "examples" or "default" or "enum" or "const" or "value" or "schemas") + if (name.StartsWith("x-", StringComparison.Ordinal) || name is "example" or "examples" or "default" or "enum" or "const" or "value" or "dataValue" or "serializedValue" or "schemas") { continue; } - if (name == "schema") + if (name is "schema" or "itemSchema") { - CheckSchema(value, path + "/schema"); + CheckSchema(value, path + "/" + name); } else if (name == "$ref") { @@ -212,7 +274,7 @@ void VisitDocument(YamlNode node, string path) } else if (value is YamlMappingNode entries && name is ("paths" or "webhooks" or "responses" or "content" or "headers" or - "parameters" or "requestBodies" or "pathItems" or "callbacks")) + "parameters" or "requestBodies" or "pathItems" or "callbacks" or "additionalOperations" or "mediaTypes")) { // Map keys are names, not object fields: a "default" response or // a parameter named "schema" still contains a schema. Only Paths @@ -240,7 +302,6 @@ void CheckMap(YamlNode node, string path) { foreach (var (key, value) in map.Children) { - RejectBoolean(value, path + "/" + key); CheckSchema(value, path + "/" + key); } } @@ -248,6 +309,17 @@ void CheckMap(YamlNode node, string path) void CheckSchema(YamlNode node, string path) { + // The SDK supports boolean schemas but its map/list readers drop scalars. + if (node is YamlScalarNode { Style: ScalarStyle.Plain, Value: { } boolean } && + bool.TryParse(boolean, out var allowed)) + { + if (openApi30) + { + throw new DocfxException($"InvalidOpenApiSchema: boolean schema at '{path}' in '{location.LocalPath}' requires OpenAPI 3.1 or 3.2."); + } + Replace(node, allowed ? "{}" : "{\"not\":{}}"); + return; + } if (node is not YamlMappingNode schema) { return; @@ -257,11 +329,18 @@ void CheckSchema(YamlNode node, string path) var name = ((YamlScalarNode)key).Value; switch (name) { - case "const" or "default" when value is YamlScalarNode { Style: ScalarStyle.Plain, Value: null or "" }: - throw new DocfxException($"UnsupportedOpenApiNullValue: OpenAPI.NET 3.10.2 reads the implicit YAML null at '{path}/{name}' in '{location.LocalPath}' as an empty string. " + - $"Write '{name}: null' explicitly to preserve its meaning."); + case "const" or "default" when value is YamlScalarNode { Style: ScalarStyle.Plain, Value: null or "" } scalar && scalar.Tag != "tag:yaml.org,2002:str": + if (name == "const" && !openApi30) + { + PreserveConst(value); + } + else + { + Replace(value, " null"); + } + break; case "const" when !openApi30: - RejectLossyConst(value, path + "/const"); + PreserveConst(value); break; case "$ref": CheckReference(value, path); @@ -277,7 +356,6 @@ void CheckSchema(YamlNode node, string path) CheckPrimitiveUnion(schema, sequence, name, path); for (var i = 0; i < sequence.Children.Count; i++) { - RejectBoolean(sequence.Children[i], path + "/" + name + "/" + i); CheckSchema(sequence.Children[i], path + "/" + name + "/" + i); } } @@ -289,30 +367,49 @@ void CheckSchema(YamlNode node, string path) } } - void RejectLossyConst(YamlNode node, string path) + void PreserveConst(YamlNode node) { - // OpenAPI.NET 3.10.2 reads const with GetScalarValue, turning numbers and - // booleans into strings and rejecting objects/arrays. Quoted scalars and - // explicit null remain supported; never infer a constant's type from type. - if (node is YamlMappingNode or YamlSequenceNode || - node is YamlScalarNode { Style: ScalarStyle.Plain, Value: { } value } && - (bool.TryParse(value, out _) || - (value.Any(char.IsAsciiDigit) && double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _)))) - { - throw new DocfxException($"UnsupportedOpenApiConst: OpenAPI.NET 3.10.2 cannot preserve the const value at '{path}' in '{location.LocalPath}'. " + - "Only string and null const values are supported."); - } + // OpenAPI.NET 3.10.2 models Const as string. Carry an opaque token through + // its reference resolution and restore the JSON value during conversion. + var token = Guid.NewGuid().ToString("N"); + constants.Add(token, JsonLiteral(node)); + Replace(node, " " + JsonConvert.SerializeObject(token)); } - void RejectBoolean(YamlNode node, string path) + static string JsonLiteral(YamlNode node) { - // OpenAPI.NET 3.10.2 JsonNodeHelper.CreateMap/CreateList drop non-object schemas. - // Do not rewrite them: fail before the SDK can silently change their meaning. - if (node is YamlScalarNode { Style: ScalarStyle.Plain, Value: { } value } && - (value.Equals("true", StringComparison.OrdinalIgnoreCase) || value.Equals("false", StringComparison.OrdinalIgnoreCase))) + if (node is YamlMappingNode map) + { + return "{" + string.Join(",", map.Children.Select(pair => + JsonConvert.SerializeObject(((YamlScalarNode)pair.Key).Value) + ":" + JsonLiteral(pair.Value))) + "}"; + } + if (node is YamlSequenceNode sequence) + { + return "[" + string.Join(",", sequence.Children.Select(JsonLiteral)) + "]"; + } + var scalar = (YamlScalarNode)node; + var value = scalar.Value; + if (scalar.Style == ScalarStyle.Plain && scalar.Tag != "tag:yaml.org,2002:str") { - throw new DocfxException($"UnsupportedBooleanSchema: OpenAPI.NET 3.10.2 cannot preserve the boolean schema at '{path}' in '{location.LocalPath}'."); + if (string.IsNullOrEmpty(value) || value == "~" || value.Equals("null", StringComparison.OrdinalIgnoreCase)) + { + return "null"; + } + if (bool.TryParse(value, out var boolean)) + { + return boolean ? "true" : "false"; + } + // Preserve JSON numbers lexically, including large integers/exponents. + if (System.Text.RegularExpressions.Regex.IsMatch(value, @"^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$")) + { + return value; + } + if (decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number)) + { + return number.ToString(CultureInfo.InvariantCulture); + } } + return JsonConvert.SerializeObject(value ?? ""); } void CheckReference(YamlNode node, string path) @@ -355,13 +452,24 @@ void CheckPrimitiveUnion(YamlMappingNode schema, YamlSequenceNode sequence, stri private sealed class ReferenceCollector : OpenApiVisitorBase { + internal bool HasEncoding { get; private set; } internal List<(IOpenApiReferenceHolder Holder, BaseOpenApiReference Reference)> References { get; } = []; private readonly HashSet _visitedReferences = new(ReferenceEqualityComparer.Instance); private readonly HashSet _visitedSchemas = new(ReferenceEqualityComparer.Instance); + public override void Visit(IOpenApiMediaType media) + { + HasEncoding |= media.Encoding is { Count: > 0 } || media.ItemEncoding != null || media.PrefixEncoding is { Count: > 0 }; + // OpenAPI.NET 3.10.2's walker visits Schema but omits ItemSchema. + if (media.ItemSchema != null) + { + WalkSchema(media.ItemSchema); + } + } + public override void Visit(IOpenApiReferenceHolder holder) { - // Operation tags in OpenAPI 3.0/3.1 are names, not required references to root tags. + // Operation tags are names, not required references to root tags. if (holder is OpenApiTagReference) { return; diff --git a/src/Docfx.Build.RestApi/OpenApiModelConverter.cs b/src/Docfx.Build.RestApi/OpenApiModelConverter.cs index 678d0875664..7aa06c076b6 100644 --- a/src/Docfx.Build.RestApi/OpenApiModelConverter.cs +++ b/src/Docfx.Build.RestApi/OpenApiModelConverter.cs @@ -14,7 +14,7 @@ namespace Docfx.Build.RestApi; -internal sealed class OpenApiModelConverter(Uri documentUri) +internal sealed class OpenApiModelConverter(Uri documentUri, IReadOnlyDictionary constants) { internal RestApiRootItemViewModel Convert(OpenApiDocument document, string raw, string version) { @@ -201,6 +201,7 @@ private JArray Content(IDictionary content) => { ["mimeType"] = pair.Key, ["schema"] = Schema(pair.Value.Schema), + ["itemSchema"] = Schema(pair.Value.ItemSchema), ["examples"] = Examples(pair.Key, pair.Value) }) ?? []); @@ -217,19 +218,30 @@ private static JArray Examples(string mimeType, IOpenApiMediaType media) { ["name"] = name, ["mimeType"] = mimeType, - ["content"] = example.Value == null ? null : Literal(example.Value), + ["content"] = example.SerializedValue ?? Literal(example.DataValue ?? example.Value), ["externalValue"] = example.ExternalValue }); } return result; } - private static string Literal(JsonNode value) => value?.ToJsonString(new() { WriteIndented = true }); + private static string Literal(JsonNode value) + { + if (value == null) + { + return null; + } + // The YAML reader uses a sentinel for nulls, including nested values. + // The SDK writer restores them; JsonNode.ToJsonString exposes the sentinel. + using var text = new StringWriter(); + new OpenApiJsonWriter(text).WriteAny(value); + return text.ToString(); + } private static JToken Serialize(IOpenApiSerializable value) { using var text = new StringWriter(); - value.SerializeAsV31(new OpenApiJsonWriter(text)); + value.SerializeAsV32(new OpenApiJsonWriter(text)); return JToken.Parse(text.ToString()); } @@ -239,7 +251,7 @@ private static Dictionary Extensions(IDictionary ancestors var targetModel = Schema(target, ancestors); var siblings = GetReferenceSiblings(reference); using var siblingText = new StringWriter(); - siblings.SerializeAsV31(new OpenApiJsonWriter(siblingText)); + siblings.SerializeAsV32(new OpenApiJsonWriter(siblingText)); var result = JObject.Parse(siblingText.ToString()).Count == 0 ? targetModel : new JObject { ["type"] = "all of", @@ -300,8 +312,9 @@ private JObject Schema(IOpenApiSchema schema, HashSet ancestors try { using var text = new StringWriter(); - schema.SerializeAsV31(new OpenApiJsonWriter(text)); + schema.SerializeAsV32(new OpenApiJsonWriter(text)); var serialized = JToken.Parse(text.ToString()); + RestoreConstants(serialized); if (serialized is JObject { Count: 1 } && serialized["not"] is JObject { Count: 0 }) { return new JObject { ["type"] = "no value" }; @@ -365,7 +378,7 @@ private JObject Schema(IOpenApiSchema schema, HashSet ancestors } if (schema.Enum is { Count: > 0 }) { - result["enum"] = new JArray(schema.Enum.Select(value => value == null ? JValue.CreateNull() : JToken.Parse(value.ToJsonString()))); + result["enum"] = new JArray(schema.Enum.Select(value => value == null ? JValue.CreateNull() : JToken.Parse(Literal(value)))); } if (schema.Examples is { Count: > 0 }) { @@ -393,6 +406,19 @@ void AddComposition(string kind, IList schemas) } } + private void RestoreConstants(JToken node) + { + if (node is JObject obj && obj["const"] is JValue { Type: JTokenType.String } value && + constants.TryGetValue((string)value, out var literal)) + { + obj["const"] = new JRaw(literal); + } + foreach (var child in node.Children()) + { + RestoreConstants(child); + } + } + internal static OpenApiSchema GetReferenceSiblings(OpenApiSchemaReference reference) { var detached = new OpenApiSchemaReference(reference.Reference.Id) diff --git a/templates/common/RestApi.common.js b/templates/common/RestApi.common.js index bae9072b4bd..c49d4de59f4 100644 --- a/templates/common/RestApi.common.js +++ b/templates/common/RestApi.common.js @@ -16,13 +16,13 @@ exports.transform = function (model) { child._hasSchemaDetails = true; (child.parameters || []).forEach(function (parameter) { collectSchemas(parameter.schema); - (parameter.content || []).forEach(function (media) { collectSchemas(media.schema); }); + (parameter.content || []).forEach(collectMediaSchemas); }); (child.responses || []).forEach(function (response) { collectSchemas(response.schema); - (response.content || []).forEach(function (media) { collectSchemas(media.schema); }); + (response.content || []).forEach(collectMediaSchemas); }); - ((child.requestBody || {}).content || []).forEach(function (media) { collectSchemas(media.schema); }); + ((child.requestBody || {}).content || []).forEach(collectMediaSchemas); }); var _fileNameWithoutExt = common.path.getFileNameWithoutExtension(model._path); model._jsonPath = _fileNameWithoutExt + ".swagger" + (model.rawExtension === ".yaml" ? ".yaml" : ".json"); @@ -169,6 +169,11 @@ exports.transform = function (model) { }); } + function collectMediaSchemas(media) { + collectSchemas(media.schema); + collectSchemas(media.itemSchema); + } + function schemaDetails(schema) { if (!schema) return false; var name = schema['x-internal-loop-ref-name'] || schema['x-internal-ref-name']; @@ -215,6 +220,7 @@ exports.transform = function (model) { function transformContent(content) { (content || []).forEach(function (media) { media.schemaDetails = schemaDetails(media.schema); + media.itemSchemaDetails = schemaDetails(media.itemSchema); media.examples = media.examples || []; media.examples.forEach(function (example) { example.name = example.name || ''; diff --git a/templates/default/partials/rest.media-schema.tmpl.partial b/templates/default/partials/rest.media-schema.tmpl.partial index 32e7c589800..748e94dded5 100644 --- a/templates/default/partials/rest.media-schema.tmpl.partial +++ b/templates/default/partials/rest.media-schema.tmpl.partial @@ -2,4 +2,7 @@
Mime type: {{mimeType}}
{{#schemaDetails}}{{>partials/rest.schema}}{{/schemaDetails}} + {{#itemSchemaDetails}} +
Stream item
{{>partials/rest.schema}}
+ {{/itemSchemaDetails}}
diff --git a/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs b/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs index 2af45ba82e6..425f207973d 100644 --- a/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs +++ b/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs @@ -12,9 +12,134 @@ namespace Docfx.Build.RestApi.Tests; [Collection("docfx STA")] public class OpenApiDocumentReaderTest : TestBase { + [Theory] + [InlineData("json")] + [InlineData("yaml")] + public void OpenApi32MapsAdditionalMethodsStreamingAndExamples(string format) + { + var model = OpenApiDocumentReader.Parse(""" + {"openapi":"3.2.0","info":{"title":"Streams","version":"1"}, + "paths":{"/events":{ + "query":{"responses":{"200":{"description":"Events","content":{ + "application/jsonl":{"$ref":"#/components/mediaTypes/Events"}}}}}, + "additionalOperations":{"COPY":{"operationId":"copyEvents","responses":{"204":{"description":"Copied"}}}} + }}, + "components":{ + "mediaTypes":{"Events":{ + "itemSchema":{"$ref":"#/components/schemas/Event"}, + "examples":{ + "data":{"dataValue":{"schema":{"const":42},"enabled":false,"items":[null]}}, + "wire":{"serializedValue":"{\"id\":42}\n{\"id\":43}\n"}, + "null":{"dataValue":null} + }}}, + "schemas":{"Event":{"type":"object","properties":{"id":{"const":42},"anything":true,"never":false}}} + }} + """, format); + Assert.Equal("3.2.0", model.Metadata["openapi"]); + Assert.Equal(new[] { "query", "copy" }, model.Children.Select(child => child.OperationName)); + var content = (JArray)Assert.Single(model.Children[0].Responses).Metadata["content"]; + var item = content[0]["itemSchema"]; + Assert.Equal("Event", item["x-internal-ref-name"]); + Assert.Equal("42", item["properties"]["id"]["constraints"][0]["value"]); + Assert.Equal("no value", item["properties"]["never"]["type"]); + var examples = content[0]["examples"]; + Assert.Equal(42, JObject.Parse((string)examples[0]["content"])["schema"]["const"]); + Assert.Equal(JTokenType.Null, JObject.Parse((string)examples[0]["content"])["items"][0].Type); + Assert.Equal("{\"id\":42}\n{\"id\":43}\n", examples[1]["content"]); + Assert.Equal("null", examples[2]["content"]); + } + + [Fact] + public void NormalizesYamlBlocksAndAliasesWithoutChangingLiteralData() + { + const string raw = """ + openapi: 3.2.0 + info: {title: Literals, version: '1'} + paths: {} + x-literal: &literal + schema: {const: 42} + flag: false + x-boolean: &boolean false + components: + schemas: + Object: + const: *literal + description: After the constant + Array: + const: + - 42 + - null + - 'false' + default: + type: array + Boolean: *boolean + Number: {const: 1e100} + String: {const: !!str 42} + """; + var model = OpenApiDocumentReader.Parse(raw, "yaml"); + Assert.Equal(raw, model.Raw); + Assert.Equal(42, ((JObject)model.Metadata["x-literal"])["schema"]["const"]); + Assert.Equal(false, model.Metadata["x-boolean"]); + var schemas = (JObject)model.Metadata["schemas"]; + Assert.Equal("After the constant", schemas["Object"]["description"]); + Assert.Equal("{\"schema\":{\"const\":42},\"flag\":false}", schemas["Object"]["constraints"][0]["value"]); + Assert.Equal("[42,null,\"false\"]", schemas["Array"]["constraints"][0]["value"]); + Assert.Equal("null", schemas["Array"]["constraints"][1]["value"]); + Assert.Equal("array", schemas["Array"]["type"]); + Assert.Equal("no value", schemas["Boolean"]["type"]); + Assert.Equal("1e100", schemas["Number"]["constraints"][0]["value"]); + Assert.Equal("\"42\"", schemas["String"]["constraints"][0]["value"]); + } + + [Fact] + public void OpenApi32ResolvesExternalMediaTypesAndStreamItemSchemas() + { + var folder = GetRandomFolder(); + var entry = CreateFile("entry.json", """ + {"openapi":"3.2.0","info":{"title":"External streams","version":"1"}, + "paths":{"/events":{"query":{"responses":{"200":{"description":"OK","content":{ + "application/jsonl":{"$ref":"media.yaml#/components/mediaTypes/Events"} + }}}}}}} + """, folder); + CreateFile("media.yaml", """ + openapi: 3.2.0 + info: {title: Media, version: '1'} + paths: {} + components: + mediaTypes: + Events: + itemSchema: {$ref: 'schemas.json#/components/schemas/Event', const: {id: 42}} + """, folder); + CreateFile("schemas.json", """ + {"openapi":"3.1.0","info":{"title":"Schemas","version":"1"},"paths":{}, + "components":{"schemas":{"Event":{"type":"object","properties":{"id":{"const":42},"never":false}}}}} + """, folder); + var model = OpenApiDocumentReader.Read(entry); + var content = (JArray)Assert.Single(Assert.Single(model.Children).Responses).Metadata["content"]; + var branches = content[0]["itemSchema"]["composition"][0]["schemas"]; + Assert.Equal("42", branches[0]["properties"]["id"]["constraints"][0]["value"]); + Assert.Equal("no value", branches[0]["properties"]["never"]["type"]); + Assert.Equal("{\"id\":42}", branches[1]["constraints"][0]["value"]); + } + + [Fact] + public void ReportsOpenApi32FeaturesWithoutDocumentationUi() + { + using var listener = new TestListenerScope(); + OpenApiDocumentReader.Parse(""" + {"openapi":"3.2.0","info":{"title":"Warnings","version":"1"}, + "tags":[{"name":"events","summary":"Events","kind":"nav"}], + "paths":{"/events":{"post":{"requestBody":{"content":{"multipart/mixed":{ + "schema":{"type":"array","items":{"type":"string"}},"itemEncoding":{"contentType":"text/plain"} + }}},"responses":{"204":{"description":"OK"}}}}}} + """, "json"); + Assert.Contains(listener.Items, item => item.Message.Contains("media-type encoding and tag summary, hierarchy and kind")); + } + [Theory] [InlineData("3.0.3")] [InlineData("3.1.0")] + [InlineData("3.2.0")] public void MapsTypedParametersBodiesResponsesAndLiteralExamples(string version) { var raw = $$""" @@ -86,6 +211,7 @@ public void MapsTypedParametersBodiesResponsesAndLiteralExamples(string version) [Theory] [InlineData("3.0.3")] [InlineData("3.1.1")] + [InlineData("3.2.0")] public void YamlUsesTheSameModelsAndDefaults(string version) { var model = OpenApiDocumentReader.Parse($$""" @@ -171,7 +297,7 @@ public void BooleanUnionCompositionAndRefSiblingsAreNotFlattened() [Theory] [InlineData("true")] [InlineData("false")] - public void RejectsBooleanSchemasTheSdkWouldDrop(string boolean) + public void PreservesBooleanSchemasInMapsAndCompositions(string boolean) { foreach (var schema in new[] { @@ -185,11 +311,18 @@ public void RejectsBooleanSchemasTheSdkWouldDrop(string boolean) $$"""{ "oneOf": [{{boolean}}] }""" }) { - var error = Assert.Throws(() => OpenApiDocumentReader.Parse( + var model = OpenApiDocumentReader.Parse( """{"openapi":"3.1.0","info":{"title":"Boolean","version":"1"},"paths":{},"components":{"schemas":{"Value":SCHEMA}}}""" - .Replace("SCHEMA", schema), "json")); - Assert.Contains("UnsupportedBooleanSchema", error.Message); - Assert.Contains("#/components/schemas/Value", error.Message); + .Replace("SCHEMA", schema), "json"); + var value = ((JObject)model.Metadata["schemas"])["Value"]; + if (schema == boolean) + Assert.Equal(boolean == "true" ? "any value" : "no value", value["type"]); + else if (schema.Contains("properties")) + Assert.Equal(boolean == "true" ? "any value" : "no value", value["properties"]["value"]["type"]); + else if (schema.Contains("Of")) + Assert.Equal(boolean == "true" ? "any value" : "no value", value["composition"][0]["schemas"][0]["type"]); + else + Assert.Contains(boolean == "true" ? "{}" : "\"not\":{}", (string)value["constraints"][0]["value"]); } } @@ -200,7 +333,7 @@ public void RejectsBooleanSchemasTheSdkWouldDrop(string boolean) [InlineData("false", "properties")] [InlineData("true", "composition")] [InlineData("false", "composition")] - public void RejectsBooleanLossInExternalDocuments(string boolean, string position) + public void PreservesBooleanSchemasInExternalDocuments(string boolean, string position) { var folder = GetRandomFolder(); var entry = CreateFile("entry.json", """ @@ -221,9 +354,15 @@ public void RejectsBooleanLossInExternalDocuments(string boolean, string positio schemas: Value: {{schema}} """, folder); - var error = Assert.Throws(() => OpenApiDocumentReader.Read(entry)); - Assert.Contains("UnsupportedBooleanSchema", error.Message); - Assert.Contains("external.yaml", error.Message); + var model = OpenApiDocumentReader.Read(entry); + var value = ((JObject)model.Metadata["schemas"])["Value"]; + var actual = position switch + { + "component" => value, + "properties" => value["properties"]["value"], + _ => value["composition"][0]["schemas"][0] + }; + Assert.Equal(boolean == "true" ? "any value" : "no value", actual["type"]); } [Fact] @@ -264,25 +403,49 @@ public void PreservesSingularSchemaExamplesFromOpenApi30() [Theory] [InlineData("json")] [InlineData("yaml")] - public void RejectsConstValuesThatTheSdkCannotPreserve(string format) + public void PreservesTypedConstValues(string format) { - foreach (var value in new[] { "42", "-1", "1.5", "1e20", "1e100", "true", "false", "{}", "[]" }) + foreach (var value in new[] { "42", "-1", "1.5", "1e20", "1e100", "true", "false", "{}", "[]", "123456789012345678901234567890", "{\"n\":42,\"flag\":false,\"items\":[null,\"42\"]}" }) { - var error = Assert.Throws(() => OpenApiDocumentReader.Parse(""" + var model = OpenApiDocumentReader.Parse(""" {"openapi":"3.1.0","info":{"title":"Constants","version":"1"},"paths":{}, - "components":{"schemas":{"Value":{"const":VALUE}}}} - """.Replace("VALUE", value), format)); - Assert.Contains("UnsupportedOpenApiConst", error.Message); - Assert.Contains("#/components/schemas/Value/const", error.Message); + "components":{"schemas":{"Value":{"const":VALUE,"enum":[1,2]}}}} + """.Replace("VALUE", value), format); + var schema = ((JObject)model.Metadata["schemas"])["Value"]; + Assert.Equal(value, (string)Assert.Single(schema["constraints"])["value"]); + Assert.Equal(new[] { 1, 2 }, schema["enum"].Values()); } } + [Theory] + [InlineData("true")] + [InlineData("false")] + public void BooleanSchemasRequireOpenApi31OrLater(string boolean) + { + var error = Assert.Throws(() => OpenApiDocumentReader.Parse(""" + {"openapi":"3.0.3","info":{"title":"Boolean","version":"1"},"paths":{}, + "components":{"schemas":{"Value":{"properties":{"value":BOOLEAN}}}}} + """.Replace("BOOLEAN", boolean), "json")); + Assert.Contains("requires OpenAPI 3.1 or 3.2", error.Message); + } + + [Theory] + [InlineData("NaN")] + [InlineData("'quoted'")] + public void NormalizationDoesNotAcceptMalformedJsonConstants(string value) + { + Assert.Throws(() => OpenApiDocumentReader.Parse(""" + {"openapi":"3.2.0","info":{"title":"Invalid JSON","version":"1"},"paths":{}, + "components":{"schemas":{"Value":{"const":VALUE}}}} + """.Replace("VALUE", value), "json")); + } + [Theory] [InlineData("json")] [InlineData("yaml")] public void PreservesStringAndNullConstantsAndExplicitNullDefaults(string format) { - foreach (var value in new[] { "\"ok\"", "\"42\"", "\"true\"", "\"null\"", "\"\"", "null" }) + foreach (var value in new[] { "\"ok\"", "\"😀\"", "\"42\"", "\"true\"", "\"null\"", "\"\"", "null" }) { var model = OpenApiDocumentReader.Parse(""" {"openapi":"3.1.0","info":{"title":"Constants","version":"1"},"paths":{}, @@ -303,6 +466,7 @@ public void PreservesStringAndNullConstantsAndExplicitNullDefaults(string format [InlineData("Infinity", "\"Infinity\"")] [InlineData("|-\n 42", "\"42\"")] [InlineData("~", "null")] + [InlineData("!!str", "\"\"")] public void PreservesYamlStringAndNullConstants(string value, string expected) { var model = OpenApiDocumentReader.Parse($$""" @@ -322,9 +486,9 @@ public void PreservesYamlStringAndNullConstants(string value, string expected) [InlineData("3.1.0", "const")] [InlineData("3.1.0", "default")] [InlineData("3.0.3", "default")] - public void RejectsImplicitYamlNullValuesThatTheSdkTurnsIntoEmptyStrings(string version, string keyword) + public void PreservesImplicitYamlNullValues(string version, string keyword) { - var error = Assert.Throws(() => OpenApiDocumentReader.Parse($$""" + var model = OpenApiDocumentReader.Parse($$""" openapi: {{version}} info: {title: Null values, version: '1'} paths: {} @@ -332,9 +496,9 @@ public void RejectsImplicitYamlNullValuesThatTheSdkTurnsIntoEmptyStrings(string schemas: Value: {{keyword}}: - """, "yaml")); - Assert.Contains("UnsupportedOpenApiNullValue", error.Message); - Assert.Contains("#/components/schemas/Value/" + keyword, error.Message); + """, "yaml"); + var schema = ((JObject)model.Metadata["schemas"])["Value"]; + Assert.Equal("null", (string)Assert.Single(schema["constraints"])["value"]); } [Theory] @@ -344,19 +508,20 @@ public void RejectsImplicitYamlNullValuesThatTheSdkTurnsIntoEmptyStrings(string [InlineData("x-parameter")] public void ChecksSchemasInNamedParameters(string name) { - var error = Assert.Throws(() => OpenApiDocumentReader.Parse(""" - {"openapi":"3.1.0","info":{"title":"Constants","version":"1"},"paths":{}, + var model = OpenApiDocumentReader.Parse(""" + {"openapi":"3.1.0","info":{"title":"Constants","version":"1"}, + "paths":{"/items":{"get":{"parameters":[{"$ref":"#/components/parameters/NAME"}],"responses":{"200":{"description":"OK"}}}}}, "components":{"parameters":{"NAME":{"name":"q","in":"query","schema":{"const":42}}}}} - """.Replace("NAME", name), "json")); - Assert.Contains("UnsupportedOpenApiConst", error.Message); - Assert.Contains("#/components/parameters/" + name + "/schema/const", error.Message); + """.Replace("NAME", name), "json"); + var schema = (JObject)Assert.Single(Assert.Single(model.Children).Parameters).Metadata["schema"]; + Assert.Equal("42", (string)Assert.Single(schema["constraints"])["value"]); } [Theory] - [InlineData("{properties: {value: {const: 42}}}", "/properties/value/const")] - [InlineData("{oneOf: [{const: true}]}", "/oneOf/0/const")] - [InlineData("{$ref: '#/components/schemas/Base', const: false}", "/const")] - public void RejectsConstLossInExternalSchemas(string schema, string path) + [InlineData("{properties: {value: {const: 42}}}", "42")] + [InlineData("{oneOf: [{const: true}]}", "true")] + [InlineData("{$ref: '#/components/schemas/Base', const: false}", "false")] + public void PreservesConstInExternalSchemas(string schema, string expected) { var folder = GetRandomFolder(); var entry = CreateFile("entry.json", """ @@ -372,29 +537,30 @@ public void RejectsConstLossInExternalSchemas(string schema, string path) Base: {type: boolean} Value: {{schema}} """, folder); - var error = Assert.Throws(() => OpenApiDocumentReader.Read(entry)); - Assert.Contains("UnsupportedOpenApiConst", error.Message); - Assert.Contains("external.yaml", error.Message); - Assert.Contains("#/components/schemas/Value" + path, error.Message); + var model = OpenApiDocumentReader.Read(entry); + var value = ((JObject)model.Metadata["schemas"])["Value"]; + var constraint = Assert.Single(value.SelectTokens("$..constraints[*]"), item => (string)item["name"] == "const"); + Assert.Equal(expected, constraint["value"]); } [Theory] [InlineData("200")] [InlineData("default")] - public void RejectsConstLossInInlineResponseSchemas(string status) + public void PreservesConstInInlineResponseSchemas(string status) { - var error = Assert.Throws(() => OpenApiDocumentReader.Parse(""" + var model = OpenApiDocumentReader.Parse(""" {"openapi":"3.1.0","info":{"title":"Constants","version":"1"}, "paths":{"/items":{"get":{"responses":{"STATUS":{"description":"OK", "content":{"application/json":{"schema":{"const":42}}}}}}}}} - """.Replace("STATUS", status), "json")); - Assert.Contains("UnsupportedOpenApiConst", error.Message); - Assert.Contains("/responses/" + status + "/content/application/json/schema/const", error.Message); + """.Replace("STATUS", status), "json"); + var content = (JArray)Assert.Single(Assert.Single(model.Children).Responses).Metadata["content"]; + Assert.Equal("42", (string)Assert.Single(content[0]["schema"]["constraints"])["value"]); } [Theory] [InlineData("3.0.3")] [InlineData("3.1.0")] + [InlineData("3.2.0")] public void DoesNotTurnExclusiveOverlappingAlternativesIntoInclusiveUnions(string version) { foreach (var (schema, lossy) in new[] @@ -422,7 +588,7 @@ public void DoesNotTurnExclusiveOverlappingAlternativesIntoInclusiveUnions(strin } [Theory] - [InlineData("3.2.0")] + [InlineData("3.3.0")] [InlineData("4.0.0")] [InlineData("3.10.0")] public void DoesNotAdvertiseUntestedVersions(string version) diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs b/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs index 7678ca42fb9..15b45a01556 100644 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs @@ -21,6 +21,78 @@ public class OpenApiOutputTest : TestBase private const string RootUid = "api.example.test/v1/SDK API/1.0"; private const string RootHtmlId = "api_example_test_v1_SDK_API_1_0"; + [Theory] + [InlineData("default")] + [InlineData("statictoc")] + [InlineData("modern")] + public void RendersOpenApi32StreamsAndTypedConstraints(string template) + { + var input = GetRandomFolder(); + var service = CreateFile("stream.yaml", """ + openapi: 3.2.0 + info: {title: Stream API, version: '1'} + paths: + /events: + query: + operationId: queryEvents + responses: + '200': + description: Events + content: + application/jsonl: + itemSchema: {$ref: '#/components/schemas/Event'} + examples: + data: {dataValue: {id: 42, active: false, items: [null]}} + wire: + serializedValue: | + {"id":42} + {"id":43} + additionalOperations: + COPY: + operationId: copyEvents + responses: + '204': {description: Copied} + components: + schemas: + Event: + type: object + properties: + id: {type: integer, const: 42} + active: {const: false} + payload: {const: {status: ok, values: [1, null]}} + missing: + const: + default: + anything: true + never: false + intersection: {allOf: [false, {type: string}]} + """, input); + var files = new FileCollection(Directory.GetCurrentDirectory()); + files.Add(DocumentType.Article, [service], input); + var output = Build(input, files, template, false, false); + var article = ReadHtml(output, "stream.html").SelectSingleNode("//article"); + var text = HtmlEntity.DeEntitize(article.InnerText); + Assert.Contains("QUERY", text); + Assert.Contains("COPY", text); + var stream = Assert.Single(article.SelectNodes(".//div[@class='stream-item-schema']")); + var streamText = HtmlEntity.DeEntitize(stream.InnerText); + Assert.Contains("Stream item", streamText); + Assert.Contains("any value", streamText); + Assert.Contains("no value", streamText); + var codes = stream.SelectNodes(".//dl[@class='schema-constraints']/dd").Select(code => HtmlEntity.DeEntitize(code.InnerText)).ToArray(); + Assert.Contains("42", codes); + Assert.DoesNotContain("\"42\"", codes); + Assert.Contains("false", codes); + Assert.Contains("null", codes); + Assert.Contains("{\"status\":\"ok\",\"values\":[1,null]}", codes); + var examples = article.SelectNodes(".//pre/code").Select(code => HtmlEntity.DeEntitize(code.InnerText)).ToArray(); + Assert.Contains(examples, example => example.Contains("\"active\": false")); + var data = JObject.Parse(Assert.Single(examples, example => example.Contains("\"active\""))); + Assert.Equal(JTokenType.Null, data["items"][0].Type); + Assert.Contains("{\"id\":42}\n{\"id\":43}\n", examples); + Assert.NotNull(article.SelectSingleNode(".//a[@href='#schema-Event']")); + } + [Theory] [InlineData("default", "3.0.3", ".json", false, false, false)] [InlineData("default", "3.0.3", ".yml", true, false, false)] @@ -30,6 +102,9 @@ public class OpenApiOutputTest : TestBase [InlineData("statictoc", "3.1.0", ".yml", false, false, false)] [InlineData("modern", "3.1.0", ".yaml", true, true, false)] [InlineData("modern", "3.0.3", ".json", false, false, false)] + [InlineData("default", "3.2.0", ".json", false, false, false)] + [InlineData("statictoc", "3.2.0", ".yaml", true, true, false)] + [InlineData("modern", "3.2.0", ".json", true, true, true)] public void BuildsOpenApiDocumentation(string template, string version, string extension, bool splitTags, bool splitOperations, bool overwrite) { @@ -213,7 +288,7 @@ string OperationPage(string id) .Select(node => HtmlEntity.DeEntitize(node.InnerText))); Assert.Equal(new[] { "null", "string" }, ((string)schema["properties"]["label"]["type"]).Split(" | ").Order(StringComparer.Ordinal)); - if (version == "3.1.0") + if (version != "3.0.3") { foreach (var (property, expected) in new[] { ("label", "\"42\""), ("nullValue", "null") }) { @@ -278,7 +353,7 @@ string OperationPage(string id) } Assert.Contains("leftField", createText); Assert.Contains("rightField", createText); - if (version == "3.1.0") + if (version != "3.0.3") { var booleanResponse = Assert.Single(operations["inspectHealth"]["responses"]); Assert.Equal("200", (string)booleanResponse["statusCode"]); @@ -391,38 +466,23 @@ public void MissingSchemaAndExampleFieldsDoNotInheritParentValues(string templat Assert.Contains("value", Assert.Single(article.SelectNodes(".//pre/code"), code => code.InnerText.Contains("child")).InnerText); } - [Theory] - [InlineData("UnsupportedBooleanSchema")] - [InlineData("UnsupportedExternalFragment")] - [InlineData("UnsupportedOpenApiConst")] - [InlineData("UnsupportedOpenApiNullValue")] - public void RejectsUnsupportedOpenApiWithoutPublishing(string diagnostic) + [Fact] + public void RejectsUnsupportedExternalFragmentsWithoutPublishing() { var input = GetRandomFolder(); - var schema = diagnostic switch - { - "UnsupportedBooleanSchema" => """{"type": "object", "properties": {"value": false}}""", - "UnsupportedOpenApiConst" => """{"const": 42}""", - "UnsupportedOpenApiNullValue" => "{const: }", - _ => """{"$ref": "schema.yaml"}""" - }; - if (diagnostic == "UnsupportedExternalFragment") - { - CreateFile("schema.yaml", "type: object\nproperties:\n value:\n type: string\n", input); - } - var fileName = diagnostic == "UnsupportedOpenApiNullValue" ? "unsupported.yaml" : "unsupported.json"; - var file = CreateFile(fileName, $$""" + CreateFile("schema.yaml", "type: object\nproperties:\n value:\n type: string\n", input); + var file = CreateFile("unsupported.json", """ { "openapi": "3.1.0", "info": { "title": "Unsupported API", "version": "1.0" }, "paths": {}, - "components": { "schemas": { "Value": {{schema}} } } + "components": { "schemas": { "Value": {"$ref": "schema.yaml"} } } } """, input); var files = new FileCollection(Directory.GetCurrentDirectory()); files.Add(DocumentType.Article, [file], input); - var output = Build(input, files, "default", false, false, diagnostic); + var output = Build(input, files, "default", false, false, "UnsupportedExternalFragment"); Assert.Empty(Directory.GetFiles(output, "*.raw.json", SearchOption.AllDirectories)); Assert.Empty(Directory.GetFiles(output, "*.html", SearchOption.AllDirectories)); @@ -439,7 +499,7 @@ public void RejectsUnsupportedOpenApiWithoutPublishing(string diagnostic) { reference.Value = ((string)reference.Value).Replace("components.json", "components" + externalExtension, StringComparison.Ordinal); } - if (version == "3.1.0") + if (version != "3.0.3") { var properties = components["components"]["schemas"]["Item"]["properties"]; properties["label"] = new JObject { ["type"] = new JArray("string", "null"), ["const"] = "42", ["default"] = null }; From 593e61d2bee34b5e592bb215feff7aaee56b3af1 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Thu, 24 Sep 2026 21:14:33 +1000 Subject: [PATCH 10/16] Unify OpenAPI documentation through typed REST view models --- .../SplitRestApiToOperationLevel.cs | 2 + .../BuildRestApiDocument.cs | 101 +++--- .../OpenApi2ModelConverter.cs | 210 +++++++++++ ...Converter.cs => OpenApi3ModelConverter.cs} | 169 ++++----- .../OpenApiDocumentReader.cs | 55 +-- .../RestApiDocumentProcessor.cs | 92 +---- .../RestApiDocumentReader.cs | 101 ++++++ ...delConverter.cs => RestApiModelUtility.cs} | 2 +- .../SwaggerModelConverter.cs | 157 +-------- .../SplitRestApiToTagLevel.cs | 4 +- .../EntityMergers/ReflectionEntityMerger.cs | 4 +- .../RestApiArrayMergeHandler.cs | 36 ++ .../RestApiChildItemViewModel.cs | 21 ++ .../RestApiExternalDocumentationViewModel.cs | 26 ++ .../RestApiInfoViewModel.cs | 36 ++ .../RestApiMediaTypeViewModel.cs | 32 ++ .../RestApiParameterViewModel.cs | 11 + .../RestApiRequestBodyViewModel.cs | 27 ++ .../RestApiResponseExampleViewModel.cs | 10 + .../RestApiResponseViewModel.cs | 16 + .../RestApiRootItemViewModel.cs | 54 ++- .../RestApiSchemaCompositionViewModel.cs | 22 ++ .../RestApiSchemaConstraintViewModel.cs | 21 ++ .../RestApiSchemaViewModel.cs | 97 ++++++ .../RestApiSecuritySchemeViewModel.cs | 21 ++ .../RestApiServerViewModel.cs | 21 ++ templates/common/RestApi.common.js | 329 ++---------------- .../default/partials/rest.child.tmpl.partial | 28 +- .../partials/rest.definition.tmpl.partial | 45 --- templates/modern/src/rest.test.ts | 58 ++- .../OpenApiDocumentReaderTest.cs | 67 ++-- .../RestApiDocumentProcessorTest.cs | 12 +- .../RestApiDocumentReaderTest.cs | 116 ++++++ .../OpenApiOutputTest.cs | 50 ++- .../SplitRestApiToOperationLevelTest.cs | 12 +- .../SplitRestApiToTagLevelTest.cs | 8 +- .../SwaggerOutputCompatibilityTest.cs | 19 +- .../ReflectionEntityMergerTest.cs | 20 ++ 38 files changed, 1236 insertions(+), 876 deletions(-) create mode 100644 src/Docfx.Build.RestApi/OpenApi2ModelConverter.cs rename src/Docfx.Build.RestApi/{OpenApiModelConverter.cs => OpenApi3ModelConverter.cs} (68%) create mode 100644 src/Docfx.Build.RestApi/RestApiDocumentReader.cs rename src/Docfx.Build.RestApi/{RestApiModelConverter.cs => RestApiModelUtility.cs} (95%) 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/RestApiMediaTypeViewModel.cs create mode 100644 src/Docfx.DataContracts.RestApi/RestApiRequestBodyViewModel.cs create mode 100644 src/Docfx.DataContracts.RestApi/RestApiSchemaCompositionViewModel.cs create mode 100644 src/Docfx.DataContracts.RestApi/RestApiSchemaConstraintViewModel.cs create mode 100644 src/Docfx.DataContracts.RestApi/RestApiSchemaViewModel.cs create mode 100644 src/Docfx.DataContracts.RestApi/RestApiSecuritySchemeViewModel.cs create mode 100644 src/Docfx.DataContracts.RestApi/RestApiServerViewModel.cs create mode 100644 test/Docfx.Build.RestApi.Tests/RestApiDocumentReaderTest.cs diff --git a/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs b/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs index 6d520c7b5a7..d4d71fab6ab 100644 --- a/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs +++ b/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs @@ -124,9 +124,11 @@ private static IEnumerable GenerateOperationModels(Res Remarks = child.Remarks, Documentation = child.Documentation, Children = [child], + Servers = child.Servers, 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 3b63adbc2d4..0a35792106b 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) @@ -41,7 +37,6 @@ protected override void BuildArticle(IHostService host, FileModel model) public static RestApiItemViewModelBase BuildItem(IHostService host, RestApiItemViewModelBase item, FileModel model, Func filter = null) { - var preserveLiteralData = item.Metadata.GetValueOrDefault("_preserveLiteralData") is true; item.Summary = Markup(host, item.Summary, model, filter); item.Description = Markup(host, item.Description, model, filter); if (model.Type != DocumentType.Overwrite) @@ -50,84 +45,70 @@ public static RestApiItemViewModelBase BuildItem(IHostService host, RestApiItemV item.Remarks = Markup(host, item.Remarks, model, filter); } - if (item is RestApiRootItemViewModel rootModel) + if (item is RestApiRootItemViewModel root) { - // Mark up recursively for swagger root except for children and tags - foreach (var jToken in GetMarkupTokens(rootModel.Metadata, preserveLiteralData)) + if (root.Info != null) root.Info.Description = Markup(host, root.Info.Description, model, filter); + if (root.ExternalDocs != null) root.ExternalDocs.Description = Markup(host, root.ExternalDocs.Description, model, filter); + foreach (var security in root.SecurityDefinitions?.Values.AsEnumerable() ?? []) { - MarkupRecursive(jToken, host, model, filter, preserveLiteralData); + if (security != null) security.Description = Markup(host, security.Description, model, filter); } + MarkupServers(root.Servers); + foreach (var schema in root.Schemas?.Values.AsEnumerable() ?? []) MarkupSchema(schema); } - - var childModel = item as RestApiChildItemViewModel; - if (childModel != null && preserveLiteralData) + if (item is RestApiChildItemViewModel child) { - foreach (var key in new[] { "requestBody", "servers" }) + MarkupServers(child.Servers); + if (child.RequestBody is { } body) { - if (childModel.Metadata.GetValueOrDefault(key) is JToken value) - { - MarkupRecursive(value, host, model, filter, preserveLiteralData); - } + body.Description = Markup(host, body.Description, model, filter); + MarkupContent(body.Content); } - } - if (childModel?.Parameters != null) - { - foreach (var param in childModel.Parameters) + foreach (var parameter in child.Parameters ?? []) { - param.Description = Markup(host, param.Description, model, filter); - - foreach (var jToken in GetMarkupTokens(param.Metadata, preserveLiteralData)) - { - MarkupRecursive(jToken, host, model, filter, preserveLiteralData); - } + parameter.Description = Markup(host, parameter.Description, model, filter); + MarkupSchema(parameter.Schema); + MarkupContent(parameter.Content); } - } - if (childModel?.Responses != null) - { - foreach (var response in childModel.Responses) + foreach (var response in child.Responses ?? []) { response.Description = Markup(host, response.Description, model, filter); - - foreach (var jToken in GetMarkupTokens(response.Metadata, preserveLiteralData)) - { - MarkupRecursive(jToken, host, model, filter, preserveLiteralData); - } + MarkupSchema(response.Schema); + MarkupContent(response.Content); + foreach (var header in response.Headers?.Values.AsEnumerable() ?? []) MarkupSchema(header); } } return item; - } - private static IEnumerable GetMarkupTokens(Dictionary metadata, bool preserveLiteralData) => - metadata.Where(pair => !preserveLiteralData || !pair.Key.StartsWith("x-", StringComparison.Ordinal)) - .Select(pair => pair.Value).OfType(); + void MarkupServers(List servers) + { + foreach (var server in servers ?? []) + { + if (server != null) server.Description = Markup(host, server.Description, model, filter); + } + } - private static void MarkupRecursive(JToken jToken, IHostService host, FileModel model, Func filter = null, bool preserveLiteralData = false) - { - if (jToken is JArray jArray) + void MarkupContent(List content) { - foreach (var item in jArray) + foreach (var media in content ?? []) { - MarkupRecursive(item, host, model, filter, preserveLiteralData); + if (media == null) continue; // Positional overwrite placeholder. + MarkupSchema(media.Schema); + MarkupSchema(media.ItemSchema); } } - if (jToken is JObject jObject) + void MarkupSchema(RestApiSchemaViewModel schema) { - foreach (var pair in jObject) + 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 composition in schema.Composition ?? []) { - if (preserveLiteralData && (pair.Key.StartsWith("x-", StringComparison.Ordinal) || - (jObject.ContainsKey("type") && pair.Key is "example" or "examples" or "enum" or "default" or "const"))) - { - continue; - } - 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, preserveLiteralData); + if (composition == null) continue; + foreach (var branch in composition.Schemas ?? []) MarkupSchema(branch); } } } diff --git a/src/Docfx.Build.RestApi/OpenApi2ModelConverter.cs b/src/Docfx.Build.RestApi/OpenApi2ModelConverter.cs new file mode 100644 index 00000000000..a6507393fc5 --- /dev/null +++ b/src/Docfx.Build.RestApi/OpenApi2ModelConverter.cs @@ -0,0 +1,210 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Docfx.Build.RestApi.Swagger; +using Docfx.Common; +using Docfx.DataContracts.Common; +using Docfx.DataContracts.RestApi; + +using Newtonsoft.Json.Linq; + +using static Docfx.Build.RestApi.RestApiModelUtility; + +namespace Docfx.Build.RestApi; + +internal static class OpenApi2ModelConverter +{ + internal static RestApiRootItemViewModel ConvertLegacy(SwaggerModel swagger) + { + var uid = GetUid(swagger); + var vm = new RestApiRootItemViewModel + { + Name = swagger.Info.Title, + Uid = uid, + HtmlId = GetHtmlId(uid), + Metadata = swagger.Metadata, + Description = swagger.Description, + Summary = swagger.Summary, + Children = [], + Raw = swagger.Raw, + Tags = [] + }; + if (swagger.Tags != null) + { + foreach (var tag in swagger.Tags) + { + vm.Tags.Add(new RestApiTagViewModel + { + Name = tag.Name, + Description = tag.Description, + HtmlId = string.IsNullOrEmpty(tag.BookmarkId) ? GetHtmlId(tag.Name) : tag.BookmarkId, // Fall back to tag name's html id + Metadata = tag.Metadata, + Uid = GetUidForTag(uid, tag) + }); + } + } + if (swagger.Paths != null) + { + foreach (var path in swagger.Paths) + { + var commonParameters = path.Value.Parameters; + foreach (var op in path.Value.Metadata) + { + // fetch operations from metadata + if (OperationNames.Contains(op.Key, StringComparer.OrdinalIgnoreCase)) + { + if (op.Value is not JObject opJObject) + { + throw new InvalidOperationException($"Value of {op.Key} should be JObject"); + } + + // convert operation from JObject to OperationObject + var operation = opJObject.ToObject(); + var parameters = GetParametersForOperation(operation.Parameters, commonParameters); + var itemUid = GetUidForOperation(uid, operation); + var itemVm = new RestApiChildItemViewModel + { + Path = path.Key, + OperationName = op.Key, + Tags = operation.Tags, + OperationId = operation.OperationId, + HtmlId = GetHtmlId(itemUid), + Uid = itemUid, + Metadata = operation.Metadata, + Description = operation.Description, + Summary = operation.Summary, + Parameters = parameters?.Select(s => new RestApiParameterViewModel + { + Description = s.Description, + Name = s.Name, + Metadata = s.Metadata + }).ToList(), + Responses = operation.Responses?.Select(s => new RestApiResponseViewModel + { + Metadata = s.Value.Metadata, + Description = s.Value.Description, + Summary = s.Value.Summary, + HttpStatusCode = s.Key, + Examples = s.Value.Examples?.Select(example => new RestApiResponseExampleViewModel + { + MimeType = example.Key, + Content = example.Value != null ? JsonUtility.Serialize(example.Value) : null, + }).ToList(), + }).ToList(), + }; + + // TODO: line number + itemVm.Metadata[Constants.PropertyName.Source] = swagger.Metadata.GetValueOrDefault(Constants.PropertyName.Source); + vm.Children.Add(itemVm); + } + } + } + } + + return vm; + } + + internal static RestApiRootItemViewModel Convert(SwaggerModel swagger) + { + var model = ConvertLegacy(swagger); + model.SpecificationVersion = "2.0"; + model.SecurityDefinitions = Take>(model.Metadata, "securityDefinitions"); + model.Info = JObject.FromObject(swagger.Info).ToObject(); + model.ExternalDocs = Take(model.Metadata, "externalDocs"); + foreach (var child in model.Children) + { + // Preserve the established Swagger URL display convention at the adapter boundary. + var query = child.Parameters?.Where(p => (string)p.Metadata.GetValueOrDefault("in") == "query").ToList() ?? []; + var required = query.Where(p => p.Metadata.GetValueOrDefault("required") is true).Select(p => p.Name).ToList(); + var optional = query.Where(p => p.Metadata.GetValueOrDefault("required") is not true).Select(p => p.Name).ToList(); + child.DisplayPath = child.Path + (required.Count > 0 ? "?" + string.Join('&', required) : "") + + (optional.Count > 0 ? "[" + (required.Count > 0 ? "&" : "?") + string.Join('&', optional) + "]" : ""); + foreach (var parameter in child.Parameters ?? []) + { + parameter.Schema = Take(parameter.Metadata, "schema"); + if (parameter.Schema == null) + { + parameter.Schema = JObject.FromObject(parameter.Metadata).ToObject(); + } + SetReferenceIds(parameter.Schema); + } + foreach (var response in child.Responses ?? []) + { + response.Schema = Take(response.Metadata, "schema"); + response.Headers = Take>(response.Metadata, "headers"); + SetReferenceIds(response.Schema); + } + } + 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; + + private static void SetReferenceIds(RestApiSchemaViewModel schema) + { + if (schema == null) return; + var name = schema.ReferenceName ?? schema.LoopReferenceName; + schema.ReferenceId = name?.Replace('.', '_'); + foreach (var property in schema.Properties?.Values.AsEnumerable() ?? []) SetReferenceIds(property); + foreach (var branch in schema.AllOf ?? []) SetReferenceIds(branch); + SetReferenceIds(schema.Items); + } + + #region Private methods + + private const string TagText = "tag"; + private static readonly string[] OperationNames = ["get", "put", "post", "delete", "options", "head", "patch"]; + + private static string GetUid(SwaggerModel swagger) + { + return GenerateUid(swagger.Host, swagger.BasePath, swagger.Info.Title, swagger.Info.Version); + } + + private static string GetUidForOperation(string parentUid, OperationObject item) + { + return GenerateUid(parentUid, item.OperationId); + } + + private static string GetUidForTag(string parentUid, TagItemObject tag) + { + return GenerateUid(parentUid, TagText, tag.Name); + } + + /// + /// Merge operation's parameters with path's parameters. + /// + /// Operation's parameters + /// Path's parameters + /// + private static IEnumerable GetParametersForOperation(List operationParameters, List pathParameters) + { + return MergeParameters(operationParameters, pathParameters, IsParameterEquals); + } + + /// + /// Judge whether two ParameterObject equal to each other. according to value of 'name' and 'in' + /// Define 'Equals' here instead of inside ParameterObject, since ParameterObject is either self defined or referenced object which 'name' and 'in' needs to be resolved. + /// + /// Fist ParameterObject + /// Second ParameterObject + private static bool IsParameterEquals(ParameterObject left, ParameterObject right) + { + if (left == null || right == null) + { + return false; + } + return string.Equals(left.Name, right.Name) && + string.Equals(GetMetadataStringValue(left, "in"), GetMetadataStringValue(right, "in")); + } + + private static string GetMetadataStringValue(ParameterObject parameter, string metadataName) + { + if (parameter.Metadata.TryGetValue(metadataName, out object metadataValue)) + { + return (string)metadataValue; + } + return null; + } + #endregion +} diff --git a/src/Docfx.Build.RestApi/OpenApiModelConverter.cs b/src/Docfx.Build.RestApi/OpenApi3ModelConverter.cs similarity index 68% rename from src/Docfx.Build.RestApi/OpenApiModelConverter.cs rename to src/Docfx.Build.RestApi/OpenApi3ModelConverter.cs index 7aa06c076b6..b95130f6faa 100644 --- a/src/Docfx.Build.RestApi/OpenApiModelConverter.cs +++ b/src/Docfx.Build.RestApi/OpenApi3ModelConverter.cs @@ -10,16 +10,16 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using static Docfx.Build.RestApi.RestApiModelConverter; +using static Docfx.Build.RestApi.RestApiModelUtility; namespace Docfx.Build.RestApi; -internal sealed class OpenApiModelConverter(Uri documentUri, IReadOnlyDictionary constants) +internal sealed class OpenApi3ModelConverter(Uri documentUri, IReadOnlyDictionary constants) { internal RestApiRootItemViewModel Convert(OpenApiDocument document, string raw, string version) { var servers = Servers(document.Servers); - var server = (string)servers[0]["url"]; + var server = servers[0].Url; var absolute = Uri.TryCreate(server, UriKind.Absolute, out var uri) && !uri.IsFile; var uid = GenerateUid(absolute ? uri.Authority : null, (absolute ? uri.AbsolutePath : server).Trim('/'), document.Info.Title, document.Info.Version); @@ -35,20 +35,19 @@ internal RestApiRootItemViewModel Convert(OpenApiDocument document, string raw, Children = [], Tags = [] }; - model.Metadata["openapi"] = version; - model.Metadata["_preserveLiteralData"] = true; - model.Metadata["servers"] = servers; - model.Metadata["info"] = Serialize(document.Info); + model.SpecificationVersion = version; + model.Servers = servers; + model.Info = Serialize(document.Info).ToObject(); if (document.ExternalDocs != null) { - model.Metadata["externalDocs"] = Serialize(document.ExternalDocs); + model.ExternalDocs = Serialize(document.ExternalDocs).ToObject(); } - var schemas = new JObject(); + var schemas = new Dictionary(); foreach (var (name, schema) in document.Components?.Schemas?.AsEnumerable() ?? []) { schemas[name] = Schema(schema); } - model.Metadata["schemas"] = schemas; + model.Schemas = schemas; foreach (var tag in document.Tags?.AsEnumerable() ?? []) { AddTag(tag.Name, tag.Description, Extensions(tag.Extensions)); @@ -86,16 +85,15 @@ internal RestApiRootItemViewModel Convert(OpenApiDocument document, string raw, Responses = operation.Responses?.Select(pair => Response(pair.Key, pair.Value)).ToList() ?? [], Metadata = Extensions(operation.Extensions) }; - child.Metadata["servers"] = effectiveServers; - child.Metadata["_preserveLiteralData"] = true; - child.Metadata["requestUrl"] = ((string)effectiveServers[0]["url"]).TrimEnd('/') + "/" + path.TrimStart('/'); + child.Servers = effectiveServers; + child.RequestUrl = effectiveServers[0].Url.TrimEnd('/') + "/" + path.TrimStart('/'); if (operation.RequestBody is { } body) { - child.Metadata["requestBody"] = new JObject + child.RequestBody = new RestApiRequestBodyViewModel { - ["description"] = body.Description, - ["required"] = body.Required, - ["content"] = Content(body.Content) + Description = body.Description, + Required = body.Required, + Content = Content(body.Content) }; } foreach (var name in child.Tags) @@ -128,13 +126,13 @@ void AddTag(string name, string description, Dictionary metadata } } - private static JArray Servers(IList servers) + private static List Servers(IList servers) { if (servers == null || servers.Count == 0) { - return new JArray(new JObject { ["url"] = "/" }); + return [new() { Url = "/" }]; } - return new JArray(servers.Select(server => + return servers.Select(server => { var url = server.Url; foreach (var (name, variable) in server.Variables?.AsEnumerable() ?? []) @@ -149,8 +147,8 @@ private static JArray Servers(IList servers) { throw new DocfxException($"OpenAPI server URL '{server.Url}' contains a variable without a default."); } - return new JObject { ["url"] = url, ["description"] = server.Description }; - })); + return new RestApiServerViewModel { Url = url, Description = server.Description }; + }).ToList(); } private RestApiParameterViewModel Parameter(IOpenApiParameter parameter) @@ -159,67 +157,57 @@ private RestApiParameterViewModel Parameter(IOpenApiParameter parameter) var metadata = Extensions(parameter.Extensions); metadata["in"] = parameter.In?.ToString().ToLowerInvariant(); metadata["required"] = parameter.Required; - metadata["schema"] = schema; metadata["style"] = parameter.Style?.ToString(); metadata["explode"] = parameter.Explode; if (parameter.Schema?.Default != null) { metadata["default"] = Literal(parameter.Schema.Default); } - if (parameter.Content is { Count: > 0 }) - { - metadata["content"] = Content(parameter.Content); - } return new RestApiParameterViewModel { Name = parameter.Name, Description = parameter.Description, + Schema = schema, + Content = parameter.Content is { Count: > 0 } ? Content(parameter.Content) : null, Metadata = metadata }; } private RestApiResponseViewModel Response(string status, IOpenApiResponse response) { - var metadata = Extensions(response.Extensions); - var content = Content(response.Content); - metadata["content"] = content; return new RestApiResponseViewModel { HttpStatusCode = status, Description = response.Description, - Metadata = metadata, - Examples = content.SelectMany(media => media["examples"]).Select(example => new RestApiResponseExampleViewModel - { - MimeType = (string)example["mimeType"], - Content = (string)example["content"] - }).ToList() + Metadata = Extensions(response.Extensions), + Content = Content(response.Content) }; } - private JArray Content(IDictionary content) => - new(content?.Select(pair => new JObject + private List Content(IDictionary content) => + content?.Select(pair => new RestApiMediaTypeViewModel { - ["mimeType"] = pair.Key, - ["schema"] = Schema(pair.Value.Schema), - ["itemSchema"] = Schema(pair.Value.ItemSchema), - ["examples"] = Examples(pair.Key, pair.Value) - }) ?? []); + MimeType = pair.Key, + Schema = Schema(pair.Value.Schema), + ItemSchema = Schema(pair.Value.ItemSchema), + Examples = Examples(pair.Key, pair.Value) + }).ToList() ?? []; - private static JArray Examples(string mimeType, IOpenApiMediaType media) + private static List Examples(string mimeType, IOpenApiMediaType media) { - var result = new JArray(); + var result = new List(); if (media.Example != null) { - result.Add(new JObject { ["mimeType"] = mimeType, ["content"] = Literal(media.Example) }); + result.Add(new() { MimeType = mimeType, Content = Literal(media.Example) }); } foreach (var (name, example) in media.Examples?.AsEnumerable() ?? []) { - result.Add(new JObject + result.Add(new() { - ["name"] = name, - ["mimeType"] = mimeType, - ["content"] = example.SerializedValue ?? Literal(example.DataValue ?? example.Value), - ["externalValue"] = example.ExternalValue + Name = name, + MimeType = mimeType, + Content = example.SerializedValue ?? Literal(example.DataValue ?? example.Value), + ExternalValue = example.ExternalValue }); } return result; @@ -258,7 +246,7 @@ private static Dictionary Extensions(IDictionary ancestors = null) + private RestApiSchemaViewModel Schema(IOpenApiSchema schema, HashSet ancestors = null) { if (schema == null) { @@ -274,7 +262,7 @@ private JObject Schema(IOpenApiSchema schema, HashSet ancestors { throw new DocfxException($"Cyclic OpenAPI schema alias '{reference.Reference?.Id}' has no concrete schema."); } - return new JObject { ["type"] = "recursive reference", ["x-internal-loop-ref-name"] = ReferenceName(reference) }; + return new() { Type = "recursive reference", LoopReferenceName = ReferenceName(reference) }; } try { @@ -282,17 +270,13 @@ private JObject Schema(IOpenApiSchema schema, HashSet ancestors var siblings = GetReferenceSiblings(reference); using var siblingText = new StringWriter(); siblings.SerializeAsV32(new OpenApiJsonWriter(siblingText)); - var result = JObject.Parse(siblingText.ToString()).Count == 0 ? targetModel : new JObject + var result = JObject.Parse(siblingText.ToString()).Count == 0 ? targetModel : new RestApiSchemaViewModel { - ["type"] = "all of", - ["description"] = reference.Reference.Description ?? target.Description, - ["composition"] = new JArray(new JObject - { - ["kind"] = "All of", - ["schemas"] = new JArray(targetModel, Schema(siblings, ancestors)) - }) + Type = "all of", + Description = reference.Reference.Description ?? target.Description, + AllOf = [targetModel, Schema(siblings, ancestors)] }; - result["x-internal-ref-name"] ??= ReferenceName(reference); + result.ReferenceName ??= ReferenceName(reference); return result; } finally @@ -302,11 +286,7 @@ private JObject Schema(IOpenApiSchema schema, HashSet ancestors } if (!ancestors.Add(schema)) { - return new JObject - { - ["type"] = "recursive reference", - ["x-internal-loop-ref-name"] = schema.Title ?? "schema" - }; + return new() { Type = "recursive reference", LoopReferenceName = schema.Title ?? "schema" }; } try @@ -317,32 +297,29 @@ private JObject Schema(IOpenApiSchema schema, HashSet ancestors RestoreConstants(serialized); if (serialized is JObject { Count: 1 } && serialized["not"] is JObject { Count: 0 }) { - return new JObject { ["type"] = "no value" }; + return new() { Type = "no value" }; } - var result = JObject.FromObject(Extensions(schema.Extensions)); - result["type"] = schema.Type?.ToString().ToLowerInvariant().Replace(", ", " | ") ?? - (serialized is JObject { Count: 0 } ? "any value" : "any type"); - result["format"] = schema.Format; - result["description"] = schema.Description; - if (schema.Properties is { Count: > 0 }) + var result = new RestApiSchemaViewModel { - result["properties"] = new JObject(schema.Properties.Select(pair => + Metadata = Extensions(schema.Extensions), + Type = schema.Type?.ToString().ToLowerInvariant().Replace(", ", " | ") ?? + (serialized is JObject { Count: 0 } ? "any value" : "any type"), + Format = schema.Format, + Description = schema.Description, + Properties = schema.Properties?.ToDictionary(pair => pair.Key, pair => { var property = Schema(pair.Value, ancestors); if (schema.Required?.Contains(pair.Key) == true) { - property["required"] = true; + property.Required = true; } - return new JProperty(pair.Key, property); - })); - } - if (schema.Items != null) - { - result["items"] = Schema(schema.Items, ancestors); - } - var composition = new JArray(); - AddComposition("All of", schema.AllOf); + return property; + }), + Items = Schema(schema.Items, ancestors) + }; + var composition = new List(); + result.AllOf = schema.AllOf is { Count: > 0 } ? schema.AllOf.Select(s => Schema(s, ancestors)).ToList() : null; AddComposition("One of", schema.OneOf); AddComposition("Any of", schema.AnyOf); if (schema.Not != null) @@ -351,43 +328,43 @@ private JObject Schema(IOpenApiSchema schema, HashSet ancestors } if (composition.Count > 0) { - result["composition"] = composition; + result.Composition = composition; } - var constraints = new JArray(); + var constraints = new List(); foreach (var property in ((JObject)serialized).Properties()) { if (!property.Name.StartsWith("x-", StringComparison.Ordinal) && property.Name is not ("type" or "format" or "description" or "properties" or "items" or "allOf" or "oneOf" or "anyOf" or "not" or "additionalProperties" or "enum" or "example" or "examples")) { - constraints.Add(new JObject { ["name"] = property.Name, ["value"] = property.Value.ToString(Formatting.None) }); + constraints.Add(new() { Name = property.Name, Value = property.Value.ToString(Formatting.None) }); } } if (schema.AdditionalProperties != null) { - composition.Add(new JObject { ["kind"] = "Additional properties", ["schemas"] = new JArray(Schema(schema.AdditionalProperties, ancestors)) }); - result["composition"] = composition; + composition.Add(new() { Kind = "Additional properties", Schemas = [Schema(schema.AdditionalProperties, ancestors)] }); + result.Composition = composition; } else if (!schema.AdditionalPropertiesAllowed) { - constraints.Add(new JObject { ["name"] = "additionalProperties", ["value"] = "false" }); + constraints.Add(new() { Name = "additionalProperties", Value = "false" }); } if (constraints.Count > 0) { - result["constraints"] = constraints; + result.Constraints = constraints; } if (schema.Enum is { Count: > 0 }) { - result["enum"] = new JArray(schema.Enum.Select(value => value == null ? JValue.CreateNull() : JToken.Parse(Literal(value)))); + result.Enum = schema.Enum.Select(value => value == null ? null : (object)JToken.Parse(Literal(value))).ToList(); } if (schema.Examples is { Count: > 0 }) { - result["examples"] = new JArray(schema.Examples.Select(example => new JObject { ["content"] = Literal(example) })); + result.Examples = schema.Examples.Select(example => new RestApiResponseExampleViewModel { Content = Literal(example) }).ToList(); } #pragma warning disable CS0618 // OpenAPI 3.0's singular schema example is still read into this SDK property. else if (schema.Example != null) { - result["examples"] = new JArray(new JObject { ["content"] = Literal(schema.Example) }); + result.Examples = [new() { Content = Literal(schema.Example) }]; } #pragma warning restore CS0618 return result; @@ -396,7 +373,7 @@ void AddComposition(string kind, IList schemas) { if (schemas is { Count: > 0 }) { - composition.Add(new JObject { ["kind"] = kind, ["schemas"] = new JArray(schemas.Select(s => Schema(s, ancestors))) }); + composition.Add(new() { Kind = kind, Schemas = schemas.Select(s => Schema(s, ancestors)).ToList() }); } } } diff --git a/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs b/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs index 18067c6142a..ad01f9b70f6 100644 --- a/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs +++ b/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs @@ -17,27 +17,6 @@ namespace Docfx.Build.RestApi; internal static class OpenApiDocumentReader { - internal static bool IsOpenApiFile(string path) - { - try - { - return GetVersion(EnvironmentContext.FileAbstractLayer.ReadAllText(path)) != null; - } - catch (FileNotFoundException ex) - { - Logger.LogVerbose($"Could not find OpenAPI file '{path}': {ex.Message}"); - } - catch (DirectoryNotFoundException ex) - { - Logger.LogVerbose($"Could not find OpenAPI file '{path}': {ex.Message}"); - } - catch (YamlException ex) - { - Logger.LogVerbose($"Could not read OpenAPI version in '{path}': {ex.Message}"); - } - return false; - } - internal static RestApiRootItemViewModel Read(string path) { var format = Path.GetExtension(path).Equals(".json", StringComparison.OrdinalIgnoreCase) ? "json" : "yaml"; @@ -45,28 +24,24 @@ internal static RestApiRootItemViewModel Read(string path) return model; } - internal static RestApiRootItemViewModel Parse(string raw, string format, Uri baseUrl = null) + internal static RestApiRootItemViewModel Parse(string raw, string format, Uri baseUrl = null, string version = null) { try { - var version = GetVersion(raw); - if (!System.Version.TryParse(version, out var parsed) || parsed.Major != 3 || parsed.Minor is not (0 or 1 or 2)) - { - throw new DocfxException($"OpenAPI version '{version}' is not supported. Use OpenAPI 3.0, 3.1 or 3.2."); - } + version ??= RestApiDocumentReader.ReadHeader(new StringReader(raw), format)?.Version; var constants = new Dictionary(); - var document = LoadDocuments(raw, format, baseUrl ?? new Uri(Path.GetFullPath("openapi.json")), constants); - var model = new OpenApiModelConverter(document.BaseUri, constants).Convert(document, raw, version); + var document = LoadDocuments(raw, format, baseUrl ?? new Uri(Path.GetFullPath("openapi.json")), version, constants); + var model = new OpenApi3ModelConverter(document.BaseUri, constants).Convert(document, raw, version); model.Metadata["rawExtension"] = format == "json" ? ".json" : ".yaml"; return model; } - catch (Exception ex) when (ex is IOException or YamlException or System.Text.Json.JsonException or OpenApiException or InvalidOperationException) + catch (Exception ex) when (ex is IOException or YamlException or JsonException or System.Text.Json.JsonException or OpenApiException or InvalidOperationException) { throw new DocfxException($"Unable to read OpenAPI document: {ex.Message}", ex); } } - private static OpenApiDocument LoadDocuments(string raw, string format, Uri root, Dictionary constants) + private static OpenApiDocument LoadDocuments(string raw, string format, Uri root, string rootVersion, Dictionary constants) { var loader = new LocalStreamLoader(); var documents = new Dictionary(); @@ -85,9 +60,13 @@ private static OpenApiDocument LoadDocuments(string raw, string format, Uri root source = reader.ReadToEnd(); sourceFormat = Path.GetExtension(location.LocalPath).Equals(".json", StringComparison.OrdinalIgnoreCase) ? "json" : "yaml"; } - var version = GetVersion(source); + var version = location == root ? rootVersion : RestApiDocumentReader.ReadHeader(new StringReader(source), sourceFormat)?.Version; if (!System.Version.TryParse(version, out var parsed) || parsed.Major != 3 || parsed.Minor is not (0 or 1 or 2)) { + if (location == root) + { + throw new DocfxException($"OpenAPI version '{version}' is not supported. Use OpenAPI 3.0, 3.1 or 3.2."); + } throw new DocfxException($"UnsupportedExternalFragment: '{location.LocalPath}' is not a complete OpenAPI 3.0, 3.1 or 3.2 document. " + "Standalone schema/component fragments are valid OpenAPI references, but are not supported by this reader integration."); } @@ -489,7 +468,7 @@ public override void Visit(IOpenApiReferenceHolder holder) References.Add((holder, reference)); if (holder is OpenApiSchemaReference schemaReference) { - WalkSchema(OpenApiModelConverter.GetReferenceSiblings(schemaReference)); + WalkSchema(OpenApi3ModelConverter.GetReferenceSiblings(schemaReference)); } } @@ -520,16 +499,6 @@ public override void Visit(IOpenApiSchema schema) }); } - private static string GetVersion(string raw) - { - var yaml = new YamlStream(); - yaml.Load(new StringReader(raw)); - return yaml.Documents.Count == 1 && - yaml.Documents[0].RootNode is YamlMappingNode root && - root.Children.TryGetValue(new YamlScalarNode("openapi"), out var node) && - node is YamlScalarNode version ? version.Value : null; - } - private sealed class LocalStreamLoader : IStreamLoader { public Task LoadAsync(Uri baseUrl, Uri uri, CancellationToken cancellationToken = default) diff --git a/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs b/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs index dfe5ca5d484..0badaf18a5a 100644 --- a/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs +++ b/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs @@ -5,17 +5,12 @@ using System.Composition; using Docfx.Build.Common; -using Docfx.Build.RestApi.Swagger; using Docfx.Common; using Docfx.Common.Git; using Docfx.DataContracts.Common; using Docfx.DataContracts.RestApi; -using Docfx.Exceptions; using Docfx.Plugins; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - namespace Docfx.Build.RestApi; [Export(typeof(IDocumentProcessor))] @@ -23,7 +18,6 @@ public class RestApiDocumentProcessor : ReferenceDocumentProcessorBase { private const string RestApiDocumentType = "RestApi"; private const string DocumentTypeKey = "documentType"; - private const string OperationIdKey = "operationId"; // To keep backward compatibility, still support and change previous file endings by first mapping sequence. // Take 'a.b_swagger2.json' for an example, the json file name would be changed to 'a.b', then the html file name would be 'a.b.html'. @@ -65,11 +59,7 @@ public class RestApiDocumentProcessor : ReferenceDocumentProcessorBase "securityDefinitions", "security", "tags", - "externalDocs" - ]; - - private static readonly string[] OpenApiSystemKeys = [ - .. SystemKeys, + "externalDocs", "openapi", "servers", "components", @@ -78,7 +68,8 @@ public class RestApiDocumentProcessor : ReferenceDocumentProcessorBase "requestUrl", "rawExtension", "jsonSchemaDialect", - "webhooks" + "webhooks", + "specificationVersion" ]; [ImportMany(nameof(RestApiDocumentProcessor))] @@ -91,7 +82,7 @@ public override ProcessingPriority GetProcessingPriority(FileAndType file) switch (file.Type) { case DocumentType.Article: - if (IsSupportedFile(file.FullPath)) + if (RestApiDocumentReader.IsSupportedFile(file.FullPath)) { return ProcessingPriority.Normal; } @@ -137,20 +128,7 @@ public override SaveResult Save(FileModel model) protected override FileModel LoadArticle(FileAndType file, ImmutableDictionary metadata) { var filePath = Path.Combine(file.BaseDir, file.File); - RestApiRootItemViewModel vm; - var isOpenApi = !(filePath.EndsWith(".json", StringComparison.OrdinalIgnoreCase) && IsSwaggerFile(filePath)) && - OpenApiDocumentReader.IsOpenApiFile(filePath); - if (isOpenApi) - { - vm = OpenApiDocumentReader.Read(filePath); - } - else - { - var swagger = SwaggerJsonParser.Parse(filePath); - swagger.Raw = EnvironmentContext.FileAbstractLayer.ReadAllText(filePath); - CheckOperationId(swagger, file.File); - vm = SwaggerModelConverter.FromSwaggerModel(swagger); - } + var vm = RestApiDocumentReader.Read(filePath, file.File); vm.Metadata[DocumentTypeKey] = RestApiDocumentType; var repoInfo = GitUtility.TryGetFileDetail(filePath); @@ -164,7 +142,7 @@ protected override FileModel LoadArticle(FileAndType file, ImmutableDictionary GetXRefInfo(RestApiRootItemViewModel rootIt } } - private static bool IsSupportedFile(string filePath) - { - return SupportedFileEndings.Any(s => IsSupportedFileEnding(filePath, s)) && - ((filePath.EndsWith(".json", StringComparison.OrdinalIgnoreCase) && IsSwaggerFile(filePath)) || - OpenApiDocumentReader.IsOpenApiFile(filePath)); - } - - private static bool IsSupportedFileEnding(string filePath, string fileEnding) - { - return filePath.EndsWith(fileEnding, StringComparison.OrdinalIgnoreCase); - } - - private static bool IsSwaggerFile(string filePath) - { - try - { - using var streamReader = EnvironmentContext.FileAbstractLayer.OpenReadText(filePath); - using JsonReader reader = new JsonTextReader(streamReader); - var jObject = JObject.Load(reader); - if (jObject.TryGetValue("swagger", out JToken swaggerValue)) - { - var swaggerString = (string)swaggerValue; - if (swaggerString is "2.0") - { - return true; - } - } - } - catch (FileNotFoundException ex) - { - Logger.LogVerbose($"In {nameof(RestApiDocumentProcessor)}, could not find {filePath}, exception details: {ex.Message}."); - } - catch (JsonException ex) - { - Logger.LogVerbose($"In {nameof(RestApiDocumentProcessor)}, could not deserialize {filePath} to JObject, exception details: {ex.Message}."); - } - - return false; - } - - private static void CheckOperationId(SwaggerModel swagger, string fileName) - { - if (swagger.Paths != null) - { - foreach (var path in swagger.Paths) - { - foreach (var operation in path.Value.Metadata) - { - if (operation.Value is JObject jObject && !jObject.TryGetValue(OperationIdKey, out JToken operationId)) - { - throw new DocfxException($"{OperationIdKey} should exist in operation '{operation.Key}' of path '{path.Key}' for swagger file '{fileName}'"); - } - } - } - } - } + private static bool IsSupportedFileEnding(string filePath, string fileEnding) => + filePath.EndsWith(fileEnding, StringComparison.OrdinalIgnoreCase); private static string ChangeFileExtension(string file) { diff --git a/src/Docfx.Build.RestApi/RestApiDocumentReader.cs b/src/Docfx.Build.RestApi/RestApiDocumentReader.cs new file mode 100644 index 00000000000..d0cacd2d0a0 --- /dev/null +++ b/src/Docfx.Build.RestApi/RestApiDocumentReader.cs @@ -0,0 +1,101 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Docfx.Build.RestApi.Swagger; +using Docfx.Common; +using Docfx.DataContracts.RestApi; +using Docfx.Exceptions; +using Docfx.Plugins; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; + +namespace Docfx.Build.RestApi; + +// Ownership detection and reader dispatch live here. Downstream code consumes REST view models. +internal static class RestApiDocumentReader +{ + internal sealed record Header(string Version, bool IsSwagger = false); + + internal static bool IsSupportedFile(string path) + { + var format = Format(path); + if (format == null) return false; + try + { + using var reader = EnvironmentContext.FileAbstractLayer.OpenReadText(path); + return ReadHeader(reader, format) != null; + } + catch (Exception ex) when (ex is IOException or JsonException or YamlException) + { + Logger.LogVerbose($"Could not identify REST API document '{path}': {ex.Message}"); + return false; + } + } + + internal static RestApiRootItemViewModel Read(string path, string fileName) + { + var raw = EnvironmentContext.FileAbstractLayer.ReadAllText(path); + var format = Format(path); + var header = ReadHeader(new StringReader(raw), format); + if (header is { IsSwagger: true }) + { + var swagger = SwaggerJsonParser.Parse(path); + swagger.Raw = raw; + // Preserve legacy diagnostics, including extension objects under a path. + foreach (var (route, item) in swagger.Paths ?? []) + { + foreach (var (method, operation) in item.Metadata) + { + if (operation is JObject obj && !obj.ContainsKey("operationId")) + { + throw new DocfxException($"operationId should exist in operation '{method}' of path '{route}' for swagger file '{fileName}'"); + } + } + } + return OpenApi2ModelConverter.Convert(swagger); + } + return OpenApiDocumentReader.Parse(raw, format, new Uri(Path.GetFullPath(path)), header?.Version); + } + + internal static string Format(string path) => Path.GetExtension(path).ToLowerInvariant() switch + { + ".json" => "json", + ".yaml" or ".yml" => "yaml", + _ => null + }; + + // Read only root markers, without allocating an object tree. The legacy JSON probe + // also validates the complete JSON syntax to retain its existing ownership behavior. + internal static Header ReadHeader(TextReader source, string format) + { + if (format == "json") + { + using var reader = new JsonTextReader(source) { DateParseHandling = DateParseHandling.None }; + if (!reader.Read() || reader.TokenType != JsonToken.StartObject) return null; + Header swagger = null; + while (reader.Read()) + { + if (reader.TokenType == JsonToken.EndObject && reader.Depth == 0) return swagger; + if (reader.TokenType != JsonToken.PropertyName || reader.Depth != 1) continue; + var key = (string)reader.Value; + if (!reader.Read()) return null; + if (key == "openapi" && reader.TokenType == JsonToken.String) return new Header((string)reader.Value); + if (key == "swagger" && reader.Value is "2.0") swagger = new Header("2.0", IsSwagger: true); + reader.Skip(); + } + return null; + } + var parser = new Parser(source); + parser.Consume(); + if (!parser.TryConsume(out _) || !parser.TryConsume(out _)) return null; + while (!parser.Accept(out _)) + { + if (!parser.TryConsume(out var key)) return null; + if (key.Value == "openapi" && parser.TryConsume(out var version)) return new Header(version.Value); + parser.SkipThisAndNestedEvents(); + } + return null; + } +} diff --git a/src/Docfx.Build.RestApi/RestApiModelConverter.cs b/src/Docfx.Build.RestApi/RestApiModelUtility.cs similarity index 95% rename from src/Docfx.Build.RestApi/RestApiModelConverter.cs rename to src/Docfx.Build.RestApi/RestApiModelUtility.cs index ce693ffa130..a654c147b29 100644 --- a/src/Docfx.Build.RestApi/RestApiModelConverter.cs +++ b/src/Docfx.Build.RestApi/RestApiModelUtility.cs @@ -5,7 +5,7 @@ namespace Docfx.Build.RestApi; -internal static partial class RestApiModelConverter +internal static partial class RestApiModelUtility { [GeneratedRegex(@"\W")] private static partial Regex HtmlEncodeRegex(); diff --git a/src/Docfx.Build.RestApi/SwaggerModelConverter.cs b/src/Docfx.Build.RestApi/SwaggerModelConverter.cs index 6e02c45793c..2a97b5ff2b0 100644 --- a/src/Docfx.Build.RestApi/SwaggerModelConverter.cs +++ b/src/Docfx.Build.RestApi/SwaggerModelConverter.cs @@ -1,163 +1,14 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using Docfx.Build.RestApi.Swagger; -using Docfx.Common; -using Docfx.DataContracts.Common; using Docfx.DataContracts.RestApi; -using Newtonsoft.Json.Linq; - -using static Docfx.Build.RestApi.RestApiModelConverter; - namespace Docfx.Build.RestApi; +// Public compatibility entry point for callers consuming the original Swagger metadata contract. public static partial class SwaggerModelConverter { - public static RestApiRootItemViewModel FromSwaggerModel(SwaggerModel swagger) - { - var uid = GetUid(swagger); - var vm = new RestApiRootItemViewModel - { - Name = swagger.Info.Title, - Uid = uid, - HtmlId = GetHtmlId(uid), - Metadata = swagger.Metadata, - Description = swagger.Description, - Summary = swagger.Summary, - Children = [], - Raw = swagger.Raw, - Tags = [] - }; - if (swagger.Tags != null) - { - foreach (var tag in swagger.Tags) - { - vm.Tags.Add(new RestApiTagViewModel - { - Name = tag.Name, - Description = tag.Description, - HtmlId = string.IsNullOrEmpty(tag.BookmarkId) ? GetHtmlId(tag.Name) : tag.BookmarkId, // Fall back to tag name's html id - Metadata = tag.Metadata, - Uid = GetUidForTag(uid, tag) - }); - } - } - if (swagger.Paths != null) - { - foreach (var path in swagger.Paths) - { - var commonParameters = path.Value.Parameters; - foreach (var op in path.Value.Metadata) - { - // fetch operations from metadata - if (OperationNames.Contains(op.Key, StringComparer.OrdinalIgnoreCase)) - { - if (op.Value is not JObject opJObject) - { - throw new InvalidOperationException($"Value of {op.Key} should be JObject"); - } - - // convert operation from JObject to OperationObject - var operation = opJObject.ToObject(); - var parameters = GetParametersForOperation(operation.Parameters, commonParameters); - var itemUid = GetUidForOperation(uid, operation); - var itemVm = new RestApiChildItemViewModel - { - Path = path.Key, - OperationName = op.Key, - Tags = operation.Tags, - OperationId = operation.OperationId, - HtmlId = GetHtmlId(itemUid), - Uid = itemUid, - Metadata = operation.Metadata, - Description = operation.Description, - Summary = operation.Summary, - Parameters = parameters?.Select(s => new RestApiParameterViewModel - { - Description = s.Description, - Name = s.Name, - Metadata = s.Metadata - }).ToList(), - Responses = operation.Responses?.Select(s => new RestApiResponseViewModel - { - Metadata = s.Value.Metadata, - Description = s.Value.Description, - Summary = s.Value.Summary, - HttpStatusCode = s.Key, - Examples = s.Value.Examples?.Select(example => new RestApiResponseExampleViewModel - { - MimeType = example.Key, - Content = example.Value != null ? JsonUtility.Serialize(example.Value) : null, - }).ToList(), - }).ToList(), - }; - - // TODO: line number - itemVm.Metadata[Constants.PropertyName.Source] = swagger.Metadata.GetValueOrDefault(Constants.PropertyName.Source); - vm.Children.Add(itemVm); - } - } - } - } - - return vm; - } - - #region Private methods - - private const string TagText = "tag"; - private static readonly string[] OperationNames = ["get", "put", "post", "delete", "options", "head", "patch"]; - - private static string GetUid(SwaggerModel swagger) - { - return GenerateUid(swagger.Host, swagger.BasePath, swagger.Info.Title, swagger.Info.Version); - } - - private static string GetUidForOperation(string parentUid, OperationObject item) - { - return GenerateUid(parentUid, item.OperationId); - } - - private static string GetUidForTag(string parentUid, TagItemObject tag) - { - return GenerateUid(parentUid, TagText, tag.Name); - } - - /// - /// Merge operation's parameters with path's parameters. - /// - /// Operation's parameters - /// Path's parameters - /// - private static IEnumerable GetParametersForOperation(List operationParameters, List pathParameters) - { - return MergeParameters(operationParameters, pathParameters, IsParameterEquals); - } - - /// - /// Judge whether two ParameterObject equal to each other. according to value of 'name' and 'in' - /// Define 'Equals' here instead of inside ParameterObject, since ParameterObject is either self defined or referenced object which 'name' and 'in' needs to be resolved. - /// - /// Fist ParameterObject - /// Second ParameterObject - private static bool IsParameterEquals(ParameterObject left, ParameterObject right) - { - if (left == null || right == null) - { - return false; - } - return string.Equals(left.Name, right.Name) && - string.Equals(GetMetadataStringValue(left, "in"), GetMetadataStringValue(right, "in")); - } - - private static string GetMetadataStringValue(ParameterObject parameter, string metadataName) - { - if (parameter.Metadata.TryGetValue(metadataName, out object metadataValue)) - { - return (string)metadataValue; - } - return null; - } - #endregion + public static RestApiRootItemViewModel FromSwaggerModel(SwaggerModel swagger) => + OpenApi2ModelConverter.ConvertLegacy(swagger); } 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.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/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/RestApiChildItemViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiChildItemViewModel.cs index 1cfd786a6fa..2e859219b56 100644 --- a/src/Docfx.DataContracts.RestApi/RestApiChildItemViewModel.cs +++ b/src/Docfx.DataContracts.RestApi/RestApiChildItemViewModel.cs @@ -10,6 +10,27 @@ namespace Docfx.DataContracts.RestApi; public class RestApiChildItemViewModel : RestApiItemViewModelBase { + [YamlMember(Alias = "servers")] + [JsonProperty("servers", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("servers")] + [Docfx.Common.EntityMergers.MergeOption(typeof(RestApiArrayMergeHandler))] + public List Servers { get; set; } + + [YamlMember(Alias = "requestUrl")] + [JsonProperty("requestUrl", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("requestUrl")] + public string RequestUrl { get; set; } + + [YamlMember(Alias = "displayPath")] + [JsonProperty("displayPath", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("displayPath")] + public string DisplayPath { get; set; } + + [YamlMember(Alias = "requestBody")] + [JsonProperty("requestBody", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("requestBody")] + public RestApiRequestBodyViewModel RequestBody { get; set; } + [YamlMember(Alias = Constants.PropertyName.Path)] [JsonProperty(Constants.PropertyName.Path)] [JsonPropertyName(Constants.PropertyName.Path)] 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..84c969912cd --- /dev/null +++ b/src/Docfx.DataContracts.RestApi/RestApiInfoViewModel.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.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; } + + [YamlMember(Alias = "summary")] + [JsonProperty("summary", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("summary")] + public string Summary { 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/RestApiMediaTypeViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiMediaTypeViewModel.cs new file mode 100644 index 00000000000..e1ed699e9da --- /dev/null +++ b/src/Docfx.DataContracts.RestApi/RestApiMediaTypeViewModel.cs @@ -0,0 +1,32 @@ +// 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 RestApiMediaTypeViewModel +{ + [YamlMember(Alias = "mimeType")] + [JsonProperty("mimeType", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("mimeType")] + public string MimeType { get; set; } + + [YamlMember(Alias = "schema")] + [JsonProperty("schema", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("schema")] + public RestApiSchemaViewModel Schema { get; set; } + + [YamlMember(Alias = "itemSchema")] + [JsonProperty("itemSchema", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("itemSchema")] + public RestApiSchemaViewModel ItemSchema { get; set; } + + [YamlMember(Alias = "examples")] + [JsonProperty("examples", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("examples")] + [Docfx.Common.EntityMergers.MergeOption(typeof(RestApiArrayMergeHandler))] + public List Examples { get; set; } +} diff --git a/src/Docfx.DataContracts.RestApi/RestApiParameterViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiParameterViewModel.cs index 2407dad082a..7028c24f43e 100644 --- a/src/Docfx.DataContracts.RestApi/RestApiParameterViewModel.cs +++ b/src/Docfx.DataContracts.RestApi/RestApiParameterViewModel.cs @@ -11,6 +11,17 @@ namespace Docfx.DataContracts.RestApi; public class RestApiParameterViewModel { + [YamlMember(Alias = "schema")] + [JsonProperty("schema", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("schema")] + public RestApiSchemaViewModel Schema { get; set; } + + [YamlMember(Alias = "content")] + [JsonProperty("content", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("content")] + [Docfx.Common.EntityMergers.MergeOption(typeof(RestApiArrayMergeHandler))] + public List Content { get; set; } + [YamlMember(Alias = "description")] [JsonProperty("description")] [JsonPropertyName("description")] diff --git a/src/Docfx.DataContracts.RestApi/RestApiRequestBodyViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiRequestBodyViewModel.cs new file mode 100644 index 00000000000..94104054a15 --- /dev/null +++ b/src/Docfx.DataContracts.RestApi/RestApiRequestBodyViewModel.cs @@ -0,0 +1,27 @@ +// 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 RestApiRequestBodyViewModel +{ + [YamlMember(Alias = "description")] + [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("description")] + public string Description { get; set; } + + [YamlMember(Alias = "required")] + [JsonProperty("required", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("required")] + public bool? Required { get; set; } + + [YamlMember(Alias = "content")] + [JsonProperty("content", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("content")] + [Docfx.Common.EntityMergers.MergeOption(typeof(RestApiArrayMergeHandler))] + public List Content { get; set; } +} diff --git a/src/Docfx.DataContracts.RestApi/RestApiResponseExampleViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiResponseExampleViewModel.cs index 356416a9757..b326996fb0d 100644 --- a/src/Docfx.DataContracts.RestApi/RestApiResponseExampleViewModel.cs +++ b/src/Docfx.DataContracts.RestApi/RestApiResponseExampleViewModel.cs @@ -9,6 +9,16 @@ namespace Docfx.DataContracts.RestApi; public class RestApiResponseExampleViewModel { + [YamlMember(Alias = "name")] + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("name")] + public string Name { get; set; } + + [YamlMember(Alias = "externalValue")] + [JsonProperty("externalValue", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("externalValue")] + public string ExternalValue { get; set; } + [YamlMember(Alias = "mimeType")] [JsonProperty("mimeType")] [JsonPropertyName("mimeType")] diff --git a/src/Docfx.DataContracts.RestApi/RestApiResponseViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiResponseViewModel.cs index b8823d01ef9..9db07b93573 100644 --- a/src/Docfx.DataContracts.RestApi/RestApiResponseViewModel.cs +++ b/src/Docfx.DataContracts.RestApi/RestApiResponseViewModel.cs @@ -11,6 +11,22 @@ 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 = "content")] + [JsonProperty("content", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("content")] + [Docfx.Common.EntityMergers.MergeOption(typeof(RestApiArrayMergeHandler))] + public List Content { 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..6532197c6ee 100644 --- a/src/Docfx.DataContracts.RestApi/RestApiRootItemViewModel.cs +++ b/src/Docfx.DataContracts.RestApi/RestApiRootItemViewModel.cs @@ -4,14 +4,47 @@ 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; } + + /// Source specification version, independent of the API version in info.version. + [YamlMember(Alias = "specificationVersion")] + [JsonProperty("specificationVersion", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("specificationVersion")] + public string SpecificationVersion { 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; } + + [YamlMember(Alias = "servers")] + [JsonProperty("servers", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("servers")] + [Docfx.Common.EntityMergers.MergeOption(typeof(RestApiArrayMergeHandler))] + public List Servers { get; set; } + + [YamlMember(Alias = "schemas")] + [JsonProperty("schemas", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("schemas")] + public Dictionary Schemas { get; set; } + /// - /// The original swagger.json content + /// The original OpenAPI source content /// `_` prefix indicates that this metadata is generated /// [YamlMember(Alias = "_raw")] @@ -29,4 +62,23 @@ 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.SpecificationVersion = SpecificationVersion; + target.Info = Inherit("info", Info); + target.ExternalDocs = Inherit("externalDocs", ExternalDocs); + target.SecurityDefinitions = Inherit("securityDefinitions", SecurityDefinitions); + target.Servers = Inherit("servers", target.Servers ?? Servers); + target.Schemas = Inherit("schemas", Schemas); + + // 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/RestApiSchemaCompositionViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiSchemaCompositionViewModel.cs new file mode 100644 index 00000000000..dda1d783502 --- /dev/null +++ b/src/Docfx.DataContracts.RestApi/RestApiSchemaCompositionViewModel.cs @@ -0,0 +1,22 @@ +// 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 RestApiSchemaCompositionViewModel +{ + [YamlMember(Alias = "kind")] + [JsonProperty("kind", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("kind")] + public string Kind { get; set; } + + [YamlMember(Alias = "schemas")] + [JsonProperty("schemas", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("schemas")] + [Docfx.Common.EntityMergers.MergeOption(typeof(RestApiArrayMergeHandler))] + public List Schemas { get; set; } +} diff --git a/src/Docfx.DataContracts.RestApi/RestApiSchemaConstraintViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiSchemaConstraintViewModel.cs new file mode 100644 index 00000000000..c2d59856526 --- /dev/null +++ b/src/Docfx.DataContracts.RestApi/RestApiSchemaConstraintViewModel.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 RestApiSchemaConstraintViewModel +{ + [YamlMember(Alias = "name")] + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("name")] + public string Name { get; set; } + + [YamlMember(Alias = "value")] + [JsonProperty("value", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("value")] + public string Value { get; set; } +} diff --git a/src/Docfx.DataContracts.RestApi/RestApiSchemaViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiSchemaViewModel.cs new file mode 100644 index 00000000000..6feaa3e1203 --- /dev/null +++ b/src/Docfx.DataContracts.RestApi/RestApiSchemaViewModel.cs @@ -0,0 +1,97 @@ +// 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 = "referenceId")] + [JsonProperty("referenceId", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("referenceId")] + public string ReferenceId { 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 = "composition")] + [JsonProperty("composition", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("composition")] + [MergeOption(typeof(RestApiArrayMergeHandler))] + public List Composition { get; set; } + + [YamlMember(Alias = "constraints")] + [JsonProperty("constraints", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("constraints")] + [MergeOption(typeof(RestApiArrayMergeHandler))] + public List Constraints { 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; } + + [YamlMember(Alias = "examples")] + [JsonProperty("examples", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("examples")] + [MergeOption(typeof(RestApiArrayMergeHandler))] + public List Examples { 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/src/Docfx.DataContracts.RestApi/RestApiServerViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiServerViewModel.cs new file mode 100644 index 00000000000..ecdcb4ccc24 --- /dev/null +++ b/src/Docfx.DataContracts.RestApi/RestApiServerViewModel.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 RestApiServerViewModel +{ + [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; } +} diff --git a/templates/common/RestApi.common.js b/templates/common/RestApi.common.js index c49d4de59f4..5644c8f2668 100644 --- a/templates/common/RestApi.common.js +++ b/templates/common/RestApi.common.js @@ -3,27 +3,9 @@ var common = require('./common.js'); exports.transform = function (model) { - var schemas = Object.create(null); - Object.keys(model.schemas || {}).forEach(function (name) { - schemas[name] = model.schemas[name]; - }); - Object.keys(schemas).forEach(function (name) { collectSchemas(schemas[name]); }); - (model.children || []).forEach(function (child) { - if (!(model._preserveLiteralData || child._preserveLiteralData || - model.schemas || child.servers || child.requestUrl || child.requestBody || - (child.parameters || []).some(function (parameter) { return parameter.content; }) || - (child.responses || []).some(function (response) { return response.content; }))) return; - child._hasSchemaDetails = true; - (child.parameters || []).forEach(function (parameter) { - collectSchemas(parameter.schema); - (parameter.content || []).forEach(collectMediaSchemas); - }); - (child.responses || []).forEach(function (response) { - collectSchemas(response.schema); - (response.content || []).forEach(collectMediaSchemas); - }); - ((child.requestBody || {}).content || []).forEach(collectMediaSchemas); - }); + var definitions = Object.create(null); + var references = []; + Object.keys(model.schemas || {}).forEach(function (name) { schemaDetails(model.schemas[name], name); }); var _fileNameWithoutExt = common.path.getFileNameWithoutExtension(model._path); model._jsonPath = _fileNameWithoutExt + ".swagger" + (model.rawExtension === ".yaml" ? ".yaml" : ".json"); model.title = model.title || model.name; @@ -37,9 +19,7 @@ exports.transform = function (model) { if (child.operation) { child.operation = child.operation.toUpperCase(); } - if (!child._hasSchemaDetails) { - child.path = appendQueryParamsToPath(child.path, child.parameters); - } + child.path = child.displayPath || child.path; child.sourceurl = child.sourceurl || common.getViewSourceHref(child, null, model._gitUrlPattern); child.conceptual = child.conceptual || ''; // set to empty incase mustache looks up child.summary = child.summary || ''; // set to empty incase mustache looks up @@ -49,26 +29,13 @@ exports.transform = function (model) { child.htmlId = common.getHtmlId(child.uid); formatExample(child.responses); - if (child._hasSchemaDetails) { - (child.servers || []).forEach(function (server) { server.description = server.description || ''; }); - (child.parameters || []).forEach(function (parameter) { - parameter.hasContent = parameter.content !== undefined && parameter.content !== null; - transformContent(parameter.content); - parameter.schemaDetails = schemaDetails(parameter.schema); - }); - if (child.requestBody) { - child.requestBody.description = child.requestBody.description || ''; - transformContent(child.requestBody.content); - } - (child.responses || []).forEach(function (response) { - response.hasContent = response.content !== undefined && response.content !== null; - transformContent(response.content); - response.schemaDetails = schemaDetails(response.schema); - }); - } else { - resolveAllOf(child); - transformReference(child); + (child.servers || []).forEach(function (server) { server.description = server.description || ''; }); + (child.parameters || []).forEach(transformPayload); + if (child.requestBody) { + child.requestBody.description = child.requestBody.description || ''; + transformContent(child.requestBody.content); } + (child.responses || []).forEach(transformPayload); }; if (!model.tags || model.tags.length === 0) { var childTags = []; @@ -122,32 +89,17 @@ 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) { - if (child._hasSchemaDetails) return; - (child.parameters || []).forEach(function(parameter) { addComplexTypeMetadata(parameter.schema, model.definitions); }); - (child.responses || []).forEach(function(response) { addComplexTypeMetadata(response.schema, model.definitions); }); - }); - }); - } - if (model.children) { - model.children.forEach(function(child) { - if (child._hasSchemaDetails) return; - (child.parameters || []).forEach(function(parameter) { addComplexTypeMetadata(parameter.schema, model.definitions); }); - (child.responses || []).forEach(function(response) { addComplexTypeMetadata(response.schema, model.definitions); }); - }); - } - Object.keys(schemas).forEach(function (name) { - var details = schemaDetails(schemas[name]); - details.id = schemaId(name); - details.name = name; + 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 = ''; } - model.definitions.push({ schemaDetails: details }); + return { schemaDetails: details }; }); return model; @@ -158,33 +110,31 @@ exports.transform = function (model) { }); } - function collectSchemas(schema) { - if (!schema) return; - var name = schema['x-internal-ref-name']; - if (name && !schemas[name]) schemas[name] = schema; - Object.keys(schema.properties || {}).forEach(function (key) { collectSchemas(schema.properties[key]); }); - collectSchemas(schema.items); - (schema.composition || []).forEach(function (composition) { - (composition.schemas || []).forEach(collectSchemas); - }); - } - - function collectMediaSchemas(media) { - collectSchemas(media.schema); - collectSchemas(media.itemSchema); + function transformPayload(payload) { + payload.hasContent = payload.content !== undefined && payload.content !== null; + transformContent(payload.content); + payload.schemaDetails = schemaDetails(payload.schema); + payload.exampleDetails = exampleDetails(payload.examples); } - function schemaDetails(schema) { + function schemaDetails(schema, definitionName) { 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. - return { + var details = {}; + [definitionName, schema['x-internal-ref-name']].forEach(function (registeredName) { + if (registeredName && (registeredName === definitionName || !definitions[registeredName])) { + definitions[registeredName] = { id: schema.referenceId || schemaId(registeredName), 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: name && schemas[name] ? schemaId(name) : '', + referenceId: '', properties: Object.keys(schema.properties || {}).map(function (key) { return { key: key, @@ -194,13 +144,13 @@ exports.transform = function (model) { }; }), items: schemaDetails(schema.items), - composition: (schema.composition || []).map(function (composition) { - return { kind: composition.kind, schemas: (composition.schemas || []).map(schemaDetails) }; + composition: (schema.allOf ? [{ kind: 'All of', schemas: schema.allOf }] : []).concat(schema.composition || []).map(function (composition) { + return { kind: composition.kind, schemas: (composition.schemas || []).map(function (branch) { return schemaDetails(branch); }) }; }), constraints: schema.constraints || [], enum: (schema.enum || []).map(function (value) { return { value: JSON.stringify(value) }; }), - exampleDetails: exampleDetails(schema.examples) - }; + exampleDetails: exampleDetails(schema.examples || (schema.example !== undefined ? [{ content: JSON.stringify(schema.example) }] : [])) + }); } function exampleDetails(examples) { @@ -262,214 +212,7 @@ 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; - - var requiredQueryParams = parameters.filter(function (p) { return p.in === 'query' && p.required; }); - if (requiredQueryParams.length > 0) { - path = formatParams(path, requiredQueryParams, true); - } - - var optionalQueryParams = parameters.filter(function (p) { return p.in === 'query' && !p.required; }); - if (optionalQueryParams.length > 0) { - path += "["; - path = formatParams(path, optionalQueryParams, requiredQueryParams.length === 0); - path += "]"; - } - return path; - } - - function formatParams(path, parameters, isFirst) { - for (var i = 0; i < parameters.length; i++) { - if (i === 0 && isFirst) { - path += "?"; - } else { - path += "&"; - } - path += parameters[i].name; - } - 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 9542de2aeb5..e012c480b7a 100644 --- a/templates/default/partials/rest.child.tmpl.partial +++ b/templates/default/partials/rest.child.tmpl.partial @@ -53,18 +53,6 @@ {{#content}}{{>partials/rest.media-schema}}{{/content}} {{^hasContent}} {{#schemaDetails}}{{>partials/rest.schema}}{{/schemaDetails}} - {{^schemaDetails}} - {{^schema.cType}} - {{schema.type}} - {{#schema.format}} - ({{schema.format}}) - {{/schema.format}} - {{/schema.cType}} - - {{#schema.cType}} - {{{schema.cType}}}{{#schema.cTypeIsArray}}[]{{/schema.cTypeIsArray}} - {{/schema.cType}} - {{/schemaDetails}} {{/hasContent}} {{default}} @@ -107,27 +95,13 @@ {{#content}}{{>partials/rest.media-schema}}{{/content}} {{^hasContent}} {{#schemaDetails}}{{>partials/rest.schema}}{{/schemaDetails}} - {{^schemaDetails}} - {{^schema.cType}} - {{schema.type}} - {{/schema.cType}} - - {{#schema.cType}} - {{{schema.cType}}}{{#schema.cTypeIsArray}}[]{{/schema.cTypeIsArray}} - {{/schema.cType}} - {{/schemaDetails}} {{/hasContent}} {{{description}}} {{#content}}{{>partials/rest.examples}}{{/content}} {{^hasContent}} - {{#examples}} -
- Mime type: {{mimeType}} -
-
{{content}}
- {{/examples}} + {{>partials/rest.examples}} {{/hasContent}} diff --git a/templates/default/partials/rest.definition.tmpl.partial b/templates/default/partials/rest.definition.tmpl.partial index 97ab63ff6de..f3f244ce101 100644 --- a/templates/default/partials/rest.definition.tmpl.partial +++ b/templates/default/partials/rest.definition.tmpl.partial @@ -4,48 +4,3 @@

{{name}}

{{>partials/rest.schema}} {{/schemaDetails}} -{{^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}} -{{/schemaDetails}} diff --git a/templates/modern/src/rest.test.ts b/templates/modern/src/rest.test.ts index 6beb8829942..682ade42062 100644 --- a/templates/modern/src/rest.test.ts +++ b/templates/modern/src/rest.test.ts @@ -30,7 +30,7 @@ test('REST raw filename hints preserve JSON compatibility and identify original assert.equal(yaml._raw, 'openapi: 3.1.0\n') }) -test('REST preserves legacy parameter paths, allOf flattening, and definitions', () => { +test('REST uses adapter display paths and renders allOf through the shared schema partial', () => { const model = rest.transform({ uid: 'legacy', _path: 'legacy.json', @@ -38,6 +38,7 @@ test('REST preserves legacy parameter paths, allOf flattening, and definitions', uid: 'get', operation: 'get', path: '/items', + displayPath: '/items?filter[&limit]', parameters: [ { name: 'filter', in: 'query', required: true, schema: { type: 'string' } }, { name: 'limit', in: 'query', schema: { type: 'integer' } } @@ -45,6 +46,7 @@ test('REST preserves legacy parameter paths, allOf flattening, and definitions', responses: [{ schema: { 'x-internal-ref-name': 'Item', + referenceId: 'Item', allOf: [{ properties: { id: { type: 'integer' } } }, { properties: { name: { type: 'string' } } }] }, examples: [{ mimeType: 'application/json', content: '{"id":1}' }] @@ -54,13 +56,13 @@ test('REST preserves legacy parameter paths, allOf flattening, and definitions', const child = model.children[0] assert.equal(child.operation, 'GET') assert.equal(child.path, '/items?filter[&limit]') - assert.equal(child._hasSchemaDetails, undefined) assert.equal(child.responses[0].examples[0].content, '{\n "id": 1\n}') - assert.equal(child.responses[0].schema.cTypeId, 'Item') - assert.deepEqual(child.responses[0].schema.properties.map(property => property.key), ['id', 'name']) - assert.equal(child.responses[0].schema.allOf, undefined) + const details = child.responses[0].schemaDetails + assert.equal(details.referenceId, 'Item') + assert.deepEqual(details.composition[0].schemas.flatMap(schema => schema.properties.map(property => property.key)), ['id', 'name']) + assert.equal(child.responses[0].schema.allOf.length, 2) assert.equal(model.definitions.length, 1) - assert.equal(model.definitions[0].schemaDetails, undefined) + assert.equal(model.definitions[0].schemaDetails.id, 'Item') }) test('REST prepares every request and response media schema and named example', () => { @@ -302,8 +304,8 @@ test('REST displays external example URLs without inventing content or linking e assert.ok(examples.every(example => example.name === 'external' && example.content === '' && !example.hasContent)) }) -for (const flagLocation of ['root', 'operation']) { - test(`REST preserves literal enum, examples, and extensions with the ${flagLocation} feature flag`, () => { +for (const specificationVersion of ['2.0', '3.0.3', '3.1.0', '3.2.0']) { + test(`REST preserves literal enum, examples, and extensions for specification ${specificationVersion}`, () => { const literal = { description: 'literal **description**, not markup', allOf: [{ type: 'string' }, { properties: { literal: { type: 'integer' } } }], @@ -324,7 +326,6 @@ for (const flagLocation of ['root', 'operation']) { const operation = { uid: 'read', path: '/literal', - _preserveLiteralData: flagLocation === 'operation', parameters: [{ name: 'filter', in: 'query', required: true, schema }], responses: [{ schema: { type: 'object', enum: [structuredClone(literal)] }, @@ -335,11 +336,10 @@ for (const flagLocation of ['root', 'operation']) { const model = rest.transform({ uid: 'literal', _path: 'literal.json', - _preserveLiteralData: flagLocation === 'root', + specificationVersion, 'x-root': structuredClone(literal), children: [operation] }) - assert.equal(operation._hasSchemaDetails, true) assert.equal(operation.path, '/literal') assert.deepEqual(schema, originalSchema) assert.deepEqual(model['x-root'], original) @@ -352,3 +352,39 @@ for (const flagLocation of ['root', 'operation']) { assert.deepEqual(model.definitions, []) }) } + +test('REST registers an external alias and its recursive target during projection', () => { + const model = rest.transform({ + uid: 'alias', + _path: 'alias.json', + schemas: { + Alias: { + type: 'object', + 'x-internal-ref-name': 'external.yaml#Node', + properties: { next: { 'x-internal-loop-ref-name': 'external.yaml#Node' } } + } + } + }) + const [alias, target] = model.definitions.map(definition => definition.schemaDetails) + assert.notEqual(alias.id, target.id) + assert.equal(alias.referenceId, target.id) + assert.equal(target.properties[0].value.referenceId, target.id) + assert.equal(target.referenceName, '') +}) + +test('REST uses declared definitions when an earlier alias supplies reference siblings', () => { + const model = rest.transform({ + uid: 'siblings', + _path: 'siblings.json', + schemas: { + Alias: { 'x-internal-ref-name': 'Target', constraints: [{ name: 'maxLength', value: '5' }] }, + Target: { type: 'string', constraints: [{ name: 'maxLength', value: '10' }] } + } + }) + const definitions = model.definitions.map(definition => definition.schemaDetails) + const alias = definitions.find(definition => definition.name === 'Alias') + const target = definitions.find(definition => definition.name === 'Target') + assert.equal(alias.referenceId, target.id) + assert.equal(alias.constraints[0].value, '5') + assert.equal(target.constraints[0].value, '10') +}) diff --git a/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs b/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs index 425f207973d..435fba6ad8b 100644 --- a/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs +++ b/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs @@ -35,9 +35,9 @@ public void OpenApi32MapsAdditionalMethodsStreamingAndExamples(string format) "schemas":{"Event":{"type":"object","properties":{"id":{"const":42},"anything":true,"never":false}}} }} """, format); - Assert.Equal("3.2.0", model.Metadata["openapi"]); + Assert.Equal("3.2.0", model.SpecificationVersion); Assert.Equal(new[] { "query", "copy" }, model.Children.Select(child => child.OperationName)); - var content = (JArray)Assert.Single(model.Children[0].Responses).Metadata["content"]; + var content = JArray.FromObject(Assert.Single(model.Children[0].Responses).Content); var item = content[0]["itemSchema"]; Assert.Equal("Event", item["x-internal-ref-name"]); Assert.Equal("42", item["properties"]["id"]["constraints"][0]["value"]); @@ -80,7 +80,7 @@ public void NormalizesYamlBlocksAndAliasesWithoutChangingLiteralData() Assert.Equal(raw, model.Raw); Assert.Equal(42, ((JObject)model.Metadata["x-literal"])["schema"]["const"]); Assert.Equal(false, model.Metadata["x-boolean"]); - var schemas = (JObject)model.Metadata["schemas"]; + var schemas = JObject.FromObject(model.Schemas); Assert.Equal("After the constant", schemas["Object"]["description"]); Assert.Equal("{\"schema\":{\"const\":42},\"flag\":false}", schemas["Object"]["constraints"][0]["value"]); Assert.Equal("[42,null,\"false\"]", schemas["Array"]["constraints"][0]["value"]); @@ -115,8 +115,8 @@ public void OpenApi32ResolvesExternalMediaTypesAndStreamItemSchemas() "components":{"schemas":{"Event":{"type":"object","properties":{"id":{"const":42},"never":false}}}}} """, folder); var model = OpenApiDocumentReader.Read(entry); - var content = (JArray)Assert.Single(Assert.Single(model.Children).Responses).Metadata["content"]; - var branches = content[0]["itemSchema"]["composition"][0]["schemas"]; + var content = JArray.FromObject(Assert.Single(Assert.Single(model.Children).Responses).Content); + var branches = content[0]["itemSchema"]["allOf"]; Assert.Equal("42", branches[0]["properties"]["id"]["constraints"][0]["value"]); Assert.Equal("no value", branches[0]["properties"]["never"]["type"]); Assert.Equal("{\"id\":42}", branches[1]["constraints"][0]["value"]); @@ -188,24 +188,24 @@ public void MapsTypedParametersBodiesResponsesAndLiteralExamples(string version) var child = Assert.Single(model.Children); Assert.Equal(model.Uid + "/createItem", child.Uid); Assert.Equal("docs", child.Metadata["x-owner"]?.ToString()); - Assert.Equal("https://api.example.test/v1/items/{id}", child.Metadata["requestUrl"]); + Assert.Equal("https://api.example.test/v1/items/{id}", child.RequestUrl); Assert.Equal(["limit", "id"], child.Parameters.Select(p => p.Name)); - Assert.Equal("integer", ((JObject)child.Parameters[0].Metadata["schema"])["type"]); + Assert.Equal("integer", (JObject.FromObject(child.Parameters[0].Schema))["type"]); Assert.Equal("0", child.Parameters[0].Metadata["default"]?.ToString()); Assert.Equal(model.Uid + "/tag/items", Assert.Single(model.Tags).Uid); - var body = (JObject)child.Metadata["requestBody"]; + var body = JObject.FromObject(child.RequestBody); Assert.True((bool)body["required"]); Assert.Equal("application/json", body["content"][0]["mimeType"]); var schema = body["content"][0]["schema"]; Assert.Equal("string", schema["properties"]["name"]["type"]); Assert.NotNull(schema["properties"]["next"]["x-internal-loop-ref-name"]); var response = Assert.Single(child.Responses); - var content = (JArray)response.Metadata["content"]; + var content = JArray.FromObject(response.Content); Assert.Equal(["application/json", "text/plain"], content.Select(c => (string)c["mimeType"])); var example = JObject.Parse((string)content[0]["examples"][0]["content"]); Assert.Equal("this-is-payload.json", example["$ref"]); Assert.Equal("**literal**", example["description"]); - Assert.Equal(2, response.Examples.Count); + Assert.Equal(2, response.Content.Sum(media => media.Examples.Count)); } [Theory] @@ -229,7 +229,7 @@ public void YamlUsesTheSameModelsAndDefaults(string version) Assert.Equal("YAML API/1", model.Uid); var operation = Assert.Single(model.Children); Assert.StartsWith("get_", operation.OperationId); - Assert.Equal("/health", operation.Metadata["requestUrl"]); + Assert.Equal("/health", operation.RequestUrl); Assert.Equal("204", Assert.Single(operation.Responses).HttpStatusCode); } @@ -254,7 +254,7 @@ static RestApiRootItemViewModel Read(string paths) => OpenApiDocumentReader.Pars var first = Read(paths); var second = Read("\"/unrelated\": {\"get\":{\"responses\":{\"200\":{\"description\":\"OK\"}}}}," + paths); Assert.Equal(["/path-base/path", "https://override.example.test/v2/path", "https://root.example.test/root/root"], - first.Children.Select(child => child.Metadata["requestUrl"])); + first.Children.Select(child => child.RequestUrl)); Assert.Equal(first.Children.Select(child => child.OperationId), second.Children.Skip(1).Select(child => child.OperationId)); Assert.All(first.Children, child => Assert.DoesNotContain("/", child.OperationId)); } @@ -278,18 +278,17 @@ public void BooleanUnionCompositionAndRefSiblingsAreNotFlattened() }} } """, "json"); - var schemas = (JObject)model.Metadata["schemas"]; - var content = (JArray)Assert.Single(Assert.Single(model.Children).Responses).Metadata["content"]; + var schemas = JObject.FromObject(model.Schemas); + var content = JArray.FromObject(Assert.Single(Assert.Single(model.Children).Responses).Content); Assert.Equal("any value", content[0]["schema"]["type"]); Assert.Equal("no value", content[1]["schema"]["type"]); Assert.Contains("string", (string)schemas["Nullable"]["type"]); Assert.Contains("null", (string)schemas["Nullable"]["type"]); Assert.Equal("sibling", schemas["Sibling"]["description"]); - var siblings = schemas["Sibling"]["composition"][0]["schemas"]; + var siblings = schemas["Sibling"]["allOf"]; Assert.Equal("10", siblings[0]["constraints"][0]["value"]); Assert.Equal("5", siblings[1]["constraints"][0]["value"]); - Assert.Equal("All of", schemas["Intersection"]["composition"][0]["kind"]); - Assert.Equal(["string", "integer"], schemas["Intersection"]["composition"][0]["schemas"].Select(s => (string)s["type"])); + Assert.Equal(["string", "integer"], schemas["Intersection"]["allOf"].Select(s => (string)s["type"])); Assert.Equal("One of", schemas["Choice"]["composition"][0]["kind"]); Assert.Null(schemas["Intersection"]["properties"]); } @@ -314,13 +313,13 @@ public void PreservesBooleanSchemasInMapsAndCompositions(string boolean) var model = OpenApiDocumentReader.Parse( """{"openapi":"3.1.0","info":{"title":"Boolean","version":"1"},"paths":{},"components":{"schemas":{"Value":SCHEMA}}}""" .Replace("SCHEMA", schema), "json"); - var value = ((JObject)model.Metadata["schemas"])["Value"]; + var value = (JObject.FromObject(model.Schemas))["Value"]; if (schema == boolean) Assert.Equal(boolean == "true" ? "any value" : "no value", value["type"]); else if (schema.Contains("properties")) Assert.Equal(boolean == "true" ? "any value" : "no value", value["properties"]["value"]["type"]); else if (schema.Contains("Of")) - Assert.Equal(boolean == "true" ? "any value" : "no value", value["composition"][0]["schemas"][0]["type"]); + Assert.Equal(boolean == "true" ? "any value" : "no value", (value["allOf"] ?? value["composition"][0]["schemas"])[0]["type"]); else Assert.Contains(boolean == "true" ? "{}" : "\"not\":{}", (string)value["constraints"][0]["value"]); } @@ -355,12 +354,12 @@ public void PreservesBooleanSchemasInExternalDocuments(string boolean, string po Value: {{schema}} """, folder); var model = OpenApiDocumentReader.Read(entry); - var value = ((JObject)model.Metadata["schemas"])["Value"]; + var value = (JObject.FromObject(model.Schemas))["Value"]; var actual = position switch { "component" => value, "properties" => value["properties"]["value"], - _ => value["composition"][0]["schemas"][0] + _ => value["allOf"][0] }; Assert.Equal(boolean == "true" ? "any value" : "no value", actual["type"]); } @@ -379,7 +378,7 @@ public void SchemaShapedLiteralExamplesAndExtensionsAreNotPreflighted() } """, "json"); Assert.NotNull(model.Metadata["x-data"]); - var example = Assert.Single(Assert.Single(Assert.Single(model.Children).Responses).Examples); + var example = Assert.Single(Assert.Single(Assert.Single(Assert.Single(model.Children).Responses).Content).Examples); Assert.Contains("false", example.Content); Assert.Contains("true", example.Content); Assert.Equal(42, (int)JObject.Parse(example.Content)["schema"]["const"]); @@ -394,7 +393,7 @@ public void PreservesSingularSchemaExamplesFromOpenApi30() "components":{"schemas":{"Value":{"type":"object","example":{"description":"**literal**","$ref":"payload"}}}} } """, "json"); - var schema = ((JObject)model.Metadata["schemas"])["Value"]; + var schema = (JObject.FromObject(model.Schemas))["Value"]; var example = JObject.Parse((string)schema["examples"][0]["content"]); Assert.Equal("**literal**", example["description"]); Assert.Equal("payload", example["$ref"]); @@ -411,7 +410,7 @@ public void PreservesTypedConstValues(string format) {"openapi":"3.1.0","info":{"title":"Constants","version":"1"},"paths":{}, "components":{"schemas":{"Value":{"const":VALUE,"enum":[1,2]}}}} """.Replace("VALUE", value), format); - var schema = ((JObject)model.Metadata["schemas"])["Value"]; + var schema = (JObject.FromObject(model.Schemas))["Value"]; Assert.Equal(value, (string)Assert.Single(schema["constraints"])["value"]); Assert.Equal(new[] { 1, 2 }, schema["enum"].Values()); } @@ -451,7 +450,7 @@ public void PreservesStringAndNullConstantsAndExplicitNullDefaults(string format {"openapi":"3.1.0","info":{"title":"Constants","version":"1"},"paths":{}, "components":{"schemas":{"Value":{"const":VALUE,"default":null}}}} """.Replace("VALUE", value), format); - var constraints = ((JObject)model.Metadata["schemas"])["Value"]["constraints"]; + var constraints = (JObject.FromObject(model.Schemas))["Value"]["constraints"]; Assert.Equal(value, (string)Assert.Single(constraints, item => (string)item["name"] == "const")["value"]); Assert.Equal("null", (string)Assert.Single(constraints, item => (string)item["name"] == "default")["value"]); } @@ -478,7 +477,7 @@ public void PreservesYamlStringAndNullConstants(string value, string expected) Value: const: {{value}} """, "yaml"); - var constraints = ((JObject)model.Metadata["schemas"])["Value"]["constraints"]; + var constraints = (JObject.FromObject(model.Schemas))["Value"]["constraints"]; Assert.Equal(expected, (string)Assert.Single(constraints)["value"]); } @@ -497,7 +496,7 @@ public void PreservesImplicitYamlNullValues(string version, string keyword) Value: {{keyword}}: """, "yaml"); - var schema = ((JObject)model.Metadata["schemas"])["Value"]; + var schema = (JObject.FromObject(model.Schemas))["Value"]; Assert.Equal("null", (string)Assert.Single(schema["constraints"])["value"]); } @@ -513,7 +512,7 @@ public void ChecksSchemasInNamedParameters(string name) "paths":{"/items":{"get":{"parameters":[{"$ref":"#/components/parameters/NAME"}],"responses":{"200":{"description":"OK"}}}}}, "components":{"parameters":{"NAME":{"name":"q","in":"query","schema":{"const":42}}}}} """.Replace("NAME", name), "json"); - var schema = (JObject)Assert.Single(Assert.Single(model.Children).Parameters).Metadata["schema"]; + var schema = JObject.FromObject(Assert.Single(Assert.Single(model.Children).Parameters).Schema); Assert.Equal("42", (string)Assert.Single(schema["constraints"])["value"]); } @@ -538,7 +537,7 @@ public void PreservesConstInExternalSchemas(string schema, string expected) Value: {{schema}} """, folder); var model = OpenApiDocumentReader.Read(entry); - var value = ((JObject)model.Metadata["schemas"])["Value"]; + var value = (JObject.FromObject(model.Schemas))["Value"]; var constraint = Assert.Single(value.SelectTokens("$..constraints[*]"), item => (string)item["name"] == "const"); Assert.Equal(expected, constraint["value"]); } @@ -553,7 +552,7 @@ public void PreservesConstInInlineResponseSchemas(string status) "paths":{"/items":{"get":{"responses":{"STATUS":{"description":"OK", "content":{"application/json":{"schema":{"const":42}}}}}}}}} """.Replace("STATUS", status), "json"); - var content = (JArray)Assert.Single(Assert.Single(model.Children).Responses).Metadata["content"]; + var content = JArray.FromObject(Assert.Single(Assert.Single(model.Children).Responses).Content); Assert.Equal("42", (string)Assert.Single(content[0]["schema"]["constraints"])["value"]); } @@ -581,7 +580,7 @@ public void DoesNotTurnExclusiveOverlappingAlternativesIntoInclusiveUnions(strin continue; } var model = OpenApiDocumentReader.Parse(raw, "json"); - var value = ((JObject)model.Metadata["schemas"])["Value"]; + var value = (JObject.FromObject(model.Schemas))["Value"]; Assert.Equal("One of", value["composition"][0]["kind"]); Assert.Equal(2, value["composition"][0]["schemas"].Count()); } @@ -651,7 +650,7 @@ public void ResolvesLocalMixedFormatDocumentsAndNestedReferences() """, folder); var model = OpenApiDocumentReader.Read(entry); var response = Assert.Single(Assert.Single(model.Children).Responses); - var schema = ((JArray)response.Metadata["content"])[0]["schema"]; + var schema = (JArray.FromObject(response.Content))[0]["schema"]; Assert.Equal("string", schema["properties"]["name"]["type"]); Assert.Equal("From YAML", schema["properties"]["name"]["description"]); } @@ -676,7 +675,7 @@ public void LoadsMixedFormatBidirectionalReferencesOnceWithoutExpandingCycles() a: { $ref: 'a.json#/components/schemas/A' } """, folder); var model = OpenApiDocumentReader.Read(entry); - var schemas = (JObject)model.Metadata["schemas"]; + var schemas = JObject.FromObject(model.Schemas); Assert.Equal("object", schemas["A"]["properties"]["b"]["type"]); Assert.Equal("A", schemas["A"]["properties"]["b"]["properties"]["a"]["x-internal-loop-ref-name"]); } @@ -708,7 +707,7 @@ public void SameRelativeFilenameInDifferentDirectoriesHasDistinctSdkIdentity() """.Replace("TYPE", type), folder); } var model = OpenApiDocumentReader.Read(entry); - var schemas = (JObject)model.Metadata["schemas"]; + var schemas = JObject.FromObject(model.Schemas); Assert.Equal("string", schemas["A"]["type"]); Assert.Equal("integer", schemas["B"]["type"]); Assert.NotEqual((string)schemas["A"]["x-internal-ref-name"], (string)schemas["B"]["x-internal-ref-name"]); diff --git a/test/Docfx.Build.RestApi.Tests/RestApiDocumentProcessorTest.cs b/test/Docfx.Build.RestApi.Tests/RestApiDocumentProcessorTest.cs index e2dcb241d03..b5a07aab261 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 diff --git a/test/Docfx.Build.RestApi.Tests/RestApiDocumentReaderTest.cs b/test/Docfx.Build.RestApi.Tests/RestApiDocumentReaderTest.cs new file mode 100644 index 00000000000..3da8fda0f70 --- /dev/null +++ b/test/Docfx.Build.RestApi.Tests/RestApiDocumentReaderTest.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Docfx.DataContracts.RestApi; +using Docfx.Common.EntityMergers; +using Docfx.Tests.Common; +using Xunit; + +namespace Docfx.Build.RestApi.Tests; + +[Collection("docfx STA")] +public class RestApiDocumentReaderTest : TestBase +{ + [Theory] + [InlineData("json", "{\"info\":{\"openapi\":\"3.2.0\"}}", null, false)] + [InlineData("yaml", "info: {openapi: 3.2.0}", null, false)] + [InlineData("json", "{\"info\":{\"version\":\"2.0\"},\"openapi\":\"3.2.0\"}", "3.2.0", false)] + [InlineData("yaml", "info: {version: '2.0'}\nopenapi: '3.1.0'", "3.1.0", false)] + [InlineData("json", "{\"swagger\":\"2.0\"}", "2.0", true)] + [InlineData("json", "{\"openapi\":\"2.0\"}", "2.0", false)] + [InlineData("yaml", "swagger: '2.0'", null, false)] + public void IdentifiesOnlyRootSpecificationMarkers(string format, string source, string version, bool swagger) + { + var header = RestApiDocumentReader.ReadHeader(new StringReader(source), format); + Assert.Equal(version, header?.Version); + Assert.Equal(swagger, header?.IsSwagger ?? false); + } + + [Fact] + public void MalformedHeaderUsesTheReaderDiagnostic() + { + Assert.Throws(() => OpenApiDocumentReader.Parse("{\"info\":]", "json")); + } + + [Theory] + [InlineData("2.0")] + [InlineData("3.0.3")] + [InlineData("3.1.0")] + [InlineData("3.2.0")] + public void ReadersProduceTheSameSchemaContract(string version) + { + var source = version == "2.0" ? """ + {"swagger":"2.0","info":{"title":"Common","version":"service-version"}, + "paths":{"/items":{"get":{"operationId":"getItems","parameters":[ + {"name":"filter","in":"query","type":"string"}], + "responses":{"200":{"description":"OK","schema":{"allOf":[ + {"type":"object","properties":{"name":{"type":"string"}}}]}}}}}}} + """ : """ + {"openapi":"VERSION","info":{"title":"Common","version":"service-version"}, + "paths":{"/items":{"get":{"operationId":"getItems","parameters":[ + {"name":"filter","in":"query","schema":{"type":"string"}}], + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"allOf":[ + {"type":"object","properties":{"name":{"type":"string"}}}]}}}}}}}}} + """.Replace("VERSION", version); + var file = CreateFile("api.json", source, GetRandomFolder()); + Assert.True(RestApiDocumentReader.IsSupportedFile(file)); + var model = RestApiDocumentReader.Read(file, "api.json"); + Assert.Equal(version, model.SpecificationVersion); + Assert.Equal("service-version", model.Info.Version); + var operation = Assert.Single(model.Children); + var parameter = Assert.Single(operation.Parameters); + Assert.Equal("string", parameter.Schema.Type); + Assert.DoesNotContain("schema", parameter.Metadata.Keys); + var response = Assert.Single(operation.Responses); + var schema = response.Schema ?? Assert.Single(response.Content).Schema; + Assert.Equal("string", Assert.Single(schema.AllOf).Properties["name"].Type); + Assert.DoesNotContain("content", response.Metadata.Keys); + } + + [Fact] + public void RequestBodyOverwritePreservesUnchangedMediaAndAcceptsFalse() + { + var body = new RestApiRequestBodyViewModel + { + Required = true, + Content = [ + new() { MimeType = "application/json", Schema = new() { Description = "JSON" } }, + new() { MimeType = "text/plain", Schema = new() { Description = "Text" } }] + }; + var merger = new MergerFacade(new KeyedListMerger(new ReflectionEntityMerger())); + merger.Merge(ref body, new RestApiRequestBodyViewModel { Description = "Body" }); + Assert.True(body.Required); + merger.Merge(ref body, new RestApiRequestBodyViewModel + { + Required = false, + Content = [null, new() { Schema = new() { Description = "Updated text" } }] + }); + Assert.False(body.Required); + Assert.Equal("JSON", body.Content[0].Schema.Description); + Assert.Equal("Updated text", body.Content[1].Schema.Description); + Assert.Equal("text/plain", body.Content[1].MimeType); + } + + [Fact] + public void SplitDocumentContextIsIndependentAndPreservesOverrides() + { + var root = new RestApiRootItemViewModel + { + SpecificationVersion = "3.2.0", + Schemas = new() { ["Item"] = new() { Description = "**Item**" } }, + Servers = [new() { Url = "/root" }], + ExternalDocs = new() { Url = "https://example.test/root" } + }; + var split = new RestApiRootItemViewModel + { + Servers = [new() { Url = "/operation" }], + Metadata = new() { ["externalDocs"] = new { url = "https://example.test/tag" } } + }; + root.CopyDocumentContextTo(split); + Assert.Equal("3.2.0", split.SpecificationVersion); + Assert.Equal("/operation", Assert.Single(split.Servers).Url); + Assert.Equal("https://example.test/tag", split.ExternalDocs.Url); + split.Schemas["Item"].Description = "

Item

"; + Assert.Equal("**Item**", root.Schemas["Item"].Description); + } +} diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs b/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs index 15b45a01556..90be5984c8b 100644 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs @@ -21,6 +21,52 @@ public class OpenApiOutputTest : TestBase private const string RootUid = "api.example.test/v1/SDK API/1.0"; private const string RootHtmlId = "api_example_test_v1_SDK_API_1_0"; + [Fact] + public void BuildsRequestBodyOverwriteWithNullMediaPlaceholderAndFalseRequired() + { + var input = GetRandomFolder(); + var service = CreateFile("overwrite.yaml", """ + openapi: 3.2.0 + info: {title: Overwrite, version: '1'} + paths: + /items: + post: + operationId: write + requestBody: + required: true + content: + application/json: + schema: {type: string, description: '**JSON**'} + text/plain: + schema: {type: string, description: '**Text**'} + responses: + '204': {description: OK} + """, input); + var overwrite = CreateFile("body.md", """ + --- + uid: Overwrite/1/write + requestBody: + required: false + content: + - null + - schema: + description: '**Updated** text' + --- + """, input); + var files = new FileCollection(Directory.GetCurrentDirectory()); + files.Add(DocumentType.Article, [service], input); + files.Add(DocumentType.Overwrite, [overwrite], input); + var output = Build(input, files, "default", false, false); + var body = Assert.Single(ReadModel(output, "overwrite.raw.json")["children"])["requestBody"]; + Assert.False((bool)body["required"]); + Assert.Equal("application/json", (string)body["content"][0]["mimeType"]); + Assert.Equal("text/plain", (string)body["content"][1]["mimeType"]); + var html = ReadHtml(output, "overwrite.html").SelectSingleNode("//div[@class='request-body']"); + Assert.Contains("Optional", html.InnerText); + Assert.NotNull(html.SelectSingleNode(".//strong[text()='JSON']")); + Assert.NotNull(html.SelectSingleNode(".//strong[text()='Updated']")); + } + [Theory] [InlineData("default")] [InlineData("statictoc")] @@ -335,7 +381,9 @@ string OperationPage(string id) } else { - var composition = Assert.Single(propertySchema["composition"]); + var composition = propertySchema["allOf"] is { } allOf + ? new JObject { ["kind"] = "All of", ["schemas"] = allOf.DeepClone() } + : Assert.Single(propertySchema["composition"]); Assert.Equal(kind, (string)composition["kind"]); Assert.Equal(property == "excluded" ? 1 : 2, composition["schemas"].Count()); Assert.Equal(kind, (string)Assert.Single(details["composition"])["kind"]); 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..d951e6b944e 100644 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs @@ -336,12 +336,15 @@ 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']")); var listPage = splitTags ? "service/items" : "service"; if (splitOperations) { @@ -355,8 +358,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.']")); 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 42417175d05942531302b265059d5436e8c68077 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Thu, 24 Sep 2026 21:24:54 +1000 Subject: [PATCH 11/16] Preserve existing names and formatting in REST API build code --- .../BuildRestApiDocument.cs | 29 ++++++++++--------- .../RestApiDocumentProcessor.cs | 6 ++-- .../SwaggerModelConverter.cs | 2 +- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/Docfx.Build.RestApi/BuildRestApiDocument.cs b/src/Docfx.Build.RestApi/BuildRestApiDocument.cs index 0a35792106b..4082960b087 100644 --- a/src/Docfx.Build.RestApi/BuildRestApiDocument.cs +++ b/src/Docfx.Build.RestApi/BuildRestApiDocument.cs @@ -45,32 +45,33 @@ public static RestApiItemViewModelBase BuildItem(IHostService host, RestApiItemV item.Remarks = Markup(host, item.Remarks, model, filter); } - if (item is RestApiRootItemViewModel root) + if (item is RestApiRootItemViewModel rootModel) { - if (root.Info != null) root.Info.Description = Markup(host, root.Info.Description, model, filter); - if (root.ExternalDocs != null) root.ExternalDocs.Description = Markup(host, root.ExternalDocs.Description, model, filter); - foreach (var security in root.SecurityDefinitions?.Values.AsEnumerable() ?? []) + 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() ?? []) { if (security != null) security.Description = Markup(host, security.Description, model, filter); } - MarkupServers(root.Servers); - foreach (var schema in root.Schemas?.Values.AsEnumerable() ?? []) MarkupSchema(schema); + MarkupServers(rootModel.Servers); + foreach (var schema in rootModel.Schemas?.Values.AsEnumerable() ?? []) MarkupSchema(schema); } - if (item is RestApiChildItemViewModel child) + + if (item is RestApiChildItemViewModel childModel) { - MarkupServers(child.Servers); - if (child.RequestBody is { } body) + MarkupServers(childModel.Servers); + if (childModel.RequestBody is { } body) { body.Description = Markup(host, body.Description, model, filter); MarkupContent(body.Content); } - foreach (var parameter in child.Parameters ?? []) + foreach (var param in childModel.Parameters ?? []) { - parameter.Description = Markup(host, parameter.Description, model, filter); - MarkupSchema(parameter.Schema); - MarkupContent(parameter.Content); + param.Description = Markup(host, param.Description, model, filter); + MarkupSchema(param.Schema); + MarkupContent(param.Content); } - foreach (var response in child.Responses ?? []) + foreach (var response in childModel.Responses ?? []) { response.Description = Markup(host, response.Description, model, filter); MarkupSchema(response.Schema); diff --git a/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs b/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs index 0badaf18a5a..06ecf5e39b8 100644 --- a/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs +++ b/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs @@ -198,8 +198,10 @@ private static IEnumerable GetXRefInfo(RestApiRootItemViewModel rootIt } } - private static bool IsSupportedFileEnding(string filePath, string fileEnding) => - filePath.EndsWith(fileEnding, StringComparison.OrdinalIgnoreCase); + private static bool IsSupportedFileEnding(string filePath, string fileEnding) + { + return filePath.EndsWith(fileEnding, StringComparison.OrdinalIgnoreCase); + } private static string ChangeFileExtension(string file) { diff --git a/src/Docfx.Build.RestApi/SwaggerModelConverter.cs b/src/Docfx.Build.RestApi/SwaggerModelConverter.cs index 2a97b5ff2b0..36786a4e93c 100644 --- a/src/Docfx.Build.RestApi/SwaggerModelConverter.cs +++ b/src/Docfx.Build.RestApi/SwaggerModelConverter.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using Docfx.Build.RestApi.Swagger; From 19cd7ab9b47eae95cefcfb25e851d29b4c093007 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Thu, 24 Sep 2026 21:38:30 +1000 Subject: [PATCH 12/16] Keep Swagger conversion in the existing converter --- .../OpenApi2ModelConverter.cs | 210 ------------------ .../RestApiDocumentReader.cs | 2 +- .../SwaggerModelConverter.cs | 202 ++++++++++++++++- 3 files changed, 200 insertions(+), 214 deletions(-) delete mode 100644 src/Docfx.Build.RestApi/OpenApi2ModelConverter.cs diff --git a/src/Docfx.Build.RestApi/OpenApi2ModelConverter.cs b/src/Docfx.Build.RestApi/OpenApi2ModelConverter.cs deleted file mode 100644 index a6507393fc5..00000000000 --- a/src/Docfx.Build.RestApi/OpenApi2ModelConverter.cs +++ /dev/null @@ -1,210 +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.Build.RestApi.Swagger; -using Docfx.Common; -using Docfx.DataContracts.Common; -using Docfx.DataContracts.RestApi; - -using Newtonsoft.Json.Linq; - -using static Docfx.Build.RestApi.RestApiModelUtility; - -namespace Docfx.Build.RestApi; - -internal static class OpenApi2ModelConverter -{ - internal static RestApiRootItemViewModel ConvertLegacy(SwaggerModel swagger) - { - var uid = GetUid(swagger); - var vm = new RestApiRootItemViewModel - { - Name = swagger.Info.Title, - Uid = uid, - HtmlId = GetHtmlId(uid), - Metadata = swagger.Metadata, - Description = swagger.Description, - Summary = swagger.Summary, - Children = [], - Raw = swagger.Raw, - Tags = [] - }; - if (swagger.Tags != null) - { - foreach (var tag in swagger.Tags) - { - vm.Tags.Add(new RestApiTagViewModel - { - Name = tag.Name, - Description = tag.Description, - HtmlId = string.IsNullOrEmpty(tag.BookmarkId) ? GetHtmlId(tag.Name) : tag.BookmarkId, // Fall back to tag name's html id - Metadata = tag.Metadata, - Uid = GetUidForTag(uid, tag) - }); - } - } - if (swagger.Paths != null) - { - foreach (var path in swagger.Paths) - { - var commonParameters = path.Value.Parameters; - foreach (var op in path.Value.Metadata) - { - // fetch operations from metadata - if (OperationNames.Contains(op.Key, StringComparer.OrdinalIgnoreCase)) - { - if (op.Value is not JObject opJObject) - { - throw new InvalidOperationException($"Value of {op.Key} should be JObject"); - } - - // convert operation from JObject to OperationObject - var operation = opJObject.ToObject(); - var parameters = GetParametersForOperation(operation.Parameters, commonParameters); - var itemUid = GetUidForOperation(uid, operation); - var itemVm = new RestApiChildItemViewModel - { - Path = path.Key, - OperationName = op.Key, - Tags = operation.Tags, - OperationId = operation.OperationId, - HtmlId = GetHtmlId(itemUid), - Uid = itemUid, - Metadata = operation.Metadata, - Description = operation.Description, - Summary = operation.Summary, - Parameters = parameters?.Select(s => new RestApiParameterViewModel - { - Description = s.Description, - Name = s.Name, - Metadata = s.Metadata - }).ToList(), - Responses = operation.Responses?.Select(s => new RestApiResponseViewModel - { - Metadata = s.Value.Metadata, - Description = s.Value.Description, - Summary = s.Value.Summary, - HttpStatusCode = s.Key, - Examples = s.Value.Examples?.Select(example => new RestApiResponseExampleViewModel - { - MimeType = example.Key, - Content = example.Value != null ? JsonUtility.Serialize(example.Value) : null, - }).ToList(), - }).ToList(), - }; - - // TODO: line number - itemVm.Metadata[Constants.PropertyName.Source] = swagger.Metadata.GetValueOrDefault(Constants.PropertyName.Source); - vm.Children.Add(itemVm); - } - } - } - } - - return vm; - } - - internal static RestApiRootItemViewModel Convert(SwaggerModel swagger) - { - var model = ConvertLegacy(swagger); - model.SpecificationVersion = "2.0"; - model.SecurityDefinitions = Take>(model.Metadata, "securityDefinitions"); - model.Info = JObject.FromObject(swagger.Info).ToObject(); - model.ExternalDocs = Take(model.Metadata, "externalDocs"); - foreach (var child in model.Children) - { - // Preserve the established Swagger URL display convention at the adapter boundary. - var query = child.Parameters?.Where(p => (string)p.Metadata.GetValueOrDefault("in") == "query").ToList() ?? []; - var required = query.Where(p => p.Metadata.GetValueOrDefault("required") is true).Select(p => p.Name).ToList(); - var optional = query.Where(p => p.Metadata.GetValueOrDefault("required") is not true).Select(p => p.Name).ToList(); - child.DisplayPath = child.Path + (required.Count > 0 ? "?" + string.Join('&', required) : "") + - (optional.Count > 0 ? "[" + (required.Count > 0 ? "&" : "?") + string.Join('&', optional) + "]" : ""); - foreach (var parameter in child.Parameters ?? []) - { - parameter.Schema = Take(parameter.Metadata, "schema"); - if (parameter.Schema == null) - { - parameter.Schema = JObject.FromObject(parameter.Metadata).ToObject(); - } - SetReferenceIds(parameter.Schema); - } - foreach (var response in child.Responses ?? []) - { - response.Schema = Take(response.Metadata, "schema"); - response.Headers = Take>(response.Metadata, "headers"); - SetReferenceIds(response.Schema); - } - } - 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; - - private static void SetReferenceIds(RestApiSchemaViewModel schema) - { - if (schema == null) return; - var name = schema.ReferenceName ?? schema.LoopReferenceName; - schema.ReferenceId = name?.Replace('.', '_'); - foreach (var property in schema.Properties?.Values.AsEnumerable() ?? []) SetReferenceIds(property); - foreach (var branch in schema.AllOf ?? []) SetReferenceIds(branch); - SetReferenceIds(schema.Items); - } - - #region Private methods - - private const string TagText = "tag"; - private static readonly string[] OperationNames = ["get", "put", "post", "delete", "options", "head", "patch"]; - - private static string GetUid(SwaggerModel swagger) - { - return GenerateUid(swagger.Host, swagger.BasePath, swagger.Info.Title, swagger.Info.Version); - } - - private static string GetUidForOperation(string parentUid, OperationObject item) - { - return GenerateUid(parentUid, item.OperationId); - } - - private static string GetUidForTag(string parentUid, TagItemObject tag) - { - return GenerateUid(parentUid, TagText, tag.Name); - } - - /// - /// Merge operation's parameters with path's parameters. - /// - /// Operation's parameters - /// Path's parameters - /// - private static IEnumerable GetParametersForOperation(List operationParameters, List pathParameters) - { - return MergeParameters(operationParameters, pathParameters, IsParameterEquals); - } - - /// - /// Judge whether two ParameterObject equal to each other. according to value of 'name' and 'in' - /// Define 'Equals' here instead of inside ParameterObject, since ParameterObject is either self defined or referenced object which 'name' and 'in' needs to be resolved. - /// - /// Fist ParameterObject - /// Second ParameterObject - private static bool IsParameterEquals(ParameterObject left, ParameterObject right) - { - if (left == null || right == null) - { - return false; - } - return string.Equals(left.Name, right.Name) && - string.Equals(GetMetadataStringValue(left, "in"), GetMetadataStringValue(right, "in")); - } - - private static string GetMetadataStringValue(ParameterObject parameter, string metadataName) - { - if (parameter.Metadata.TryGetValue(metadataName, out object metadataValue)) - { - return (string)metadataValue; - } - return null; - } - #endregion -} diff --git a/src/Docfx.Build.RestApi/RestApiDocumentReader.cs b/src/Docfx.Build.RestApi/RestApiDocumentReader.cs index d0cacd2d0a0..2968e2366fc 100644 --- a/src/Docfx.Build.RestApi/RestApiDocumentReader.cs +++ b/src/Docfx.Build.RestApi/RestApiDocumentReader.cs @@ -54,7 +54,7 @@ internal static RestApiRootItemViewModel Read(string path, string fileName) } } } - return OpenApi2ModelConverter.Convert(swagger); + return SwaggerModelConverter.Convert(swagger); } return OpenApiDocumentReader.Parse(raw, format, new Uri(Path.GetFullPath(path)), header?.Version); } diff --git a/src/Docfx.Build.RestApi/SwaggerModelConverter.cs b/src/Docfx.Build.RestApi/SwaggerModelConverter.cs index 36786a4e93c..3ad63fa3390 100644 --- a/src/Docfx.Build.RestApi/SwaggerModelConverter.cs +++ b/src/Docfx.Build.RestApi/SwaggerModelConverter.cs @@ -2,13 +2,209 @@ // The .NET Foundation licenses this file to you under the MIT license. using Docfx.Build.RestApi.Swagger; +using Docfx.Common; +using Docfx.DataContracts.Common; using Docfx.DataContracts.RestApi; +using Newtonsoft.Json.Linq; + +using static Docfx.Build.RestApi.RestApiModelUtility; + namespace Docfx.Build.RestApi; -// Public compatibility entry point for callers consuming the original Swagger metadata contract. public static partial class SwaggerModelConverter { - public static RestApiRootItemViewModel FromSwaggerModel(SwaggerModel swagger) => - OpenApi2ModelConverter.ConvertLegacy(swagger); + public static RestApiRootItemViewModel FromSwaggerModel(SwaggerModel swagger) + { + var uid = GetUid(swagger); + var vm = new RestApiRootItemViewModel + { + Name = swagger.Info.Title, + Uid = uid, + HtmlId = GetHtmlId(uid), + Metadata = swagger.Metadata, + Description = swagger.Description, + Summary = swagger.Summary, + Children = [], + Raw = swagger.Raw, + Tags = [] + }; + if (swagger.Tags != null) + { + foreach (var tag in swagger.Tags) + { + vm.Tags.Add(new RestApiTagViewModel + { + Name = tag.Name, + Description = tag.Description, + HtmlId = string.IsNullOrEmpty(tag.BookmarkId) ? GetHtmlId(tag.Name) : tag.BookmarkId, // Fall back to tag name's html id + Metadata = tag.Metadata, + Uid = GetUidForTag(uid, tag) + }); + } + } + if (swagger.Paths != null) + { + foreach (var path in swagger.Paths) + { + var commonParameters = path.Value.Parameters; + foreach (var op in path.Value.Metadata) + { + // fetch operations from metadata + if (OperationNames.Contains(op.Key, StringComparer.OrdinalIgnoreCase)) + { + if (op.Value is not JObject opJObject) + { + throw new InvalidOperationException($"Value of {op.Key} should be JObject"); + } + + // convert operation from JObject to OperationObject + var operation = opJObject.ToObject(); + var parameters = GetParametersForOperation(operation.Parameters, commonParameters); + var itemUid = GetUidForOperation(uid, operation); + var itemVm = new RestApiChildItemViewModel + { + Path = path.Key, + OperationName = op.Key, + Tags = operation.Tags, + OperationId = operation.OperationId, + HtmlId = GetHtmlId(itemUid), + Uid = itemUid, + Metadata = operation.Metadata, + Description = operation.Description, + Summary = operation.Summary, + Parameters = parameters?.Select(s => new RestApiParameterViewModel + { + Description = s.Description, + Name = s.Name, + Metadata = s.Metadata + }).ToList(), + Responses = operation.Responses?.Select(s => new RestApiResponseViewModel + { + Metadata = s.Value.Metadata, + Description = s.Value.Description, + Summary = s.Value.Summary, + HttpStatusCode = s.Key, + Examples = s.Value.Examples?.Select(example => new RestApiResponseExampleViewModel + { + MimeType = example.Key, + Content = example.Value != null ? JsonUtility.Serialize(example.Value) : null, + }).ToList(), + }).ToList(), + }; + + // TODO: line number + itemVm.Metadata[Constants.PropertyName.Source] = swagger.Metadata.GetValueOrDefault(Constants.PropertyName.Source); + vm.Children.Add(itemVm); + } + } + } + } + + return vm; + } + + internal static RestApiRootItemViewModel Convert(SwaggerModel swagger) + { + var model = FromSwaggerModel(swagger); + model.SpecificationVersion = "2.0"; + model.SecurityDefinitions = Take>(model.Metadata, "securityDefinitions"); + model.Info = JObject.FromObject(swagger.Info).ToObject(); + model.ExternalDocs = Take(model.Metadata, "externalDocs"); + foreach (var child in model.Children) + { + // Preserve the established Swagger URL display convention at the adapter boundary. + var query = child.Parameters?.Where(p => (string)p.Metadata.GetValueOrDefault("in") == "query").ToList() ?? []; + var required = query.Where(p => p.Metadata.GetValueOrDefault("required") is true).Select(p => p.Name).ToList(); + var optional = query.Where(p => p.Metadata.GetValueOrDefault("required") is not true).Select(p => p.Name).ToList(); + child.DisplayPath = child.Path + (required.Count > 0 ? "?" + string.Join('&', required) : "") + + (optional.Count > 0 ? "[" + (required.Count > 0 ? "&" : "?") + string.Join('&', optional) + "]" : ""); + foreach (var parameter in child.Parameters ?? []) + { + parameter.Schema = Take(parameter.Metadata, "schema"); + if (parameter.Schema == null) + { + parameter.Schema = JObject.FromObject(parameter.Metadata).ToObject(); + } + SetReferenceIds(parameter.Schema); + } + foreach (var response in child.Responses ?? []) + { + response.Schema = Take(response.Metadata, "schema"); + response.Headers = Take>(response.Metadata, "headers"); + SetReferenceIds(response.Schema); + } + } + 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; + + private static void SetReferenceIds(RestApiSchemaViewModel schema) + { + if (schema == null) return; + var name = schema.ReferenceName ?? schema.LoopReferenceName; + schema.ReferenceId = name?.Replace('.', '_'); + foreach (var property in schema.Properties?.Values.AsEnumerable() ?? []) SetReferenceIds(property); + foreach (var branch in schema.AllOf ?? []) SetReferenceIds(branch); + SetReferenceIds(schema.Items); + } + + #region Private methods + + private const string TagText = "tag"; + private static readonly string[] OperationNames = ["get", "put", "post", "delete", "options", "head", "patch"]; + + private static string GetUid(SwaggerModel swagger) + { + return GenerateUid(swagger.Host, swagger.BasePath, swagger.Info.Title, swagger.Info.Version); + } + + private static string GetUidForOperation(string parentUid, OperationObject item) + { + return GenerateUid(parentUid, item.OperationId); + } + + private static string GetUidForTag(string parentUid, TagItemObject tag) + { + return GenerateUid(parentUid, TagText, tag.Name); + } + + /// + /// Merge operation's parameters with path's parameters. + /// + /// Operation's parameters + /// Path's parameters + /// + private static IEnumerable GetParametersForOperation(List operationParameters, List pathParameters) + { + return MergeParameters(operationParameters, pathParameters, IsParameterEquals); + } + + /// + /// Judge whether two ParameterObject equal to each other. according to value of 'name' and 'in' + /// Define 'Equals' here instead of inside ParameterObject, since ParameterObject is either self defined or referenced object which 'name' and 'in' needs to be resolved. + /// + /// Fist ParameterObject + /// Second ParameterObject + private static bool IsParameterEquals(ParameterObject left, ParameterObject right) + { + if (left == null || right == null) + { + return false; + } + return string.Equals(left.Name, right.Name) && + string.Equals(GetMetadataStringValue(left, "in"), GetMetadataStringValue(right, "in")); + } + + private static string GetMetadataStringValue(ParameterObject parameter, string metadataName) + { + if (parameter.Metadata.TryGetValue(metadataName, out object metadataValue)) + { + return (string)metadataValue; + } + return null; + } + #endregion } From 6eaddd5f41ac32f453255f6676266dddfd5522e4 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Thu, 24 Sep 2026 23:53:31 +1000 Subject: [PATCH 13/16] Adapt OpenAPI 3 to the existing REST metadata contract --- docs/docs/openapi-unsupported-features.md | 40 +-- docs/docs/rest-api-docs.md | 5 +- .../SplitRestApiToOperationLevel.cs | 2 - .../BuildRestApiDocument.cs | 154 +++++++--- .../OpenApi3ModelConverter.cs | 172 +++++------ .../OpenApiDocumentReader.cs | 179 ++++-------- .../RestApiDocumentReader.cs | 6 +- .../RestApiModelUtility.cs | 31 -- .../SwaggerModelConverter.cs | 90 +++--- .../SplitRestApiToTagLevel.cs | 4 +- .../RestApiArrayMergeHandler.cs | 36 --- .../RestApiChildItemViewModel.cs | 21 -- .../RestApiExternalDocumentationViewModel.cs | 26 -- .../RestApiInfoViewModel.cs | 36 --- .../RestApiMediaTypeViewModel.cs | 32 --- .../RestApiParameterViewModel.cs | 11 - .../RestApiRequestBodyViewModel.cs | 27 -- .../RestApiResponseExampleViewModel.cs | 10 - .../RestApiResponseViewModel.cs | 16 -- .../RestApiRootItemViewModel.cs | 54 +--- .../RestApiSchemaCompositionViewModel.cs | 22 -- .../RestApiSchemaConstraintViewModel.cs | 21 -- .../RestApiSchemaViewModel.cs | 97 ------- .../RestApiSecuritySchemeViewModel.cs | 21 -- .../RestApiServerViewModel.cs | 21 -- templates/common/RestApi.common.js | 269 ++++++++++++++++-- .../default/partials/rest.child.tmpl.partial | 31 +- .../partials/rest.definition.tmpl.partial | 46 +++ templates/modern/src/rest.test.ts | 52 ++-- .../OpenApiDocumentReaderTest.cs | 253 ++-------------- .../RestApiDocumentProcessorTest.cs | 12 +- .../RestApiDocumentReaderTest.cs | 66 +---- .../OpenApiOutputTest.cs | 17 +- .../SplitRestApiToOperationLevelTest.cs | 12 +- .../SplitRestApiToTagLevelTest.cs | 8 +- .../SwaggerOutputCompatibilityTest.cs | 19 +- .../TestData/openapi/components.json | 32 --- .../TestData/openapi/service.json | 29 +- 38 files changed, 757 insertions(+), 1223 deletions(-) delete mode 100644 src/Docfx.Build.RestApi/RestApiModelUtility.cs 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/RestApiMediaTypeViewModel.cs delete mode 100644 src/Docfx.DataContracts.RestApi/RestApiRequestBodyViewModel.cs delete mode 100644 src/Docfx.DataContracts.RestApi/RestApiSchemaCompositionViewModel.cs delete mode 100644 src/Docfx.DataContracts.RestApi/RestApiSchemaConstraintViewModel.cs delete mode 100644 src/Docfx.DataContracts.RestApi/RestApiSchemaViewModel.cs delete mode 100644 src/Docfx.DataContracts.RestApi/RestApiSecuritySchemeViewModel.cs delete mode 100644 src/Docfx.DataContracts.RestApi/RestApiServerViewModel.cs delete mode 100644 test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/openapi/components.json diff --git a/docs/docs/openapi-unsupported-features.md b/docs/docs/openapi-unsupported-features.md index dac08e660fb..751d9d31208 100644 --- a/docs/docs/openapi-unsupported-features.md +++ b/docs/docs/openapi-unsupported-features.md @@ -18,49 +18,11 @@ The affected document is not generated. | Feature | Current behavior | Reason or alternative | | --- | --- | --- | -| Standalone external schema/component fragments | `UnsupportedExternalFragment` | This integration loads complete OpenAPI documents, not standalone fragments. Put shared components in a complete local document and reference its component path. | -| References without a fragment identifier | `UnsupportedExternalFragment` | Use a supported component reference such as `components.yaml#/components/schemas/Pet`. | -| HTTP/HTTPS or network-share references | Rejected; no network fetching | Use local referenced documents. Server URLs and ordinary documentation links are not restricted by this rule. | +| Cross-file and network `$ref` targets | `UnsupportedExternalReference` | Put components in the current document and use references such as `#/components/schemas/Pet`. | | Future specification versions | Version error | Only OpenAPI 3.0, 3.1 and 3.2 are enabled. | | Dynamic schema references (`$dynamicRef`) | `UnsupportedOpenApiSchema` | Dynamic scope is not implemented. Ordinary `$ref`, including recursive references, is supported. | | Certain OpenAPI 3.0 primitive compositions | `UnsupportedOpenApiComposition` | The pinned SDK can lose exclusive alternatives or branch examples. See [primitive compositions](#primitive-compositions). | -### Standalone external fragments - -A file containing only a schema is a valid OpenAPI reference target, but is not -supported by this integration: - -```yaml -# schemas/Pet.yaml -type: object -properties: - name: - type: string -``` - -For the supported form, put the schema in a complete component document: - -```yaml -# components.yaml -openapi: 3.1.0 -info: - title: Shared components - version: '1.0' -paths: {} -components: - schemas: - Pet: - type: object - properties: - name: - type: string -``` - -Then use `$ref: './components.yaml#/components/schemas/Pet'`. -Local component documents can mix JSON and YAML, and references between them can -form cycles. This limitation is in the current Docfx integration; it does not mean -standalone fragments are invalid OpenAPI. - ### Primitive compositions In some OpenAPI 3.0 cases, the pinned SDK combines primitive alternatives into a diff --git a/docs/docs/rest-api-docs.md b/docs/docs/rest-api-docs.md index ae592c703ff..dbbaf7f3fb0 100644 --- a/docs/docs/rest-api-docs.md +++ b/docs/docs/rest-api-docs.md @@ -38,9 +38,8 @@ Include the entry documents in `build.content`, for example: } ``` -Both `.yaml` and `.yml` are supported. Referenced OpenAPI documents can mix JSON and YAML and -do not need to be listed as separate entry documents. References are loaded through -Docfx's file abstraction; HTTP/HTTPS and network-share references are not fetched. +Both `.yaml` and `.yml` are supported. References must target the current document, +for example `#/components/schemas/Pet`. Cross-file and network references are not supported. An invalid document or unresolved reference produces an input error, not a fallback to the Swagger reader. OpenAPI 3.0, 3.1 and 3.2 are supported. diff --git a/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs b/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs index d4d71fab6ab..6d520c7b5a7 100644 --- a/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs +++ b/src/Docfx.Build.OperationLevelRestApi/SplitRestApiToOperationLevel.cs @@ -124,11 +124,9 @@ private static IEnumerable GenerateOperationModels(Res Remarks = child.Remarks, Documentation = child.Documentation, Children = [child], - Servers = child.Servers, 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 4082960b087..25bd79cfea2 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) @@ -37,6 +41,9 @@ protected override void BuildArticle(IHostService host, FileModel model) public static RestApiItemViewModelBase BuildItem(IHostService host, RestApiItemViewModelBase item, FileModel model, Func filter = null) { + var documents = model.Type == DocumentType.Overwrite ? host.LookupByUid(item.Uid) : [model]; + var openApi3 = documents?.Any(document => document.Content is RestApiRootItemViewModel root && + root.Metadata.GetValueOrDefault("specificationVersion") is string version && version.StartsWith("3.", StringComparison.Ordinal)) == true; item.Summary = Markup(host, item.Summary, model, filter); item.Description = Markup(host, item.Description, model, filter); if (model.Type != DocumentType.Overwrite) @@ -45,71 +52,142 @@ public static RestApiItemViewModelBase BuildItem(IHostService host, RestApiItemV item.Remarks = Markup(host, item.Remarks, model, filter); } - if (item is RestApiRootItemViewModel rootModel) + if (openApi3) + { + MarkupOpenApiMetadata(item.Metadata); + } + else 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); } - MarkupServers(rootModel.Servers); - foreach (var schema in rootModel.Schemas?.Values.AsEnumerable() ?? []) MarkupSchema(schema); } - if (item is RestApiChildItemViewModel childModel) + var childModel = item as RestApiChildItemViewModel; + if (childModel?.Parameters != null) { - MarkupServers(childModel.Servers); - if (childModel.RequestBody is { } body) - { - body.Description = Markup(host, body.Description, model, filter); - MarkupContent(body.Content); - } - foreach (var param in childModel.Parameters ?? []) + foreach (var param in childModel.Parameters) { param.Description = Markup(host, param.Description, model, filter); - MarkupSchema(param.Schema); - MarkupContent(param.Content); + + if (openApi3) + { + MarkupOpenApiMetadata(param.Metadata); + } + else + { + foreach (var jToken in param.Metadata.Values.OfType()) + { + MarkupRecursive(jToken, host, model, filter); + } + } } - foreach (var response in childModel.Responses ?? []) + } + if (childModel?.Responses != null) + { + foreach (var response in childModel.Responses) { response.Description = Markup(host, response.Description, model, filter); - MarkupSchema(response.Schema); - MarkupContent(response.Content); - foreach (var header in response.Headers?.Values.AsEnumerable() ?? []) MarkupSchema(header); + + if (openApi3) + { + MarkupOpenApiMetadata(response.Metadata); + } + else + { + foreach (var jToken in response.Metadata.Values.OfType()) + { + MarkupRecursive(jToken, host, model, filter); + } + } } } return item; - void MarkupServers(List servers) + void MarkupOpenApiMetadata(Dictionary metadata) + { + MarkupDescription(metadata.GetValueOrDefault("info")); + MarkupDescription(metadata.GetValueOrDefault("externalDocs")); + foreach (var server in GetChildren(metadata.GetValueOrDefault("servers"))) MarkupDescription(server); + foreach (var schema in GetChildren(metadata.GetValueOrDefault("schemas"))) MarkupSchema(schema); + var body = metadata.GetValueOrDefault("requestBody"); + MarkupDescription(body); + MarkupContent(GetProperty(body, "content")); + MarkupSchema(metadata.GetValueOrDefault("schema")); + MarkupContent(metadata.GetValueOrDefault("content")); + } + + void MarkupDescription(object node) { - foreach (var server in servers ?? []) + var value = GetProperty(node, "description"); + if (value is JValue { Type: JTokenType.String } token) value = (string)token; + if (value is not string description) return; + var html = Markup(host, description, model, filter); + if (node is JObject obj) obj["description"] = html; + else if (node is Dictionary dictionary) dictionary["description"] = html; + } + + void MarkupContent(object content) + { + foreach (var media in GetChildren(content)) { - if (server != null) server.Description = Markup(host, server.Description, model, filter); + MarkupSchema(GetProperty(media, "schema")); + MarkupSchema(GetProperty(media, "itemSchema")); } } - void MarkupContent(List content) + void MarkupSchema(object schema) + { + MarkupDescription(schema); + foreach (var property in GetChildren(GetProperty(schema, "properties"))) MarkupSchema(property); + var items = GetProperty(schema, "items"); + if (items != null) MarkupSchema(items); + foreach (var branch in GetChildren(GetProperty(schema, "allOf"))) MarkupSchema(branch); + foreach (var composition in GetChildren(GetProperty(schema, "composition"))) + foreach (var branch in GetChildren(GetProperty(composition, "schemas"))) MarkupSchema(branch); + } + + // Keep overwrite dictionaries/lists intact: JObjectMerger/JArrayMerger consume those types. + static object GetProperty(object node, string name) => node switch + { + JObject obj => obj[name], + Dictionary dictionary => dictionary.GetValueOrDefault(name), + _ => null + }; + + static IEnumerable GetChildren(object node) => node switch + { + JObject obj => obj.PropertyValues(), + Dictionary dictionary => dictionary.Values, + IEnumerable array => array, + _ => [] + }; + } + + private static void MarkupRecursive(JToken jToken, IHostService host, FileModel model, Func filter = null) + { + if (jToken is JArray jArray) { - foreach (var media in content ?? []) + foreach (var item in jArray) { - if (media == null) continue; // Positional overwrite placeholder. - MarkupSchema(media.Schema); - MarkupSchema(media.ItemSchema); + MarkupRecursive(item, host, model, filter); } } - void MarkupSchema(RestApiSchemaViewModel schema) + if (jToken is JObject jObject) { - 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 composition in schema.Composition ?? []) + foreach (var pair in jObject) { - if (composition == null) continue; - foreach (var branch in composition.Schemas ?? []) MarkupSchema(branch); + 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/OpenApi3ModelConverter.cs b/src/Docfx.Build.RestApi/OpenApi3ModelConverter.cs index b95130f6faa..e4a8ffcdd12 100644 --- a/src/Docfx.Build.RestApi/OpenApi3ModelConverter.cs +++ b/src/Docfx.Build.RestApi/OpenApi3ModelConverter.cs @@ -4,22 +4,21 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using Docfx.DataContracts.RestApi; using Docfx.Exceptions; using Microsoft.OpenApi; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using static Docfx.Build.RestApi.RestApiModelUtility; - namespace Docfx.Build.RestApi; -internal sealed class OpenApi3ModelConverter(Uri documentUri, IReadOnlyDictionary constants) +internal sealed partial class OpenApi3ModelConverter(IReadOnlyDictionary constants) { internal RestApiRootItemViewModel Convert(OpenApiDocument document, string raw, string version) { var servers = Servers(document.Servers); - var server = servers[0].Url; + var server = (string)servers[0]["url"]; var absolute = Uri.TryCreate(server, UriKind.Absolute, out var uri) && !uri.IsFile; var uid = GenerateUid(absolute ? uri.Authority : null, (absolute ? uri.AbsolutePath : server).Trim('/'), document.Info.Title, document.Info.Version); @@ -35,19 +34,19 @@ internal RestApiRootItemViewModel Convert(OpenApiDocument document, string raw, Children = [], Tags = [] }; - model.SpecificationVersion = version; - model.Servers = servers; - model.Info = Serialize(document.Info).ToObject(); + model.Metadata["specificationVersion"] = version; + model.Metadata["servers"] = servers; + model.Metadata["info"] = Serialize(document.Info); if (document.ExternalDocs != null) { - model.ExternalDocs = Serialize(document.ExternalDocs).ToObject(); + model.Metadata["externalDocs"] = Serialize(document.ExternalDocs); } - var schemas = new Dictionary(); + var schemas = new JObject(); foreach (var (name, schema) in document.Components?.Schemas?.AsEnumerable() ?? []) { schemas[name] = Schema(schema); } - model.Schemas = schemas; + model.Metadata["schemas"] = schemas; foreach (var tag in document.Tags?.AsEnumerable() ?? []) { AddTag(tag.Name, tag.Description, Extensions(tag.Extensions)); @@ -69,8 +68,7 @@ internal RestApiRootItemViewModel Convert(OpenApiDocument document, string raw, var operationUid = GenerateUid(uid, id); var effectiveServers = Servers(operation.Servers is { Count: > 0 } ? operation.Servers : pathItem.Servers is { Count: > 0 } ? pathItem.Servers : document.Servers); - var parameters = MergeParameters(operation.Parameters, pathItem.Parameters, - (left, right) => left.Name == right.Name && left.In == right.In); + var parameters = MergeParameters(operation.Parameters, pathItem.Parameters); var child = new RestApiChildItemViewModel { Uid = operationUid, @@ -85,15 +83,15 @@ internal RestApiRootItemViewModel Convert(OpenApiDocument document, string raw, Responses = operation.Responses?.Select(pair => Response(pair.Key, pair.Value)).ToList() ?? [], Metadata = Extensions(operation.Extensions) }; - child.Servers = effectiveServers; - child.RequestUrl = effectiveServers[0].Url.TrimEnd('/') + "/" + path.TrimStart('/'); + child.Metadata["servers"] = effectiveServers; + child.Metadata["requestUrl"] = ((string)effectiveServers[0]["url"]).TrimEnd('/') + "/" + path.TrimStart('/'); if (operation.RequestBody is { } body) { - child.RequestBody = new RestApiRequestBodyViewModel + child.Metadata["requestBody"] = new JObject { - Description = body.Description, - Required = body.Required, - Content = Content(body.Content) + ["description"] = body.Description, + ["required"] = body.Required, + ["content"] = Content(body.Content) }; } foreach (var name in child.Tags) @@ -126,13 +124,13 @@ void AddTag(string name, string description, Dictionary metadata } } - private static List Servers(IList servers) + private static JArray Servers(IList servers) { if (servers == null || servers.Count == 0) { - return [new() { Url = "/" }]; + return new JArray(new JObject { ["url"] = "/" }); } - return servers.Select(server => + return new JArray(servers.Select(server => { var url = server.Url; foreach (var (name, variable) in server.Variables?.AsEnumerable() ?? []) @@ -147,8 +145,8 @@ private static List Servers(IList servers { throw new DocfxException($"OpenAPI server URL '{server.Url}' contains a variable without a default."); } - return new RestApiServerViewModel { Url = url, Description = server.Description }; - }).ToList(); + return new JObject { ["url"] = url, ["description"] = server.Description }; + })); } private RestApiParameterViewModel Parameter(IOpenApiParameter parameter) @@ -159,6 +157,11 @@ private RestApiParameterViewModel Parameter(IOpenApiParameter parameter) metadata["required"] = parameter.Required; metadata["style"] = parameter.Style?.ToString(); metadata["explode"] = parameter.Explode; + metadata["schema"] = schema; + if (parameter.Content is { Count: > 0 }) + { + metadata["content"] = Content(parameter.Content); + } if (parameter.Schema?.Default != null) { metadata["default"] = Literal(parameter.Schema.Default); @@ -167,47 +170,46 @@ private RestApiParameterViewModel Parameter(IOpenApiParameter parameter) { Name = parameter.Name, Description = parameter.Description, - Schema = schema, - Content = parameter.Content is { Count: > 0 } ? Content(parameter.Content) : null, Metadata = metadata }; } private RestApiResponseViewModel Response(string status, IOpenApiResponse response) { + var metadata = Extensions(response.Extensions); + metadata["content"] = Content(response.Content); return new RestApiResponseViewModel { HttpStatusCode = status, Description = response.Description, - Metadata = Extensions(response.Extensions), - Content = Content(response.Content) + Metadata = metadata }; } - private List Content(IDictionary content) => - content?.Select(pair => new RestApiMediaTypeViewModel + private JArray Content(IDictionary content) => + new(content?.Select(pair => new JObject { - MimeType = pair.Key, - Schema = Schema(pair.Value.Schema), - ItemSchema = Schema(pair.Value.ItemSchema), - Examples = Examples(pair.Key, pair.Value) - }).ToList() ?? []; + ["mimeType"] = pair.Key, + ["schema"] = Schema(pair.Value.Schema), + ["itemSchema"] = Schema(pair.Value.ItemSchema), + ["examples"] = Examples(pair.Key, pair.Value) + }) ?? []); - private static List Examples(string mimeType, IOpenApiMediaType media) + private static JArray Examples(string mimeType, IOpenApiMediaType media) { - var result = new List(); + var result = new JArray(); if (media.Example != null) { - result.Add(new() { MimeType = mimeType, Content = Literal(media.Example) }); + result.Add(new JObject { ["mimeType"] = mimeType, ["content"] = Literal(media.Example) }); } foreach (var (name, example) in media.Examples?.AsEnumerable() ?? []) { - result.Add(new() + result.Add(new JObject { - Name = name, - MimeType = mimeType, - Content = example.SerializedValue ?? Literal(example.DataValue ?? example.Value), - ExternalValue = example.ExternalValue + ["name"] = name, + ["mimeType"] = mimeType, + ["content"] = example.SerializedValue ?? Literal(example.DataValue ?? example.Value), + ["externalValue"] = example.ExternalValue }); } return result; @@ -246,7 +248,7 @@ private static Dictionary Extensions(IDictionary ancestors = null) + private JObject Schema(IOpenApiSchema schema, HashSet ancestors = null) { if (schema == null) { @@ -262,7 +264,7 @@ private RestApiSchemaViewModel Schema(IOpenApiSchema schema, HashSet pair.Key, pair => + result["properties"] = new JObject(schema.Properties.Select(pair => { var property = Schema(pair.Value, ancestors); if (schema.Required?.Contains(pair.Key) == true) { - property.Required = true; + property["required"] = true; } - return property; - }), - Items = Schema(schema.Items, ancestors) - }; - var composition = new List(); - result.AllOf = schema.AllOf is { Count: > 0 } ? schema.AllOf.Select(s => Schema(s, ancestors)).ToList() : null; + return new JProperty(pair.Key, property); + })); + } + if (schema.Items != null) result["items"] = Schema(schema.Items, ancestors); + var composition = new JArray(); + if (schema.AllOf is { Count: > 0 }) result["allOf"] = new JArray(schema.AllOf.Select(s => Schema(s, ancestors))); AddComposition("One of", schema.OneOf); AddComposition("Any of", schema.AnyOf); if (schema.Not != null) @@ -328,43 +330,43 @@ private RestApiSchemaViewModel Schema(IOpenApiSchema schema, HashSet 0) { - result.Composition = composition; + result["composition"] = composition; } - var constraints = new List(); + var constraints = new JArray(); foreach (var property in ((JObject)serialized).Properties()) { if (!property.Name.StartsWith("x-", StringComparison.Ordinal) && property.Name is not ("type" or "format" or "description" or "properties" or "items" or "allOf" or "oneOf" or "anyOf" or "not" or "additionalProperties" or "enum" or "example" or "examples")) { - constraints.Add(new() { Name = property.Name, Value = property.Value.ToString(Formatting.None) }); + constraints.Add(new JObject { ["name"] = property.Name, ["value"] = property.Value.ToString(Formatting.None) }); } } if (schema.AdditionalProperties != null) { - composition.Add(new() { Kind = "Additional properties", Schemas = [Schema(schema.AdditionalProperties, ancestors)] }); - result.Composition = composition; + composition.Add(new JObject { ["kind"] = "Additional properties", ["schemas"] = new JArray(Schema(schema.AdditionalProperties, ancestors)) }); + result["composition"] = composition; } else if (!schema.AdditionalPropertiesAllowed) { - constraints.Add(new() { Name = "additionalProperties", Value = "false" }); + constraints.Add(new JObject { ["name"] = "additionalProperties", ["value"] = "false" }); } if (constraints.Count > 0) { - result.Constraints = constraints; + result["constraints"] = constraints; } if (schema.Enum is { Count: > 0 }) { - result.Enum = schema.Enum.Select(value => value == null ? null : (object)JToken.Parse(Literal(value))).ToList(); + result["enum"] = new JArray(schema.Enum.Select(value => value == null ? null : JToken.Parse(Literal(value)))); } if (schema.Examples is { Count: > 0 }) { - result.Examples = schema.Examples.Select(example => new RestApiResponseExampleViewModel { Content = Literal(example) }).ToList(); + result["examples"] = new JArray(schema.Examples.Select(example => new JObject { ["content"] = Literal(example) })); } #pragma warning disable CS0618 // OpenAPI 3.0's singular schema example is still read into this SDK property. else if (schema.Example != null) { - result.Examples = [new() { Content = Literal(schema.Example) }]; + result["examples"] = new JArray(new JObject { ["content"] = Literal(schema.Example) }); } #pragma warning restore CS0618 return result; @@ -373,7 +375,7 @@ void AddComposition(string kind, IList schemas) { if (schemas is { Count: > 0 }) { - composition.Add(new() { Kind = kind, Schemas = schemas.Select(s => Schema(s, ancestors)).ToList() }); + composition.Add(new JObject { ["kind"] = kind, ["schemas"] = new JArray(schemas.Select(s => Schema(s, ancestors))) }); } } } @@ -407,11 +409,19 @@ internal static OpenApiSchema GetReferenceSiblings(OpenApiSchemaReference refere return siblings; } - private string ReferenceName(OpenApiSchemaReference reference) + private static string ReferenceName(OpenApiSchemaReference reference) => reference.Reference.Id; + + [GeneratedRegex(@"\W")] + private static partial Regex HtmlEncodeRegex(); + + private static string GetHtmlId(string id) => string.IsNullOrEmpty(id) ? null : HtmlEncodeRegex().Replace(id, "_"); + + private static string GenerateUid(params string[] segments) => + string.Join('/', segments.Where(s => !string.IsNullOrEmpty(s)).Select(s => s.Trim('/'))); + + private static IEnumerable MergeParameters(IList operationParameters, IList pathParameters) { - var host = reference.Reference.HostDocument?.BaseUri ?? documentUri; - var target = reference.Reference.ExternalResource is { } external ? new Uri(host, external) : host; - return target == documentUri ? reference.Reference.Id : - documentUri.MakeRelativeUri(target) + "#" + reference.Reference.Id; + return (operationParameters ?? []).Concat((pathParameters ?? []).Where(parameter => + operationParameters?.Any(overridden => overridden.Name == parameter.Name && overridden.In == parameter.In) != true)); } } diff --git a/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs b/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs index ad01f9b70f6..2efe607f974 100644 --- a/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs +++ b/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs @@ -30,8 +30,8 @@ internal static RestApiRootItemViewModel Parse(string raw, string format, Uri ba { version ??= RestApiDocumentReader.ReadHeader(new StringReader(raw), format)?.Version; var constants = new Dictionary(); - var document = LoadDocuments(raw, format, baseUrl ?? new Uri(Path.GetFullPath("openapi.json")), version, constants); - var model = new OpenApi3ModelConverter(document.BaseUri, constants).Convert(document, raw, version); + var document = LoadDocument(raw, format, baseUrl ?? new Uri(Path.GetFullPath("openapi.json")), version, constants); + var model = new OpenApi3ModelConverter(constants).Convert(document, raw, version); model.Metadata["rawExtension"] = format == "json" ? ".json" : ".yaml"; return model; } @@ -41,117 +41,64 @@ internal static RestApiRootItemViewModel Parse(string raw, string format, Uri ba } } - private static OpenApiDocument LoadDocuments(string raw, string format, Uri root, string rootVersion, Dictionary constants) + private static OpenApiDocument LoadDocument(string raw, string format, Uri root, string rootVersion, Dictionary constants) { - var loader = new LocalStreamLoader(); - var documents = new Dictionary(); - var references = new Dictionary>(); - var pending = new Queue(); - var scheduled = new HashSet { root }; - pending.Enqueue(root); - while (pending.TryDequeue(out var location)) + if (!System.Version.TryParse(rootVersion, out var parsed) || parsed.Major != 3 || parsed.Minor is not (0 or 1 or 2)) { - var source = raw; - var sourceFormat = format; - if (location != root) - { - using var input = loader.LoadAsync(root, location).GetAwaiter().GetResult(); - using var reader = new StreamReader(input); - source = reader.ReadToEnd(); - sourceFormat = Path.GetExtension(location.LocalPath).Equals(".json", StringComparison.OrdinalIgnoreCase) ? "json" : "yaml"; - } - var version = location == root ? rootVersion : RestApiDocumentReader.ReadHeader(new StringReader(source), sourceFormat)?.Version; - if (!System.Version.TryParse(version, out var parsed) || parsed.Major != 3 || parsed.Minor is not (0 or 1 or 2)) - { - if (location == root) - { - throw new DocfxException($"OpenAPI version '{version}' is not supported. Use OpenAPI 3.0, 3.1 or 3.2."); - } - throw new DocfxException($"UnsupportedExternalFragment: '{location.LocalPath}' is not a complete OpenAPI 3.0, 3.1 or 3.2 document. " + - "Standalone schema/component fragments are valid OpenAPI references, but are not supported by this reader integration."); - } - if (sourceFormat == "json") - { - // Replacing a const value must not make malformed JSON appear valid. - using var json = System.Text.Json.JsonDocument.Parse(source); - } - source = PrepareSchemas(source, location, parsed.Minor == 0, constants); - var settings = new OpenApiReaderSettings - { - BaseUrl = location, - LoadExternalRefs = false, - CustomExternalLoader = loader - }; - settings.AddYamlReader(); - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(source)); - var result = Task.Run(() => OpenApiDocument.LoadAsync(stream, sourceFormat, settings)).GetAwaiter().GetResult(); - if (result.Diagnostic.Errors.Count > 0) - { - throw new DocfxException($"Invalid OpenAPI document '{location.LocalPath}': " + - string.Join("; ", result.Diagnostic.Errors.Select(e => e.ToString()))); - } - foreach (var warning in result.Diagnostic.Warnings) - { - Logger.LogWarning($"OpenAPI '{location.LocalPath}': {warning}"); - } - var document = result.Document ?? throw new DocfxException($"The OpenAPI reader did not produce a document for '{location.LocalPath}'."); - documents.Add(location, document); - if (document.Webhooks is { Count: > 0 } || document.Security is { Count: > 0 } || - document.Components?.SecuritySchemes is { Count: > 0 } || - document.Paths?.Values.Any(path => path.Operations?.Values.Any(operation => - operation.Callbacks is { Count: > 0 } || operation.Security is { Count: > 0 } || - operation.Responses?.Values.Any(response => response.Links is { Count: > 0 }) == true) == true) == true) - { - Logger.LogWarning($"OpenAPI '{location.LocalPath}': callbacks, webhooks, security configuration and response links do not have dedicated documentation UI."); - } - var collector = new ReferenceCollector(); - new OpenApiWalker(collector).Walk(document); - if (collector.HasEncoding || document.Tags?.Any(tag => tag.Parent != null || tag.Kind != null || tag.Summary != null) == true) - { - Logger.LogWarning($"OpenAPI '{location.LocalPath}': media-type encoding and tag summary, hierarchy and kind do not have dedicated documentation UI."); - } - references.Add(location, collector.References); - foreach (var (_, reference) in collector.References) - { - if (reference.ExternalResource is { } external) - { - var target = LocalStreamLoader.Resolve(location, new Uri(external, UriKind.RelativeOrAbsolute)); - if (scheduled.Add(target)) - { - pending.Enqueue(target); - } - } - } + throw new DocfxException($"OpenAPI version '{rootVersion}' is not supported. Use OpenAPI 3.0, 3.1 or 3.2."); } - - // The SDK aliases external names globally within a workspace. Each host needs its own - // aliases so two documents can both refer to "common.yaml" in different directories. - foreach (var (location, document) in documents) + if (format == "json") { - document.Workspace = new OpenApiWorkspace(); - foreach (var other in documents.Values) - { - document.Workspace.RegisterComponents(other); - } - foreach (var (_, reference) in references[location]) - { - if (reference.ExternalResource is { } external) - { - document.Workspace.AddDocumentId(external, new Uri(location, external)); - } - } + // Replacing a const value must not make malformed JSON appear valid. + using var json = System.Text.Json.JsonDocument.Parse(raw); + } + raw = PrepareSchemas(raw, root, parsed.Minor == 0, constants); + var settings = new OpenApiReaderSettings + { + BaseUrl = root, + LoadExternalRefs = false + }; + settings.AddYamlReader(); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(raw)); + var result = Task.Run(() => OpenApiDocument.LoadAsync(stream, format, settings)).GetAwaiter().GetResult(); + if (result.Diagnostic.Errors.Count > 0) + { + throw new DocfxException($"Invalid OpenAPI document '{root.LocalPath}': " + + string.Join("; ", result.Diagnostic.Errors.Select(e => e.ToString()))); + } + foreach (var warning in result.Diagnostic.Warnings) + { + Logger.LogWarning($"OpenAPI '{root.LocalPath}': {warning}"); + } + var document = result.Document ?? throw new DocfxException($"The OpenAPI reader did not produce a document for '{root.LocalPath}'."); + if (document.Webhooks is { Count: > 0 } || document.Security is { Count: > 0 } || + document.Components?.SecuritySchemes is { Count: > 0 } || + document.Paths?.Values.Any(path => path.Operations?.Values.Any(operation => + operation.Callbacks is { Count: > 0 } || operation.Security is { Count: > 0 } || + operation.Responses?.Values.Any(response => response.Links is { Count: > 0 }) == true) == true) == true) + { + Logger.LogWarning($"OpenAPI '{root.LocalPath}': callbacks, webhooks, security configuration and response links do not have dedicated documentation UI."); + } + var collector = new ReferenceCollector(); + new OpenApiWalker(collector).Walk(document); + if (collector.HasEncoding || document.Tags?.Any(tag => tag.Parent != null || tag.Kind != null || tag.Summary != null) == true) + { + Logger.LogWarning($"OpenAPI '{root.LocalPath}': media-type encoding and tag summary, hierarchy and kind do not have dedicated documentation UI."); } - foreach (var (location, holders) in references) + document.Workspace = new OpenApiWorkspace(); + document.Workspace.RegisterComponents(document); + foreach (var (holder, reference) in collector.References) { - foreach (var (holder, reference) in holders) + if (reference.ExternalResource != null) { - if (holder.UnresolvedReference) - { - throw new DocfxException($"Could not resolve OpenAPI reference '{reference.ReferenceV3}' in '{location.LocalPath}'."); - } + throw new DocfxException($"UnsupportedExternalReference: '{reference.ReferenceV3}'. References must target the current OpenAPI document."); + } + if (holder.UnresolvedReference) + { + throw new DocfxException($"Could not resolve OpenAPI reference '{reference.ReferenceV3}' in '{root.LocalPath}'."); } } - return documents[root]; + return document; } private static string PrepareSchemas(string source, Uri location, bool openApi30, Dictionary constants) @@ -393,10 +340,10 @@ static string JsonLiteral(YamlNode node) void CheckReference(YamlNode node, string path) { - if (node is YamlScalarNode { Value: { } value } && !value.Contains('#')) + if (node is YamlScalarNode { Value: { } value } && !value.StartsWith('#')) { - throw new DocfxException($"UnsupportedExternalFragment: reference '{value}' at '{path}' in '{location.LocalPath}' " + - "requires a complete OpenAPI component document and a fragment identifier."); + throw new DocfxException($"UnsupportedExternalReference: reference '{value}' at '{path}' in '{location.LocalPath}' " + + "must target the current OpenAPI document."); } } @@ -499,22 +446,4 @@ public override void Visit(IOpenApiSchema schema) }); } - private sealed class LocalStreamLoader : IStreamLoader - { - public Task LoadAsync(Uri baseUrl, Uri uri, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(EnvironmentContext.FileAbstractLayer.OpenRead(Resolve(baseUrl, uri).LocalPath)); - } - - internal static Uri Resolve(Uri baseUrl, Uri uri) - { - uri = uri.IsAbsoluteUri ? uri : new Uri(baseUrl, uri); - if (!uri.IsAbsoluteUri || !uri.IsFile || uri.IsUnc || !string.IsNullOrEmpty(uri.Host)) - { - throw new DocfxException($"Only local file references are supported in OpenAPI documents: '{uri}'."); - } - return new Uri(Path.GetFullPath(uri.LocalPath)); - } - } } diff --git a/src/Docfx.Build.RestApi/RestApiDocumentReader.cs b/src/Docfx.Build.RestApi/RestApiDocumentReader.cs index 2968e2366fc..c8dc67cbc79 100644 --- a/src/Docfx.Build.RestApi/RestApiDocumentReader.cs +++ b/src/Docfx.Build.RestApi/RestApiDocumentReader.cs @@ -43,7 +43,7 @@ internal static RestApiRootItemViewModel Read(string path, string fileName) { var swagger = SwaggerJsonParser.Parse(path); swagger.Raw = raw; - // Preserve legacy diagnostics, including extension objects under a path. + // Preserve Swagger 2.0 diagnostics, including extension objects under a path. foreach (var (route, item) in swagger.Paths ?? []) { foreach (var (method, operation) in item.Metadata) @@ -54,7 +54,7 @@ internal static RestApiRootItemViewModel Read(string path, string fileName) } } } - return SwaggerModelConverter.Convert(swagger); + return SwaggerModelConverter.FromSwaggerModel(swagger); } return OpenApiDocumentReader.Parse(raw, format, new Uri(Path.GetFullPath(path)), header?.Version); } @@ -66,7 +66,7 @@ internal static RestApiRootItemViewModel Read(string path, string fileName) _ => null }; - // Read only root markers, without allocating an object tree. The legacy JSON probe + // Read only root markers, without allocating an object tree. The Swagger 2.0 JSON probe // also validates the complete JSON syntax to retain its existing ownership behavior. internal static Header ReadHeader(TextReader source, string format) { diff --git a/src/Docfx.Build.RestApi/RestApiModelUtility.cs b/src/Docfx.Build.RestApi/RestApiModelUtility.cs deleted file mode 100644 index a654c147b29..00000000000 --- a/src/Docfx.Build.RestApi/RestApiModelUtility.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.RegularExpressions; - -namespace Docfx.Build.RestApi; - -internal static partial class RestApiModelUtility -{ - [GeneratedRegex(@"\W")] - private static partial Regex HtmlEncodeRegex(); - - internal static string GetHtmlId(string id) => string.IsNullOrEmpty(id) ? null : HtmlEncodeRegex().Replace(id, "_"); - - internal static string GenerateUid(params string[] segments) => - string.Join('/', segments.Where(s => !string.IsNullOrEmpty(s)).Select(s => s.Trim('/'))); - - internal static IEnumerable MergeParameters(IList operationParameters, IList pathParameters, Func equals) - { - if (pathParameters == null || pathParameters.Count == 0) - { - return operationParameters; - } - if (operationParameters == null || operationParameters.Count == 0) - { - return pathParameters; - } - - return operationParameters.Union(pathParameters.Where(p => !operationParameters.Any(o => equals(p, o)))).ToList(); - } -} diff --git a/src/Docfx.Build.RestApi/SwaggerModelConverter.cs b/src/Docfx.Build.RestApi/SwaggerModelConverter.cs index 3ad63fa3390..9a83e692a9b 100644 --- a/src/Docfx.Build.RestApi/SwaggerModelConverter.cs +++ b/src/Docfx.Build.RestApi/SwaggerModelConverter.cs @@ -1,6 +1,8 @@ // 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.RegularExpressions; + using Docfx.Build.RestApi.Swagger; using Docfx.Common; using Docfx.DataContracts.Common; @@ -8,8 +10,6 @@ using Newtonsoft.Json.Linq; -using static Docfx.Build.RestApi.RestApiModelUtility; - namespace Docfx.Build.RestApi; public static partial class SwaggerModelConverter @@ -104,58 +104,25 @@ public static RestApiRootItemViewModel FromSwaggerModel(SwaggerModel swagger) return vm; } - internal static RestApiRootItemViewModel Convert(SwaggerModel swagger) - { - var model = FromSwaggerModel(swagger); - model.SpecificationVersion = "2.0"; - model.SecurityDefinitions = Take>(model.Metadata, "securityDefinitions"); - model.Info = JObject.FromObject(swagger.Info).ToObject(); - model.ExternalDocs = Take(model.Metadata, "externalDocs"); - foreach (var child in model.Children) - { - // Preserve the established Swagger URL display convention at the adapter boundary. - var query = child.Parameters?.Where(p => (string)p.Metadata.GetValueOrDefault("in") == "query").ToList() ?? []; - var required = query.Where(p => p.Metadata.GetValueOrDefault("required") is true).Select(p => p.Name).ToList(); - var optional = query.Where(p => p.Metadata.GetValueOrDefault("required") is not true).Select(p => p.Name).ToList(); - child.DisplayPath = child.Path + (required.Count > 0 ? "?" + string.Join('&', required) : "") + - (optional.Count > 0 ? "[" + (required.Count > 0 ? "&" : "?") + string.Join('&', optional) + "]" : ""); - foreach (var parameter in child.Parameters ?? []) - { - parameter.Schema = Take(parameter.Metadata, "schema"); - if (parameter.Schema == null) - { - parameter.Schema = JObject.FromObject(parameter.Metadata).ToObject(); - } - SetReferenceIds(parameter.Schema); - } - foreach (var response in child.Responses ?? []) - { - response.Schema = Take(response.Metadata, "schema"); - response.Headers = Take>(response.Metadata, "headers"); - SetReferenceIds(response.Schema); - } - } - 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; - - private static void SetReferenceIds(RestApiSchemaViewModel schema) - { - if (schema == null) return; - var name = schema.ReferenceName ?? schema.LoopReferenceName; - schema.ReferenceId = name?.Replace('.', '_'); - foreach (var property in schema.Properties?.Values.AsEnumerable() ?? []) SetReferenceIds(property); - foreach (var branch in schema.AllOf ?? []) SetReferenceIds(branch); - SetReferenceIds(schema.Items); - } - #region Private methods + [GeneratedRegex(@"\W")] + private static partial Regex HtmlEncodeRegex(); + private const string TagText = "tag"; private static readonly string[] OperationNames = ["get", "put", "post", "delete", "options", "head", "patch"]; + /// + /// TODO: merge with the one in XrefDetails + /// + /// + /// + private static string GetHtmlId(string id) + { + if (string.IsNullOrEmpty(id)) return null; + return HtmlEncodeRegex().Replace(id, "_"); + } + private static string GetUid(SwaggerModel swagger) { return GenerateUid(swagger.Host, swagger.BasePath, swagger.Info.Title, swagger.Info.Version); @@ -171,6 +138,16 @@ private static string GetUidForTag(string parentUid, TagItemObject tag) return GenerateUid(parentUid, TagText, tag.Name); } + /// + /// UID is joined by '/', if segment ends with '/', use that one instead + /// + /// The segments to generate UID + /// + private static string GenerateUid(params string[] segments) + { + return string.Join('/', segments.Where(s => !string.IsNullOrEmpty(s)).Select(s => s.Trim('/'))); + } + /// /// Merge operation's parameters with path's parameters. /// @@ -179,7 +156,20 @@ private static string GetUidForTag(string parentUid, TagItemObject tag) /// private static IEnumerable GetParametersForOperation(List operationParameters, List pathParameters) { - return MergeParameters(operationParameters, pathParameters, IsParameterEquals); + if (pathParameters == null || pathParameters.Count == 0) + { + return operationParameters; + } + if (operationParameters == null || operationParameters.Count == 0) + { + return pathParameters; + } + + // Path parameters can be overridden at the operation level. + var uniquePathParams = pathParameters.Where( + p => !operationParameters.Any(o => IsParameterEquals(p, o))).ToList(); + + return operationParameters.Union(uniquePathParams).ToList(); } /// diff --git a/src/Docfx.Build.TagLevelRestApi/SplitRestApiToTagLevel.cs b/src/Docfx.Build.TagLevelRestApi/SplitRestApiToTagLevel.cs index 03221938ef8..021a9aabac4 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) { - var model = new RestApiRootItemViewModel + yield return new RestApiRootItemViewModel { Uid = tag.Uid, HtmlId = tag.HtmlId, @@ -121,8 +121,6 @@ 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 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/RestApiChildItemViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiChildItemViewModel.cs index 2e859219b56..1cfd786a6fa 100644 --- a/src/Docfx.DataContracts.RestApi/RestApiChildItemViewModel.cs +++ b/src/Docfx.DataContracts.RestApi/RestApiChildItemViewModel.cs @@ -10,27 +10,6 @@ namespace Docfx.DataContracts.RestApi; public class RestApiChildItemViewModel : RestApiItemViewModelBase { - [YamlMember(Alias = "servers")] - [JsonProperty("servers", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("servers")] - [Docfx.Common.EntityMergers.MergeOption(typeof(RestApiArrayMergeHandler))] - public List Servers { get; set; } - - [YamlMember(Alias = "requestUrl")] - [JsonProperty("requestUrl", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("requestUrl")] - public string RequestUrl { get; set; } - - [YamlMember(Alias = "displayPath")] - [JsonProperty("displayPath", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("displayPath")] - public string DisplayPath { get; set; } - - [YamlMember(Alias = "requestBody")] - [JsonProperty("requestBody", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("requestBody")] - public RestApiRequestBodyViewModel RequestBody { get; set; } - [YamlMember(Alias = Constants.PropertyName.Path)] [JsonProperty(Constants.PropertyName.Path)] [JsonPropertyName(Constants.PropertyName.Path)] 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 84c969912cd..00000000000 --- a/src/Docfx.DataContracts.RestApi/RestApiInfoViewModel.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.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; } - - [YamlMember(Alias = "summary")] - [JsonProperty("summary", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("summary")] - public string Summary { 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/RestApiMediaTypeViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiMediaTypeViewModel.cs deleted file mode 100644 index e1ed699e9da..00000000000 --- a/src/Docfx.DataContracts.RestApi/RestApiMediaTypeViewModel.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 System.Text.Json.Serialization; -using Newtonsoft.Json; -using YamlDotNet.Serialization; - -namespace Docfx.DataContracts.RestApi; - -public class RestApiMediaTypeViewModel -{ - [YamlMember(Alias = "mimeType")] - [JsonProperty("mimeType", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("mimeType")] - public string MimeType { get; set; } - - [YamlMember(Alias = "schema")] - [JsonProperty("schema", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("schema")] - public RestApiSchemaViewModel Schema { get; set; } - - [YamlMember(Alias = "itemSchema")] - [JsonProperty("itemSchema", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("itemSchema")] - public RestApiSchemaViewModel ItemSchema { get; set; } - - [YamlMember(Alias = "examples")] - [JsonProperty("examples", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("examples")] - [Docfx.Common.EntityMergers.MergeOption(typeof(RestApiArrayMergeHandler))] - public List Examples { get; set; } -} diff --git a/src/Docfx.DataContracts.RestApi/RestApiParameterViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiParameterViewModel.cs index 7028c24f43e..2407dad082a 100644 --- a/src/Docfx.DataContracts.RestApi/RestApiParameterViewModel.cs +++ b/src/Docfx.DataContracts.RestApi/RestApiParameterViewModel.cs @@ -11,17 +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 = "content")] - [JsonProperty("content", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("content")] - [Docfx.Common.EntityMergers.MergeOption(typeof(RestApiArrayMergeHandler))] - public List Content { get; set; } - [YamlMember(Alias = "description")] [JsonProperty("description")] [JsonPropertyName("description")] diff --git a/src/Docfx.DataContracts.RestApi/RestApiRequestBodyViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiRequestBodyViewModel.cs deleted file mode 100644 index 94104054a15..00000000000 --- a/src/Docfx.DataContracts.RestApi/RestApiRequestBodyViewModel.cs +++ /dev/null @@ -1,27 +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 RestApiRequestBodyViewModel -{ - [YamlMember(Alias = "description")] - [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("description")] - public string Description { get; set; } - - [YamlMember(Alias = "required")] - [JsonProperty("required", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("required")] - public bool? Required { get; set; } - - [YamlMember(Alias = "content")] - [JsonProperty("content", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("content")] - [Docfx.Common.EntityMergers.MergeOption(typeof(RestApiArrayMergeHandler))] - public List Content { get; set; } -} diff --git a/src/Docfx.DataContracts.RestApi/RestApiResponseExampleViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiResponseExampleViewModel.cs index b326996fb0d..356416a9757 100644 --- a/src/Docfx.DataContracts.RestApi/RestApiResponseExampleViewModel.cs +++ b/src/Docfx.DataContracts.RestApi/RestApiResponseExampleViewModel.cs @@ -9,16 +9,6 @@ namespace Docfx.DataContracts.RestApi; public class RestApiResponseExampleViewModel { - [YamlMember(Alias = "name")] - [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("name")] - public string Name { get; set; } - - [YamlMember(Alias = "externalValue")] - [JsonProperty("externalValue", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("externalValue")] - public string ExternalValue { get; set; } - [YamlMember(Alias = "mimeType")] [JsonProperty("mimeType")] [JsonPropertyName("mimeType")] diff --git a/src/Docfx.DataContracts.RestApi/RestApiResponseViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiResponseViewModel.cs index 9db07b93573..b8823d01ef9 100644 --- a/src/Docfx.DataContracts.RestApi/RestApiResponseViewModel.cs +++ b/src/Docfx.DataContracts.RestApi/RestApiResponseViewModel.cs @@ -11,22 +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 = "content")] - [JsonProperty("content", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("content")] - [Docfx.Common.EntityMergers.MergeOption(typeof(RestApiArrayMergeHandler))] - public List Content { 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 6532197c6ee..429a1fef1e5 100644 --- a/src/Docfx.DataContracts.RestApi/RestApiRootItemViewModel.cs +++ b/src/Docfx.DataContracts.RestApi/RestApiRootItemViewModel.cs @@ -4,47 +4,14 @@ 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; } - - /// Source specification version, independent of the API version in info.version. - [YamlMember(Alias = "specificationVersion")] - [JsonProperty("specificationVersion", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("specificationVersion")] - public string SpecificationVersion { 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; } - - [YamlMember(Alias = "servers")] - [JsonProperty("servers", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("servers")] - [Docfx.Common.EntityMergers.MergeOption(typeof(RestApiArrayMergeHandler))] - public List Servers { get; set; } - - [YamlMember(Alias = "schemas")] - [JsonProperty("schemas", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("schemas")] - public Dictionary Schemas { get; set; } - /// - /// The original OpenAPI source content + /// The original swagger.json content /// `_` prefix indicates that this metadata is generated /// [YamlMember(Alias = "_raw")] @@ -62,23 +29,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.SpecificationVersion = SpecificationVersion; - target.Info = Inherit("info", Info); - target.ExternalDocs = Inherit("externalDocs", ExternalDocs); - target.SecurityDefinitions = Inherit("securityDefinitions", SecurityDefinitions); - target.Servers = Inherit("servers", target.Servers ?? Servers); - target.Schemas = Inherit("schemas", Schemas); - - // 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/RestApiSchemaCompositionViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiSchemaCompositionViewModel.cs deleted file mode 100644 index dda1d783502..00000000000 --- a/src/Docfx.DataContracts.RestApi/RestApiSchemaCompositionViewModel.cs +++ /dev/null @@ -1,22 +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 RestApiSchemaCompositionViewModel -{ - [YamlMember(Alias = "kind")] - [JsonProperty("kind", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("kind")] - public string Kind { get; set; } - - [YamlMember(Alias = "schemas")] - [JsonProperty("schemas", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("schemas")] - [Docfx.Common.EntityMergers.MergeOption(typeof(RestApiArrayMergeHandler))] - public List Schemas { get; set; } -} diff --git a/src/Docfx.DataContracts.RestApi/RestApiSchemaConstraintViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiSchemaConstraintViewModel.cs deleted file mode 100644 index c2d59856526..00000000000 --- a/src/Docfx.DataContracts.RestApi/RestApiSchemaConstraintViewModel.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 RestApiSchemaConstraintViewModel -{ - [YamlMember(Alias = "name")] - [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("name")] - public string Name { get; set; } - - [YamlMember(Alias = "value")] - [JsonProperty("value", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("value")] - public string Value { get; set; } -} diff --git a/src/Docfx.DataContracts.RestApi/RestApiSchemaViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiSchemaViewModel.cs deleted file mode 100644 index 6feaa3e1203..00000000000 --- a/src/Docfx.DataContracts.RestApi/RestApiSchemaViewModel.cs +++ /dev/null @@ -1,97 +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 = "referenceId")] - [JsonProperty("referenceId", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("referenceId")] - public string ReferenceId { 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 = "composition")] - [JsonProperty("composition", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("composition")] - [MergeOption(typeof(RestApiArrayMergeHandler))] - public List Composition { get; set; } - - [YamlMember(Alias = "constraints")] - [JsonProperty("constraints", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("constraints")] - [MergeOption(typeof(RestApiArrayMergeHandler))] - public List Constraints { 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; } - - [YamlMember(Alias = "examples")] - [JsonProperty("examples", NullValueHandling = NullValueHandling.Ignore)] - [JsonPropertyName("examples")] - [MergeOption(typeof(RestApiArrayMergeHandler))] - public List Examples { 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/src/Docfx.DataContracts.RestApi/RestApiServerViewModel.cs b/src/Docfx.DataContracts.RestApi/RestApiServerViewModel.cs deleted file mode 100644 index ecdcb4ccc24..00000000000 --- a/src/Docfx.DataContracts.RestApi/RestApiServerViewModel.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 RestApiServerViewModel -{ - [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; } -} diff --git a/templates/common/RestApi.common.js b/templates/common/RestApi.common.js index 5644c8f2668..9a173182a50 100644 --- a/templates/common/RestApi.common.js +++ b/templates/common/RestApi.common.js @@ -3,9 +3,10 @@ var common = require('./common.js'); exports.transform = function (model) { + var openApi3 = typeof model.specificationVersion === "string" && model.specificationVersion.indexOf("3.") === 0; var definitions = Object.create(null); var references = []; - Object.keys(model.schemas || {}).forEach(function (name) { schemaDetails(model.schemas[name], name); }); + if (openApi3) Object.keys(model.schemas || {}).forEach(function (name) { schemaDetails(model.schemas[name], name); }); var _fileNameWithoutExt = common.path.getFileNameWithoutExtension(model._path); model._jsonPath = _fileNameWithoutExt + ".swagger" + (model.rawExtension === ".yaml" ? ".yaml" : ".json"); model.title = model.title || model.name; @@ -19,7 +20,7 @@ exports.transform = function (model) { if (child.operation) { child.operation = child.operation.toUpperCase(); } - child.path = child.displayPath || child.path; + child.path = openApi3 ? child.path : appendQueryParamsToPath(child.path, child.parameters); child.sourceurl = child.sourceurl || common.getViewSourceHref(child, null, model._gitUrlPattern); child.conceptual = child.conceptual || ''; // set to empty incase mustache looks up child.summary = child.summary || ''; // set to empty incase mustache looks up @@ -29,13 +30,18 @@ exports.transform = function (model) { child.htmlId = common.getHtmlId(child.uid); formatExample(child.responses); - (child.servers || []).forEach(function (server) { server.description = server.description || ''; }); - (child.parameters || []).forEach(transformPayload); - if (child.requestBody) { - child.requestBody.description = child.requestBody.description || ''; - transformContent(child.requestBody.content); + if (openApi3) { + (child.servers || []).forEach(function (server) { server.description = server.description || ''; }); + (child.parameters || []).forEach(transformPayload); + if (child.requestBody) { + child.requestBody.description = child.requestBody.description || ''; + transformContent(child.requestBody.content); + } + (child.responses || []).forEach(transformPayload); + } else { + resolveAllOf(child); + transformReference(child); } - (child.responses || []).forEach(transformPayload); }; if (!model.tags || model.tags.length === 0) { var childTags = []; @@ -89,18 +95,36 @@ 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 = ''; + if (openApi3) { + 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 }; + }); + } else { + 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); }); + }); + }); } - return { schemaDetails: details }; - }); + 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; @@ -212,7 +236,214 @@ 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; + + var requiredQueryParams = parameters.filter(function (p) { return p.in === 'query' && p.required; }); + if (requiredQueryParams.length > 0) { + path = formatParams(path, requiredQueryParams, true); + } + + var optionalQueryParams = parameters.filter(function (p) { return p.in === 'query' && !p.required; }); + if (optionalQueryParams.length > 0) { + path += "["; + path = formatParams(path, optionalQueryParams, requiredQueryParams.length === 0); + path += "]"; + } + return path; + } + + function formatParams(path, parameters, isFirst) { + for (var i = 0; i < parameters.length; i++) { + if (i === 0 && isFirst) { + path += "?"; + } else { + path += "&"; + } + path += parameters[i].name; + } + 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 e012c480b7a..63e37e63e22 100644 --- a/templates/default/partials/rest.child.tmpl.partial +++ b/templates/default/partials/rest.child.tmpl.partial @@ -53,6 +53,18 @@ {{#content}}{{>partials/rest.media-schema}}{{/content}} {{^hasContent}} {{#schemaDetails}}{{>partials/rest.schema}}{{/schemaDetails}} + {{^schemaDetails}} + {{^schema.cType}} + {{schema.type}} + {{#schema.format}} + ({{schema.format}}) + {{/schema.format}} + {{/schema.cType}} + + {{#schema.cType}} + {{{schema.cType}}}{{#schema.cTypeIsArray}}[]{{/schema.cTypeIsArray}} + {{/schema.cType}} + {{/schemaDetails}} {{/hasContent}} {{default}} @@ -95,13 +107,30 @@ {{#content}}{{>partials/rest.media-schema}}{{/content}} {{^hasContent}} {{#schemaDetails}}{{>partials/rest.schema}}{{/schemaDetails}} + {{^schemaDetails}} + {{^schema.cType}} + {{schema.type}} + {{/schema.cType}} + + {{#schema.cType}} + {{{schema.cType}}}{{#schema.cTypeIsArray}}[]{{/schema.cTypeIsArray}} + {{/schema.cType}} + {{/schemaDetails}} {{/hasContent}} {{{description}}} {{#content}}{{>partials/rest.examples}}{{/content}} {{^hasContent}} - {{>partials/rest.examples}} + {{#exampleDetails.0}}{{>partials/rest.examples}}{{/exampleDetails.0}} + {{^exampleDetails.0}} + {{#examples}} +
+ Mime type: {{mimeType}} +
+
{{content}}
+ {{/examples}} + {{/exampleDetails.0}} {{/hasContent}} diff --git a/templates/default/partials/rest.definition.tmpl.partial b/templates/default/partials/rest.definition.tmpl.partial index f3f244ce101..87dc37b8031 100644 --- a/templates/default/partials/rest.definition.tmpl.partial +++ b/templates/default/partials/rest.definition.tmpl.partial @@ -4,3 +4,49 @@

{{name}}

{{>partials/rest.schema}} {{/schemaDetails}} + +{{^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}} +{{/schemaDetails}} diff --git a/templates/modern/src/rest.test.ts b/templates/modern/src/rest.test.ts index 682ade42062..1bd23f160ba 100644 --- a/templates/modern/src/rest.test.ts +++ b/templates/modern/src/rest.test.ts @@ -18,27 +18,26 @@ const rest = runInThisContext(`(function(require) { })`)(() => common) test('REST raw filename hints preserve JSON compatibility and identify original YAML', () => { - const legacy = rest.transform({ uid: 'legacy', _path: 'legacy.html' }) - assert.equal(legacy._jsonPath, 'legacy.swagger.json') + const swagger2 = rest.transform({ uid: 'swagger2', _path: 'swagger2.html' }) + assert.equal(swagger2._jsonPath, 'swagger2.swagger.json') - const json = rest.transform({ uid: 'json', _path: 'openapi.html', rawExtension: '.json', _raw: '{"openapi":"3.1.0"}' }) + const json = rest.transform({ specificationVersion: '3.2.0', uid: 'json', _path: 'openapi.html', rawExtension: '.json', _raw: '{"openapi":"3.1.0"}' }) assert.equal(json._jsonPath, 'openapi.swagger.json') assert.equal(json._raw, '{"openapi":"3.1.0"}') - const yaml = rest.transform({ uid: 'yaml', _path: 'openapi.html', rawExtension: '.yaml', _raw: 'openapi: 3.1.0\n' }) + const yaml = rest.transform({ specificationVersion: '3.2.0', uid: 'yaml', _path: 'openapi.html', rawExtension: '.yaml', _raw: 'openapi: 3.1.0\n' }) assert.equal(yaml._jsonPath, 'openapi.swagger.yaml') assert.equal(yaml._raw, 'openapi: 3.1.0\n') }) -test('REST uses adapter display paths and renders allOf through the shared schema partial', () => { +test('Swagger 2.0 preserves query paths and flattened allOf definitions', () => { const model = rest.transform({ - uid: 'legacy', - _path: 'legacy.json', + uid: 'swagger2', + _path: 'swagger2.json', children: [{ uid: 'get', operation: 'get', path: '/items', - displayPath: '/items?filter[&limit]', parameters: [ { name: 'filter', in: 'query', required: true, schema: { type: 'string' } }, { name: 'limit', in: 'query', schema: { type: 'integer' } } @@ -57,16 +56,17 @@ test('REST uses adapter display paths and renders allOf through the shared schem 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.flatMap(schema => schema.properties.map(property => property.key)), ['id', 'name']) - assert.equal(child.responses[0].schema.allOf.length, 2) + const schema = child.responses[0].schema + assert.equal(schema.cTypeId, 'Item') + assert.deepEqual(schema.properties.map(property => property.key), ['id', 'name']) + assert.equal(schema.allOf, undefined) assert.equal(model.definitions.length, 1) - assert.equal(model.definitions[0].schemaDetails.id, 'Item') + assert.equal(model.definitions[0].cTypeId, 'Item') }) test('REST prepares every request and response media schema and named example', () => { const model = rest.transform({ + specificationVersion: '3.2.0', uid: 'media', _path: 'media.json', schemas: {}, @@ -142,7 +142,7 @@ test('REST keeps nested composition, constraints, unions, boolean schemas, and f } } const original = structuredClone(schema) - const model = rest.transform({ uid: 'nested', _path: 'nested.json', schemas: { Nested: schema } }) + const model = rest.transform({ specificationVersion: '3.2.0', uid: 'nested', _path: 'nested.json', schemas: { Nested: schema } }) const details = model.definitions[0].schemaDetails assert.deepEqual(schema, original) assert.equal(details.properties[0].required, true) @@ -161,6 +161,7 @@ test('REST keeps nested composition, constraints, unions, boolean schemas, and f test('REST links recursive references and aliases without colliding schema anchors', () => { const model = rest.transform({ + specificationVersion: '3.2.0', uid: 'references', _path: 'references.json', schemas: { @@ -191,6 +192,7 @@ test('REST links recursive references and aliases without colliding schema ancho test('REST adds inline reference definitions and leaves unresolved references as text', () => { const model = rest.transform({ + specificationVersion: '3.2.0', uid: 'inline', _path: 'inline.json', children: [{ @@ -212,8 +214,9 @@ test('REST adds inline reference definitions and leaves unresolved references as assert.equal(details.properties[0].value.referenceId, '') }) -test('REST renders parameter content and keeps same-name external schema references distinct', () => { +test('REST renders parameter content and keeps schema references distinct', () => { const model = rest.transform({ + specificationVersion: '3.2.0', uid: 'parameters', _path: 'parameters.json', children: [{ @@ -228,14 +231,14 @@ test('REST renders parameter content and keeps same-name external schema referen mimeType: 'application/json', schema: { type: 'object', - 'x-internal-ref-name': 'models/first.yaml#Filter', - properties: { next: { 'x-internal-loop-ref-name': 'models/first.yaml#Filter' } } + 'x-internal-ref-name': 'FirstFilter', + properties: { next: { 'x-internal-loop-ref-name': 'FirstFilter' } } }, examples: [{ name: 'active', content: '{"active":true}' }] }, { mimeType: 'text/plain', - schema: { type: 'string', 'x-internal-ref-name': 'models/second.yaml#Filter' }, + schema: { type: 'string', 'x-internal-ref-name': 'SecondFilter' }, examples: [{ content: 'active' }] } ] @@ -264,7 +267,7 @@ test('REST renders schema examples without inheriting names, MIME types, or ance items: { type: 'string' } } const original = structuredClone(schema) - const model = rest.transform({ uid: 'examples', _path: 'examples.json', schemas: { Example: schema } }) + const model = rest.transform({ specificationVersion: '3.2.0', uid: 'examples', _path: 'examples.json', schemas: { Example: schema } }) const details = model.definitions[0].schemaDetails assert.deepEqual(schema, original) assert.deepEqual(details.exampleDetails[0], { @@ -285,6 +288,7 @@ test('REST renders schema examples without inheriting names, MIME types, or ance test('REST displays external example URLs without inventing content or linking executable schemes', () => { const urls = ['https://example.test/sample.json', 'http://example.test/sample.json', 'samples/local.json', 'javascript:alert(1)'] const model = rest.transform({ + specificationVersion: '3.2.0', uid: 'external-examples', _path: 'external-examples.json', children: [{ @@ -304,7 +308,7 @@ test('REST displays external example URLs without inventing content or linking e assert.ok(examples.every(example => example.name === 'external' && example.content === '' && !example.hasContent)) }) -for (const specificationVersion of ['2.0', '3.0.3', '3.1.0', '3.2.0']) { +for (const specificationVersion of ['3.0.3', '3.1.0', '3.2.0']) { test(`REST preserves literal enum, examples, and extensions for specification ${specificationVersion}`, () => { const literal = { description: 'literal **description**, not markup', @@ -353,15 +357,16 @@ for (const specificationVersion of ['2.0', '3.0.3', '3.1.0', '3.2.0']) { }) } -test('REST registers an external alias and its recursive target during projection', () => { +test('REST registers an alias and its recursive target during projection', () => { const model = rest.transform({ + specificationVersion: '3.2.0', uid: 'alias', _path: 'alias.json', schemas: { Alias: { type: 'object', - 'x-internal-ref-name': 'external.yaml#Node', - properties: { next: { 'x-internal-loop-ref-name': 'external.yaml#Node' } } + 'x-internal-ref-name': 'Node', + properties: { next: { 'x-internal-loop-ref-name': 'Node' } } } } }) @@ -374,6 +379,7 @@ test('REST registers an external alias and its recursive target during projectio test('REST uses declared definitions when an earlier alias supplies reference siblings', () => { const model = rest.transform({ + specificationVersion: '3.2.0', uid: 'siblings', _path: 'siblings.json', schemas: { diff --git a/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs b/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs index 435fba6ad8b..904f31693fe 100644 --- a/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs +++ b/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs @@ -35,9 +35,9 @@ public void OpenApi32MapsAdditionalMethodsStreamingAndExamples(string format) "schemas":{"Event":{"type":"object","properties":{"id":{"const":42},"anything":true,"never":false}}} }} """, format); - Assert.Equal("3.2.0", model.SpecificationVersion); + Assert.Equal("3.2.0", model.Metadata["specificationVersion"]); Assert.Equal(new[] { "query", "copy" }, model.Children.Select(child => child.OperationName)); - var content = JArray.FromObject(Assert.Single(model.Children[0].Responses).Content); + var content = JArray.FromObject(Assert.Single(model.Children[0].Responses).Metadata["content"]); var item = content[0]["itemSchema"]; Assert.Equal("Event", item["x-internal-ref-name"]); Assert.Equal("42", item["properties"]["id"]["constraints"][0]["value"]); @@ -80,7 +80,7 @@ public void NormalizesYamlBlocksAndAliasesWithoutChangingLiteralData() Assert.Equal(raw, model.Raw); Assert.Equal(42, ((JObject)model.Metadata["x-literal"])["schema"]["const"]); Assert.Equal(false, model.Metadata["x-boolean"]); - var schemas = JObject.FromObject(model.Schemas); + var schemas = JObject.FromObject(model.Metadata["schemas"]); Assert.Equal("After the constant", schemas["Object"]["description"]); Assert.Equal("{\"schema\":{\"const\":42},\"flag\":false}", schemas["Object"]["constraints"][0]["value"]); Assert.Equal("[42,null,\"false\"]", schemas["Array"]["constraints"][0]["value"]); @@ -91,37 +91,6 @@ public void NormalizesYamlBlocksAndAliasesWithoutChangingLiteralData() Assert.Equal("\"42\"", schemas["String"]["constraints"][0]["value"]); } - [Fact] - public void OpenApi32ResolvesExternalMediaTypesAndStreamItemSchemas() - { - var folder = GetRandomFolder(); - var entry = CreateFile("entry.json", """ - {"openapi":"3.2.0","info":{"title":"External streams","version":"1"}, - "paths":{"/events":{"query":{"responses":{"200":{"description":"OK","content":{ - "application/jsonl":{"$ref":"media.yaml#/components/mediaTypes/Events"} - }}}}}}} - """, folder); - CreateFile("media.yaml", """ - openapi: 3.2.0 - info: {title: Media, version: '1'} - paths: {} - components: - mediaTypes: - Events: - itemSchema: {$ref: 'schemas.json#/components/schemas/Event', const: {id: 42}} - """, folder); - CreateFile("schemas.json", """ - {"openapi":"3.1.0","info":{"title":"Schemas","version":"1"},"paths":{}, - "components":{"schemas":{"Event":{"type":"object","properties":{"id":{"const":42},"never":false}}}}} - """, folder); - var model = OpenApiDocumentReader.Read(entry); - var content = JArray.FromObject(Assert.Single(Assert.Single(model.Children).Responses).Content); - var branches = content[0]["itemSchema"]["allOf"]; - Assert.Equal("42", branches[0]["properties"]["id"]["constraints"][0]["value"]); - Assert.Equal("no value", branches[0]["properties"]["never"]["type"]); - Assert.Equal("{\"id\":42}", branches[1]["constraints"][0]["value"]); - } - [Fact] public void ReportsOpenApi32FeaturesWithoutDocumentationUi() { @@ -188,24 +157,24 @@ public void MapsTypedParametersBodiesResponsesAndLiteralExamples(string version) var child = Assert.Single(model.Children); Assert.Equal(model.Uid + "/createItem", child.Uid); Assert.Equal("docs", child.Metadata["x-owner"]?.ToString()); - Assert.Equal("https://api.example.test/v1/items/{id}", child.RequestUrl); + Assert.Equal("https://api.example.test/v1/items/{id}", child.Metadata["requestUrl"]); Assert.Equal(["limit", "id"], child.Parameters.Select(p => p.Name)); - Assert.Equal("integer", (JObject.FromObject(child.Parameters[0].Schema))["type"]); + Assert.Equal("integer", (JObject.FromObject(child.Parameters[0].Metadata["schema"]))["type"]); Assert.Equal("0", child.Parameters[0].Metadata["default"]?.ToString()); Assert.Equal(model.Uid + "/tag/items", Assert.Single(model.Tags).Uid); - var body = JObject.FromObject(child.RequestBody); + var body = JObject.FromObject(child.Metadata["requestBody"]); Assert.True((bool)body["required"]); Assert.Equal("application/json", body["content"][0]["mimeType"]); var schema = body["content"][0]["schema"]; Assert.Equal("string", schema["properties"]["name"]["type"]); Assert.NotNull(schema["properties"]["next"]["x-internal-loop-ref-name"]); var response = Assert.Single(child.Responses); - var content = JArray.FromObject(response.Content); + var content = JArray.FromObject(response.Metadata["content"]); Assert.Equal(["application/json", "text/plain"], content.Select(c => (string)c["mimeType"])); var example = JObject.Parse((string)content[0]["examples"][0]["content"]); Assert.Equal("this-is-payload.json", example["$ref"]); Assert.Equal("**literal**", example["description"]); - Assert.Equal(2, response.Content.Sum(media => media.Examples.Count)); + Assert.Equal(2, content.Sum(media => media["examples"].Count())); } [Theory] @@ -229,7 +198,7 @@ public void YamlUsesTheSameModelsAndDefaults(string version) Assert.Equal("YAML API/1", model.Uid); var operation = Assert.Single(model.Children); Assert.StartsWith("get_", operation.OperationId); - Assert.Equal("/health", operation.RequestUrl); + Assert.Equal("/health", operation.Metadata["requestUrl"]); Assert.Equal("204", Assert.Single(operation.Responses).HttpStatusCode); } @@ -254,7 +223,7 @@ static RestApiRootItemViewModel Read(string paths) => OpenApiDocumentReader.Pars var first = Read(paths); var second = Read("\"/unrelated\": {\"get\":{\"responses\":{\"200\":{\"description\":\"OK\"}}}}," + paths); Assert.Equal(["/path-base/path", "https://override.example.test/v2/path", "https://root.example.test/root/root"], - first.Children.Select(child => child.RequestUrl)); + first.Children.Select(child => child.Metadata["requestUrl"])); Assert.Equal(first.Children.Select(child => child.OperationId), second.Children.Skip(1).Select(child => child.OperationId)); Assert.All(first.Children, child => Assert.DoesNotContain("/", child.OperationId)); } @@ -278,8 +247,8 @@ public void BooleanUnionCompositionAndRefSiblingsAreNotFlattened() }} } """, "json"); - var schemas = JObject.FromObject(model.Schemas); - var content = JArray.FromObject(Assert.Single(Assert.Single(model.Children).Responses).Content); + var schemas = JObject.FromObject(model.Metadata["schemas"]); + var content = JArray.FromObject(Assert.Single(Assert.Single(model.Children).Responses).Metadata["content"]); Assert.Equal("any value", content[0]["schema"]["type"]); Assert.Equal("no value", content[1]["schema"]["type"]); Assert.Contains("string", (string)schemas["Nullable"]["type"]); @@ -313,7 +282,7 @@ public void PreservesBooleanSchemasInMapsAndCompositions(string boolean) var model = OpenApiDocumentReader.Parse( """{"openapi":"3.1.0","info":{"title":"Boolean","version":"1"},"paths":{},"components":{"schemas":{"Value":SCHEMA}}}""" .Replace("SCHEMA", schema), "json"); - var value = (JObject.FromObject(model.Schemas))["Value"]; + var value = (JObject.FromObject(model.Metadata["schemas"]))["Value"]; if (schema == boolean) Assert.Equal(boolean == "true" ? "any value" : "no value", value["type"]); else if (schema.Contains("properties")) @@ -325,45 +294,6 @@ public void PreservesBooleanSchemasInMapsAndCompositions(string boolean) } } - [Theory] - [InlineData("true", "component")] - [InlineData("false", "component")] - [InlineData("true", "properties")] - [InlineData("false", "properties")] - [InlineData("true", "composition")] - [InlineData("false", "composition")] - public void PreservesBooleanSchemasInExternalDocuments(string boolean, string position) - { - var folder = GetRandomFolder(); - var entry = CreateFile("entry.json", """ - {"openapi":"3.1.0","info":{"title":"External","version":"1"},"paths":{}, - "components":{"schemas":{"Value":{"$ref":"external.yaml#/components/schemas/Value"}}}} - """, folder); - var schema = position switch - { - "component" => boolean, - "properties" => "{ properties: { value: " + boolean + " } }", - _ => "{ allOf: [" + boolean + "] }" - }; - CreateFile("external.yaml", $$""" - openapi: 3.1.0 - info: { title: External, version: '1' } - paths: {} - components: - schemas: - Value: {{schema}} - """, folder); - var model = OpenApiDocumentReader.Read(entry); - var value = (JObject.FromObject(model.Schemas))["Value"]; - var actual = position switch - { - "component" => value, - "properties" => value["properties"]["value"], - _ => value["allOf"][0] - }; - Assert.Equal(boolean == "true" ? "any value" : "no value", actual["type"]); - } - [Fact] public void SchemaShapedLiteralExamplesAndExtensionsAreNotPreflighted() { @@ -378,10 +308,11 @@ public void SchemaShapedLiteralExamplesAndExtensionsAreNotPreflighted() } """, "json"); Assert.NotNull(model.Metadata["x-data"]); - var example = Assert.Single(Assert.Single(Assert.Single(Assert.Single(model.Children).Responses).Content).Examples); - Assert.Contains("false", example.Content); - Assert.Contains("true", example.Content); - Assert.Equal(42, (int)JObject.Parse(example.Content)["schema"]["const"]); + var content = Assert.IsType(Assert.Single(Assert.Single(model.Children).Responses).Metadata["content"]); + var example = Assert.Single(Assert.Single(content)["examples"]); + Assert.Contains("false", (string)example["content"]); + Assert.Contains("true", (string)example["content"]); + Assert.Equal(42, (int)JObject.Parse((string)example["content"])["schema"]["const"]); } [Fact] @@ -393,7 +324,7 @@ public void PreservesSingularSchemaExamplesFromOpenApi30() "components":{"schemas":{"Value":{"type":"object","example":{"description":"**literal**","$ref":"payload"}}}} } """, "json"); - var schema = (JObject.FromObject(model.Schemas))["Value"]; + var schema = (JObject.FromObject(model.Metadata["schemas"]))["Value"]; var example = JObject.Parse((string)schema["examples"][0]["content"]); Assert.Equal("**literal**", example["description"]); Assert.Equal("payload", example["$ref"]); @@ -410,7 +341,7 @@ public void PreservesTypedConstValues(string format) {"openapi":"3.1.0","info":{"title":"Constants","version":"1"},"paths":{}, "components":{"schemas":{"Value":{"const":VALUE,"enum":[1,2]}}}} """.Replace("VALUE", value), format); - var schema = (JObject.FromObject(model.Schemas))["Value"]; + var schema = (JObject.FromObject(model.Metadata["schemas"]))["Value"]; Assert.Equal(value, (string)Assert.Single(schema["constraints"])["value"]); Assert.Equal(new[] { 1, 2 }, schema["enum"].Values()); } @@ -450,7 +381,7 @@ public void PreservesStringAndNullConstantsAndExplicitNullDefaults(string format {"openapi":"3.1.0","info":{"title":"Constants","version":"1"},"paths":{}, "components":{"schemas":{"Value":{"const":VALUE,"default":null}}}} """.Replace("VALUE", value), format); - var constraints = (JObject.FromObject(model.Schemas))["Value"]["constraints"]; + var constraints = (JObject.FromObject(model.Metadata["schemas"]))["Value"]["constraints"]; Assert.Equal(value, (string)Assert.Single(constraints, item => (string)item["name"] == "const")["value"]); Assert.Equal("null", (string)Assert.Single(constraints, item => (string)item["name"] == "default")["value"]); } @@ -477,7 +408,7 @@ public void PreservesYamlStringAndNullConstants(string value, string expected) Value: const: {{value}} """, "yaml"); - var constraints = (JObject.FromObject(model.Schemas))["Value"]["constraints"]; + var constraints = (JObject.FromObject(model.Metadata["schemas"]))["Value"]["constraints"]; Assert.Equal(expected, (string)Assert.Single(constraints)["value"]); } @@ -496,7 +427,7 @@ public void PreservesImplicitYamlNullValues(string version, string keyword) Value: {{keyword}}: """, "yaml"); - var schema = (JObject.FromObject(model.Schemas))["Value"]; + var schema = (JObject.FromObject(model.Metadata["schemas"]))["Value"]; Assert.Equal("null", (string)Assert.Single(schema["constraints"])["value"]); } @@ -512,36 +443,10 @@ public void ChecksSchemasInNamedParameters(string name) "paths":{"/items":{"get":{"parameters":[{"$ref":"#/components/parameters/NAME"}],"responses":{"200":{"description":"OK"}}}}}, "components":{"parameters":{"NAME":{"name":"q","in":"query","schema":{"const":42}}}}} """.Replace("NAME", name), "json"); - var schema = JObject.FromObject(Assert.Single(Assert.Single(model.Children).Parameters).Schema); + var schema = JObject.FromObject(Assert.Single(Assert.Single(model.Children).Parameters).Metadata["schema"]); Assert.Equal("42", (string)Assert.Single(schema["constraints"])["value"]); } - [Theory] - [InlineData("{properties: {value: {const: 42}}}", "42")] - [InlineData("{oneOf: [{const: true}]}", "true")] - [InlineData("{$ref: '#/components/schemas/Base', const: false}", "false")] - public void PreservesConstInExternalSchemas(string schema, string expected) - { - var folder = GetRandomFolder(); - var entry = CreateFile("entry.json", """ - {"openapi":"3.1.0","info":{"title":"Constants","version":"1"},"paths":{}, - "components":{"schemas":{"Value":{"$ref":"external.yaml#/components/schemas/Value"}}}} - """, folder); - CreateFile("external.yaml", $$""" - openapi: 3.1.0 - info: {title: Constants, version: '1'} - paths: {} - components: - schemas: - Base: {type: boolean} - Value: {{schema}} - """, folder); - var model = OpenApiDocumentReader.Read(entry); - var value = (JObject.FromObject(model.Schemas))["Value"]; - var constraint = Assert.Single(value.SelectTokens("$..constraints[*]"), item => (string)item["name"] == "const"); - Assert.Equal(expected, constraint["value"]); - } - [Theory] [InlineData("200")] [InlineData("default")] @@ -552,7 +457,7 @@ public void PreservesConstInInlineResponseSchemas(string status) "paths":{"/items":{"get":{"responses":{"STATUS":{"description":"OK", "content":{"application/json":{"schema":{"const":42}}}}}}}}} """.Replace("STATUS", status), "json"); - var content = JArray.FromObject(Assert.Single(Assert.Single(model.Children).Responses).Content); + var content = JArray.FromObject(Assert.Single(Assert.Single(model.Children).Responses).Metadata["content"]); Assert.Equal("42", (string)Assert.Single(content[0]["schema"]["constraints"])["value"]); } @@ -580,7 +485,7 @@ public void DoesNotTurnExclusiveOverlappingAlternativesIntoInclusiveUnions(strin continue; } var model = OpenApiDocumentReader.Parse(raw, "json"); - var value = (JObject.FromObject(model.Schemas))["Value"]; + var value = (JObject.FromObject(model.Metadata["schemas"]))["Value"]; Assert.Equal("One of", value["composition"][0]["kind"]); Assert.Equal(2, value["composition"][0]["schemas"].Count()); } @@ -613,111 +518,11 @@ public void InvalidAndNetworkReferencesAreErrors(string reference) Assert.NotEmpty(error.Message); } - [Fact] - public void ResolvesLocalMixedFormatDocumentsAndNestedReferences() - { - var folder = GetRandomFolder(); - var entry = CreateFile("entry.yaml", """ - openapi: 3.1.0 - info: { title: References, version: '1' } - paths: - /items: - get: - operationId: list - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: models/first.json#/components/schemas/Item - """, folder); - CreateFile("models/first.json", """ - { - "openapi":"3.1.0","info":{"title":"Models","version":"1"},"paths":{}, - "components":{"schemas":{"Item":{"type":"object","properties":{ - "name":{"$ref":"second.yaml#/components/schemas/Name"} - }}}} - } - """, folder); - CreateFile("models/second.yaml", """ - openapi: 3.1.0 - info: { title: Types, version: '1' } - paths: {} - components: - schemas: - Name: { type: string, description: From YAML } - """, folder); - var model = OpenApiDocumentReader.Read(entry); - var response = Assert.Single(Assert.Single(model.Children).Responses); - var schema = (JArray.FromObject(response.Content))[0]["schema"]; - Assert.Equal("string", schema["properties"]["name"]["type"]); - Assert.Equal("From YAML", schema["properties"]["name"]["description"]); - } - - [Fact] - public void LoadsMixedFormatBidirectionalReferencesOnceWithoutExpandingCycles() - { - var folder = GetRandomFolder(); - var entry = CreateFile("a.json", """ - {"openapi":"3.1.0","info":{"title":"Cycle","version":"1"},"paths":{}, - "components":{"schemas":{"A":{"type":"object","properties":{"b":{"$ref":"b.yaml#/components/schemas/B"}}}}}} - """, folder); - CreateFile("b.yaml", """ - openapi: 3.1.0 - info: { title: Other, version: '1' } - paths: {} - components: - schemas: - B: - type: object - properties: - a: { $ref: 'a.json#/components/schemas/A' } - """, folder); - var model = OpenApiDocumentReader.Read(entry); - var schemas = JObject.FromObject(model.Schemas); - Assert.Equal("object", schemas["A"]["properties"]["b"]["type"]); - Assert.Equal("A", schemas["A"]["properties"]["b"]["properties"]["a"]["x-internal-loop-ref-name"]); - } - - [Fact] - public void SameRelativeFilenameInDifferentDirectoriesHasDistinctSdkIdentity() - { - var folder = GetRandomFolder(); - var entry = CreateFile("entry.json", """ - {"openapi":"3.1.0","info":{"title":"Identity","version":"1"},"paths":{}, - "components":{"schemas":{ - "A":{"$ref":"a/document.yaml#/components/schemas/Value"}, - "B":{"$ref":"b/document.yaml#/components/schemas/Value"} - }}} - """, folder); - foreach (var (directory, type) in new[] { ("a", "string"), ("b", "integer") }) - { - CreateFile($"{directory}/document.yaml", """ - openapi: 3.1.0 - info: { title: Reference, version: '1' } - paths: {} - components: - schemas: - Value: { $ref: 'common.json#/components/schemas/Value' } - """, folder); - CreateFile($"{directory}/common.json", """ - {"openapi":"3.1.0","info":{"title":"Common","version":"1"},"paths":{}, - "components":{"schemas":{"Value":{"type":"TYPE"}}}} - """.Replace("TYPE", type), folder); - } - var model = OpenApiDocumentReader.Read(entry); - var schemas = JObject.FromObject(model.Schemas); - Assert.Equal("string", schemas["A"]["type"]); - Assert.Equal("integer", schemas["B"]["type"]); - Assert.NotEqual((string)schemas["A"]["x-internal-ref-name"], (string)schemas["B"]["x-internal-ref-name"]); - } - [Theory] [InlineData("missing.yaml#/components/schemas/Value", false, "missing.yaml")] - [InlineData("external.yaml#/components/schemas/Missing", true, "Could not resolve")] - [InlineData("fragment.yaml", true, "UnsupportedExternalFragment")] - [InlineData("fragment.yaml#/components/schemas/Value", true, "UnsupportedExternalFragment")] + [InlineData("external.yaml#/components/schemas/Missing", true, "UnsupportedExternalReference")] + [InlineData("fragment.yaml", true, "UnsupportedExternalReference")] + [InlineData("fragment.yaml#/components/schemas/Value", true, "UnsupportedExternalReference")] public void MissingTargetsAndStandaloneFragmentsNeverSucceed(string reference, bool createExternal, string diagnostic) { var folder = GetRandomFolder(); diff --git a/test/Docfx.Build.RestApi.Tests/RestApiDocumentProcessorTest.cs b/test/Docfx.Build.RestApi.Tests/RestApiDocumentProcessorTest.cs index b5a07aab261..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 diff --git a/test/Docfx.Build.RestApi.Tests/RestApiDocumentReaderTest.cs b/test/Docfx.Build.RestApi.Tests/RestApiDocumentReaderTest.cs index 3da8fda0f70..4f322c71757 100644 --- a/test/Docfx.Build.RestApi.Tests/RestApiDocumentReaderTest.cs +++ b/test/Docfx.Build.RestApi.Tests/RestApiDocumentReaderTest.cs @@ -1,8 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Docfx.DataContracts.RestApi; -using Docfx.Common.EntityMergers; +using Newtonsoft.Json.Linq; using Docfx.Tests.Common; using Xunit; @@ -37,7 +36,7 @@ public void MalformedHeaderUsesTheReaderDiagnostic() [InlineData("3.0.3")] [InlineData("3.1.0")] [InlineData("3.2.0")] - public void ReadersProduceTheSameSchemaContract(string version) + public void ReadersKeepSchemaDataInMetadata(string version) { var source = version == "2.0" ? """ {"swagger":"2.0","info":{"title":"Common","version":"service-version"}, @@ -55,62 +54,15 @@ public void ReadersProduceTheSameSchemaContract(string version) var file = CreateFile("api.json", source, GetRandomFolder()); Assert.True(RestApiDocumentReader.IsSupportedFile(file)); var model = RestApiDocumentReader.Read(file, "api.json"); - Assert.Equal(version, model.SpecificationVersion); - Assert.Equal("service-version", model.Info.Version); + Assert.Equal(version == "2.0" ? null : version, model.Metadata.GetValueOrDefault("specificationVersion")); + Assert.Equal("Common/service-version", model.Uid); var operation = Assert.Single(model.Children); var parameter = Assert.Single(operation.Parameters); - Assert.Equal("string", parameter.Schema.Type); - Assert.DoesNotContain("schema", parameter.Metadata.Keys); + Assert.Equal("string", version == "2.0" ? parameter.Metadata["type"] + : (string)Assert.IsType(parameter.Metadata["schema"])["type"]); var response = Assert.Single(operation.Responses); - var schema = response.Schema ?? Assert.Single(response.Content).Schema; - Assert.Equal("string", Assert.Single(schema.AllOf).Properties["name"].Type); - Assert.DoesNotContain("content", response.Metadata.Keys); - } - - [Fact] - public void RequestBodyOverwritePreservesUnchangedMediaAndAcceptsFalse() - { - var body = new RestApiRequestBodyViewModel - { - Required = true, - Content = [ - new() { MimeType = "application/json", Schema = new() { Description = "JSON" } }, - new() { MimeType = "text/plain", Schema = new() { Description = "Text" } }] - }; - var merger = new MergerFacade(new KeyedListMerger(new ReflectionEntityMerger())); - merger.Merge(ref body, new RestApiRequestBodyViewModel { Description = "Body" }); - Assert.True(body.Required); - merger.Merge(ref body, new RestApiRequestBodyViewModel - { - Required = false, - Content = [null, new() { Schema = new() { Description = "Updated text" } }] - }); - Assert.False(body.Required); - Assert.Equal("JSON", body.Content[0].Schema.Description); - Assert.Equal("Updated text", body.Content[1].Schema.Description); - Assert.Equal("text/plain", body.Content[1].MimeType); - } - - [Fact] - public void SplitDocumentContextIsIndependentAndPreservesOverrides() - { - var root = new RestApiRootItemViewModel - { - SpecificationVersion = "3.2.0", - Schemas = new() { ["Item"] = new() { Description = "**Item**" } }, - Servers = [new() { Url = "/root" }], - ExternalDocs = new() { Url = "https://example.test/root" } - }; - var split = new RestApiRootItemViewModel - { - Servers = [new() { Url = "/operation" }], - Metadata = new() { ["externalDocs"] = new { url = "https://example.test/tag" } } - }; - root.CopyDocumentContextTo(split); - Assert.Equal("3.2.0", split.SpecificationVersion); - Assert.Equal("/operation", Assert.Single(split.Servers).Url); - Assert.Equal("https://example.test/tag", split.ExternalDocs.Url); - split.Schemas["Item"].Description = "

Item

"; - Assert.Equal("**Item**", root.Schemas["Item"].Description); + var schema = version == "2.0" ? Assert.IsType(response.Metadata["schema"]) + : Assert.IsType(response.Metadata["content"])[0]["schema"]; + Assert.Equal("string", (string)Assert.Single(schema["allOf"])["properties"]["name"]["type"]); } } diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs b/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs index 90be5984c8b..23d3337617b 100644 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs @@ -515,7 +515,7 @@ public void MissingSchemaAndExampleFieldsDoNotInheritParentValues(string templat } [Fact] - public void RejectsUnsupportedExternalFragmentsWithoutPublishing() + public void RejectsUnsupportedExternalReferencesWithoutPublishing() { var input = GetRandomFolder(); CreateFile("schema.yaml", "type: object\nproperties:\n value:\n type: string\n", input); @@ -530,7 +530,7 @@ public void RejectsUnsupportedExternalFragmentsWithoutPublishing() var files = new FileCollection(Directory.GetCurrentDirectory()); files.Add(DocumentType.Article, [file], input); - var output = Build(input, files, "default", false, false, "UnsupportedExternalFragment"); + var output = Build(input, files, "default", false, false, "UnsupportedExternalReference"); Assert.Empty(Directory.GetFiles(output, "*.raw.json", SearchOption.AllDirectories)); Assert.Empty(Directory.GetFiles(output, "*.html", SearchOption.AllDirectories)); @@ -540,20 +540,12 @@ public void RejectsUnsupportedExternalFragmentsWithoutPublishing() { var input = GetRandomFolder(); var document = ReadModel(Path.Combine("TestData", "openapi"), "service.json"); - var components = ReadModel(Path.Combine("TestData", "openapi"), "components.json"); - document["openapi"] = components["openapi"] = version; - var externalExtension = extension == ".json" ? ".yaml" : ".json"; - foreach (var reference in document.Descendants().OfType().Where(property => property.Name == "$ref")) - { - reference.Value = ((string)reference.Value).Replace("components.json", "components" + externalExtension, StringComparison.Ordinal); - } + document["openapi"] = version; if (version != "3.0.3") { - var properties = components["components"]["schemas"]["Item"]["properties"]; + var properties = document["components"]["schemas"]["Item"]["properties"]; properties["label"] = new JObject { ["type"] = new JArray("string", "null"), ["const"] = "42", ["default"] = null }; properties["nullValue"] = new JObject { ["const"] = null, ["default"] = null }; - // SDK 3.10.2 drops booleans in schema maps and composition lists; the reader rejects those forms. - // Exercise supported inline media schemas, using full OpenAPI documents for external references. document["paths"]["/health"]["get"]["responses"] = new JObject { ["200"] = new JObject @@ -567,7 +559,6 @@ public void RejectsUnsupportedExternalFragmentsWithoutPublishing() } }; } - CreateFile("components" + externalExtension, Serialize(components, externalExtension), input); var original = Serialize(document, extension); var service = CreateFile("service" + extension, original, input); var toc = CreateFile("toc.yml", $"- name: SDK API\n href: service{extension}\n", input); 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 0d2447d36d9..d7f0a3571e5 100644 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/SwaggerOutputCompatibilityTest.cs @@ -452,15 +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']")); + 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) { @@ -474,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.']")); diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/openapi/components.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/openapi/components.json deleted file mode 100644 index 09d19883321..00000000000 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/openapi/components.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "openapi": "3.0.3", - "info": { "title": "Shared schemas", "version": "1.0" }, - "paths": {}, - "components": { - "schemas": { - "Item": { - "type": "object", - "required": ["name"], - "properties": { - "name": { "type": "string", "description": "The **display name**." }, - "state": { "type": "string", "enum": ["active", "archived"] }, - "label": { "type": "string", "nullable": true }, - "choice": { "oneOf": [{ "type": "string" }, { "type": "integer" }] }, - "combined": { - "allOf": [ - { "type": "object", "properties": { "leftField": { "type": "boolean" } } }, - { "type": "object", "properties": { "rightField": { "type": "number" } } } - ] - }, - "either": { "anyOf": [{ "type": "boolean" }, { "type": "number" }] }, - "excluded": { "not": { "type": "integer" } } - }, - "example": { "name": "schema", "$ref": "literal-schema.json#/data", "description": "**literal schema**" } - }, - "Result": { - "type": "object", - "properties": { "receipt": { "type": "string", "description": "The **receipt**." } } - } - } - } -} diff --git a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/openapi/service.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/openapi/service.json index f5242b5035e..4d626798528 100644 --- a/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/openapi/service.json +++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/openapi/service.json @@ -61,6 +61,31 @@ } }, "components": { + "schemas": { + "Item": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "description": "The **display name**." }, + "state": { "type": "string", "enum": ["active", "archived"] }, + "label": { "type": "string", "nullable": true }, + "choice": { "oneOf": [{ "type": "string" }, { "type": "integer" }] }, + "combined": { + "allOf": [ + { "type": "object", "properties": { "leftField": { "type": "boolean" } } }, + { "type": "object", "properties": { "rightField": { "type": "number" } } } + ] + }, + "either": { "anyOf": [{ "type": "boolean" }, { "type": "number" }] }, + "excluded": { "not": { "type": "integer" } } + }, + "example": { "name": "schema", "$ref": "literal-schema.json#/data", "description": "**literal schema**" } + }, + "Result": { + "type": "object", + "properties": { "receipt": { "type": "string", "description": "The **receipt**." } } + } + }, "parameters": { "Id": { "name": "id", "in": "path", "required": true, "description": "The item **identifier**.", "schema": { "type": "string" } } }, @@ -70,7 +95,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "components.json#/components/schemas/Item" }, + "schema": { "$ref": "#/components/schemas/Item" }, "examples": { "request": { "$ref": "#/components/examples/Request" } } }, "application/xml": { @@ -87,7 +112,7 @@ "description": "The **created** item.", "content": { "application/json": { - "schema": { "$ref": "components.json#/components/schemas/Result" }, + "schema": { "$ref": "#/components/schemas/Result" }, "examples": { "response": { "value": { "receipt": "one", "$ref": "literal-response.json#/data", "description": "**literal response**" } From f59c6c17b1882530cfafd4e3ef1d979e18915bbe Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Fri, 25 Sep 2026 00:37:23 +1000 Subject: [PATCH 14/16] Map OpenAPI schema constraints directly from SDK properties --- .../OpenApi3ModelConverter.cs | 151 +++++++++++++----- .../OpenApiDocumentReaderTest.cs | 54 +++++++ 2 files changed, 169 insertions(+), 36 deletions(-) diff --git a/src/Docfx.Build.RestApi/OpenApi3ModelConverter.cs b/src/Docfx.Build.RestApi/OpenApi3ModelConverter.cs index e4a8ffcdd12..0b8c2ecca65 100644 --- a/src/Docfx.Build.RestApi/OpenApi3ModelConverter.cs +++ b/src/Docfx.Build.RestApi/OpenApi3ModelConverter.cs @@ -36,10 +36,10 @@ internal RestApiRootItemViewModel Convert(OpenApiDocument document, string raw, }; model.Metadata["specificationVersion"] = version; model.Metadata["servers"] = servers; - model.Metadata["info"] = Serialize(document.Info); + model.Metadata["info"] = JToken.Parse(Serialize(document.Info)); if (document.ExternalDocs != null) { - model.Metadata["externalDocs"] = Serialize(document.ExternalDocs); + model.Metadata["externalDocs"] = JToken.Parse(Serialize(document.ExternalDocs)); } var schemas = new JObject(); foreach (var (name, schema) in document.Components?.Schemas?.AsEnumerable() ?? []) @@ -215,7 +215,7 @@ private static JArray Examples(string mimeType, IOpenApiMediaType media) return result; } - private static string Literal(JsonNode value) + private static string Literal(JsonNode value, bool terse = false) { if (value == null) { @@ -224,15 +224,22 @@ private static string Literal(JsonNode value) // The YAML reader uses a sentinel for nulls, including nested values. // The SDK writer restores them; JsonNode.ToJsonString exposes the sentinel. using var text = new StringWriter(); - new OpenApiJsonWriter(text).WriteAny(value); + new OpenApiJsonWriter(text, new OpenApiJsonWriterSettings { Terse = terse }).WriteAny(value); return text.ToString(); } - private static JToken Serialize(IOpenApiSerializable value) + private string Serialize(IOpenApiSerializable value) { using var text = new StringWriter(); - value.SerializeAsV32(new OpenApiJsonWriter(text)); - return JToken.Parse(text.ToString()); + value.SerializeAsV32(new OpenApiJsonWriter(text, new OpenApiJsonWriterSettings { Terse = true })); + if (value is IOpenApiSchema && constants.Count > 0) + { + // Schema-valued constraints are displayed as JSON, including preserved const values. + var token = JToken.Parse(text.ToString()); + RestoreConstants(token); + return token.ToString(Formatting.None); + } + return text.ToString(); } private static Dictionary Extensions(IDictionary extensions) @@ -269,14 +276,12 @@ private JObject Schema(IOpenApiSchema schema, HashSet ancestors try { var targetModel = Schema(target, ancestors); - var siblings = GetReferenceSiblings(reference); - using var siblingText = new StringWriter(); - siblings.SerializeAsV32(new OpenApiJsonWriter(siblingText)); - var result = JObject.Parse(siblingText.ToString()).Count == 0 ? targetModel : new JObject + var siblings = Schema(GetReferenceSiblings(reference), ancestors); + var result = IsEmptySchema(siblings) ? targetModel : new JObject { ["type"] = "all of", ["description"] = reference.Reference.Description ?? target.Description, - ["allOf"] = new JArray(targetModel, Schema(siblings, ancestors)) + ["allOf"] = new JArray(targetModel, siblings) }; result["x-internal-ref-name"] ??= ReferenceName(reference); return result; @@ -293,21 +298,10 @@ private JObject Schema(IOpenApiSchema schema, HashSet ancestors try { - using var text = new StringWriter(); - schema.SerializeAsV32(new OpenApiJsonWriter(text)); - var serialized = JToken.Parse(text.ToString()); - RestoreConstants(serialized); - if (serialized is JObject { Count: 1 } && serialized["not"] is JObject { Count: 0 }) - { - return new JObject { ["type"] = "no value" }; - } - var result = JObject.FromObject(Extensions(schema.Extensions)); - result["type"] = schema.Type?.ToString().ToLowerInvariant().Replace(", ", " | ") ?? - (serialized is JObject { Count: 0 } ? "any value" : "any type"); - if (schema.Format != null) result["format"] = schema.Format; - if (schema.Description != null) result["description"] = schema.Description; - if (schema.Properties != null) + if (!string.IsNullOrEmpty(schema.Format)) result["format"] = schema.Format; + if (!string.IsNullOrEmpty(schema.Description)) result["description"] = schema.Description; + if (schema.Properties is { Count: > 0 }) { result["properties"] = new JObject(schema.Properties.Select(pair => { @@ -332,16 +326,7 @@ private JObject Schema(IOpenApiSchema schema, HashSet ancestors { result["composition"] = composition; } - var constraints = new JArray(); - foreach (var property in ((JObject)serialized).Properties()) - { - if (!property.Name.StartsWith("x-", StringComparison.Ordinal) && property.Name is not - ("type" or "format" or "description" or "properties" or "items" or "allOf" or "oneOf" or "anyOf" or "not" or - "additionalProperties" or "enum" or "example" or "examples")) - { - constraints.Add(new JObject { ["name"] = property.Name, ["value"] = property.Value.ToString(Formatting.None) }); - } - } + var constraints = Constraints(schema); if (schema.AdditionalProperties != null) { composition.Add(new JObject { ["kind"] = "Additional properties", ["schemas"] = new JArray(Schema(schema.AdditionalProperties, ancestors)) }); @@ -369,6 +354,14 @@ private JObject Schema(IOpenApiSchema schema, HashSet ancestors result["examples"] = new JArray(new JObject { ["content"] = Literal(schema.Example) }); } #pragma warning restore CS0618 + // The SDK represents true as an empty schema and false as { "not": {} }. + if (schema.Type == null && result.Count == 1 && schema.Not != null && composition.Count == 1 && + IsEmptySchema((JObject)composition[0]["schemas"][0])) + { + return new JObject { ["type"] = "no value" }; + } + result["type"] = schema.Type?.ToString().ToLowerInvariant().Replace(", ", " | ") ?? + (result.Count == 0 ? "any value" : "any type"); return result; void AddComposition(string kind, IList schemas) @@ -385,6 +378,92 @@ void AddComposition(string kind, IList schemas) } } + private static bool IsEmptySchema(JObject schema) => schema.Count == 1 && (string)schema["type"] == "any value"; + + private JArray Constraints(IOpenApiSchema schema) + { + var result = new JArray(); + Add("$id", schema.Id); + Add("$schema", schema.Schema?.ToString()); + Add("$comment", schema.Comment); + if (schema.Const != null) + Add("const", constants.TryGetValue(schema.Const, out var constant) ? constant : JsonConvert.SerializeObject(schema.Const), literal: true); + Add("$vocabulary", schema.Vocabulary); + AddSchemas("$defs", schema.Definitions); + Add("$dynamicRef", schema.DynamicRef); + Add("$dynamicAnchor", schema.DynamicAnchor); + if (!schema.Type.HasValue || schema.Type.Value.HasFlag(JsonSchemaType.Object)) + { + if (schema is IOpenApiSchemaMissingProperties { UnevaluatedPropertiesSchema: { } unevaluated }) + Add("unevaluatedProperties", unevaluated); + else if (!schema.UnevaluatedProperties) + Add("unevaluatedProperties", false); + } + AddSchemas("patternProperties", schema.PatternProperties); + Add("dependentRequired", schema.DependentRequired); + if (schema is IOpenApiSchemaMissingProperties extra) + { + Add("$anchor", extra.Anchor); + Add("contains", extra.Contains); + Add("maxContains", extra.MaxContains); + Add("minContains", extra.MinContains); + Add("contentEncoding", extra.ContentEncoding); + Add("contentMediaType", extra.ContentMediaType); + Add("contentSchema", extra.ContentSchema); + Add("propertyNames", extra.PropertyNames); + AddSchemas("dependentSchemas", extra.DependentSchemas); + Add("if", extra.If); + Add("then", extra.Then); + Add("else", extra.Else); + } + Add("title", schema.Title); + Add("multipleOf", schema.MultipleOf); + Add(schema.ExclusiveMaximum != null ? "exclusiveMaximum" : "maximum", schema.ExclusiveMaximum ?? schema.Maximum, literal: true); + Add(schema.ExclusiveMinimum != null ? "exclusiveMinimum" : "minimum", schema.ExclusiveMinimum ?? schema.Minimum, literal: true); + Add("maxLength", schema.MaxLength); + Add("minLength", schema.MinLength); + Add("pattern", schema.Pattern); + Add("maxItems", schema.MaxItems); + Add("minItems", schema.MinItems); + Add("uniqueItems", schema.UniqueItems); + Add("maxProperties", schema.MaxProperties); + Add("minProperties", schema.MinProperties); + if (schema.Required is { Count: > 0 }) Add("required", schema.Required); + Add("default", schema.Default); + Add("discriminator", schema.Discriminator); + if (schema.ReadOnly) Add("readOnly", true); + if (schema.WriteOnly) Add("writeOnly", true); + Add("xml", schema.Xml); + Add("externalDocs", schema.ExternalDocs); + if (schema.Deprecated) Add("deprecated", true); + Add("unrecognizedKeywords", schema.UnrecognizedKeywords?.ToDictionary(pair => pair.Key, pair => new JRaw(Literal(pair.Value, terse: true) ?? "null"))); + return result; + + void Add(string name, object value, bool literal = false) + { + if (value is null or "" || value is System.Collections.ICollection { Count: 0 }) + { + return; + } + var content = literal ? (string)value : value switch + { + JsonNode node => Literal(node, terse: true), + IOpenApiSerializable model => Serialize(model), + _ => JsonConvert.SerializeObject(value) + }; + result.Add(new JObject { ["name"] = name, ["value"] = content }); + } + + void AddSchemas(string name, IDictionary schemas) + { + if (schemas is { Count: > 0 }) + { + // These constraints are displayed as JSON; only serialize their values. + Add(name, schemas.ToDictionary(pair => pair.Key, pair => new JRaw(Serialize(pair.Value)))); + } + } + } + private void RestoreConstants(JToken node) { if (node is JObject obj && obj["const"] is JValue { Type: JTokenType.String } value && diff --git a/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs b/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs index 904f31693fe..60626e0796d 100644 --- a/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs +++ b/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs @@ -294,6 +294,60 @@ public void PreservesBooleanSchemasInMapsAndCompositions(string boolean) } } + [Theory] + [InlineData("3.0.3", "\"minimum\":0,\"exclusiveMinimum\":true")] + [InlineData("3.1.0", "\"exclusiveMinimum\":0")] + [InlineData("3.2.0", "\"exclusiveMinimum\":0")] + public void PreservesNumericBoundsAndFalseConstraints(string version, string minimum) + { + var model = OpenApiDocumentReader.Parse($$""" + {"openapi":"{{version}}","info":{"title":"Constraints","version":"1"},"paths":{}, + "components":{"schemas":{ + "Number":{"type":"number",{{minimum}},"maximum":9007199254740993,"multipleOf":0.5}, + "Array":{"type":"array","items":{"type":"string","minLength":0},"minItems":0,"uniqueItems":false,"default":[]} + } } } + """, "json"); + var schemas = (JObject)model.Metadata["schemas"]; + var number = schemas["Number"]["constraints"].ToDictionary(item => (string)item["name"], item => (string)item["value"]); + Assert.Equal("0", number["exclusiveMinimum"]); + Assert.False(number.ContainsKey("minimum")); + Assert.Equal("9007199254740993", number["maximum"]); + Assert.Equal("0.5", number["multipleOf"]); + var array = schemas["Array"]["constraints"].ToDictionary(item => (string)item["name"], item => (string)item["value"]); + Assert.Equal("0", array["minItems"]); + Assert.Equal("false", array["uniqueItems"]); + Assert.Empty(JArray.Parse(array["default"])); + Assert.Equal("0", Assert.Single(schemas["Array"]["items"]["constraints"])["value"]); + } + + [Fact] + public void PreservesConstantsInsideSchemaValuedConstraintsAndReferenceSiblings() + { + var model = OpenApiDocumentReader.Parse(""" + {"openapi":"3.1.0","info":{"title":"Constraints","version":"1"},"paths":{}, + "components":{"schemas":{ + "Base":{}, + "Alias":{"$ref":"#/components/schemas/Base"}, + "Constrained":{"$ref":"#/components/schemas/Base", + "patternProperties":{"^flag$":{"const":false}}, + "dependentSchemas":{"flag":{"properties":{"value":{"const":42,"default":null}}}}, + "unevaluatedProperties":false}, + "Annotated":{"not":{},"description":"No value is accepted"} + }}} + """, "json"); + var schemas = (JObject)model.Metadata["schemas"]; + Assert.Equal("any value", schemas["Alias"]["type"]); + Assert.Null(schemas["Alias"]["allOf"]); + var constraints = schemas["Constrained"]["allOf"][1]["constraints"] + .ToDictionary(item => (string)item["name"], item => JToken.Parse((string)item["value"])); + Assert.Equal(false, constraints["patternProperties"]["^flag$"]["const"]); + Assert.Equal(42, constraints["dependentSchemas"]["flag"]["properties"]["value"]["const"]); + Assert.Equal(JTokenType.Null, constraints["dependentSchemas"]["flag"]["properties"]["value"]["default"].Type); + Assert.Empty(constraints["unevaluatedProperties"]["not"]); + Assert.Equal("No value is accepted", schemas["Annotated"]["description"]); + Assert.Equal("Not", schemas["Annotated"]["composition"][0]["kind"]); + } + [Fact] public void SchemaShapedLiteralExamplesAndExtensionsAreNotPreflighted() { From dd2bc54faa26df1178883b2347af7c42fcee4d49 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Fri, 25 Sep 2026 00:54:15 +1000 Subject: [PATCH 15/16] Reuse SDK schema serialization in the REST adapter --- .../OpenApi3ModelConverter.cs | 163 +++++------------- 1 file changed, 44 insertions(+), 119 deletions(-) diff --git a/src/Docfx.Build.RestApi/OpenApi3ModelConverter.cs b/src/Docfx.Build.RestApi/OpenApi3ModelConverter.cs index 0b8c2ecca65..57219ed4251 100644 --- a/src/Docfx.Build.RestApi/OpenApi3ModelConverter.cs +++ b/src/Docfx.Build.RestApi/OpenApi3ModelConverter.cs @@ -36,10 +36,10 @@ internal RestApiRootItemViewModel Convert(OpenApiDocument document, string raw, }; model.Metadata["specificationVersion"] = version; model.Metadata["servers"] = servers; - model.Metadata["info"] = JToken.Parse(Serialize(document.Info)); + model.Metadata["info"] = Serialize(document.Info); if (document.ExternalDocs != null) { - model.Metadata["externalDocs"] = JToken.Parse(Serialize(document.ExternalDocs)); + model.Metadata["externalDocs"] = Serialize(document.ExternalDocs); } var schemas = new JObject(); foreach (var (name, schema) in document.Components?.Schemas?.AsEnumerable() ?? []) @@ -215,7 +215,7 @@ private static JArray Examples(string mimeType, IOpenApiMediaType media) return result; } - private static string Literal(JsonNode value, bool terse = false) + private static string Literal(JsonNode value) { if (value == null) { @@ -224,22 +224,20 @@ private static string Literal(JsonNode value, bool terse = false) // The YAML reader uses a sentinel for nulls, including nested values. // The SDK writer restores them; JsonNode.ToJsonString exposes the sentinel. using var text = new StringWriter(); - new OpenApiJsonWriter(text, new OpenApiJsonWriterSettings { Terse = terse }).WriteAny(value); + new OpenApiJsonWriter(text).WriteAny(value); return text.ToString(); } - private string Serialize(IOpenApiSerializable value) + private JToken Serialize(IOpenApiSerializable value) { using var text = new StringWriter(); - value.SerializeAsV32(new OpenApiJsonWriter(text, new OpenApiJsonWriterSettings { Terse = true })); - if (value is IOpenApiSchema && constants.Count > 0) + value.SerializeAsV32(new OpenApiJsonWriter(text)); + var token = JToken.Parse(text.ToString()); + if (value is IOpenApiSchema) { - // Schema-valued constraints are displayed as JSON, including preserved const values. - var token = JToken.Parse(text.ToString()); RestoreConstants(token); - return token.ToString(Formatting.None); } - return text.ToString(); + return token; } private static Dictionary Extensions(IDictionary extensions) @@ -255,7 +253,7 @@ private static Dictionary Extensions(IDictionary ancestors = null) + private JObject Schema(IOpenApiSchema schema, HashSet ancestors = null, JObject serialized = null) { if (schema == null) { @@ -277,7 +275,7 @@ private JObject Schema(IOpenApiSchema schema, HashSet ancestors { var targetModel = Schema(target, ancestors); var siblings = Schema(GetReferenceSiblings(reference), ancestors); - var result = IsEmptySchema(siblings) ? targetModel : new JObject + var result = siblings.Count == 1 && (string)siblings["type"] == "any value" ? targetModel : new JObject { ["type"] = "all of", ["description"] = reference.Reference.Description ?? target.Description, @@ -298,14 +296,23 @@ private JObject Schema(IOpenApiSchema schema, HashSet ancestors try { - var result = JObject.FromObject(Extensions(schema.Extensions)); + // Reuse the SDK's serialized subtree when descending into inline schemas. + serialized ??= (JObject)Serialize(schema); + if (serialized.Count == 1 && serialized["not"] is JObject { Count: 0 }) + { + return new JObject { ["type"] = "no value" }; + } + + var result = new JObject(serialized.Properties().Where(p => p.Name.StartsWith("x-", StringComparison.Ordinal))); + result["type"] = schema.Type?.ToString().ToLowerInvariant().Replace(", ", " | ") ?? + (serialized.Count == 0 ? "any value" : "any type"); if (!string.IsNullOrEmpty(schema.Format)) result["format"] = schema.Format; if (!string.IsNullOrEmpty(schema.Description)) result["description"] = schema.Description; if (schema.Properties is { Count: > 0 }) { result["properties"] = new JObject(schema.Properties.Select(pair => { - var property = Schema(pair.Value, ancestors); + var property = Schema(pair.Value, ancestors, serialized["properties"]?[pair.Key] as JObject); if (schema.Required?.Contains(pair.Key) == true) { property["required"] = true; @@ -313,23 +320,32 @@ private JObject Schema(IOpenApiSchema schema, HashSet ancestors return new JProperty(pair.Key, property); })); } - if (schema.Items != null) result["items"] = Schema(schema.Items, ancestors); + if (schema.Items != null) result["items"] = Schema(schema.Items, ancestors, serialized["items"] as JObject); var composition = new JArray(); - if (schema.AllOf is { Count: > 0 }) result["allOf"] = new JArray(schema.AllOf.Select(s => Schema(s, ancestors))); - AddComposition("One of", schema.OneOf); - AddComposition("Any of", schema.AnyOf); + if (schema.AllOf is { Count: > 0 }) result["allOf"] = Schemas(schema.AllOf, serialized["allOf"] as JArray); + AddComposition("One of", schema.OneOf, serialized["oneOf"] as JArray); + AddComposition("Any of", schema.AnyOf, serialized["anyOf"] as JArray); if (schema.Not != null) { - AddComposition("Not", [schema.Not]); + composition.Add(new JObject { ["kind"] = "Not", ["schemas"] = new JArray(Schema(schema.Not, ancestors, serialized["not"] as JObject)) }); } if (composition.Count > 0) { result["composition"] = composition; } - var constraints = Constraints(schema); + var constraints = new JArray(); + foreach (var property in serialized.Properties()) + { + if (!property.Name.StartsWith("x-", StringComparison.Ordinal) && property.Name is not + ("type" or "format" or "description" or "properties" or "items" or "allOf" or "oneOf" or "anyOf" or "not" or + "additionalProperties" or "enum" or "example" or "examples")) + { + constraints.Add(new JObject { ["name"] = property.Name, ["value"] = property.Value.ToString(Formatting.None) }); + } + } if (schema.AdditionalProperties != null) { - composition.Add(new JObject { ["kind"] = "Additional properties", ["schemas"] = new JArray(Schema(schema.AdditionalProperties, ancestors)) }); + composition.Add(new JObject { ["kind"] = "Additional properties", ["schemas"] = new JArray(Schema(schema.AdditionalProperties, ancestors, serialized["additionalProperties"] as JObject)) }); result["composition"] = composition; } else if (!schema.AdditionalPropertiesAllowed) @@ -342,7 +358,7 @@ private JObject Schema(IOpenApiSchema schema, HashSet ancestors } if (schema.Enum is { Count: > 0 }) { - result["enum"] = new JArray(schema.Enum.Select(value => value == null ? null : JToken.Parse(Literal(value)))); + result["enum"] = serialized["enum"]; } if (schema.Examples is { Count: > 0 }) { @@ -354,21 +370,16 @@ private JObject Schema(IOpenApiSchema schema, HashSet ancestors result["examples"] = new JArray(new JObject { ["content"] = Literal(schema.Example) }); } #pragma warning restore CS0618 - // The SDK represents true as an empty schema and false as { "not": {} }. - if (schema.Type == null && result.Count == 1 && schema.Not != null && composition.Count == 1 && - IsEmptySchema((JObject)composition[0]["schemas"][0])) - { - return new JObject { ["type"] = "no value" }; - } - result["type"] = schema.Type?.ToString().ToLowerInvariant().Replace(", ", " | ") ?? - (result.Count == 0 ? "any value" : "any type"); return result; - void AddComposition(string kind, IList schemas) + JArray Schemas(IList schemas, JArray values) => + new(schemas.Select((schema, index) => Schema(schema, ancestors, values?[index] as JObject))); + + void AddComposition(string kind, IList schemas, JArray values) { if (schemas is { Count: > 0 }) { - composition.Add(new JObject { ["kind"] = kind, ["schemas"] = new JArray(schemas.Select(s => Schema(s, ancestors))) }); + composition.Add(new JObject { ["kind"] = kind, ["schemas"] = Schemas(schemas, values) }); } } } @@ -378,92 +389,6 @@ void AddComposition(string kind, IList schemas) } } - private static bool IsEmptySchema(JObject schema) => schema.Count == 1 && (string)schema["type"] == "any value"; - - private JArray Constraints(IOpenApiSchema schema) - { - var result = new JArray(); - Add("$id", schema.Id); - Add("$schema", schema.Schema?.ToString()); - Add("$comment", schema.Comment); - if (schema.Const != null) - Add("const", constants.TryGetValue(schema.Const, out var constant) ? constant : JsonConvert.SerializeObject(schema.Const), literal: true); - Add("$vocabulary", schema.Vocabulary); - AddSchemas("$defs", schema.Definitions); - Add("$dynamicRef", schema.DynamicRef); - Add("$dynamicAnchor", schema.DynamicAnchor); - if (!schema.Type.HasValue || schema.Type.Value.HasFlag(JsonSchemaType.Object)) - { - if (schema is IOpenApiSchemaMissingProperties { UnevaluatedPropertiesSchema: { } unevaluated }) - Add("unevaluatedProperties", unevaluated); - else if (!schema.UnevaluatedProperties) - Add("unevaluatedProperties", false); - } - AddSchemas("patternProperties", schema.PatternProperties); - Add("dependentRequired", schema.DependentRequired); - if (schema is IOpenApiSchemaMissingProperties extra) - { - Add("$anchor", extra.Anchor); - Add("contains", extra.Contains); - Add("maxContains", extra.MaxContains); - Add("minContains", extra.MinContains); - Add("contentEncoding", extra.ContentEncoding); - Add("contentMediaType", extra.ContentMediaType); - Add("contentSchema", extra.ContentSchema); - Add("propertyNames", extra.PropertyNames); - AddSchemas("dependentSchemas", extra.DependentSchemas); - Add("if", extra.If); - Add("then", extra.Then); - Add("else", extra.Else); - } - Add("title", schema.Title); - Add("multipleOf", schema.MultipleOf); - Add(schema.ExclusiveMaximum != null ? "exclusiveMaximum" : "maximum", schema.ExclusiveMaximum ?? schema.Maximum, literal: true); - Add(schema.ExclusiveMinimum != null ? "exclusiveMinimum" : "minimum", schema.ExclusiveMinimum ?? schema.Minimum, literal: true); - Add("maxLength", schema.MaxLength); - Add("minLength", schema.MinLength); - Add("pattern", schema.Pattern); - Add("maxItems", schema.MaxItems); - Add("minItems", schema.MinItems); - Add("uniqueItems", schema.UniqueItems); - Add("maxProperties", schema.MaxProperties); - Add("minProperties", schema.MinProperties); - if (schema.Required is { Count: > 0 }) Add("required", schema.Required); - Add("default", schema.Default); - Add("discriminator", schema.Discriminator); - if (schema.ReadOnly) Add("readOnly", true); - if (schema.WriteOnly) Add("writeOnly", true); - Add("xml", schema.Xml); - Add("externalDocs", schema.ExternalDocs); - if (schema.Deprecated) Add("deprecated", true); - Add("unrecognizedKeywords", schema.UnrecognizedKeywords?.ToDictionary(pair => pair.Key, pair => new JRaw(Literal(pair.Value, terse: true) ?? "null"))); - return result; - - void Add(string name, object value, bool literal = false) - { - if (value is null or "" || value is System.Collections.ICollection { Count: 0 }) - { - return; - } - var content = literal ? (string)value : value switch - { - JsonNode node => Literal(node, terse: true), - IOpenApiSerializable model => Serialize(model), - _ => JsonConvert.SerializeObject(value) - }; - result.Add(new JObject { ["name"] = name, ["value"] = content }); - } - - void AddSchemas(string name, IDictionary schemas) - { - if (schemas is { Count: > 0 }) - { - // These constraints are displayed as JSON; only serialize their values. - Add(name, schemas.ToDictionary(pair => pair.Key, pair => new JRaw(Serialize(pair.Value)))); - } - } - } - private void RestoreConstants(JToken node) { if (node is JObject obj && obj["const"] is JValue { Type: JTokenType.String } value && From ff1ee05de8bdf70124bdbec35927c73a5b4efa75 Mon Sep 17 00:00:00 2001 From: "Liangying.Wei" Date: Fri, 25 Sep 2026 13:36:46 +1000 Subject: [PATCH 16/16] Share JSON and YAML input detection across document processors --- src/Docfx.Build.Common/DocumentInput.cs | 128 ++++++++++++++++++ .../ManagedReferenceDocumentProcessor.cs | 7 +- .../OpenApiDocumentReader.cs | 12 +- .../RestApiDocumentProcessor.cs | 11 +- .../RestApiDocumentReader.cs | 92 +++---------- .../Swagger/Internals/SwaggerJsonBuilder.cs | 5 +- .../Swagger/SwaggerJsonParser.cs | 6 +- .../SchemaDrivenDocumentProcessor.cs | 5 +- .../UniversalReferenceDocumentProcessor.cs | 5 +- src/Docfx.Build/ApiPage/ApiPageProcessor.cs | 5 +- src/Docfx.Build/SingleDocumentBuilder.cs | 3 + .../DocumentInputTest.cs | 62 +++++++++ .../OpenApiDocumentReaderTest.cs | 54 ++++---- .../RestApiDocumentReaderTest.cs | 18 ++- 14 files changed, 278 insertions(+), 135 deletions(-) create mode 100644 src/Docfx.Build.Common/DocumentInput.cs create mode 100644 test/Docfx.Build.Common.Tests/DocumentInputTest.cs diff --git a/src/Docfx.Build.Common/DocumentInput.cs b/src/Docfx.Build.Common/DocumentInput.cs new file mode 100644 index 00000000000..e886d67d4f4 --- /dev/null +++ b/src/Docfx.Build.Common/DocumentInput.cs @@ -0,0 +1,128 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; +using Docfx.Common; +using Docfx.Plugins; +using Newtonsoft.Json; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; + +namespace Docfx.Build.Common; + +public sealed class DocumentInput +{ + public sealed record DocumentHeader(string Kind, string Version = null); + + private static readonly ConditionalWeakTable Inputs = new(); + private readonly Func _open; + private readonly Lazy _header; + private readonly Lazy _text; + + private DocumentInput(string path, string format, Func open) + { + Path = path; + Format = format; + _open = open; + _header = new(() => + { + if (format == null) return null; + try + { + using var reader = open(); + return ReadHeader(reader, format); + } + catch (Exception ex) when (ex is IOException or JsonException or YamlException) + { + Logger.LogVerbose($"Could not identify document '{path}': {ex.Message}"); + return null; + } + }); + _text = new(() => + { + using var reader = open(); + return reader.ReadToEnd(); + }); + } + + public string Path { get; } + public string Format { get; } + public DocumentHeader Header => _header.Value; + public string ReadAllText() => _text.Value; + public TextReader OpenRead() => _text.IsValueCreated ? new StringReader(_text.Value) : _open(); + + public static DocumentInput Get(FileAndType file) => Inputs.TryGetValue(file, out var input) ? input : Create(file); + + public static DocumentInput FromText(string text, string format) => + new(System.IO.Path.GetFullPath("document." + format), format, () => new StringReader(text)); + + // Share inputs across processor selection and loading, without retaining open files + // or reusing stale contents when the same FileCollection is built again. + public static IDisposable BeginRead(IEnumerable files) => new InputScope(files.ToArray()); + + private static DocumentInput Create(FileAndType file) + { + var path = System.IO.Path.Combine(file.BaseDir, file.File); + var format = System.IO.Path.GetExtension(file.File).ToLowerInvariant() switch + { + ".json" => "json", + ".yaml" or ".yml" or ".csyaml" or ".csyml" => "yaml", + _ => null + }; + return new(path, format, () => EnvironmentContext.FileAbstractLayer.OpenReadText(path)); + } + + private sealed class InputScope : IDisposable + { + private readonly FileAndType[] _files; + + public InputScope(FileAndType[] files) + { + _files = files; + foreach (var file in files) Inputs.Add(file, Create(file)); + } + + public void Dispose() + { + foreach (var file in _files) Inputs.Remove(file); + } + } + + public static DocumentHeader ReadHeader(TextReader source, string format) + { + if (format == "json") + { + using var reader = new JsonTextReader(source) { DateParseHandling = DateParseHandling.None, CloseInput = false }; + if (!reader.Read() || reader.TokenType != JsonToken.StartObject) return null; + DocumentHeader swagger = null; + while (reader.Read()) + { + if (reader.TokenType == JsonToken.EndObject && reader.Depth == 0) return swagger; + if (reader.TokenType != JsonToken.PropertyName || reader.Depth != 1) continue; + var key = (string)reader.Value; + if (!reader.Read()) return null; + if (reader.TokenType == JsonToken.String) + { + if (key == "openapi") return new(key, (string)reader.Value); + // Retain Swagger's existing ownership rule: malformed JSON is not claimed. + if (key == "swagger") swagger = new(key, (string)reader.Value); + } + reader.Skip(); + } + return null; + } + if (format != "yaml") return null; + // A leading YamlMime comment identifies the document even if its body is invalid. + if (source.Peek() == '#' && YamlMime.ReadMime(source) is { } mime) return new(mime); + var parser = new Parser(source); + parser.Consume(); + if (!parser.TryConsume(out _) || !parser.TryConsume(out _)) return null; + while (!parser.Accept(out _)) + { + if (!parser.TryConsume(out var key)) return null; + if (key.Value is "openapi" or "swagger" && parser.TryConsume(out var version)) return new(key.Value, version.Value); + parser.SkipThisAndNestedEvents(); + } + return null; + } +} diff --git a/src/Docfx.Build.ManagedReference/ManagedReferenceDocumentProcessor.cs b/src/Docfx.Build.ManagedReference/ManagedReferenceDocumentProcessor.cs index f0e6be31c1f..29ab5a070d5 100644 --- a/src/Docfx.Build.ManagedReference/ManagedReferenceDocumentProcessor.cs +++ b/src/Docfx.Build.ManagedReference/ManagedReferenceDocumentProcessor.cs @@ -69,7 +69,7 @@ public ManagedReferenceDocumentProcessor() protected override FileModel LoadArticle(FileAndType file, ImmutableDictionary metadata) { - if (YamlMime.ReadMime(file.File) == null) + if (DocumentInput.Get(file).Header?.Kind.StartsWith(YamlMime.YamlMimePrefix, StringComparison.Ordinal) != true) { Logger.LogWarning( "Please add `YamlMime` as the first line of file, e.g.: `### YamlMime:ManagedReference`, otherwise the file will be not treated as ManagedReference source file in near future.", @@ -77,7 +77,8 @@ protected override FileModel LoadArticle(FileAndType file, ImmutableDictionary(file.File); + using var reader = DocumentInput.Get(file).OpenRead(); + var page = YamlUtility.Deserialize(reader); if (page?.Items == null || page.Items.Count == 0) { return null; @@ -123,7 +124,7 @@ public override ProcessingPriority GetProcessingPriority(FileAndType file) if (".yml".Equals(Path.GetExtension(file.File), StringComparison.OrdinalIgnoreCase) || ".yaml".Equals(Path.GetExtension(file.File), StringComparison.OrdinalIgnoreCase)) { - var mime = YamlMime.ReadMime(file.File); + var mime = DocumentInput.Get(file).Header?.Kind; switch (mime) { case YamlMime.ManagedReference: diff --git a/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs b/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs index 2efe607f974..2fcb2b73ddd 100644 --- a/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs +++ b/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs @@ -17,20 +17,12 @@ namespace Docfx.Build.RestApi; internal static class OpenApiDocumentReader { - internal static RestApiRootItemViewModel Read(string path) - { - var format = Path.GetExtension(path).Equals(".json", StringComparison.OrdinalIgnoreCase) ? "json" : "yaml"; - var model = Parse(EnvironmentContext.FileAbstractLayer.ReadAllText(path), format, new Uri(Path.GetFullPath(path))); - return model; - } - - internal static RestApiRootItemViewModel Parse(string raw, string format, Uri baseUrl = null, string version = null) + internal static RestApiRootItemViewModel Parse(string raw, string format, Uri baseUrl, string version) { try { - version ??= RestApiDocumentReader.ReadHeader(new StringReader(raw), format)?.Version; var constants = new Dictionary(); - var document = LoadDocument(raw, format, baseUrl ?? new Uri(Path.GetFullPath("openapi.json")), version, constants); + var document = LoadDocument(raw, format, baseUrl, version, constants); var model = new OpenApi3ModelConverter(constants).Convert(document, raw, version); model.Metadata["rawExtension"] = format == "json" ? ".json" : ".yaml"; return model; diff --git a/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs b/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs index 06ecf5e39b8..3ab40ee6688 100644 --- a/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs +++ b/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs @@ -82,7 +82,10 @@ public override ProcessingPriority GetProcessingPriority(FileAndType file) switch (file.Type) { case DocumentType.Article: - if (RestApiDocumentReader.IsSupportedFile(file.FullPath)) + if (Path.GetExtension(file.File).ToLowerInvariant() is not (".json" or ".yaml" or ".yml")) break; + var input = DocumentInput.Get(file); + if (input.Header is { Kind: "openapi" } || + (input.Format == "json" && input.Header is { Kind: "swagger", Version: "2.0" })) { return ProcessingPriority.Normal; } @@ -127,11 +130,11 @@ public override SaveResult Save(FileModel model) protected override FileModel LoadArticle(FileAndType file, ImmutableDictionary metadata) { - var filePath = Path.Combine(file.BaseDir, file.File); - var vm = RestApiDocumentReader.Read(filePath, file.File); + var input = DocumentInput.Get(file); + var vm = RestApiDocumentReader.Read(input, file.File); vm.Metadata[DocumentTypeKey] = RestApiDocumentType; - var repoInfo = GitUtility.TryGetFileDetail(filePath); + var repoInfo = GitUtility.TryGetFileDetail(input.Path); if (repoInfo != null) { vm.Metadata["source"] = new SourceDetail { Remote = repoInfo }; diff --git a/src/Docfx.Build.RestApi/RestApiDocumentReader.cs b/src/Docfx.Build.RestApi/RestApiDocumentReader.cs index c8dc67cbc79..76833f25427 100644 --- a/src/Docfx.Build.RestApi/RestApiDocumentReader.cs +++ b/src/Docfx.Build.RestApi/RestApiDocumentReader.cs @@ -1,101 +1,43 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Docfx.Build.Common; using Docfx.Build.RestApi.Swagger; -using Docfx.Common; using Docfx.DataContracts.RestApi; using Docfx.Exceptions; -using Docfx.Plugins; -using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using YamlDotNet.Core; -using YamlDotNet.Core.Events; namespace Docfx.Build.RestApi; -// Ownership detection and reader dispatch live here. Downstream code consumes REST view models. internal static class RestApiDocumentReader { - internal sealed record Header(string Version, bool IsSwagger = false); + internal static RestApiRootItemViewModel Parse(string raw, string format) => Read(DocumentInput.FromText(raw, format)); - internal static bool IsSupportedFile(string path) + internal static RestApiRootItemViewModel Read(DocumentInput input, string fileName = null) { - var format = Format(path); - if (format == null) return false; - try + if (input.Header is { Kind: "openapi", Version: var version }) { - using var reader = EnvironmentContext.FileAbstractLayer.OpenReadText(path); - return ReadHeader(reader, format) != null; + return OpenApiDocumentReader.Parse(input.ReadAllText(), input.Format, new Uri(Path.GetFullPath(input.Path)), version); } - catch (Exception ex) when (ex is IOException or JsonException or YamlException) + if (input.Format != "json" || input.Header is not { Kind: "swagger", Version: "2.0" }) { - Logger.LogVerbose($"Could not identify REST API document '{path}': {ex.Message}"); - return false; + throw new DocfxException($"Unable to identify REST API document '{input.Path}'."); } - } - - internal static RestApiRootItemViewModel Read(string path, string fileName) - { - var raw = EnvironmentContext.FileAbstractLayer.ReadAllText(path); - var format = Format(path); - var header = ReadHeader(new StringReader(raw), format); - if (header is { IsSwagger: true }) + var raw = input.ReadAllText(); + using var reader = input.OpenRead(); + var swagger = SwaggerJsonParser.Parse(input.Path, reader); + swagger.Raw = raw; + // Preserve Swagger 2.0 diagnostics, including extension objects under a path. + foreach (var (route, item) in swagger.Paths ?? []) { - var swagger = SwaggerJsonParser.Parse(path); - swagger.Raw = raw; - // Preserve Swagger 2.0 diagnostics, including extension objects under a path. - foreach (var (route, item) in swagger.Paths ?? []) + foreach (var (method, operation) in item.Metadata) { - foreach (var (method, operation) in item.Metadata) + if (operation is JObject obj && !obj.ContainsKey("operationId")) { - if (operation is JObject obj && !obj.ContainsKey("operationId")) - { - throw new DocfxException($"operationId should exist in operation '{method}' of path '{route}' for swagger file '{fileName}'"); - } + throw new DocfxException($"operationId should exist in operation '{method}' of path '{route}' for swagger file '{fileName ?? input.Path}'"); } } - return SwaggerModelConverter.FromSwaggerModel(swagger); - } - return OpenApiDocumentReader.Parse(raw, format, new Uri(Path.GetFullPath(path)), header?.Version); - } - - internal static string Format(string path) => Path.GetExtension(path).ToLowerInvariant() switch - { - ".json" => "json", - ".yaml" or ".yml" => "yaml", - _ => null - }; - - // Read only root markers, without allocating an object tree. The Swagger 2.0 JSON probe - // also validates the complete JSON syntax to retain its existing ownership behavior. - internal static Header ReadHeader(TextReader source, string format) - { - if (format == "json") - { - using var reader = new JsonTextReader(source) { DateParseHandling = DateParseHandling.None }; - if (!reader.Read() || reader.TokenType != JsonToken.StartObject) return null; - Header swagger = null; - while (reader.Read()) - { - if (reader.TokenType == JsonToken.EndObject && reader.Depth == 0) return swagger; - if (reader.TokenType != JsonToken.PropertyName || reader.Depth != 1) continue; - var key = (string)reader.Value; - if (!reader.Read()) return null; - if (key == "openapi" && reader.TokenType == JsonToken.String) return new Header((string)reader.Value); - if (key == "swagger" && reader.Value is "2.0") swagger = new Header("2.0", IsSwagger: true); - reader.Skip(); - } - return null; - } - var parser = new Parser(source); - parser.Consume(); - if (!parser.TryConsume(out _) || !parser.TryConsume(out _)) return null; - while (!parser.Accept(out _)) - { - if (!parser.TryConsume(out var key)) return null; - if (key.Value == "openapi" && parser.TryConsume(out var version)) return new Header(version.Value); - parser.SkipThisAndNestedEvents(); } - return null; + return SwaggerModelConverter.FromSwaggerModel(swagger); } } diff --git a/src/Docfx.Build.RestApi/Swagger/Internals/SwaggerJsonBuilder.cs b/src/Docfx.Build.RestApi/Swagger/Internals/SwaggerJsonBuilder.cs index 871b936684f..e9376a85c2d 100644 --- a/src/Docfx.Build.RestApi/Swagger/Internals/SwaggerJsonBuilder.cs +++ b/src/Docfx.Build.RestApi/Swagger/Internals/SwaggerJsonBuilder.cs @@ -25,9 +25,10 @@ public SwaggerJsonBuilder() _resolvedObjectCache = new Dictionary(); } - public SwaggerObjectBase Read(string swaggerPath) + public SwaggerObjectBase Read(string swaggerPath, TextReader source = null) { - var swagger = Load(swaggerPath); + using var reader = source == null ? null : new JsonTextReader(source) { DateParseHandling = DateParseHandling.None, CloseInput = false }; + var swagger = reader == null ? Load(swaggerPath) : LoadCore(JToken.ReadFrom(reader), swaggerPath); return ResolveReferences(swagger, swaggerPath, new Stack()); } diff --git a/src/Docfx.Build.RestApi/Swagger/SwaggerJsonParser.cs b/src/Docfx.Build.RestApi/Swagger/SwaggerJsonParser.cs index 9535f59f60c..0c7f43443c2 100644 --- a/src/Docfx.Build.RestApi/Swagger/SwaggerJsonParser.cs +++ b/src/Docfx.Build.RestApi/Swagger/SwaggerJsonParser.cs @@ -22,11 +22,13 @@ public class SwaggerJsonParser return jsonSerializer; }); - public static SwaggerModel Parse(string swaggerFilePath) + public static SwaggerModel Parse(string swaggerFilePath) => Parse(swaggerFilePath, null); + + internal static SwaggerModel Parse(string swaggerFilePath, TextReader source) { // Deserialize to internal swagger model var builder = new SwaggerJsonBuilder(); - var swagger = builder.Read(swaggerFilePath); + var swagger = builder.Read(swaggerFilePath, source); // Serialize to JToken var token = JToken.FromObject(swagger, Serializer.Value); diff --git a/src/Docfx.Build.SchemaDriven/SchemaDrivenDocumentProcessor.cs b/src/Docfx.Build.SchemaDriven/SchemaDrivenDocumentProcessor.cs index 545e0d3e837..ba730196f48 100644 --- a/src/Docfx.Build.SchemaDriven/SchemaDrivenDocumentProcessor.cs +++ b/src/Docfx.Build.SchemaDriven/SchemaDrivenDocumentProcessor.cs @@ -68,7 +68,7 @@ public override ProcessingPriority GetProcessingPriority(FileAndType file) if (".yml".Equals(Path.GetExtension(file.File), StringComparison.OrdinalIgnoreCase) || ".yaml".Equals(Path.GetExtension(file.File), StringComparison.OrdinalIgnoreCase)) { - var mime = YamlMime.ReadMime(file.File); + var mime = DocumentInput.Get(file).Header?.Kind; if (string.Equals(mime, YamlMime.YamlMimePrefix + _schemaName)) { return ProcessingPriority.Normal; @@ -99,7 +99,8 @@ public override FileModel Load(FileAndType file, ImmutableDictionary>(file.File); + using var reader = DocumentInput.Get(file).OpenRead(); + var obj = YamlUtility.Deserialize>(reader); // load overwrite fragments string markdownFragmentsContent = null; diff --git a/src/Docfx.Build.UniversalReference/UniversalReferenceDocumentProcessor.cs b/src/Docfx.Build.UniversalReference/UniversalReferenceDocumentProcessor.cs index f8bfa207266..c1b0260f77e 100644 --- a/src/Docfx.Build.UniversalReference/UniversalReferenceDocumentProcessor.cs +++ b/src/Docfx.Build.UniversalReference/UniversalReferenceDocumentProcessor.cs @@ -21,7 +21,8 @@ public class UniversalReferenceDocumentProcessor : ReferenceDocumentProcessorBas protected override FileModel LoadArticle(FileAndType file, ImmutableDictionary metadata) { - var page = YamlUtility.Deserialize(file.File); + using var reader = DocumentInput.Get(file).OpenRead(); + var page = YamlUtility.Deserialize(reader); if (page.Items == null || page.Items.Count == 0) { Logger.LogWarning("No items found from YAML file. No output is generated"); @@ -73,7 +74,7 @@ public override ProcessingPriority GetProcessingPriority(FileAndType file) if (".yml".Equals(Path.GetExtension(file.File), StringComparison.OrdinalIgnoreCase) || ".yaml".Equals(Path.GetExtension(file.File), StringComparison.OrdinalIgnoreCase)) { - var mime = YamlMime.ReadMime(file.File); + var mime = DocumentInput.Get(file).Header?.Kind; switch (mime) { case UniversalReferenceConstants.UniversalReferenceYamlMime: diff --git a/src/Docfx.Build/ApiPage/ApiPageProcessor.cs b/src/Docfx.Build/ApiPage/ApiPageProcessor.cs index e84aee5cf76..89bd561b18b 100644 --- a/src/Docfx.Build/ApiPage/ApiPageProcessor.cs +++ b/src/Docfx.Build/ApiPage/ApiPageProcessor.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using System.Text.Json; +using Docfx.Build.Common; using Docfx.Common; using Docfx.Plugins; using YamlDotNet.Serialization; @@ -29,7 +30,7 @@ public ProcessingPriority GetProcessingPriority(FileAndType file) if (".yml".Equals(extension, StringComparison.OrdinalIgnoreCase) || ".yaml".Equals(extension, StringComparison.OrdinalIgnoreCase)) { - return YamlMime.ReadMime(file.File) == "YamlMime:ApiPage" ? ProcessingPriority.High : ProcessingPriority.NotSupported; + return DocumentInput.Get(file).Header?.Kind == "YamlMime:ApiPage" ? ProcessingPriority.High : ProcessingPriority.NotSupported; } return ProcessingPriority.NotSupported; @@ -37,7 +38,7 @@ public ProcessingPriority GetProcessingPriority(FileAndType file) public FileModel Load(FileAndType file, ImmutableDictionary metadata) { - var yml = EnvironmentContext.FileAbstractLayer.ReadAllText(file.File); + var yml = DocumentInput.Get(file).ReadAllText(); var json = JsonSerializer.Serialize(deserializer.Deserialize(yml)); var data = JsonSerializer.Deserialize(json, ApiPage.JsonSerializerOptions); var content = new Dictionary(metadata.OrderBy(item => item.Key)); diff --git a/src/Docfx.Build/SingleDocumentBuilder.cs b/src/Docfx.Build/SingleDocumentBuilder.cs index 09c7149b840..181d6f14da9 100644 --- a/src/Docfx.Build/SingleDocumentBuilder.cs +++ b/src/Docfx.Build/SingleDocumentBuilder.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using Docfx.Build.Common; using Docfx.Common; using Docfx.Plugins; @@ -21,6 +22,7 @@ public static ImmutableList Build( DocumentBuildParameters parameters, IMarkdownService markdownService) { + using var inputs = DocumentInput.BeginRead(parameters.Files.EnumerateFiles()); var hostServiceCreator = new HostServiceCreator(null); var hostService = hostServiceCreator.CreateHostService( parameters, @@ -55,6 +57,7 @@ public Manifest Build(DocumentBuildParameters parameters, IMarkdownService markd Directory.CreateDirectory(parameters.OutputBaseDir); + using var inputs = DocumentInput.BeginRead(parameters.Files.EnumerateFiles()); var context = new DocumentBuildContext(parameters, cancellationToken); // Start building document... diff --git a/test/Docfx.Build.Common.Tests/DocumentInputTest.cs b/test/Docfx.Build.Common.Tests/DocumentInputTest.cs new file mode 100644 index 00000000000..277bc8d4cf9 --- /dev/null +++ b/test/Docfx.Build.Common.Tests/DocumentInputTest.cs @@ -0,0 +1,62 @@ +// 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; +using Docfx.Plugins; +using Docfx.Tests.Common; +using Xunit; + +namespace Docfx.Build.Common.Tests; + +[Collection("docfx STA")] +public class DocumentInputTest : TestBase +{ + [Theory] + [InlineData("YamlMime:ManagedReference")] + [InlineData("YamlMime:CustomProtocol")] + public void YamlMimeTakesPrecedenceOverFieldsInTheBody(string mime) + { + var input = DocumentInput.FromText($"### {mime}\nopenapi: 3.2.0\ninvalid: [", "yaml"); + Assert.Equal(mime, input.Header.Kind); + Assert.Null(input.Header.Version); + } + + [Theory] + [InlineData("json", "{\"openapi\":\"3.2.0\",\"invalid\":]")] + [InlineData("yaml", "# API document\nopenapi: 3.2.0\ninvalid: [")] + public void DetectsOpenApiBeforeParsingTheBody(string format, string source) + { + var input = DocumentInput.FromText(source, format); + Assert.Equal("openapi", input.Header.Kind); + Assert.Equal("3.2.0", input.Header.Version); + using var reader = input.OpenRead(); + Assert.Equal(source, reader.ReadToEnd()); + } + + [Fact] + public void InputsAreSharedWithinABuildAndRefreshedForTheNextBuild() + { + var folder = GetRandomFolder(); + var path = CreateFile("api.yaml", "openapi: 3.0.3", folder); + var file = new FileAndType(Path.GetFullPath(folder), "api.yaml", DocumentType.Article); + using (DocumentInput.BeginRead([file])) + { + var input = DocumentInput.Get(file); + Assert.Equal("3.0.3", input.Header.Version); + Assert.Equal("openapi: 3.0.3", input.ReadAllText()); + File.Delete(path); + var shared = DocumentInput.Get(file); + Assert.Equal("3.0.3", shared.Header.Version); + using var reader = shared.OpenRead(); + Assert.Equal("openapi: 3.0.3", reader.ReadToEnd()); + } + File.WriteAllText(path, "### YamlMime:CustomProtocol\nvalue: 42"); + using (DocumentInput.BeginRead([file])) + { + var input = DocumentInput.Get(file); + Assert.Equal("YamlMime:CustomProtocol", input.Header.Kind); + using var reader = input.OpenRead(); + Assert.Equal(42, YamlUtility.Deserialize>(reader)["value"]); + } + } +} diff --git a/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs b/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs index 60626e0796d..f1002995ee5 100644 --- a/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs +++ b/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Docfx.Build.Common; using Docfx.DataContracts.RestApi; +using Docfx.Plugins; using Docfx.Exceptions; using Docfx.Tests.Common; using Newtonsoft.Json.Linq; @@ -17,7 +19,7 @@ public class OpenApiDocumentReaderTest : TestBase [InlineData("yaml")] public void OpenApi32MapsAdditionalMethodsStreamingAndExamples(string format) { - var model = OpenApiDocumentReader.Parse(""" + var model = RestApiDocumentReader.Parse(""" {"openapi":"3.2.0","info":{"title":"Streams","version":"1"}, "paths":{"/events":{ "query":{"responses":{"200":{"description":"Events","content":{ @@ -76,7 +78,7 @@ public void NormalizesYamlBlocksAndAliasesWithoutChangingLiteralData() Number: {const: 1e100} String: {const: !!str 42} """; - var model = OpenApiDocumentReader.Parse(raw, "yaml"); + var model = RestApiDocumentReader.Parse(raw, "yaml"); Assert.Equal(raw, model.Raw); Assert.Equal(42, ((JObject)model.Metadata["x-literal"])["schema"]["const"]); Assert.Equal(false, model.Metadata["x-boolean"]); @@ -95,7 +97,7 @@ public void NormalizesYamlBlocksAndAliasesWithoutChangingLiteralData() public void ReportsOpenApi32FeaturesWithoutDocumentationUi() { using var listener = new TestListenerScope(); - OpenApiDocumentReader.Parse(""" + RestApiDocumentReader.Parse(""" {"openapi":"3.2.0","info":{"title":"Warnings","version":"1"}, "tags":[{"name":"events","summary":"Events","kind":"nav"}], "paths":{"/events":{"post":{"requestBody":{"content":{"multipart/mixed":{ @@ -150,7 +152,7 @@ public void MapsTypedParametersBodiesResponsesAndLiteralExamples(string version) } } } } """; - var model = OpenApiDocumentReader.Parse(raw, "json"); + var model = RestApiDocumentReader.Parse(raw, "json"); Assert.Equal(raw, model.Raw); Assert.Equal("api.example.test/v1/Typed API/1", model.Uid); Assert.Equal("**API**", model.Description); @@ -183,7 +185,7 @@ public void MapsTypedParametersBodiesResponsesAndLiteralExamples(string version) [InlineData("3.2.0")] public void YamlUsesTheSameModelsAndDefaults(string version) { - var model = OpenApiDocumentReader.Parse($$""" + var model = RestApiDocumentReader.Parse($$""" openapi: {{version}} info: title: YAML API @@ -205,7 +207,7 @@ public void YamlUsesTheSameModelsAndDefaults(string version) [Fact] public void ServerPrecedenceAndGeneratedIdsAreStable() { - static RestApiRootItemViewModel Read(string paths) => OpenApiDocumentReader.Parse($$""" + static RestApiRootItemViewModel Read(string paths) => RestApiDocumentReader.Parse($$""" { "openapi":"3.1.0", "info":{"title":"Servers","version":"1"}, "servers":[{"url":"https://root.example.test/root"}], @@ -231,7 +233,7 @@ static RestApiRootItemViewModel Read(string paths) => OpenApiDocumentReader.Pars [Fact] public void BooleanUnionCompositionAndRefSiblingsAreNotFlattened() { - var model = OpenApiDocumentReader.Parse(""" + var model = RestApiDocumentReader.Parse(""" { "openapi":"3.1.0", "info":{"title":"Schemas","version":"1"}, "paths":{"/boolean":{"get":{"responses":{"200":{"description":"OK","content":{ @@ -279,7 +281,7 @@ public void PreservesBooleanSchemasInMapsAndCompositions(string boolean) $$"""{ "oneOf": [{{boolean}}] }""" }) { - var model = OpenApiDocumentReader.Parse( + var model = RestApiDocumentReader.Parse( """{"openapi":"3.1.0","info":{"title":"Boolean","version":"1"},"paths":{},"components":{"schemas":{"Value":SCHEMA}}}""" .Replace("SCHEMA", schema), "json"); var value = (JObject.FromObject(model.Metadata["schemas"]))["Value"]; @@ -300,7 +302,7 @@ public void PreservesBooleanSchemasInMapsAndCompositions(string boolean) [InlineData("3.2.0", "\"exclusiveMinimum\":0")] public void PreservesNumericBoundsAndFalseConstraints(string version, string minimum) { - var model = OpenApiDocumentReader.Parse($$""" + var model = RestApiDocumentReader.Parse($$""" {"openapi":"{{version}}","info":{"title":"Constraints","version":"1"},"paths":{}, "components":{"schemas":{ "Number":{"type":"number",{{minimum}},"maximum":9007199254740993,"multipleOf":0.5}, @@ -323,7 +325,7 @@ public void PreservesNumericBoundsAndFalseConstraints(string version, string min [Fact] public void PreservesConstantsInsideSchemaValuedConstraintsAndReferenceSiblings() { - var model = OpenApiDocumentReader.Parse(""" + var model = RestApiDocumentReader.Parse(""" {"openapi":"3.1.0","info":{"title":"Constraints","version":"1"},"paths":{}, "components":{"schemas":{ "Base":{}, @@ -351,7 +353,7 @@ public void PreservesConstantsInsideSchemaValuedConstraintsAndReferenceSiblings( [Fact] public void SchemaShapedLiteralExamplesAndExtensionsAreNotPreflighted() { - var model = OpenApiDocumentReader.Parse(""" + var model = RestApiDocumentReader.Parse(""" { "openapi":"3.1.0","info":{"title":"Data","version":"1"}, "x-data":{"schema":{"allOf":[false],"const":42},"components":{"schemas":{"Value":true}}}, @@ -372,7 +374,7 @@ public void SchemaShapedLiteralExamplesAndExtensionsAreNotPreflighted() [Fact] public void PreservesSingularSchemaExamplesFromOpenApi30() { - var model = OpenApiDocumentReader.Parse(""" + var model = RestApiDocumentReader.Parse(""" { "openapi":"3.0.3","info":{"title":"Examples","version":"1"},"paths":{}, "components":{"schemas":{"Value":{"type":"object","example":{"description":"**literal**","$ref":"payload"}}}} @@ -391,7 +393,7 @@ public void PreservesTypedConstValues(string format) { foreach (var value in new[] { "42", "-1", "1.5", "1e20", "1e100", "true", "false", "{}", "[]", "123456789012345678901234567890", "{\"n\":42,\"flag\":false,\"items\":[null,\"42\"]}" }) { - var model = OpenApiDocumentReader.Parse(""" + var model = RestApiDocumentReader.Parse(""" {"openapi":"3.1.0","info":{"title":"Constants","version":"1"},"paths":{}, "components":{"schemas":{"Value":{"const":VALUE,"enum":[1,2]}}}} """.Replace("VALUE", value), format); @@ -406,7 +408,7 @@ public void PreservesTypedConstValues(string format) [InlineData("false")] public void BooleanSchemasRequireOpenApi31OrLater(string boolean) { - var error = Assert.Throws(() => OpenApiDocumentReader.Parse(""" + var error = Assert.Throws(() => RestApiDocumentReader.Parse(""" {"openapi":"3.0.3","info":{"title":"Boolean","version":"1"},"paths":{}, "components":{"schemas":{"Value":{"properties":{"value":BOOLEAN}}}}} """.Replace("BOOLEAN", boolean), "json")); @@ -418,7 +420,7 @@ public void BooleanSchemasRequireOpenApi31OrLater(string boolean) [InlineData("'quoted'")] public void NormalizationDoesNotAcceptMalformedJsonConstants(string value) { - Assert.Throws(() => OpenApiDocumentReader.Parse(""" + Assert.Throws(() => RestApiDocumentReader.Parse(""" {"openapi":"3.2.0","info":{"title":"Invalid JSON","version":"1"},"paths":{}, "components":{"schemas":{"Value":{"const":VALUE}}}} """.Replace("VALUE", value), "json")); @@ -431,7 +433,7 @@ public void PreservesStringAndNullConstantsAndExplicitNullDefaults(string format { foreach (var value in new[] { "\"ok\"", "\"😀\"", "\"42\"", "\"true\"", "\"null\"", "\"\"", "null" }) { - var model = OpenApiDocumentReader.Parse(""" + var model = RestApiDocumentReader.Parse(""" {"openapi":"3.1.0","info":{"title":"Constants","version":"1"},"paths":{}, "components":{"schemas":{"Value":{"const":VALUE,"default":null}}}} """.Replace("VALUE", value), format); @@ -453,7 +455,7 @@ public void PreservesStringAndNullConstantsAndExplicitNullDefaults(string format [InlineData("!!str", "\"\"")] public void PreservesYamlStringAndNullConstants(string value, string expected) { - var model = OpenApiDocumentReader.Parse($$""" + var model = RestApiDocumentReader.Parse($$""" openapi: 3.1.0 info: {title: Constants, version: '1'} paths: {} @@ -472,7 +474,7 @@ public void PreservesYamlStringAndNullConstants(string value, string expected) [InlineData("3.0.3", "default")] public void PreservesImplicitYamlNullValues(string version, string keyword) { - var model = OpenApiDocumentReader.Parse($$""" + var model = RestApiDocumentReader.Parse($$""" openapi: {{version}} info: {title: Null values, version: '1'} paths: {} @@ -492,7 +494,7 @@ public void PreservesImplicitYamlNullValues(string version, string keyword) [InlineData("x-parameter")] public void ChecksSchemasInNamedParameters(string name) { - var model = OpenApiDocumentReader.Parse(""" + var model = RestApiDocumentReader.Parse(""" {"openapi":"3.1.0","info":{"title":"Constants","version":"1"}, "paths":{"/items":{"get":{"parameters":[{"$ref":"#/components/parameters/NAME"}],"responses":{"200":{"description":"OK"}}}}}, "components":{"parameters":{"NAME":{"name":"q","in":"query","schema":{"const":42}}}}} @@ -506,7 +508,7 @@ public void ChecksSchemasInNamedParameters(string name) [InlineData("default")] public void PreservesConstInInlineResponseSchemas(string status) { - var model = OpenApiDocumentReader.Parse(""" + var model = RestApiDocumentReader.Parse(""" {"openapi":"3.1.0","info":{"title":"Constants","version":"1"}, "paths":{"/items":{"get":{"responses":{"STATUS":{"description":"OK", "content":{"application/json":{"schema":{"const":42}}}}}}}}} @@ -534,11 +536,11 @@ public void DoesNotTurnExclusiveOverlappingAlternativesIntoInclusiveUnions(strin """.Replace("VERSION", version).Replace("SCHEMA", schema); if (version == "3.0.3" && lossy) { - var error = Assert.Throws(() => OpenApiDocumentReader.Parse(raw, "json")); + var error = Assert.Throws(() => RestApiDocumentReader.Parse(raw, "json")); Assert.Contains("UnsupportedOpenApiComposition", error.Message); continue; } - var model = OpenApiDocumentReader.Parse(raw, "json"); + var model = RestApiDocumentReader.Parse(raw, "json"); var value = (JObject.FromObject(model.Metadata["schemas"]))["Value"]; Assert.Equal("One of", value["composition"][0]["kind"]); Assert.Equal(2, value["composition"][0]["schemas"].Count()); @@ -551,7 +553,7 @@ public void DoesNotTurnExclusiveOverlappingAlternativesIntoInclusiveUnions(strin [InlineData("3.10.0")] public void DoesNotAdvertiseUntestedVersions(string version) { - var error = Assert.Throws(() => OpenApiDocumentReader.Parse( + var error = Assert.Throws(() => RestApiDocumentReader.Parse( """{"openapi":"VERSION","info":{"title":"Future","version":"1"},"paths":{}}""".Replace("VERSION", version), "json")); Assert.Contains("3.0", error.Message); Assert.Contains("3.1", error.Message); @@ -563,7 +565,7 @@ public void DoesNotAdvertiseUntestedVersions(string version) [InlineData("file://server/share/schema.json")] public void InvalidAndNetworkReferencesAreErrors(string reference) { - var error = Assert.Throws(() => OpenApiDocumentReader.Parse(""" + var error = Assert.Throws(() => RestApiDocumentReader.Parse(""" { "openapi":"3.1.0","info":{"title":"References","version":"1"}, "paths":{},"components":{"schemas":{"Item":{"$ref":"REFERENCE"}}} @@ -580,7 +582,7 @@ public void InvalidAndNetworkReferencesAreErrors(string reference) public void MissingTargetsAndStandaloneFragmentsNeverSucceed(string reference, bool createExternal, string diagnostic) { var folder = GetRandomFolder(); - var entry = CreateFile("entry.json", """ + CreateFile("entry.json", """ {"openapi":"3.1.0","info":{"title":"Missing","version":"1"},"paths":{}, "components":{"schemas":{"Value":{"$ref":"REFERENCE"}}}} """.Replace("REFERENCE", reference), folder); @@ -589,7 +591,7 @@ public void MissingTargetsAndStandaloneFragmentsNeverSucceed(string reference, b CreateFile("external.yaml", "openapi: 3.1.0\ninfo: { title: External, version: '1' }\npaths: {}\ncomponents: { schemas: {} }", folder); CreateFile("fragment.yaml", "type: string", folder); } - var error = Assert.Throws(() => OpenApiDocumentReader.Read(entry)); + var error = Assert.Throws(() => RestApiDocumentReader.Read(DocumentInput.Get(new FileAndType(Path.GetFullPath(folder), "entry.json", DocumentType.Article)))); Assert.Contains(diagnostic, error.Message); } } diff --git a/test/Docfx.Build.RestApi.Tests/RestApiDocumentReaderTest.cs b/test/Docfx.Build.RestApi.Tests/RestApiDocumentReaderTest.cs index 4f322c71757..61e86f71dc2 100644 --- a/test/Docfx.Build.RestApi.Tests/RestApiDocumentReaderTest.cs +++ b/test/Docfx.Build.RestApi.Tests/RestApiDocumentReaderTest.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using Newtonsoft.Json.Linq; +using Docfx.Build.Common; +using Docfx.Plugins; using Docfx.Tests.Common; using Xunit; @@ -17,18 +19,18 @@ public class RestApiDocumentReaderTest : TestBase [InlineData("yaml", "info: {version: '2.0'}\nopenapi: '3.1.0'", "3.1.0", false)] [InlineData("json", "{\"swagger\":\"2.0\"}", "2.0", true)] [InlineData("json", "{\"openapi\":\"2.0\"}", "2.0", false)] - [InlineData("yaml", "swagger: '2.0'", null, false)] + [InlineData("yaml", "swagger: '2.0'", "2.0", true)] public void IdentifiesOnlyRootSpecificationMarkers(string format, string source, string version, bool swagger) { - var header = RestApiDocumentReader.ReadHeader(new StringReader(source), format); + var header = DocumentInput.ReadHeader(new StringReader(source), format); Assert.Equal(version, header?.Version); - Assert.Equal(swagger, header?.IsSwagger ?? false); + Assert.Equal(swagger, header?.Kind == "swagger"); } [Fact] public void MalformedHeaderUsesTheReaderDiagnostic() { - Assert.Throws(() => OpenApiDocumentReader.Parse("{\"info\":]", "json")); + Assert.Throws(() => RestApiDocumentReader.Parse("{\"info\":]", "json")); } [Theory] @@ -51,9 +53,11 @@ public void ReadersKeepSchemaDataInMetadata(string version) "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"allOf":[ {"type":"object","properties":{"name":{"type":"string"}}}]}}}}}}}}} """.Replace("VERSION", version); - var file = CreateFile("api.json", source, GetRandomFolder()); - Assert.True(RestApiDocumentReader.IsSupportedFile(file)); - var model = RestApiDocumentReader.Read(file, "api.json"); + var folder = GetRandomFolder(); + CreateFile("api.json", source, folder); + var input = DocumentInput.Get(new FileAndType(Path.GetFullPath(folder), "api.json", DocumentType.Article)); + Assert.Equal(version == "2.0" ? "swagger" : "openapi", input.Header.Kind); + var model = RestApiDocumentReader.Read(input, "api.json"); Assert.Equal(version == "2.0" ? null : version, model.Metadata.GetValueOrDefault("specificationVersion")); Assert.Equal("Common/service-version", model.Uid); var operation = Assert.Single(model.Children);