diff --git a/src/Microsoft.OpenApi/Extensions/OpenAPIWriterExtensions.cs b/src/Microsoft.OpenApi/Extensions/OpenAPIWriterExtensions.cs index 383fdf8d3..c01a75364 100644 --- a/src/Microsoft.OpenApi/Extensions/OpenAPIWriterExtensions.cs +++ b/src/Microsoft.OpenApi/Extensions/OpenAPIWriterExtensions.cs @@ -15,5 +15,13 @@ internal static OpenApiWriterSettings GetSettings(this IOpenApiWriter openApiWri } return new(); } + + internal static void CountInlineReference(this IOpenApiWriter openApiWriter) + { + if (openApiWriter is OpenApiWriterBase @base) + { + @base.CountInlineReference(); + } + } } } diff --git a/src/Microsoft.OpenApi/Models/OpenApiHeader.cs b/src/Microsoft.OpenApi/Models/OpenApiHeader.cs index 8bb155f85..bcd13248d 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiHeader.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiHeader.cs @@ -90,7 +90,7 @@ public virtual void SerializeAsV3(IOpenApiWriter writer) SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_0, (writer, element) => element.SerializeAsV3(writer)); } - internal void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version, + internal void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version, Action callback) { Utils.CheckArgumentNull(writer); @@ -167,7 +167,12 @@ public virtual void SerializeAsV2(IOpenApiWriter writer) writer.WriteProperty(OpenApiConstants.AllowReserved, AllowReserved, false); // schema - var targetSchema = Schema switch { + if (Schema is OpenApiSchemaReference) + { + writer.CountInlineReference(); + } + var targetSchema = Schema switch + { OpenApiSchemaReference schemaReference => schemaReference.RecursiveTarget, OpenApiSchema schema => schema, _ => null, diff --git a/src/Microsoft.OpenApi/Models/OpenApiParameter.cs b/src/Microsoft.OpenApi/Models/OpenApiParameter.cs index eb226c908..f0c5b5c77 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiParameter.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiParameter.cs @@ -107,7 +107,7 @@ public virtual void SerializeAsV3(IOpenApiWriter writer) SerializeInternal(writer, OpenApiSpecVersion.OpenApi3_0, (writer, element) => element.SerializeAsV3(writer)); } - internal void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version, + internal void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion version, Action callback) { Utils.CheckArgumentNull(writer); @@ -202,7 +202,12 @@ internal virtual void WriteRequestBodySchemaForV2(IOpenApiWriter writer, Diction // uniqueItems // enum // multipleOf - var targetSchema = Schema switch { + if (Schema is OpenApiSchemaReference) + { + writer.CountInlineReference(); + } + var targetSchema = Schema switch + { OpenApiSchemaReference schemaReference => schemaReference.RecursiveTarget, OpenApiSchema schema => schema, _ => null, diff --git a/src/Microsoft.OpenApi/Models/References/BaseOpenApiReferenceHolder.cs b/src/Microsoft.OpenApi/Models/References/BaseOpenApiReferenceHolder.cs index 058a8caa1..19979648f 100644 --- a/src/Microsoft.OpenApi/Models/References/BaseOpenApiReferenceHolder.cs +++ b/src/Microsoft.OpenApi/Models/References/BaseOpenApiReferenceHolder.cs @@ -207,7 +207,7 @@ protected BaseOpenApiReferenceHolder(string referenceId, OpenApiDocument? hostDo /// public virtual void SerializeAsV3(IOpenApiWriter writer) { - if (!writer.GetSettings().ShouldInlineReference(Reference) + if (!writer.GetSettings().ShouldInlineReference(Reference) || Reference.Type == ReferenceType.Tag) // tags are held as references need to drop in. { Reference.SerializeAsV3(writer); @@ -254,6 +254,7 @@ private protected void SerializeInternal(IOpenApiWriter writer, Action action) { Utils.CheckArgumentNull(writer); + writer.CountInlineReference(); ApplyToTarget(element => action(writer, element)); } } diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiRequestBodyReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiRequestBodyReference.cs index 5dea45316..1ab7884b3 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiRequestBodyReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiRequestBodyReference.cs @@ -64,6 +64,7 @@ public override void SerializeAsV2(IOpenApiWriter writer) { if (writer.GetSettings().ShouldInlineReference(Reference)) { + writer.CountInlineReference(); return GetFromTarget(target => target.ConvertToBodyParameter(writer)); } @@ -74,6 +75,7 @@ public override void SerializeAsV2(IOpenApiWriter writer) { if (writer.GetSettings().ShouldInlineReference(Reference)) { + writer.CountInlineReference(); return GetFromTarget(target => target.ConvertToFormDataParameters(writer)); } diff --git a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs index 6453e7475..fbc55b9b7 100644 --- a/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs +++ b/src/Microsoft.OpenApi/Models/References/OpenApiSchemaReference.cs @@ -250,8 +250,14 @@ private void SerializeAsWithoutLoops(IOpenApiWriter writer, Action action(w, element)); - writer.GetSettings().LoopDetector.PopLoop(); + try + { + SerializeInternal(writer, (w, element) => action(w, element)); + } + finally + { + writer.GetSettings().LoopDetector.PopLoop(); + } } } diff --git a/src/Microsoft.OpenApi/Properties/SRResource.Designer.cs b/src/Microsoft.OpenApi/Properties/SRResource.Designer.cs index eb88eab6f..b1161375c 100644 --- a/src/Microsoft.OpenApi/Properties/SRResource.Designer.cs +++ b/src/Microsoft.OpenApi/Properties/SRResource.Designer.cs @@ -113,6 +113,15 @@ internal static string IndentationLevelInvalid { return ResourceManager.GetString("IndentationLevelInvalid", resourceCulture); } } + + /// + /// Looks up a localized string similar to The maximum number of inline references ({0}) has been exceeded.. + /// + internal static string MaximumInlineReferenceCountExceeded { + get { + return ResourceManager.GetString("MaximumInlineReferenceCountExceeded", resourceCulture); + } + } /// /// Looks up a localized string similar to The input item should be in type of '{0}'.. diff --git a/src/Microsoft.OpenApi/Properties/SRResource.resx b/src/Microsoft.OpenApi/Properties/SRResource.resx index c2e953ec4..983750a4b 100644 --- a/src/Microsoft.OpenApi/Properties/SRResource.resx +++ b/src/Microsoft.OpenApi/Properties/SRResource.resx @@ -132,6 +132,9 @@ Indentation level cannot be lower than 0. + + The maximum number of inline references ({0}) has been exceeded. + The input item should be in type of '{0}'. diff --git a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt index cc7b76897..cb0df9c08 100644 --- a/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt +++ b/src/Microsoft.OpenApi/PublicAPI.Unshipped.txt @@ -1,2 +1,4 @@ #nullable enable Microsoft.OpenApi.OpenApiDocument.GetOperationById(string! operationId) -> Microsoft.OpenApi.OpenApiOperation? +Microsoft.OpenApi.OpenApiWriterSettings.MaxInlineReferenceCount.get -> int +Microsoft.OpenApi.OpenApiWriterSettings.MaxInlineReferenceCount.set -> void diff --git a/src/Microsoft.OpenApi/Writers/OpenApiWriterBase.cs b/src/Microsoft.OpenApi/Writers/OpenApiWriterBase.cs index e3848ec69..508809504 100644 --- a/src/Microsoft.OpenApi/Writers/OpenApiWriterBase.cs +++ b/src/Microsoft.OpenApi/Writers/OpenApiWriterBase.cs @@ -35,6 +35,7 @@ public abstract class OpenApiWriterBase : IOpenApiWriter /// Number which specifies the level of indentation. /// private int _indentLevel; + private int _inlineReferenceCount; /// /// Initializes a new instance of the class. @@ -70,6 +71,20 @@ protected OpenApiWriterBase(TextWriter textWriter, OpenApiWriterSettings? settin /// protected TextWriter Writer { get; } + internal void CountInlineReference() + { + if (_inlineReferenceCount >= Settings.MaxInlineReferenceCount) + { + throw new OpenApiWriterException( + string.Format( + CultureInfo.InvariantCulture, + SRResource.MaximumInlineReferenceCountExceeded, + Settings.MaxInlineReferenceCount)); + } + + _inlineReferenceCount++; + } + /// /// Write start object. /// @@ -319,7 +334,7 @@ public virtual void WriteIndentation() Writer.Write(IndentationString); } } - + /// /// Get current scope. /// diff --git a/src/Microsoft.OpenApi/Writers/OpenApiWriterSettings.cs b/src/Microsoft.OpenApi/Writers/OpenApiWriterSettings.cs index 4a9051f8d..85766d58a 100644 --- a/src/Microsoft.OpenApi/Writers/OpenApiWriterSettings.cs +++ b/src/Microsoft.OpenApi/Writers/OpenApiWriterSettings.cs @@ -5,7 +5,10 @@ /// public class OpenApiWriterSettings { + private const int DefaultMaxInlineReferenceCount = 10_000; + internal LoopDetector LoopDetector { get; } = new(); + /// /// Indicates if local references should be rendered as an inline object /// @@ -16,6 +19,12 @@ public class OpenApiWriterSettings /// public bool InlineExternalReferences { get; set; } + /// + /// Gets or sets the maximum number of references that can be expanded inline by a writer. + /// + /// The default is 10,000. + public int MaxInlineReferenceCount { get; set; } = DefaultMaxInlineReferenceCount; + internal bool ShouldInlineReference(BaseOpenApiReference reference) { return (reference.IsLocal && InlineLocalReferences) diff --git a/test/Microsoft.OpenApi.Tests/Writers/OpenApiWriterInlineReferenceTests.cs b/test/Microsoft.OpenApi.Tests/Writers/OpenApiWriterInlineReferenceTests.cs new file mode 100644 index 000000000..bf7b47f9d --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Writers/OpenApiWriterInlineReferenceTests.cs @@ -0,0 +1,305 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Writers +{ + public class OpenApiWriterInlineReferenceTests + { + private const int InlineReferenceLimit = 128; + private const int OutputCharacterLimit = 100_000; + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi2_0, false)] + [InlineData(OpenApiSpecVersion.OpenApi2_0, true)] + [InlineData(OpenApiSpecVersion.OpenApi3_0, false)] + [InlineData(OpenApiSpecVersion.OpenApi3_0, true)] + [InlineData(OpenApiSpecVersion.OpenApi3_1, false)] + [InlineData(OpenApiSpecVersion.OpenApi3_1, true)] + public void SerializeWithRepeatedInlineReferencesStopsBeforeWriterLimit( + OpenApiSpecVersion specVersion, + bool useYaml) + { + var readResult = OpenApiDocument.Parse(CreateDocumentWithRepeatedReferences(18), "json"); + Assert.Empty(readResult.Diagnostic.Errors); + Assert.NotNull(readResult.Document); + + using var textWriter = new CharacterLimitedTextWriter(OutputCharacterLimit); + IOpenApiWriter writer = useYaml + ? new OpenApiYamlWriter(textWriter, CreateWriterSettings(InlineReferenceLimit)) + : new OpenApiJsonWriter(textWriter, CreateWriterSettings(InlineReferenceLimit)); + + var exception = Assert.Throws( + () => Serialize(readResult.Document, writer, specVersion)); + Assert.Contains(InlineReferenceLimit.ToString(CultureInfo.InvariantCulture), exception.Message); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi2_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + public void SerializeWithInlineReferencesBelowLimitWorks(OpenApiSpecVersion specVersion) + { + var readResult = OpenApiDocument.Parse(CreateDocumentWithRepeatedReferences(4), "json"); + Assert.Empty(readResult.Diagnostic.Errors); + Assert.NotNull(readResult.Document); + + using var textWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(textWriter, CreateWriterSettings(32)); + + Serialize(readResult.Document, writer, specVersion); + + Assert.NotEmpty(textWriter.ToString()); + } + + [Fact] + public void ReusingSettingsWithNewWriterStartsANewInlineReferenceCount() + { + var largeReadResult = OpenApiDocument.Parse(CreateDocumentWithRepeatedReferences(6), "json"); + Assert.Empty(largeReadResult.Diagnostic.Errors); + Assert.NotNull(largeReadResult.Document); + var smallReadResult = OpenApiDocument.Parse(CreateDocumentWithRepeatedReferences(4), "json"); + Assert.Empty(smallReadResult.Diagnostic.Errors); + Assert.NotNull(smallReadResult.Document); + var settings = CreateWriterSettings(32); + + using var firstOutput = new StringWriter(CultureInfo.InvariantCulture); + Assert.Throws( + () => Serialize( + largeReadResult.Document, + new OpenApiJsonWriter(firstOutput, settings), + OpenApiSpecVersion.OpenApi3_0)); + + using var secondOutput = new StringWriter(CultureInfo.InvariantCulture); + Serialize( + smallReadResult.Document, + new OpenApiJsonWriter(secondOutput, settings), + OpenApiSpecVersion.OpenApi3_0); + + Assert.DoesNotContain("$ref", secondOutput.ToString()); + } + + [Fact] + public void ConvertRequestBodyReferenceToV2ParameterUsesInlineReferenceCount() + { + var document = CreateDocumentWithRequestBodyComponent(); + var reference = new OpenApiRequestBodyReference("shared", document); + using var output = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(output, CreateWriterSettings(0)); + + Assert.Throws(() => reference.ConvertToBodyParameter(writer)); + Assert.Throws(() => reference.ConvertToFormDataParameters(writer)); + } + + [Fact] + public void SerializeV2HeaderSchemaReferenceUsesInlineReferenceCount() + { + var document = CreateDocumentWithSchemaComponent(); + var header = new OpenApiHeader + { + Schema = new OpenApiSchemaReference("shared", document) + }; + using var output = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(output, CreateWriterSettings(0)); + + Assert.Throws(() => header.SerializeAsV2(writer)); + } + + [Fact] + public void SerializeV2ParameterSchemaReferenceUsesInlineReferenceCount() + { + var document = CreateDocumentWithSchemaComponent(); + var parameter = new OpenApiParameter + { + Name = "value", + In = ParameterLocation.Query, + Schema = new OpenApiSchemaReference("shared", document) + }; + using var output = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(output, CreateWriterSettings(0)); + + Assert.Throws(() => parameter.SerializeAsV2(writer)); + } + + private static OpenApiDocument CreateDocumentWithRequestBodyComponent() + { + var document = new OpenApiDocument + { + Components = new OpenApiComponents + { + RequestBodies = new Dictionary + { + ["shared"] = new OpenApiRequestBody + { + Content = new Dictionary + { + ["application/x-www-form-urlencoded"] = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["value"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + } + } + } + } + } + } + }; + document.Workspace.RegisterComponents(document); + return document; + } + + private static OpenApiDocument CreateDocumentWithSchemaComponent() + { + var document = new OpenApiDocument + { + Components = new OpenApiComponents + { + Schemas = new Dictionary + { + ["shared"] = new OpenApiSchema { Type = JsonSchemaType.String } + } + } + }; + document.Workspace.RegisterComponents(document); + return document; + } + + private static string CreateDocumentWithRepeatedReferences(int levels) + { + var schemas = new StringBuilder(); + for (var i = 0; i < levels; i++) + { + if (i > 0) + { + schemas.Append(','); + } + + schemas + .Append("\"s") + .Append(i.ToString(CultureInfo.InvariantCulture)) + .Append("\":{\"allOf\":[{\"$ref\":\"#/components/schemas/s") + .Append((i + 1).ToString(CultureInfo.InvariantCulture)) + .Append("\"},{\"$ref\":\"#/components/schemas/s") + .Append((i + 1).ToString(CultureInfo.InvariantCulture)) + .Append("\"}]}"); + } + + schemas + .Append(",\"s") + .Append(levels.ToString(CultureInfo.InvariantCulture)) + .Append("\":{\"type\":\"string\"}"); + + return $$""" + { + "openapi": "3.0.0", + "info": { + "title": "Repeated references", + "version": "1.0.0" + }, + "paths": { + "/test": { + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/s0" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + {{schemas}} + } + } + } + """; + } + + private static void Serialize( + IOpenApiSerializable serializable, + IOpenApiWriter writer, + OpenApiSpecVersion specVersion) + { + switch (specVersion) + { + case OpenApiSpecVersion.OpenApi2_0: + serializable.SerializeAsV2(writer); + break; + case OpenApiSpecVersion.OpenApi3_0: + serializable.SerializeAsV3(writer); + break; + case OpenApiSpecVersion.OpenApi3_1: + serializable.SerializeAsV31(writer); + break; + default: + throw new ArgumentOutOfRangeException(nameof(specVersion), specVersion, null); + } + } + + private static OpenApiWriterSettings CreateWriterSettings(int maxInlineReferenceCount) + { + return new OpenApiWriterSettings + { + InlineLocalReferences = true, + MaxInlineReferenceCount = maxInlineReferenceCount + }; + } + + private sealed class CharacterLimitedTextWriter(int characterLimit) : TextWriter + { + private int _characterCount; + + public override Encoding Encoding => Encoding.UTF8; + + public override void Write(char value) + { + CountCharacters(1); + } + + public override void Write(string value) + { + CountCharacters(value?.Length ?? 0); + } + + public override void Write(char[] buffer, int index, int count) + { + CountCharacters(count); + } + + public override void Write(ReadOnlySpan buffer) + { + CountCharacters(buffer.Length); + } + + private void CountCharacters(int count) + { + _characterCount += count; + if (_characterCount > characterLimit) + { + throw new CharacterLimitExceededException(); + } + } + } + + private sealed class CharacterLimitExceededException : Exception; + } +}