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,23 @@
{{#schema.cType}}
{{{schema.cType}}}{{#schema.cTypeIsArray}}[]{{/schema.cTypeIsArray}}
{{/schema.cType}}
+ {{/schemaDetails}}
+ {{/hasContent}}
|
{{{description}}} |
+ {{#content}}{{>partials/rest.examples}}{{/content}}
+ {{^hasContent}}
+ {{#exampleDetails.0}}{{>partials/rest.examples}}{{/exampleDetails.0}}
+ {{^exampleDetails.0}}
{{#examples}}
Mime type: {{mimeType}}
{{content}}
{{/examples}}
+ {{/exampleDetails.0}}
+ {{/hasContent}}
|
{{/responses}}
diff --git a/templates/default/partials/rest.definition.tmpl.partial b/templates/default/partials/rest.definition.tmpl.partial
index 83cc77ef00b..87dc37b8031 100644
--- a/templates/default/partials/rest.definition.tmpl.partial
+++ b/templates/default/partials/rest.definition.tmpl.partial
@@ -1,5 +1,11 @@
{{!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 +49,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..748e94dded5
--- /dev/null
+++ b/templates/default/partials/rest.media-schema.tmpl.partial
@@ -0,0 +1,8 @@
+{{!Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license.}}
+
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}}
+
+ | Name | Schema |
+
+ {{#properties}}
+
+ | {{key}}{{#required}} (Required){{/required}} |
+ {{#value}}{{>partials/rest.schema}}{{/value}} |
+
+ {{/properties}}
+
+
+ {{/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..1bd23f160ba
--- /dev/null
+++ b/templates/modern/src/rest.test.ts
@@ -0,0 +1,396 @@
+// 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 swagger2 = rest.transform({ uid: 'swagger2', _path: 'swagger2.html' })
+ assert.equal(swagger2._jsonPath, 'swagger2.swagger.json')
+
+ 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({ 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('Swagger 2.0 preserves query paths and flattened allOf definitions', () => {
+ const model = rest.transform({
+ uid: 'swagger2',
+ _path: 'swagger2.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',
+ referenceId: '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.responses[0].examples[0].content, '{\n "id": 1\n}')
+ 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].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: {},
+ 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, '')
+ assert.equal(child.parameters[0].schemaDetails.type, 'string | null')
+ assert.equal(child.parameters[0].schemaDetails.format, 'uuid')
+ 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, '')
+ assert.deepEqual(child.responses[0].content[2].examples, [])
+ 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')
+})
+
+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({ 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)
+ 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, false)
+ assert.deepEqual(composition[3].schemas[0].composition, [])
+})
+
+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: {
+ '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({
+ specificationVersion: '3.2.0',
+ 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, '')
+})
+
+test('REST renders parameter content and keeps schema references distinct', () => {
+ const model = rest.transform({
+ specificationVersion: '3.2.0',
+ 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': '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': 'SecondFilter' },
+ examples: [{ content: 'active' }]
+ }
+ ]
+ }]
+ }]
+ })
+ const parameter = model.children[0].parameters[0]
+ assert.equal(parameter.hasContent, true)
+ 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')
+ 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({ 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], {
+ name: '',
+ mimeType: '',
+ content: '{"state":"active"}',
+ hasContent: true,
+ externalValue: '',
+ externalHref: ''
+ })
+ 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({
+ specificationVersion: '3.2.0',
+ 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), '', ''])
+ assert.ok(examples.every(example => example.name === 'external' && example.content === '' && !example.hasContent))
+})
+
+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',
+ 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',
+ 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',
+ specificationVersion,
+ 'x-root': structuredClone(literal),
+ children: [operation]
+ })
+ 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, [])
+ })
+}
+
+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': 'Node',
+ properties: { next: { 'x-internal-loop-ref-name': '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({
+ specificationVersion: '3.2.0',
+ 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.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
new file mode 100644
index 00000000000..f1002995ee5
--- /dev/null
+++ b/test/Docfx.Build.RestApi.Tests/OpenApiDocumentReaderTest.cs
@@ -0,0 +1,597 @@
+// 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;
+using Xunit;
+
+namespace Docfx.Build.RestApi.Tests;
+
+[Collection("docfx STA")]
+public class OpenApiDocumentReaderTest : TestBase
+{
+ [Theory]
+ [InlineData("json")]
+ [InlineData("yaml")]
+ public void OpenApi32MapsAdditionalMethodsStreamingAndExamples(string format)
+ {
+ var model = RestApiDocumentReader.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["specificationVersion"]);
+ Assert.Equal(new[] { "query", "copy" }, model.Children.Select(child => child.OperationName));
+ 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"]);
+ 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 = 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"]);
+ 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"]);
+ 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 ReportsOpenApi32FeaturesWithoutDocumentationUi()
+ {
+ using var listener = new TestListenerScope();
+ 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":{
+ "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 = $$"""
+ {
+ "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 = 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);
+ 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.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.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.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, content.Sum(media => media["examples"].Count()));
+ }
+
+ [Theory]
+ [InlineData("3.0.3")]
+ [InlineData("3.1.1")]
+ [InlineData("3.2.0")]
+ public void YamlUsesTheSameModelsAndDefaults(string version)
+ {
+ var model = RestApiDocumentReader.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) => RestApiDocumentReader.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 = RestApiDocumentReader.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.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"]);
+ Assert.Contains("null", (string)schemas["Nullable"]["type"]);
+ Assert.Equal("sibling", schemas["Sibling"]["description"]);
+ var siblings = schemas["Sibling"]["allOf"];
+ Assert.Equal("10", siblings[0]["constraints"][0]["value"]);
+ Assert.Equal("5", siblings[1]["constraints"][0]["value"]);
+ 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"]);
+ }
+
+ [Theory]
+ [InlineData("true")]
+ [InlineData("false")]
+ public void PreservesBooleanSchemasInMapsAndCompositions(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 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"];
+ 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["allOf"] ?? value["composition"][0]["schemas"])[0]["type"]);
+ else
+ Assert.Contains(boolean == "true" ? "{}" : "\"not\":{}", (string)value["constraints"][0]["value"]);
+ }
+ }
+
+ [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 = RestApiDocumentReader.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 = RestApiDocumentReader.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()
+ {
+ var model = RestApiDocumentReader.Parse("""
+ {
+ "openapi":"3.1.0","info":{"title":"Data","version":"1"},
+ "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");
+ Assert.NotNull(model.Metadata["x-data"]);
+ 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]
+ public void PreservesSingularSchemaExamplesFromOpenApi30()
+ {
+ var model = RestApiDocumentReader.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.FromObject(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("json")]
+ [InlineData("yaml")]
+ 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 = RestApiDocumentReader.Parse("""
+ {"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.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(() => RestApiDocumentReader.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(() => RestApiDocumentReader.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" })
+ {
+ 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);
+ 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"]);
+ }
+ }
+
+ [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")]
+ [InlineData("!!str", "\"\"")]
+ public void PreservesYamlStringAndNullConstants(string value, string expected)
+ {
+ var model = RestApiDocumentReader.Parse($$"""
+ openapi: 3.1.0
+ info: {title: Constants, version: '1'}
+ paths: {}
+ components:
+ schemas:
+ Value:
+ const: {{value}}
+ """, "yaml");
+ var constraints = (JObject.FromObject(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 PreservesImplicitYamlNullValues(string version, string keyword)
+ {
+ var model = RestApiDocumentReader.Parse($$"""
+ openapi: {{version}}
+ info: {title: Null values, version: '1'}
+ paths: {}
+ components:
+ schemas:
+ Value:
+ {{keyword}}:
+ """, "yaml");
+ var schema = (JObject.FromObject(model.Metadata["schemas"]))["Value"];
+ Assert.Equal("null", (string)Assert.Single(schema["constraints"])["value"]);
+ }
+
+ [Theory]
+ [InlineData("default")]
+ [InlineData("schema")]
+ [InlineData("value")]
+ [InlineData("x-parameter")]
+ public void ChecksSchemasInNamedParameters(string name)
+ {
+ 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}}}}}
+ """.Replace("NAME", name), "json");
+ var schema = JObject.FromObject(Assert.Single(Assert.Single(model.Children).Parameters).Metadata["schema"]);
+ Assert.Equal("42", (string)Assert.Single(schema["constraints"])["value"]);
+ }
+
+ [Theory]
+ [InlineData("200")]
+ [InlineData("default")]
+ public void PreservesConstInInlineResponseSchemas(string status)
+ {
+ 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}}}}}}}}}
+ """.Replace("STATUS", status), "json");
+ var content = JArray.FromObject(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[]
+ {
+ ("""{"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(() => RestApiDocumentReader.Parse(raw, "json"));
+ Assert.Contains("UnsupportedOpenApiComposition", error.Message);
+ continue;
+ }
+ 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());
+ }
+ }
+
+ [Theory]
+ [InlineData("3.3.0")]
+ [InlineData("4.0.0")]
+ [InlineData("3.10.0")]
+ public void DoesNotAdvertiseUntestedVersions(string version)
+ {
+ 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);
+ }
+
+ [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(() => RestApiDocumentReader.Parse("""
+ {
+ "openapi":"3.1.0","info":{"title":"References","version":"1"},
+ "paths":{},"components":{"schemas":{"Item":{"$ref":"REFERENCE"}}}
+ }
+ """.Replace("REFERENCE", reference), "json"));
+ Assert.NotEmpty(error.Message);
+ }
+
+ [Theory]
+ [InlineData("missing.yaml#/components/schemas/Value", false, "missing.yaml")]
+ [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();
+ 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(() => 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
new file mode 100644
index 00000000000..61e86f71dc2
--- /dev/null
+++ b/test/Docfx.Build.RestApi.Tests/RestApiDocumentReaderTest.cs
@@ -0,0 +1,72 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// 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;
+
+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'", "2.0", true)]
+ public void IdentifiesOnlyRootSpecificationMarkers(string format, string source, string version, bool swagger)
+ {
+ var header = DocumentInput.ReadHeader(new StringReader(source), format);
+ Assert.Equal(version, header?.Version);
+ Assert.Equal(swagger, header?.Kind == "swagger");
+ }
+
+ [Fact]
+ public void MalformedHeaderUsesTheReaderDiagnostic()
+ {
+ Assert.Throws(() => RestApiDocumentReader.Parse("{\"info\":]", "json"));
+ }
+
+ [Theory]
+ [InlineData("2.0")]
+ [InlineData("3.0.3")]
+ [InlineData("3.1.0")]
+ [InlineData("3.2.0")]
+ public void ReadersKeepSchemaDataInMetadata(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 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);
+ var parameter = Assert.Single(operation.Parameters);
+ Assert.Equal("string", version == "2.0" ? parameter.Metadata["type"]
+ : (string)Assert.IsType(parameter.Metadata["schema"])["type"]);
+ var response = Assert.Single(operation.Responses);
+ 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
new file mode 100644
index 00000000000..23d3337617b
--- /dev/null
+++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/OpenApiOutputTest.cs
@@ -0,0 +1,714 @@
+// 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";
+
+ [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")]
+ [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)]
+ [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)]
+ [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)
+ {
+ 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));
+ if (version != "3.0.3")
+ {
+ 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"]));
+ 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 = 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"]);
+ 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.0.3")
+ {
+ 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("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);
+ }
+
+ [Fact]
+ public void RejectsUnsupportedExternalReferencesWithoutPublishing()
+ {
+ var input = GetRandomFolder();
+ 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": {"$ref": "schema.yaml"} } }
+ }
+ """, input);
+ var files = new FileCollection(Directory.GetCurrentDirectory());
+ files.Add(DocumentType.Article, [file], input);
+
+ 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));
+ }
+
+ 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");
+ document["openapi"] = version;
+ if (version != "3.0.3")
+ {
+ 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 };
+ 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 }
+ }
+ }
+ };
+ }
+ 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 { 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}")
+ };
+
+ 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/service.json b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/openapi/service.json
new file mode 100644
index 00000000000..4d626798528
--- /dev/null
+++ b/test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/openapi/service.json
@@ -0,0 +1,135 @@
+{
+ "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": {
+ "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" } }
+ },
+ "requestBodies": {
+ "Create": {
+ "description": "The **body** to create.",
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/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/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**" }
+ }
+ }
+ }
+}