Skip to content

Commit e4cf9a8

Browse files
authored
Merge pull request #1169 from json-api-dotnet/data-types
Add support for DateOnly/TimeOnly
2 parents 8e8427e + 9d17ce1 commit e4cf9a8

File tree

10 files changed

+633
-205
lines changed

10 files changed

+633
-205
lines changed

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

+32-4
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,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/InputValidation/ModelState/ModelStateFakers.cs

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Globalization;
12
using Bogus;
23
using TestBuildingBlocks;
34

@@ -8,6 +9,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState;
89

910
internal sealed class ModelStateFakers : FakerContainer
1011
{
12+
private static readonly DateOnly MinCreatedOn = DateOnly.Parse("2000-01-01", CultureInfo.InvariantCulture);
13+
private static readonly DateOnly MaxCreatedOn = DateOnly.Parse("2050-01-01", CultureInfo.InvariantCulture);
14+
15+
private static readonly TimeOnly MinCreatedAt = TimeOnly.Parse("09:00:00", CultureInfo.InvariantCulture);
16+
private static readonly TimeOnly MaxCreatedAt = TimeOnly.Parse("17:30:00", CultureInfo.InvariantCulture);
17+
1118
private readonly Lazy<Faker<SystemVolume>> _lazySystemVolumeFaker = new(() =>
1219
new Faker<SystemVolume>()
1320
.UseSeed(GetFakerSeed())
@@ -18,7 +25,9 @@ internal sealed class ModelStateFakers : FakerContainer
1825
.UseSeed(GetFakerSeed())
1926
.RuleFor(systemFile => systemFile.FileName, faker => faker.System.FileName())
2027
.RuleFor(systemFile => systemFile.Attributes, faker => faker.Random.Enum(FileAttributes.Normal, FileAttributes.Hidden, FileAttributes.ReadOnly))
21-
.RuleFor(systemFile => systemFile.SizeInBytes, faker => faker.Random.Long(0, 1_000_000)));
28+
.RuleFor(systemFile => systemFile.SizeInBytes, faker => faker.Random.Long(0, 1_000_000))
29+
.RuleFor(systemFile => systemFile.CreatedOn, faker => faker.Date.BetweenDateOnly(MinCreatedOn, MaxCreatedOn))
30+
.RuleFor(systemFile => systemFile.CreatedAt, faker => faker.Date.BetweenTimeOnly(MinCreatedAt, MaxCreatedAt)));
2231

2332
private readonly Lazy<Faker<SystemDirectory>> _lazySystemDirectoryFaker = new(() =>
2433
new Faker<SystemDirectory>()

test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs

+54
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Net;
22
using FluentAssertions;
33
using JsonApiDotNetCore.Serialization.Objects;
4+
using Microsoft.Extensions.DependencyInjection;
45
using TestBuildingBlocks;
56
using Xunit;
67

@@ -17,6 +18,12 @@ public ModelStateValidationTests(IntegrationTestContext<TestableStartup<ModelSta
1718

1819
testContext.UseController<SystemDirectoriesController>();
1920
testContext.UseController<SystemFilesController>();
21+
22+
testContext.ConfigureServicesBeforeStartup(services =>
23+
{
24+
// Polyfill for missing DateOnly/TimeOnly support in .NET 6 ModelState validation.
25+
services.AddDateOnlyTimeOnlyStringConverters();
26+
});
2027
}
2128

2229
[Fact]
@@ -123,6 +130,53 @@ public async Task Cannot_create_resource_with_invalid_attribute_value()
123130
error.Source.Pointer.Should().Be("/data/attributes/directoryName");
124131
}
125132

133+
[Fact]
134+
public async Task Cannot_create_resource_with_invalid_DateOnly_TimeOnly_attribute_value()
135+
{
136+
// Arrange
137+
SystemFile newFile = _fakers.SystemFile.Generate();
138+
139+
var requestBody = new
140+
{
141+
data = new
142+
{
143+
type = "systemFiles",
144+
attributes = new
145+
{
146+
fileName = newFile.FileName,
147+
attributes = newFile.Attributes,
148+
sizeInBytes = newFile.SizeInBytes,
149+
createdOn = DateOnly.MinValue,
150+
createdAt = TimeOnly.MinValue
151+
}
152+
}
153+
};
154+
155+
const string route = "/systemFiles";
156+
157+
// Act
158+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(route, requestBody);
159+
160+
// Assert
161+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity);
162+
163+
responseDocument.Errors.ShouldHaveCount(2);
164+
165+
ErrorObject error1 = responseDocument.Errors[0];
166+
error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
167+
error1.Title.Should().Be("Input validation failed.");
168+
error1.Detail.Should().StartWith("The field CreatedAt must be between ");
169+
error1.Source.ShouldNotBeNull();
170+
error1.Source.Pointer.Should().Be("/data/attributes/createdAt");
171+
172+
ErrorObject error2 = responseDocument.Errors[1];
173+
error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
174+
error2.Title.Should().Be("Input validation failed.");
175+
error2.Detail.Should().StartWith("The field CreatedOn must be between ");
176+
error2.Source.ShouldNotBeNull();
177+
error2.Source.Pointer.Should().Be("/data/attributes/createdOn");
178+
}
179+
126180
[Fact]
127181
public async Task Can_create_resource_with_valid_attribute_value()
128182
{

test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs

+8
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,12 @@ public sealed class SystemFile : Identifiable<int>
2020
[Attr]
2121
[Range(typeof(long), "1", "9223372036854775807")]
2222
public long SizeInBytes { get; set; }
23+
24+
[Attr]
25+
[Range(typeof(DateOnly), "2000-01-01", "2050-01-01")]
26+
public DateOnly CreatedOn { get; set; }
27+
28+
[Attr]
29+
[Range(typeof(TimeOnly), "09:00:00", "17:30:00")]
30+
public TimeOnly CreatedAt { get; set; }
2331
}

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

+70-3
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)