diff --git a/src/Microsoft.OpenApi/Extensions/OpenApiServerExtensions.cs b/src/Microsoft.OpenApi/Extensions/OpenApiServerExtensions.cs new file mode 100644 index 000000000..b885cb235 --- /dev/null +++ b/src/Microsoft.OpenApi/Extensions/OpenApiServerExtensions.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Properties; + +namespace Microsoft.OpenApi.Extensions; + +/// +/// Extension methods for serialization. +/// +public static class OpenApiServerExtensions +{ + /// + /// Replaces URL variables in a server's URL + /// + /// The OpenAPI server object + /// The server variable values that will be used to replace the default values. + /// A URL with the provided variables substituted. + /// + /// Thrown when: + /// 1. A substitution has no valid value in both the supplied dictionary and the default + /// 2. A substitution's value is not available in the enum provided + /// + public static string ReplaceServerUrlVariables(this OpenApiServer server, IDictionary values = null) + { + var parsedUrl = server.Url; + foreach (var variable in server.Variables) + { + // Try to get the value from the provided values + if (values is not { } v || !v.TryGetValue(variable.Key, out var value) || string.IsNullOrEmpty(value)) + { + // Fall back to the default value + value = variable.Value.Default; + } + + // Validate value + if (string.IsNullOrEmpty(value)) + { + // According to the spec, the variable's default value is required. + // This code path should be hit when a value isn't provided & a default value isn't available + throw new ArgumentException( + string.Format(SRResource.ParseServerUrlDefaultValueNotAvailable, variable.Key), nameof(server)); + } + + // If an enum is provided, the array should not be empty & the value should exist in the enum + if (variable.Value.Enum is {} e && (e.Count == 0 || !e.Contains(value))) + { + throw new ArgumentException( + string.Format(SRResource.ParseServerUrlValueNotValid, value, variable.Key), nameof(values)); + } + + parsedUrl = parsedUrl.Replace($"{{{variable.Key}}}", value); + } + + return parsedUrl; + } +} diff --git a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs index 712bddb03..201b321f1 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiDocument.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiDocument.cs @@ -8,6 +8,7 @@ using System.Security.Cryptography; using System.Text; using Microsoft.OpenApi.Exceptions; +using Microsoft.OpenApi.Extensions; using Microsoft.OpenApi.Interfaces; using Microsoft.OpenApi.Services; using Microsoft.OpenApi.Writers; @@ -283,14 +284,7 @@ public void SerializeAsV2(IOpenApiWriter writer) private static string ParseServerUrl(OpenApiServer server) { - var parsedUrl = server.Url; - - var variables = server.Variables; - foreach (var variable in variables.Where(static x => !string.IsNullOrEmpty(x.Value.Default))) - { - parsedUrl = parsedUrl.Replace($"{{{variable.Key}}}", variable.Value.Default); - } - return parsedUrl; + return server.ReplaceServerUrlVariables(new Dictionary(0)); } private static void WriteHostInfoV2(IOpenApiWriter writer, IList servers) diff --git a/src/Microsoft.OpenApi/Models/OpenApiServerVariable.cs b/src/Microsoft.OpenApi/Models/OpenApiServerVariable.cs index fe5293cd4..4ab8bdcaa 100644 --- a/src/Microsoft.OpenApi/Models/OpenApiServerVariable.cs +++ b/src/Microsoft.OpenApi/Models/OpenApiServerVariable.cs @@ -26,7 +26,10 @@ public class OpenApiServerVariable : IOpenApiSerializable, IOpenApiExtensible /// /// An enumeration of string values to be used if the substitution options are from a limited set. /// - public List Enum { get; set; } = new(); + /// + /// If the server variable in the OpenAPI document has no enum member, this property will be null. + /// + public List Enum { get; set; } /// /// This object MAY be extended with Specification Extensions. diff --git a/src/Microsoft.OpenApi/Properties/SRResource.Designer.cs b/src/Microsoft.OpenApi/Properties/SRResource.Designer.cs index 1a9ab3014..511f300f7 100644 --- a/src/Microsoft.OpenApi/Properties/SRResource.Designer.cs +++ b/src/Microsoft.OpenApi/Properties/SRResource.Designer.cs @@ -1,7 +1,6 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. -// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -222,6 +221,24 @@ internal static string OpenApiWriterExceptionGenericError { } } + /// + /// Looks up a localized string similar to Invalid server variable '{0}'. A value was not provided and no default value was provided.. + /// + internal static string ParseServerUrlDefaultValueNotAvailable { + get { + return ResourceManager.GetString("ParseServerUrlDefaultValueNotAvailable", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Value '{0}' is not a valid value for variable '{1}'. If an enum is provided, it should not be empty and the value provided should exist in the enum. + /// + internal static string ParseServerUrlValueNotValid { + get { + return ResourceManager.GetString("ParseServerUrlValueNotValid", resourceCulture); + } + } + /// /// Looks up a localized string similar to The given primitive type '{0}' is not supported.. /// diff --git a/src/Microsoft.OpenApi/Properties/SRResource.resx b/src/Microsoft.OpenApi/Properties/SRResource.resx index f0bb497d3..0effa1d44 100644 --- a/src/Microsoft.OpenApi/Properties/SRResource.resx +++ b/src/Microsoft.OpenApi/Properties/SRResource.resx @@ -225,4 +225,10 @@ OpenAPI document must be added to an OpenApiWorkspace to be able to resolve external references. + + Invalid server variable '{0}'. A value was not provided and no default value was provided. + + + Value '{0}' is not a valid value for variable '{1}'. If an enum is provided, it should not be empty and the value provided should exist in the enum + diff --git a/src/Microsoft.OpenApi/Validations/Rules/OpenApiServerRules.cs b/src/Microsoft.OpenApi/Validations/Rules/OpenApiServerRules.cs index dd11a661d..35d4b9a25 100644 --- a/src/Microsoft.OpenApi/Validations/Rules/OpenApiServerRules.cs +++ b/src/Microsoft.OpenApi/Validations/Rules/OpenApiServerRules.cs @@ -26,9 +26,32 @@ public static class OpenApiServerRules context.CreateError(nameof(ServerRequiredFields), String.Format(SRResource.Validation_FieldIsRequired, "url", "server")); } + + context.Exit(); + context.Enter("variables"); + foreach (var variable in server.Variables) + { + context.Enter(variable.Key); + ValidateServerVariableRequiredFields(context, variable.Key, variable.Value); + context.Exit(); + } context.Exit(); }); // add more rules + + /// + /// Validate required fields in server variable + /// + private static void ValidateServerVariableRequiredFields(IValidationContext context, string key, OpenApiServerVariable item) + { + context.Enter("default"); + if (string.IsNullOrEmpty(item.Default)) + { + context.CreateError("ServerVariableMustHaveDefaultValue", + String.Format(SRResource.Validation_FieldIsRequired, "default", key)); + } + context.Exit(); + } } } diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDocumentTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDocumentTests.cs index d67c0054f..bb3db096f 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDocumentTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDocumentTests.cs @@ -1367,5 +1367,70 @@ public void ParseDocumetWithWrongReferenceTypeShouldReturnADiagnosticError() diagnostic.Errors.Should().BeEquivalentTo(new List { new( new OpenApiException("Invalid Reference Type 'Schema'.")) }); } + + [Fact] + public void ParseBasicDocumentWithServerVariableShouldSucceed() + { + var openApiDoc = new OpenApiStringReader().Read(""" + openapi : 3.0.0 + info: + title: The API + version: 0.9.1 + servers: + - url: http://www.example.org/api/{version} + description: The http endpoint + variables: + version: + default: v2 + enum: [v1, v2] + paths: {} + """, out var diagnostic); + + diagnostic.Should().BeEquivalentTo( + new OpenApiDiagnostic { SpecificationVersion = OpenApiSpecVersion.OpenApi3_0 }); + + openApiDoc.Should().BeEquivalentTo( + new OpenApiDocument + { + Info = new() + { + Title = "The API", + Version = "0.9.1", + }, + Servers = + { + new OpenApiServer + { + Url = "http://www.example.org/api/{version}", + Description = "The http endpoint", + Variables = new Dictionary + { + {"version", new OpenApiServerVariable {Default = "v2", Enum = ["v1", "v2"]}} + } + } + }, + Paths = new() + }); + } + + [Fact] + public void ParseBasicDocumentWithServerVariableAndNoDefaultShouldFail() + { + var openApiDoc = new OpenApiStringReader().Read(""" + openapi : 3.0.0 + info: + title: The API + version: 0.9.1 + servers: + - url: http://www.example.org/api/{version} + description: The http endpoint + variables: + version: + enum: [v1, v2] + paths: {} + """, out var diagnostic); + + diagnostic.Errors.Should().NotBeEmpty(); + } } } diff --git a/test/Microsoft.OpenApi.Tests/Extensions/OpenApiServerExtensionsTests.cs b/test/Microsoft.OpenApi.Tests/Extensions/OpenApiServerExtensionsTests.cs new file mode 100644 index 000000000..b8f581541 --- /dev/null +++ b/test/Microsoft.OpenApi.Tests/Extensions/OpenApiServerExtensionsTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using Microsoft.OpenApi.Extensions; +using Microsoft.OpenApi.Models; +using Xunit; + +namespace Microsoft.OpenApi.Tests.Extensions; + +public class OpenApiServerExtensionsTests +{ + [Fact] + public void ShouldSubstituteServerVariableWithProvidedValues() + { + var variable = new OpenApiServer + { + Url = "http://example.com/api/{version}", + Description = string.Empty, + Variables = new Dictionary + { + { "version", new OpenApiServerVariable { Default = "v1", Enum = ["v1", "v2"]} } + } + }; + + var url = variable.ReplaceServerUrlVariables(new Dictionary {{"version", "v2"}}); + + url.Should().Be("http://example.com/api/v2"); + } + + [Fact] + public void ShouldSubstituteServerVariableWithDefaultValues() + { + var variable = new OpenApiServer + { + Url = "http://example.com/api/{version}", + Description = string.Empty, + Variables = new Dictionary + { + { "version", new OpenApiServerVariable { Default = "v1", Enum = ["v1", "v2"]} } + } + }; + + var url = variable.ReplaceServerUrlVariables(new Dictionary(0)); + + url.Should().Be("http://example.com/api/v1"); + } + + [Fact] + public void ShouldFailIfNoValueIsAvailable() + { + var variable = new OpenApiServer + { + Url = "http://example.com/api/{version}", + Description = string.Empty, + Variables = new Dictionary + { + { "version", new OpenApiServerVariable { Enum = ["v1", "v2"]} } + } + }; + + Assert.Throws(() => + { + variable.ReplaceServerUrlVariables(new Dictionary(0)); + }); + } + + [Fact] + public void ShouldFailIfProvidedValueIsNotInEnum() + { + var variable = new OpenApiServer + { + Url = "http://example.com/api/{version}", + Description = string.Empty, + Variables = new Dictionary + { + { "version", new OpenApiServerVariable { Enum = ["v1", "v2"]} } + } + }; + + Assert.Throws(() => + { + variable.ReplaceServerUrlVariables(new Dictionary {{"version", "v3"}}); + }); + } + + [Fact] + public void ShouldFailIfEnumIsEmpty() + { + var variable = new OpenApiServer + { + Url = "http://example.com/api/{version}", + Description = string.Empty, + Variables = new Dictionary + { + { "version", new OpenApiServerVariable { Enum = []} } + } + }; + + Assert.Throws(() => + { + variable.ReplaceServerUrlVariables(new Dictionary {{"version", "v1"}}); + }); + } +} diff --git a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt index fbfe564f3..9483e5f6e 100755 --- a/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt +++ b/test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt @@ -285,6 +285,10 @@ namespace Microsoft.OpenApi.Extensions public static void SerializeAsYaml(this T element, System.IO.Stream stream, Microsoft.OpenApi.OpenApiSpecVersion specVersion) where T : Microsoft.OpenApi.Interfaces.IOpenApiSerializable { } } + public static class OpenApiServerExtensions + { + public static string ReplaceServerUrlVariables(this Microsoft.OpenApi.Models.OpenApiServer server, System.Collections.Generic.IDictionary values = null) { } + } public static class OpenApiTypeMapper { public static System.Type MapOpenApiPrimitiveTypeToSimpleType(this Microsoft.OpenApi.Models.OpenApiSchema schema) { }