Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit e4cf9a8

Browse files
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

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+
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

Lines changed: 10 additions & 1 deletion
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

Lines changed: 54 additions & 0 deletions
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

Lines changed: 8 additions & 0 deletions
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

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)

AltStyle によって変換されたページ (->オリジナル) /