diff --git a/src/Microsoft.OpenApi/Models/OpenApiParameter.cs b/src/Microsoft.OpenApi/Models/OpenApiParameter.cs index f1300f027..4f9bb1e34 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiParameter.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiParameter.cs @@ -44,7 +44,7 @@ public ParameterStyle? Style /// public bool Explode { - get => _explode ?? Style == ParameterStyle.Form; + get => _explode ?? (Style is ParameterStyle.Form or ParameterStyle.Cookie); set => _explode = value; } @@ -115,6 +115,12 @@ internal void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion versio Action callback) { Utils.CheckArgumentNull(writer); + + // Validate that Cookie style is only used in OpenAPI 3.2 and later + if (Style == ParameterStyle.Cookie && version < OpenApiSpecVersion.OpenApi3_2) + { + throw new OpenApiException($"Parameter style 'cookie' is only supported in OpenAPI 3.2 and later versions. Current version: {version}"); + } writer.WriteStartObject(); @@ -143,7 +149,7 @@ internal void SerializeInternal(IOpenApiWriter writer, OpenApiSpecVersion versio } // explode - writer.WriteProperty(OpenApiConstants.Explode, _explode, Style is ParameterStyle.Form); + writer.WriteProperty(OpenApiConstants.Explode, _explode, Style is ParameterStyle.Form or ParameterStyle.Cookie); // allowReserved writer.WriteProperty(OpenApiConstants.AllowReserved, AllowReserved, false); @@ -251,6 +257,12 @@ internal virtual void WriteRequestBodySchemaForV2(IOpenApiWriter writer, Diction public virtual void SerializeAsV2(IOpenApiWriter writer) { Utils.CheckArgumentNull(writer); + + // Validate that Cookie style is only used in OpenAPI 3.2 and later + if (Style == ParameterStyle.Cookie) + { + throw new OpenApiException($"Parameter style 'cookie' is only supported in OpenAPI 3.2 and later versions. Current version: {OpenApiSpecVersion.OpenApi2_0}"); + } writer.WriteStartObject(); diff --git a/src/Microsoft.OpenApi/Models/ParameterStyle.cs b/src/Microsoft.OpenApi/Models/ParameterStyle.cs index 3edb049a5..f948fc6fb 100644 --- a/src/Microsoft.OpenApi/Models/ParameterStyle.cs +++ b/src/Microsoft.OpenApi/Models/ParameterStyle.cs @@ -41,6 +41,11 @@ public enum ParameterStyle /// /// Provides a simple way of rendering nested objects using form parameters. /// - [Display("deepObject")] DeepObject + [Display("deepObject")] DeepObject, + + /// + /// Cookie style parameters. Introduced in OpenAPI 3.2. + /// + [Display("cookie")] Cookie } } diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterCookieStyleTests.SerializeCookieParameterAsV32JsonWorksAsync_produceTerseOutput=False.verified.txt b/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterCookieStyleTests.SerializeCookieParameterAsV32JsonWorksAsync_produceTerseOutput=False.verified.txt new file mode 100644 index 000000000..7e87bc43e --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterCookieStyleTests.SerializeCookieParameterAsV32JsonWorksAsync_produceTerseOutput=False.verified.txt @@ -0,0 +1,9 @@ +{ + "name": "sessionId", + "in": "cookie", + "description": "Session identifier stored in cookie", + "style": "cookie", + "schema": { + "type": "string" + } +} \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterCookieStyleTests.SerializeCookieParameterAsV32JsonWorksAsync_produceTerseOutput=True.verified.txt b/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterCookieStyleTests.SerializeCookieParameterAsV32JsonWorksAsync_produceTerseOutput=True.verified.txt new file mode 100644 index 000000000..f2b086e6b --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterCookieStyleTests.SerializeCookieParameterAsV32JsonWorksAsync_produceTerseOutput=True.verified.txt @@ -0,0 +1 @@ +{"name":"sessionId","in":"cookie","description":"Session identifier stored in cookie","style":"cookie","schema":{"type":"string"}} \ No newline at end of file diff --git a/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterCookieStyleTests.cs b/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterCookieStyleTests.cs new file mode 100644 index 000000000..cf0348fa9 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Models/OpenApiParameterCookieStyleTests.cs @@ -0,0 +1,248 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System; +using System.Globalization; +using System.IO; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Xunit; +using VerifyXunit; + +namespace Microsoft.OpenApi.Tests.Models +{ + [Collection("DefaultSettings")] + public class OpenApiParameterCookieStyleTests + { + private static OpenApiParameter CookieParameter => new() + { + Name = "sessionId", + In = ParameterLocation.Cookie, + Style = ParameterStyle.Cookie, + Description = "Session identifier stored in cookie", + Schema = new OpenApiSchema() + { + Type = JsonSchemaType.String + } + }; + + private static OpenApiParameter CookieParameterWithDefault => new() + { + Name = "preferences", + In = ParameterLocation.Cookie, + Description = "User preferences stored in cookie", + Schema = new OpenApiSchema() + { + Type = JsonSchemaType.String + } + }; + + [Fact] + public void CookieParameterStyleIsAvailable() + { + // Arrange & Act + var parameter = CookieParameter; + + // Assert + Assert.Equal(ParameterStyle.Cookie, parameter.Style); + Assert.Equal(ParameterLocation.Cookie, parameter.In); + } + + [Fact] + public void CookieParameterHasCorrectDefaultStyle() + { + // Arrange & Act + var parameter = CookieParameterWithDefault; + + // Assert + Assert.Equal(ParameterStyle.Form, parameter.Style); // Default for cookie location should be Form + } + + [Fact] + public void CookieParameterStyleDisplayNameIsCookie() + { + // Arrange & Act + var displayName = ParameterStyle.Cookie.GetDisplayName(); + + // Assert + Assert.Equal("cookie", displayName); + } + + [Fact] + public async Task SerializeCookieParameterAsV32JsonWorks() + { + // Arrange + var expected = + """ + { + "name": "sessionId", + "in": "cookie", + "description": "Session identifier stored in cookie", + "style": "cookie", + "schema": { + "type": "string" + } + } + """; + + // Act + var actual = await CookieParameter.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); + } + + [Fact] + public async Task SerializeCookieParameterWithDefaultStyleAsV32JsonWorks() + { + // Arrange + var expected = + """ + { + "name": "preferences", + "in": "cookie", + "description": "User preferences stored in cookie", + "schema": { + "type": "string" + } + } + """; + + // Act + var actual = await CookieParameterWithDefault.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + + // Assert + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(actual), JsonNode.Parse(expected))); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi2_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + public void SerializeCookieParameterStyleThrowsForEarlierVersions(OpenApiSpecVersion version) + { + // Arrange + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter); + + // Act & Assert + var exception = Assert.Throws(() => + { + switch (version) + { + case OpenApiSpecVersion.OpenApi2_0: + CookieParameter.SerializeAsV2(writer); + break; + case OpenApiSpecVersion.OpenApi3_0: + CookieParameter.SerializeAsV3(writer); + break; + case OpenApiSpecVersion.OpenApi3_1: + CookieParameter.SerializeAsV31(writer); + break; + } + }); + + Assert.Contains("Parameter style 'cookie' is only supported in OpenAPI 3.2 and later versions", exception.Message); + Assert.Contains($"Current version: {version}", exception.Message); + } + + [Theory] + [InlineData(OpenApiSpecVersion.OpenApi2_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_0)] + [InlineData(OpenApiSpecVersion.OpenApi3_1)] + public async Task SerializeCookieParameterWithDefaultStyleWorksForEarlierVersions(OpenApiSpecVersion version) + { + // Arrange & Act + string actual = version switch + { + OpenApiSpecVersion.OpenApi2_0 => await CookieParameterWithDefault.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi2_0), + OpenApiSpecVersion.OpenApi3_0 => await CookieParameterWithDefault.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_0), + OpenApiSpecVersion.OpenApi3_1 => await CookieParameterWithDefault.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1), + _ => throw new ArgumentOutOfRangeException() + }; + + // Assert - Should not throw because default style (Form) is being used + Assert.NotEmpty(actual); + Assert.DoesNotContain("\"style\"", actual); // Style should not be emitted when it's the default + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SerializeCookieParameterAsV32JsonWorksAsync(bool produceTerseOutput) + { + // Arrange + var outputStringWriter = new StringWriter(CultureInfo.InvariantCulture); + var writer = new OpenApiJsonWriter(outputStringWriter, new() { Terse = produceTerseOutput }); + + // Act + CookieParameter.SerializeAsV32(writer); + await writer.FlushAsync(); + + // Assert + await Verifier.Verify(outputStringWriter).UseParameters(produceTerseOutput); + } + + [Fact] + public void CookieParameterStyleEnumValueExists() + { + // Arrange & Act + var cookieStyleExists = Enum.IsDefined(typeof(ParameterStyle), ParameterStyle.Cookie); + + // Assert + Assert.True(cookieStyleExists); + } + + [Fact] + public void CookieParameterStyleCanBeDeserialized() + { + // Arrange + var cookieStyleString = "cookie"; + + // Act + var success = cookieStyleString.TryGetEnumFromDisplayName(out var parameterStyle); + + // Assert + Assert.True(success); + Assert.Equal(ParameterStyle.Cookie, parameterStyle); + } + + [Fact] + public async Task SerializeCookieParameterAsYamlV32Works() + { + // Arrange + var expected = """ + name: sessionId + in: cookie + description: Session identifier stored in cookie + style: cookie + schema: + type: string + """; + + // Act + var actual = await CookieParameter.SerializeAsYamlAsync(OpenApiSpecVersion.OpenApi3_2); + + // Assert + Assert.Equal(expected.MakeLineBreaksEnvironmentNeutral(), actual.MakeLineBreaksEnvironmentNeutral()); + } + + [Theory] + [InlineData(ParameterStyle.Form, true)] + [InlineData(ParameterStyle.SpaceDelimited, false)] + [InlineData(ParameterStyle.Cookie, true)] + public void WhenStyleIsFormOrCookieTheDefaultValueOfExplodeShouldBeTrueOtherwiseFalse(ParameterStyle? style, bool expectedExplode) + { + // Arrange + var parameter = new OpenApiParameter + { + Name = "name1", + In = ParameterLocation.Query, + Style = style + }; + + // Act & Assert + Assert.Equal(expectedExplode, parameter.Explode); + } + } +} diff --git a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt index d2a93e2c8..1f98ad513 100644 --- a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt +++ b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt @@ -1753,6 +1753,8 @@ namespace Microsoft.OpenApi PipeDelimited = 5, [Microsoft.OpenApi.Display("deepObject")] DeepObject = 6, + [Microsoft.OpenApi.Display("cookie")] + Cookie = 7, } public sealed class PathExpression : Microsoft.OpenApi.SourceExpression { diff --git a/test/Microsoft.OpenApi.Tests/Reader/OpenApiParameterCookieStyleDeserializationTests.cs b/test/Microsoft.OpenApi.Tests/Reader/OpenApiParameterCookieStyleDeserializationTests.cs new file mode 100644 index 000000000..6a98dea42 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Reader/OpenApiParameterCookieStyleDeserializationTests.cs @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Reader +{ + [Collection("DefaultSettings")] + public class OpenApiParameterCookieStyleDeserializationTests + { + [Fact] + public void DeserializeCookieParameterStyleFromJsonWorks() + { + // Arrange + var json = """ + { + "openapi": "3.2.0", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "paths": { + "/test": { + "get": { + "parameters": [ + { + "name": "sessionId", + "in": "cookie", + "style": "cookie", + "description": "Session identifier stored in cookie", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + } + } + } + """; + + // Act + var result = OpenApiDocument.Parse(json, "json", SettingsFixture.ReaderSettings); + + // Assert + Assert.NotNull(result.Document); + Assert.Empty(result.Diagnostic.Errors); + + var parameter = result.Document.Paths["/test"].Operations[System.Net.Http.HttpMethod.Get].Parameters[0]; + Assert.Equal("sessionId", parameter.Name); + Assert.Equal(ParameterLocation.Cookie, parameter.In); + Assert.Equal(ParameterStyle.Cookie, parameter.Style); + } + + [Fact] + public void DeserializeCookieParameterWithDefaultStyleFromJsonWorks() + { + // Arrange + var json = """ + { + "openapi": "3.2.0", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "paths": { + "/test": { + "get": { + "parameters": [ + { + "name": "preferences", + "in": "cookie", + "description": "User preferences stored in cookie", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + } + } + } + """; + + // Act + var result = OpenApiDocument.Parse(json, "json", SettingsFixture.ReaderSettings); + + // Assert + Assert.NotNull(result.Document); + Assert.Empty(result.Diagnostic.Errors); + + var parameter = result.Document.Paths["/test"].Operations[System.Net.Http.HttpMethod.Get].Parameters[0]; + Assert.Equal("preferences", parameter.Name); + Assert.Equal(ParameterLocation.Cookie, parameter.In); + Assert.Equal(ParameterStyle.Form, parameter.Style); // Should default to Form for cookie location + } + + [Fact] + public void DeserializeCookieParameterStyleFromYamlWorks() + { + // Arrange + var yaml = """ + openapi: 3.2.0 + info: + title: Test API + version: 1.0.0 + paths: + /test: + get: + parameters: + - name: sessionId + in: cookie + style: cookie + description: Session identifier stored in cookie + schema: + type: string + responses: + '200': + description: Success + """; + + // Act + var result = OpenApiDocument.Parse(yaml, "yaml", SettingsFixture.ReaderSettings); + + // Assert + Assert.NotNull(result.Document); + Assert.Empty(result.Diagnostic.Errors); + + var parameter = result.Document.Paths["/test"].Operations[System.Net.Http.HttpMethod.Get].Parameters[0]; + Assert.Equal("sessionId", parameter.Name); + Assert.Equal(ParameterLocation.Cookie, parameter.In); + Assert.Equal(ParameterStyle.Cookie, parameter.Style); + } + + [Fact] + public async Task SerializeAndDeserializeCookieParameterRoundTrip() + { + // Arrange + var original = new OpenApiParameter + { + Name = "trackingId", + In = ParameterLocation.Cookie, + Style = ParameterStyle.Cookie, + Description = "Tracking identifier", + Schema = new OpenApiSchema { Type = JsonSchemaType.String } + }; + + var document = new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test", Version = "1.0.0" }, + Paths = new OpenApiPaths + { + ["/test"] = new OpenApiPathItem + { + Operations = new() + { + [System.Net.Http.HttpMethod.Get] = new OpenApiOperation + { + Parameters = new[] { original }, + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse { Description = "Success" } + } + } + } + } + } + }; + + // Act + var json = await document.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_2); + var result = OpenApiDocument.Parse(json, "json", SettingsFixture.ReaderSettings); + + // Assert + Assert.NotNull(result.Document); + Assert.Empty(result.Diagnostic.Errors); + + var parameter = result.Document.Paths["/test"].Operations[System.Net.Http.HttpMethod.Get].Parameters[0]; + Assert.Equal(original.Name, parameter.Name); + Assert.Equal(original.In, parameter.In); + Assert.Equal(original.Style, parameter.Style); + Assert.Equal(original.Description, parameter.Description); + } + } +}