diff --git a/Directory.Packages.props b/Directory.Packages.props index ccf90de4970..15ed6096669 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,8 @@ + + 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/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 new file mode 100644 index 00000000000..751d9d31208 --- /dev/null +++ b/docs/docs/openapi-unsupported-features.md @@ -0,0 +1,94 @@ +# 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, 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. + +## 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 | +| --- | --- | --- | +| 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). | + +### 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/3.2 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. | +| 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. + +## Supported schema values and OpenAPI 3.2 features + +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. + +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 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` 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 + 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 02c8afc79e3..dbbaf7f3fb0 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, 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. To add REST API docs, include the swagger JSON file to the `build` config in `docfx.json`: @@ -16,6 +19,65 @@ 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 + +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: + +```json +{ + "build": { + "content": [{ + "files": ["api/service.yaml", "api/other.json"] + }] + } +} +``` + +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. + +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/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. +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. + +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. ## Organize REST APIs using Tags diff --git a/docs/docs/toc.yml b/docs/docs/toc.yml index cc18f0efabb..0f446419541 100644 --- a/docs/docs/toc.yml +++ b/docs/docs/toc.yml @@ -10,6 +10,11 @@ - href: dotnet-api-docs.md - href: sdk-compatibility.md - 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.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/BuildRestApiDocument.cs b/src/Docfx.Build.RestApi/BuildRestApiDocument.cs index 8e2b79feafb..25bd79cfea2 100644 --- a/src/Docfx.Build.RestApi/BuildRestApiDocument.cs +++ b/src/Docfx.Build.RestApi/BuildRestApiDocument.cs @@ -41,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) @@ -49,7 +52,11 @@ 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) { // Mark up recursively for swagger root except for children and tags foreach (var jToken in rootModel.Metadata.Values.OfType()) @@ -65,9 +72,16 @@ public static RestApiItemViewModelBase BuildItem(IHostService host, RestApiItemV { param.Description = Markup(host, param.Description, model, filter); - foreach (var jToken in param.Metadata.Values.OfType()) + if (openApi3) { - MarkupRecursive(jToken, host, model, filter); + MarkupOpenApiMetadata(param.Metadata); + } + else + { + foreach (var jToken in param.Metadata.Values.OfType()) + { + MarkupRecursive(jToken, host, model, filter); + } } } } @@ -77,13 +91,79 @@ public static RestApiItemViewModelBase BuildItem(IHostService host, RestApiItemV { response.Description = Markup(host, response.Description, model, filter); - foreach (var jToken in response.Metadata.Values.OfType()) + if (openApi3) + { + MarkupOpenApiMetadata(response.Metadata); + } + else { - MarkupRecursive(jToken, host, model, filter); + foreach (var jToken in response.Metadata.Values.OfType()) + { + MarkupRecursive(jToken, host, model, filter); + } } } } return item; + + 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) + { + 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)) + { + MarkupSchema(GetProperty(media, "schema")); + MarkupSchema(GetProperty(media, "itemSchema")); + } + } + + 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) 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/OpenApi3ModelConverter.cs b/src/Docfx.Build.RestApi/OpenApi3ModelConverter.cs new file mode 100644 index 00000000000..57219ed4251 --- /dev/null +++ b/src/Docfx.Build.RestApi/OpenApi3ModelConverter.cs @@ -0,0 +1,431 @@ +// 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 System.Text.RegularExpressions; +using Docfx.DataContracts.RestApi; +using Docfx.Exceptions; +using Microsoft.OpenApi; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Docfx.Build.RestApi; + +internal sealed partial class OpenApi3ModelConverter(IReadOnlyDictionary constants) +{ + 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["specificationVersion"] = version; + 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); + 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["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["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); + } + return new RestApiParameterViewModel + { + Name = parameter.Name, + Description = parameter.Description, + 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 = metadata + }; + } + + 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) + }) ?? []); + + 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.SerializedValue ?? Literal(example.DataValue ?? example.Value), + ["externalValue"] = example.ExternalValue + }); + } + return result; + } + + 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 JToken Serialize(IOpenApiSerializable value) + { + using var text = new StringWriter(); + value.SerializeAsV32(new OpenApiJsonWriter(text)); + var token = JToken.Parse(text.ToString()); + if (value is IOpenApiSchema) + { + RestoreConstants(token); + } + return token; + } + + 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_2); + 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, JObject serialized = 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 = Schema(GetReferenceSiblings(reference), ancestors); + var result = siblings.Count == 1 && (string)siblings["type"] == "any value" ? targetModel : new JObject + { + ["type"] = "all of", + ["description"] = reference.Reference.Description ?? target.Description, + ["allOf"] = new JArray(targetModel, siblings) + }; + 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 + { + // 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, serialized["properties"]?[pair.Key] as JObject); + 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, serialized["items"] as JObject); + var composition = new JArray(); + 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) + { + 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 = 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, serialized["additionalProperties"] as JObject)) }); + 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"] = serialized["enum"]; + } + 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; + + 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"] = Schemas(schemas, values) }); + } + } + } + finally + { + ancestors.Remove(schema); + } + } + + 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) + { + Reference = new JsonSchemaReference(reference.Reference) { HostDocument = null } + }; + var siblings = (OpenApiSchema)detached.CopyReferenceAsTargetElementWithOverrides(new OpenApiSchema()); + siblings.Description = reference.Reference.Description; + return siblings; + } + + 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) + { + 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 new file mode 100644 index 00000000000..2fcb2b73ddd --- /dev/null +++ b/src/Docfx.Build.RestApi/OpenApiDocumentReader.cs @@ -0,0 +1,441 @@ +// 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; +using Docfx.Exceptions; +using Docfx.Plugins; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; +using Newtonsoft.Json; +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; + +namespace Docfx.Build.RestApi; + +internal static class OpenApiDocumentReader +{ + internal static RestApiRootItemViewModel Parse(string raw, string format, Uri baseUrl, string version) + { + try + { + var constants = new Dictionary(); + 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; + } + 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 LoadDocument(string raw, string format, Uri root, string rootVersion, Dictionary constants) + { + if (!System.Version.TryParse(rootVersion, out var parsed) || parsed.Major != 3 || parsed.Minor is not (0 or 1 or 2)) + { + throw new DocfxException($"OpenAPI version '{rootVersion}' is not supported. Use OpenAPI 3.0, 3.1 or 3.2."); + } + if (format == "json") + { + // 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."); + } + document.Workspace = new OpenApiWorkspace(); + document.Workspace.RegisterComponents(document); + foreach (var (holder, reference) in collector.References) + { + if (reference.ExternalResource != null) + { + 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 document; + } + + 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) && + components is YamlMappingNode componentMap && + componentMap.Children.TryGetValue(new YamlScalarNode("schemas"), out var schemas)) + { + 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) + { + 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 "dataValue" or "serializedValue" or "schemas") + { + continue; + } + if (name is "schema" or "itemSchema") + { + CheckSchema(value, path + "/" + name); + } + else if (name == "$ref") + { + 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" 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 + // 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); + } + } + } + + void CheckMap(YamlNode node, string path) + { + if (node is YamlMappingNode map) + { + foreach (var (key, value) in map.Children) + { + CheckSchema(value, path + "/" + key); + } + } + } + + 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; + } + foreach (var (key, value) in schema.Children) + { + var name = ((YamlScalarNode)key).Value; + switch (name) + { + 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: + PreserveConst(value); + break; + 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++) + { + 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 PreserveConst(YamlNode node) + { + // 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)); + } + + static string JsonLiteral(YamlNode node) + { + 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") + { + 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) + { + if (node is YamlScalarNode { Value: { } value } && !value.StartsWith('#')) + { + throw new DocfxException($"UnsupportedExternalReference: reference '{value}' at '{path}' in '{location.LocalPath}' " + + "must target the current OpenAPI document."); + } + } + + 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 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 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(OpenApi3ModelConverter.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 } } + }); + } + +} diff --git a/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs b/src/Docfx.Build.RestApi/RestApiDocumentProcessor.cs index 72bc22a9968..3ab40ee6688 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'. @@ -34,6 +28,8 @@ public class RestApiDocumentProcessor : ReferenceDocumentProcessorBase ".swagger.json", ".swagger2.json", ".json", + ".yaml", + ".yml", ]; protected static readonly string[] SystemKeys = [ @@ -63,7 +59,17 @@ public class RestApiDocumentProcessor : ReferenceDocumentProcessorBase "securityDefinitions", "security", "tags", - "externalDocs" + "externalDocs", + "openapi", + "servers", + "components", + "schemas", + "requestBody", + "requestUrl", + "rawExtension", + "jsonSchemaDialect", + "webhooks", + "specificationVersion" ]; [ImportMany(nameof(RestApiDocumentProcessor))] @@ -76,7 +82,10 @@ public override ProcessingPriority GetProcessingPriority(FileAndType file) switch (file.Type) { case DocumentType.Article: - if (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; } @@ -121,20 +130,21 @@ 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); + 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) { - 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 = 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] = SystemKeys; var displayLocalPath = PathUtility.MakeRelativePath(EnvironmentContext.BaseDirectory, file.FullPath); @@ -191,61 +201,11 @@ private static IEnumerable GetXRefInfo(RestApiRootItemViewModel rootIt } } - private static bool IsSupportedFile(string filePath) - { - return SupportedFileEndings.Any(s => IsSupportedFileEnding(filePath, s)) && IsSwaggerFile(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 string ChangeFileExtension(string file) { var suffix = SupportedFileEndings.First(s => IsSupportedFileEnding(file, s)); diff --git a/src/Docfx.Build.RestApi/RestApiDocumentReader.cs b/src/Docfx.Build.RestApi/RestApiDocumentReader.cs new file mode 100644 index 00000000000..76833f25427 --- /dev/null +++ b/src/Docfx.Build.RestApi/RestApiDocumentReader.cs @@ -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. + +using Docfx.Build.Common; +using Docfx.Build.RestApi.Swagger; +using Docfx.DataContracts.RestApi; +using Docfx.Exceptions; +using Newtonsoft.Json.Linq; + +namespace Docfx.Build.RestApi; + +internal static class RestApiDocumentReader +{ + internal static RestApiRootItemViewModel Parse(string raw, string format) => Read(DocumentInput.FromText(raw, format)); + + internal static RestApiRootItemViewModel Read(DocumentInput input, string fileName = null) + { + if (input.Header is { Kind: "openapi", Version: var version }) + { + return OpenApiDocumentReader.Parse(input.ReadAllText(), input.Format, new Uri(Path.GetFullPath(input.Path)), version); + } + if (input.Format != "json" || input.Header is not { Kind: "swagger", Version: "2.0" }) + { + throw new DocfxException($"Unable to identify REST API document '{input.Path}'."); + } + 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 ?? []) + { + 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 ?? input.Path}'"); + } + } + } + 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/templates/common/RestApi.common.js b/templates/common/RestApi.common.js index cdb07596e22..9a173182a50 100644 --- a/templates/common/RestApi.common.js +++ b/templates/common/RestApi.common.js @@ -3,8 +3,12 @@ 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 = []; + 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.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 +20,7 @@ exports.transform = function (model) { if (child.operation) { child.operation = child.operation.toUpperCase(); } - child.path = appendQueryParamsToPath(child.path, child.parameters); + 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 @@ -26,8 +30,18 @@ exports.transform = function (model) { child.htmlId = common.getHtmlId(child.uid); formatExample(child.responses); - resolveAllOf(child); - transformReference(child); + 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); + } }; if (!model.tags || model.tags.length === 0) { var childTags = []; @@ -81,23 +95,115 @@ 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 (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); }); + }); + }); + } + if (model.children) { + model.children.forEach(function(child) { (child.parameters || []).forEach(function(parameter) { addComplexTypeMetadata(parameter.schema, model.definitions); }); (child.responses || []).forEach(function(response) { addComplexTypeMetadata(response.schema, model.definitions); }); }); + } + } + + return model; + + function schemaId(name) { + return "schema-" + name.replace(/[^a-zA-Z0-9-]/g, function (character) { + return "_" + character.charCodeAt(0).toString(16) + "_"; }); } - if (model.children) { - model.children.forEach(function(child) { - (child.parameters || []).forEach(function(parameter) { addComplexTypeMetadata(parameter.schema, model.definitions); }); - (child.responses || []).forEach(function(response) { addComplexTypeMetadata(response.schema, model.definitions); }); + + function 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, 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. + 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: '', + properties: Object.keys(schema.properties || {}).map(function (key) { + return { + key: key, + required: schema.properties[key].required === true || + (Array.isArray(schema.required) && schema.required.indexOf(key) >= 0), + value: schemaDetails(schema.properties[key]) + }; + }), + items: schemaDetails(schema.items), + composition: (schema.allOf ? [{ kind: 'All of', schemas: schema.allOf }] : []).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 || (schema.example !== undefined ? [{ content: JSON.stringify(schema.example) }] : [])) }); } - return model; + function exampleDetails(examples) { + return (examples || []).map(function (example) { + var externalValue = example.externalValue || ''; + return { + 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 : '' + }; + }); + } + + 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 || ''; + example.mimeType = example.mimeType || media.mimeType; + }); + }); + formatExample(content); + (content || []).forEach(function (media) { media.exampleDetails = exampleDetails(media.examples); }); + } function getChildrenByTag(children, tag) { if (!children) return; diff --git a/templates/default/partials/rest.child.tmpl.partial b/templates/default/partials/rest.child.tmpl.partial index a64d6b7dd8b..63e37e63e22 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,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.}} +
+
Mime type: {{mimeType}}
+ {{#schemaDetails}}{{>partials/rest.schema}}{{/schemaDetails}} + {{#itemSchemaDetails}} +
Stream item
{{>partials/rest.schema}}
+ {{/itemSchemaDetails}} +
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..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**" } + } + } + } +}