Skip to content

Commit c6338f7

Browse files
committed
Fix culture sensitivity in query strings (added AppContext switch for backwards-compatibility)
1 parent 5de5f9d commit c6338f7

File tree

5 files changed

+368
-203
lines changed

5 files changed

+368
-203
lines changed

src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,26 @@ namespace JsonApiDotNetCore.Resources.Internal;
88
[PublicAPI]
99
public static class RuntimeTypeConverter
1010
{
11+
private const string ParseQueryStringsUsingCurrentCultureSwitchName = "JsonApiDotNetCore.ParseQueryStringsUsingCurrentCulture";
12+
1113
public static object? ConvertType(object? value, Type type)
1214
{
1315
ArgumentGuard.NotNull(type);
1416

17+
// Earlier versions of JsonApiDotNetCore failed to pass CultureInfo.InvariantCulture in the parsing below, which resulted in the 'current'
18+
// culture being used. Unlike parsing JSON request/response bodies, this effectively meant that query strings were parsed based on the
19+
// OS-level regional settings of the web server.
20+
// Because this was fixed in a non-major release, the switch below enables to revert to the old behavior.
21+
22+
// With the switch activated, API developers can still choose between:
23+
// - Requiring localized date/number formats: parsing occurs using the OS-level regional settings (the default).
24+
// - Requiring culture-invariant date/number formats: requires setting CultureInfo.DefaultThreadCurrentCulture to CultureInfo.InvariantCulture at startup.
25+
// - Allowing clients to choose by sending an Accept-Language HTTP header: requires app.UseRequestLocalization() at startup.
26+
27+
CultureInfo? cultureInfo = AppContext.TryGetSwitch(ParseQueryStringsUsingCurrentCultureSwitchName, out bool useCurrentCulture) && useCurrentCulture
28+
? null
29+
: CultureInfo.InvariantCulture;
30+
1531
if (value == null)
1632
{
1733
if (!CanContainNull(type))
@@ -50,19 +66,19 @@ public static class RuntimeTypeConverter
5066

5167
if (nonNullableType == typeof(DateTime))
5268
{
53-
DateTime convertedValue = DateTime.Parse(stringValue, null, DateTimeStyles.RoundtripKind);
69+
DateTime convertedValue = DateTime.Parse(stringValue, cultureInfo, DateTimeStyles.RoundtripKind);
5470
return isNullableTypeRequested ? (DateTime?)convertedValue : convertedValue;
5571
}
5672

5773
if (nonNullableType == typeof(DateTimeOffset))
5874
{
59-
DateTimeOffset convertedValue = DateTimeOffset.Parse(stringValue, null, DateTimeStyles.RoundtripKind);
75+
DateTimeOffset convertedValue = DateTimeOffset.Parse(stringValue, cultureInfo, DateTimeStyles.RoundtripKind);
6076
return isNullableTypeRequested ? (DateTimeOffset?)convertedValue : convertedValue;
6177
}
6278

6379
if (nonNullableType == typeof(TimeSpan))
6480
{
65-
TimeSpan convertedValue = TimeSpan.Parse(stringValue);
81+
TimeSpan convertedValue = TimeSpan.Parse(stringValue, cultureInfo);
6682
return isNullableTypeRequested ? (TimeSpan?)convertedValue : convertedValue;
6783
}
6884

@@ -75,7 +91,7 @@ public static class RuntimeTypeConverter
7591
}
7692

7793
// https://bradwilson.typepad.com/blog/2008/07/creating-nullab.html
78-
return Convert.ChangeType(stringValue, nonNullableType);
94+
return Convert.ChangeType(stringValue, nonNullableType, cultureInfo);
7995
}
8096
catch (Exception exception) when (exception is FormatException or OverflowException or InvalidCastException or ArgumentException)
8197
{

test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Globalization;
12
using System.Net;
23
using System.Reflection;
34
using System.Text.Json.Serialization;
@@ -60,7 +61,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
6061
});
6162

6263
string attributeName = propertyName.Camelize();
63-
string route = $"/filterableResources?filter=equals({attributeName},'{propertyValue}')";
64+
string? attributeValue = Convert.ToString(propertyValue, CultureInfo.InvariantCulture);
65+
66+
string route = $"/filterableResources?filter=equals({attributeName},'{attributeValue}')";
6467

6568
// Act
6669
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
@@ -88,7 +91,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
8891
await dbContext.SaveChangesAsync();
8992
});
9093

91-
string route = $"/filterableResources?filter=equals(someDecimal,'{resource.SomeDecimal}')";
94+
string route = $"/filterableResources?filter=equals(someDecimal,'{resource.SomeDecimal.ToString(CultureInfo.InvariantCulture)}')";
9295

9396
// Act
9497
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
@@ -232,7 +235,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
232235
await dbContext.SaveChangesAsync();
233236
});
234237

235-
string route = $"/filterableResources?filter=equals(someTimeSpan,'{resource.SomeTimeSpan}')";
238+
string route = $"/filterableResources?filter=equals(someTimeSpan,'{resource.SomeTimeSpan:c}')";
236239

237240
// Act
238241
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);

0 commit comments

Comments
 (0)