Skip to content

Commit 42dda81

Browse files
authored
Add server variable substitution logic. (#1783)
* Add server variable substitution logic.
1 parent aa62dc0 commit 42dda81

File tree

9 files changed

+283
-10
lines changed

9 files changed

+283
-10
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using Microsoft.OpenApi.Models;
4+
using Microsoft.OpenApi.Properties;
5+
6+
namespace Microsoft.OpenApi.Extensions;
7+
8+
/// <summary>
9+
/// Extension methods for <see cref="OpenApiServer"/> serialization.
10+
/// </summary>
11+
public static class OpenApiServerExtensions
12+
{
13+
/// <summary>
14+
/// Replaces URL variables in a server's URL
15+
/// </summary>
16+
/// <param name="server">The OpenAPI server object</param>
17+
/// <param name="values">The server variable values that will be used to replace the default values.</param>
18+
/// <returns>A URL with the provided variables substituted.</returns>
19+
/// <exception cref="ArgumentException">
20+
/// Thrown when:
21+
/// 1. A substitution has no valid value in both the supplied dictionary and the default
22+
/// 2. A substitution's value is not available in the enum provided
23+
/// </exception>
24+
public static string ReplaceServerUrlVariables(this OpenApiServer server, IDictionary<string, string> values = null)
25+
{
26+
var parsedUrl = server.Url;
27+
foreach (var variable in server.Variables)
28+
{
29+
// Try to get the value from the provided values
30+
if (values is not { } v || !v.TryGetValue(variable.Key, out var value) || string.IsNullOrEmpty(value))
31+
{
32+
// Fall back to the default value
33+
value = variable.Value.Default;
34+
}
35+
36+
// Validate value
37+
if (string.IsNullOrEmpty(value))
38+
{
39+
// According to the spec, the variable's default value is required.
40+
// This code path should be hit when a value isn't provided & a default value isn't available
41+
throw new ArgumentException(
42+
string.Format(SRResource.ParseServerUrlDefaultValueNotAvailable, variable.Key), nameof(server));
43+
}
44+
45+
// If an enum is provided, the array should not be empty & the value should exist in the enum
46+
if (variable.Value.Enum is {} e && (e.Count == 0 || !e.Contains(value)))
47+
{
48+
throw new ArgumentException(
49+
string.Format(SRResource.ParseServerUrlValueNotValid, value, variable.Key), nameof(values));
50+
}
51+
52+
parsedUrl = parsedUrl.Replace($"{{{variable.Key}}}", value);
53+
}
54+
55+
return parsedUrl;
56+
}
57+
}

src/Microsoft.OpenApi/Models/OpenApiDocument.cs

+2-8
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using System.Security.Cryptography;
99
using System.Text;
1010
using Microsoft.OpenApi.Exceptions;
11+
using Microsoft.OpenApi.Extensions;
1112
using Microsoft.OpenApi.Interfaces;
1213
using Microsoft.OpenApi.Services;
1314
using Microsoft.OpenApi.Writers;
@@ -283,14 +284,7 @@ public void SerializeAsV2(IOpenApiWriter writer)
283284

284285
private static string ParseServerUrl(OpenApiServer server)
285286
{
286-
var parsedUrl = server.Url;
287-
288-
var variables = server.Variables;
289-
foreach (var variable in variables.Where(static x => !string.IsNullOrEmpty(x.Value.Default)))
290-
{
291-
parsedUrl = parsedUrl.Replace($"{{{variable.Key}}}", variable.Value.Default);
292-
}
293-
return parsedUrl;
287+
return server.ReplaceServerUrlVariables(new Dictionary<string, string>(0));
294288
}
295289

296290
private static void WriteHostInfoV2(IOpenApiWriter writer, IList<OpenApiServer> servers)

src/Microsoft.OpenApi/Models/OpenApiServerVariable.cs

+4-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ public class OpenApiServerVariable : IOpenApiSerializable, IOpenApiExtensible
2626
/// <summary>
2727
/// An enumeration of string values to be used if the substitution options are from a limited set.
2828
/// </summary>
29-
public List<string> Enum { get; set; } = new();
29+
/// <remarks>
30+
/// If the server variable in the OpenAPI document has no <code>enum</code> member, this property will be null.
31+
/// </remarks>
32+
public List<string> Enum { get; set; }
3033

3134
/// <summary>
3235
/// This object MAY be extended with Specification Extensions.

src/Microsoft.OpenApi/Properties/SRResource.Designer.cs

+18-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Microsoft.OpenApi/Properties/SRResource.resx

+6
Original file line numberDiff line numberDiff line change
@@ -225,4 +225,10 @@
225225
<data name="WorkspaceRequredForExternalReferenceResolution" xml:space="preserve">
226226
<value>OpenAPI document must be added to an OpenApiWorkspace to be able to resolve external references.</value>
227227
</data>
228+
<data name="ParseServerUrlDefaultValueNotAvailable" xml:space="preserve">
229+
<value>Invalid server variable '{0}'. A value was not provided and no default value was provided.</value>
230+
</data>
231+
<data name="ParseServerUrlValueNotValid" xml:space="preserve">
232+
<value>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</value>
233+
</data>
228234
</root>

src/Microsoft.OpenApi/Validations/Rules/OpenApiServerRules.cs

+23
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,32 @@ public static class OpenApiServerRules
2626
context.CreateError(nameof(ServerRequiredFields),
2727
String.Format(SRResource.Validation_FieldIsRequired, "url", "server"));
2828
}
29+
30+
context.Exit();
31+
context.Enter("variables");
32+
foreach (var variable in server.Variables)
33+
{
34+
context.Enter(variable.Key);
35+
ValidateServerVariableRequiredFields(context, variable.Key, variable.Value);
36+
context.Exit();
37+
}
2938
context.Exit();
3039
});
3140

3241
// add more rules
42+
43+
/// <summary>
44+
/// Validate required fields in server variable
45+
/// </summary>
46+
private static void ValidateServerVariableRequiredFields(IValidationContext context, string key, OpenApiServerVariable item)
47+
{
48+
context.Enter("default");
49+
if (string.IsNullOrEmpty(item.Default))
50+
{
51+
context.CreateError("ServerVariableMustHaveDefaultValue",
52+
String.Format(SRResource.Validation_FieldIsRequired, "default", key));
53+
}
54+
context.Exit();
55+
}
3356
}
3457
}

test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDocumentTests.cs

+65
Original file line numberDiff line numberDiff line change
@@ -1367,5 +1367,70 @@ public void ParseDocumetWithWrongReferenceTypeShouldReturnADiagnosticError()
13671367
diagnostic.Errors.Should().BeEquivalentTo(new List<OpenApiError> {
13681368
new( new OpenApiException("Invalid Reference Type 'Schema'.")) });
13691369
}
1370+
1371+
[Fact]
1372+
public void ParseBasicDocumentWithServerVariableShouldSucceed()
1373+
{
1374+
var openApiDoc = new OpenApiStringReader().Read("""
1375+
openapi : 3.0.0
1376+
info:
1377+
title: The API
1378+
version: 0.9.1
1379+
servers:
1380+
- url: http://www.example.org/api/{version}
1381+
description: The http endpoint
1382+
variables:
1383+
version:
1384+
default: v2
1385+
enum: [v1, v2]
1386+
paths: {}
1387+
""", out var diagnostic);
1388+
1389+
diagnostic.Should().BeEquivalentTo(
1390+
new OpenApiDiagnostic { SpecificationVersion = OpenApiSpecVersion.OpenApi3_0 });
1391+
1392+
openApiDoc.Should().BeEquivalentTo(
1393+
new OpenApiDocument
1394+
{
1395+
Info = new()
1396+
{
1397+
Title = "The API",
1398+
Version = "0.9.1",
1399+
},
1400+
Servers =
1401+
{
1402+
new OpenApiServer
1403+
{
1404+
Url = "http://www.example.org/api/{version}",
1405+
Description = "The http endpoint",
1406+
Variables = new Dictionary<string, OpenApiServerVariable>
1407+
{
1408+
{"version", new OpenApiServerVariable {Default = "v2", Enum = ["v1", "v2"]}}
1409+
}
1410+
}
1411+
},
1412+
Paths = new()
1413+
});
1414+
}
1415+
1416+
[Fact]
1417+
public void ParseBasicDocumentWithServerVariableAndNoDefaultShouldFail()
1418+
{
1419+
var openApiDoc = new OpenApiStringReader().Read("""
1420+
openapi : 3.0.0
1421+
info:
1422+
title: The API
1423+
version: 0.9.1
1424+
servers:
1425+
- url: http://www.example.org/api/{version}
1426+
description: The http endpoint
1427+
variables:
1428+
version:
1429+
enum: [v1, v2]
1430+
paths: {}
1431+
""", out var diagnostic);
1432+
1433+
diagnostic.Errors.Should().NotBeEmpty();
1434+
}
13701435
}
13711436
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using FluentAssertions;
4+
using Microsoft.OpenApi.Extensions;
5+
using Microsoft.OpenApi.Models;
6+
using Xunit;
7+
8+
namespace Microsoft.OpenApi.Tests.Extensions;
9+
10+
public class OpenApiServerExtensionsTests
11+
{
12+
[Fact]
13+
public void ShouldSubstituteServerVariableWithProvidedValues()
14+
{
15+
var variable = new OpenApiServer
16+
{
17+
Url = "http://example.com/api/{version}",
18+
Description = string.Empty,
19+
Variables = new Dictionary<string, OpenApiServerVariable>
20+
{
21+
{ "version", new OpenApiServerVariable { Default = "v1", Enum = ["v1", "v2"]} }
22+
}
23+
};
24+
25+
var url = variable.ReplaceServerUrlVariables(new Dictionary<string, string> {{"version", "v2"}});
26+
27+
url.Should().Be("http://example.com/api/v2");
28+
}
29+
30+
[Fact]
31+
public void ShouldSubstituteServerVariableWithDefaultValues()
32+
{
33+
var variable = new OpenApiServer
34+
{
35+
Url = "http://example.com/api/{version}",
36+
Description = string.Empty,
37+
Variables = new Dictionary<string, OpenApiServerVariable>
38+
{
39+
{ "version", new OpenApiServerVariable { Default = "v1", Enum = ["v1", "v2"]} }
40+
}
41+
};
42+
43+
var url = variable.ReplaceServerUrlVariables(new Dictionary<string, string>(0));
44+
45+
url.Should().Be("http://example.com/api/v1");
46+
}
47+
48+
[Fact]
49+
public void ShouldFailIfNoValueIsAvailable()
50+
{
51+
var variable = new OpenApiServer
52+
{
53+
Url = "http://example.com/api/{version}",
54+
Description = string.Empty,
55+
Variables = new Dictionary<string, OpenApiServerVariable>
56+
{
57+
{ "version", new OpenApiServerVariable { Enum = ["v1", "v2"]} }
58+
}
59+
};
60+
61+
Assert.Throws<ArgumentException>(() =>
62+
{
63+
variable.ReplaceServerUrlVariables(new Dictionary<string, string>(0));
64+
});
65+
}
66+
67+
[Fact]
68+
public void ShouldFailIfProvidedValueIsNotInEnum()
69+
{
70+
var variable = new OpenApiServer
71+
{
72+
Url = "http://example.com/api/{version}",
73+
Description = string.Empty,
74+
Variables = new Dictionary<string, OpenApiServerVariable>
75+
{
76+
{ "version", new OpenApiServerVariable { Enum = ["v1", "v2"]} }
77+
}
78+
};
79+
80+
Assert.Throws<ArgumentException>(() =>
81+
{
82+
variable.ReplaceServerUrlVariables(new Dictionary<string, string> {{"version", "v3"}});
83+
});
84+
}
85+
86+
[Fact]
87+
public void ShouldFailIfEnumIsEmpty()
88+
{
89+
var variable = new OpenApiServer
90+
{
91+
Url = "http://example.com/api/{version}",
92+
Description = string.Empty,
93+
Variables = new Dictionary<string, OpenApiServerVariable>
94+
{
95+
{ "version", new OpenApiServerVariable { Enum = []} }
96+
}
97+
};
98+
99+
Assert.Throws<ArgumentException>(() =>
100+
{
101+
variable.ReplaceServerUrlVariables(new Dictionary<string, string> {{"version", "v1"}});
102+
});
103+
}
104+
}

test/Microsoft.OpenApi.Tests/PublicApi/PublicApi.approved.txt

+4
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,10 @@ namespace Microsoft.OpenApi.Extensions
285285
public static void SerializeAsYaml<T>(this T element, System.IO.Stream stream, Microsoft.OpenApi.OpenApiSpecVersion specVersion)
286286
where T : Microsoft.OpenApi.Interfaces.IOpenApiSerializable { }
287287
}
288+
public static class OpenApiServerExtensions
289+
{
290+
public static string ReplaceServerUrlVariables(this Microsoft.OpenApi.Models.OpenApiServer server, System.Collections.Generic.IDictionary<string, string> values = null) { }
291+
}
288292
public static class OpenApiTypeMapper
289293
{
290294
public static System.Type MapOpenApiPrimitiveTypeToSimpleType(this Microsoft.OpenApi.Models.OpenApiSchema schema) { }

0 commit comments

Comments
 (0)