Skip to content

Commit 7ee4d2e

Browse files
committed
Add support for DateOnly/TimeOnly, fix culture-sensitivity in query strings (added compat switch)
1 parent 0185311 commit 7ee4d2e

File tree

10 files changed

+942
-203
lines changed

10 files changed

+942
-203
lines changed

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

Lines changed: 32 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+
internal const string ParseQueryStringsUsingCurrentCultureSwitchName = "JsonApiDotNetCore.ParseQueryStringsUsingCurrentCulture";
12+
1113
public static object? ConvertType(object? value, Type type)
1214
{
1315
ArgumentGuard.NotNull(type, nameof(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,22 +66,34 @@ 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

85+
if (nonNullableType == typeof(DateOnly))
86+
{
87+
DateOnly convertedValue = DateOnly.Parse(stringValue, cultureInfo);
88+
return isNullableTypeRequested ? (DateOnly?)convertedValue : convertedValue;
89+
}
90+
91+
if (nonNullableType == typeof(TimeOnly))
92+
{
93+
TimeOnly convertedValue = TimeOnly.Parse(stringValue, cultureInfo);
94+
return isNullableTypeRequested ? (TimeOnly?)convertedValue : convertedValue;
95+
}
96+
6997
if (nonNullableType.IsEnum)
7098
{
7199
object convertedValue = Enum.Parse(nonNullableType, stringValue);
@@ -75,7 +103,7 @@ public static class RuntimeTypeConverter
75103
}
76104

77105
// https://bradwilson.typepad.com/blog/2008/07/creating-nullab.html
78-
return Convert.ChangeType(stringValue, nonNullableType);
106+
return Convert.ChangeType(stringValue, nonNullableType, cultureInfo);
79107
}
80108
catch (Exception exception) when (exception is FormatException or OverflowException or InvalidCastException or ArgumentException)
81109
{

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

Lines changed: 70 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);
@@ -244,6 +247,62 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
244247
responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeSpan").With(value => value.Should().Be(resource.SomeTimeSpan));
245248
}
246249

250+
[Fact]
251+
public async Task Can_filter_equality_on_type_DateOnly()
252+
{
253+
// Arrange
254+
var resource = new FilterableResource
255+
{
256+
SomeDateOnly = DateOnly.FromDateTime(27.January(2003))
257+
};
258+
259+
await _testContext.RunOnDatabaseAsync(async dbContext =>
260+
{
261+
await dbContext.ClearTableAsync<FilterableResource>();
262+
dbContext.FilterableResources.AddRange(resource, new FilterableResource());
263+
await dbContext.SaveChangesAsync();
264+
});
265+
266+
string route = $"/filterableResources?filter=equals(someDateOnly,'{resource.SomeDateOnly:O}')";
267+
268+
// Act
269+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
270+
271+
// Assert
272+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
273+
274+
responseDocument.Data.ManyValue.ShouldHaveCount(1);
275+
responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDateOnly").With(value => value.Should().Be(resource.SomeDateOnly));
276+
}
277+
278+
[Fact]
279+
public async Task Can_filter_equality_on_type_TimeOnly()
280+
{
281+
// Arrange
282+
var resource = new FilterableResource
283+
{
284+
SomeTimeOnly = new TimeOnly(23, 59, 59, 999)
285+
};
286+
287+
await _testContext.RunOnDatabaseAsync(async dbContext =>
288+
{
289+
await dbContext.ClearTableAsync<FilterableResource>();
290+
dbContext.FilterableResources.AddRange(resource, new FilterableResource());
291+
await dbContext.SaveChangesAsync();
292+
});
293+
294+
string route = $"/filterableResources?filter=equals(someTimeOnly,'{resource.SomeTimeOnly:O}')";
295+
296+
// Act
297+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
298+
299+
// Assert
300+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
301+
302+
responseDocument.Data.ManyValue.ShouldHaveCount(1);
303+
responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeOnly").With(value => value.Should().Be(resource.SomeTimeOnly));
304+
}
305+
247306
[Fact]
248307
public async Task Cannot_filter_equality_on_incompatible_value()
249308
{
@@ -288,6 +347,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
288347
[InlineData(nameof(FilterableResource.SomeNullableDateTime))]
289348
[InlineData(nameof(FilterableResource.SomeNullableDateTimeOffset))]
290349
[InlineData(nameof(FilterableResource.SomeNullableTimeSpan))]
350+
[InlineData(nameof(FilterableResource.SomeNullableDateOnly))]
351+
[InlineData(nameof(FilterableResource.SomeNullableTimeOnly))]
291352
[InlineData(nameof(FilterableResource.SomeNullableEnum))]
292353
public async Task Can_filter_is_null_on_type(string propertyName)
293354
{
@@ -308,6 +369,8 @@ public async Task Can_filter_is_null_on_type(string propertyName)
308369
SomeNullableDateTime = 1.January(2001).AsUtc(),
309370
SomeNullableDateTimeOffset = 1.January(2001).AsUtc(),
310371
SomeNullableTimeSpan = TimeSpan.FromHours(1),
372+
SomeNullableDateOnly = DateOnly.FromDateTime(1.January(2001)),
373+
SomeNullableTimeOnly = new TimeOnly(1, 0),
311374
SomeNullableEnum = DayOfWeek.Friday
312375
};
313376

@@ -342,6 +405,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
342405
[InlineData(nameof(FilterableResource.SomeNullableDateTime))]
343406
[InlineData(nameof(FilterableResource.SomeNullableDateTimeOffset))]
344407
[InlineData(nameof(FilterableResource.SomeNullableTimeSpan))]
408+
[InlineData(nameof(FilterableResource.SomeNullableDateOnly))]
409+
[InlineData(nameof(FilterableResource.SomeNullableTimeOnly))]
345410
[InlineData(nameof(FilterableResource.SomeNullableEnum))]
346411
public async Task Can_filter_is_not_null_on_type(string propertyName)
347412
{
@@ -358,6 +423,8 @@ public async Task Can_filter_is_not_null_on_type(string propertyName)
358423
SomeNullableDateTime = 1.January(2001).AsUtc(),
359424
SomeNullableDateTimeOffset = 1.January(2001).AsUtc(),
360425
SomeNullableTimeSpan = TimeSpan.FromHours(1),
426+
SomeNullableDateOnly = DateOnly.FromDateTime(1.January(2001)),
427+
SomeNullableTimeOnly = new TimeOnly(1, 0),
361428
SomeNullableEnum = DayOfWeek.Friday
362429
};
363430

0 commit comments

Comments
 (0)