diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentSerializationTests .cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentSerializationTests .cs new file mode 100644 index 000000000..03724efa1 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentSerializationTests .cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Writers; +using Xunit; + +namespace Microsoft.OpenApi.Readers.Tests.V31Tests +{ + public class OpenApiDocumentSerializationTests + { + private const string SampleFolderPath = "V31Tests/Samples/OpenApiDocument/"; + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + [InlineData(OpenApiSpecVersion.OpenApi2_0)] + public async Task Serialize_DoesNotMutateDom(OpenApiSpecVersion version) + { + // Arrange + var filePath = Path.Combine(SampleFolderPath, "docWith31properties.json"); + var (doc, _) = await OpenApiDocument.LoadAsync(filePath, SettingsFixture.ReaderSettings); + + // Act: Serialize using System.Text.Json + var options = new JsonSerializerOptions + { + Converters = + { + new HttpMethodOperationDictionaryConverter() + }, + }; + var originalSerialized = JsonSerializer.Serialize(doc, options); + Assert.NotNull(originalSerialized); // sanity check + + // Serialize using native OpenAPI writer + var jsonWriter = new StringWriter(); + var openApiWriter = new OpenApiJsonWriter(jsonWriter); + switch (version) + { + case OpenApiSpecVersion.OpenApi3_1: + doc.SerializeAsV31(openApiWriter); + break; + case OpenApiSpecVersion.OpenApi3_0: + doc.SerializeAsV3(openApiWriter); + break; + default: + doc.SerializeAsV2(openApiWriter); + break; + } + + // Serialize again with STJ after native writer serialization + var finalSerialized = JsonSerializer.Serialize(doc, options); + Assert.NotNull(finalSerialized); // sanity check + + // Assert: Ensure no mutation occurred in the DOM after native serialization + Assert.True(JsonNode.DeepEquals(originalSerialized, finalSerialized), "OpenAPI DOM was mutated by the native serializer."); + } + } + + public class HttpMethodOperationDictionaryConverter : JsonConverter> + { + public override Dictionary Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, Dictionary value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + foreach (var kvp in value) + { + writer.WritePropertyName(kvp.Key.Method.ToLowerInvariant()); + JsonSerializer.Serialize(writer, kvp.Value, options); + } + + writer.WriteEndObject(); + } + } +} diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiDocument/docWith31properties.json b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiDocument/docWith31properties.json new file mode 100644 index 000000000..aabf7b10e --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/Samples/OpenApiDocument/docWith31properties.json @@ -0,0 +1,166 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "Sample OpenAPI 3.1 API", + "description": "A sample API demonstrating OpenAPI 3.1 features", + "version": "2.0.0", + "summary": "Sample OpenAPI 3.1 API with the latest features", + "license": { + "name": "Apache 2.0", + "identifier": "Apache-2.0" + } + }, + "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", + "servers": [ + { + "url": "https://api.example.com/v2", + "description": "Main production server" + } + ], + "webhooks": { + "newPetAlert": { + "post": { + "summary": "Notify about a new pet being added", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "responses": { + "200": { + "description": "Webhook processed successfully" + } + } + } + } + }, + "paths": { + "/pets": { + "get": { + "summary": "List all pets", + "operationId": "listPets", + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "How many items to return at one time (max 100)", + "required": false, + "schema": { + "type": "integer", + "exclusiveMinimum": 1, + "exclusiveMaximum": 100 + } + } + ], + "responses": { + "200": { + "description": "A paged array of pets", + "content": { + "application/json": { + "schema": { + "$ref": "https://example.com/schemas/pet.json" + } + } + } + } + } + } + }, + "/sample": { + "get": { + "summary": "Sample endpoint", + "responses": { + "200": { + "description": "Sample response", + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/person.schema.yaml", + "$comment": "A schema defining a pet object with optional references to dynamic components.", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": false, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": false + }, + "title": "Pet", + "description": "Schema for a pet object", + "type": "object", + "properties": { + "name": { + "type": "string", + "$comment": "The pet's full name" + }, + "address": { + "$dynamicRef": "#addressDef", + "$comment": "Reference to an address definition which can change dynamically" + } + }, + "required": [ + "name" + ], + "$dynamicAnchor": "addressDef" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "api_key": { + "type": "apiKey", + "name": "api_key", + "in": "header" + } + }, + "schemas": { + "Pet": { + "$id": "https://example.com/schemas/pet.json", + "type": "object", + "required": [ + "id", + "weight" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "weight": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Weight of the pet in kilograms" + }, + "attributes": { + "type": [ + "object", + "null" + ], + "description": "Dynamic attributes for the pet", + "patternProperties": { + "^attr_[A-Za-z]+$": { + "type": "string" + } + } + } + }, + "$comment": "This schema represents a pet in the system.", + "$defs": { + "ExtraInfo": { + "type": "string" + } + } + } + } + } +} \ No newline at end of file