Skip to content

Commit

Permalink
Merge pull request #1169 from json-api-dotnet/data-types
Browse files Browse the repository at this point in the history
Add support for DateOnly/TimeOnly
  • Loading branch information
bkoelman authored Feb 7, 2023
2 parents 8e8427e + 9d17ce1 commit e4cf9a8
Show file tree
Hide file tree
Showing 10 changed files with 633 additions and 205 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,26 @@ namespace JsonApiDotNetCore.Resources.Internal;
[PublicAPI]
public static class RuntimeTypeConverter
{
private const string ParseQueryStringsUsingCurrentCultureSwitchName = "JsonApiDotNetCore.ParseQueryStringsUsingCurrentCulture";

public static object? ConvertType(object? value, Type type)
{
ArgumentGuard.NotNull(type);

// Earlier versions of JsonApiDotNetCore failed to pass CultureInfo.InvariantCulture in the parsing below, which resulted in the 'current'
// culture being used. Unlike parsing JSON request/response bodies, this effectively meant that query strings were parsed based on the
// OS-level regional settings of the web server.
// Because this was fixed in a non-major release, the switch below enables to revert to the old behavior.

// With the switch activated, API developers can still choose between:
// - Requiring localized date/number formats: parsing occurs using the OS-level regional settings (the default).
// - Requiring culture-invariant date/number formats: requires setting CultureInfo.DefaultThreadCurrentCulture to CultureInfo.InvariantCulture at startup.
// - Allowing clients to choose by sending an Accept-Language HTTP header: requires app.UseRequestLocalization() at startup.

CultureInfo? cultureInfo = AppContext.TryGetSwitch(ParseQueryStringsUsingCurrentCultureSwitchName, out bool useCurrentCulture) && useCurrentCulture
? null
: CultureInfo.InvariantCulture;

if (value == null)
{
if (!CanContainNull(type))
Expand Down Expand Up @@ -50,22 +66,34 @@ public static class RuntimeTypeConverter

if (nonNullableType == typeof(DateTime))
{
DateTime convertedValue = DateTime.Parse(stringValue, null, DateTimeStyles.RoundtripKind);
DateTime convertedValue = DateTime.Parse(stringValue, cultureInfo, DateTimeStyles.RoundtripKind);
return isNullableTypeRequested ? (DateTime?)convertedValue : convertedValue;
}

if (nonNullableType == typeof(DateTimeOffset))
{
DateTimeOffset convertedValue = DateTimeOffset.Parse(stringValue, null, DateTimeStyles.RoundtripKind);
DateTimeOffset convertedValue = DateTimeOffset.Parse(stringValue, cultureInfo, DateTimeStyles.RoundtripKind);
return isNullableTypeRequested ? (DateTimeOffset?)convertedValue : convertedValue;
}

if (nonNullableType == typeof(TimeSpan))
{
TimeSpan convertedValue = TimeSpan.Parse(stringValue);
TimeSpan convertedValue = TimeSpan.Parse(stringValue, cultureInfo);
return isNullableTypeRequested ? (TimeSpan?)convertedValue : convertedValue;
}

if (nonNullableType == typeof(DateOnly))
{
DateOnly convertedValue = DateOnly.Parse(stringValue, cultureInfo);
return isNullableTypeRequested ? (DateOnly?)convertedValue : convertedValue;
}

if (nonNullableType == typeof(TimeOnly))
{
TimeOnly convertedValue = TimeOnly.Parse(stringValue, cultureInfo);
return isNullableTypeRequested ? (TimeOnly?)convertedValue : convertedValue;
}

if (nonNullableType.IsEnum)
{
object convertedValue = Enum.Parse(nonNullableType, stringValue);
Expand All @@ -75,7 +103,7 @@ public static class RuntimeTypeConverter
}

// https://bradwilson.typepad.com/blog/2008/07/creating-nullab.html
return Convert.ChangeType(stringValue, nonNullableType);
return Convert.ChangeType(stringValue, nonNullableType, cultureInfo);
}
catch (Exception exception) when (exception is FormatException or OverflowException or InvalidCastException or ArgumentException)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using Bogus;
using TestBuildingBlocks;

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

internal sealed class ModelStateFakers : FakerContainer
{
private static readonly DateOnly MinCreatedOn = DateOnly.Parse("2000-01-01", CultureInfo.InvariantCulture);
private static readonly DateOnly MaxCreatedOn = DateOnly.Parse("2050-01-01", CultureInfo.InvariantCulture);

private static readonly TimeOnly MinCreatedAt = TimeOnly.Parse("09:00:00", CultureInfo.InvariantCulture);
private static readonly TimeOnly MaxCreatedAt = TimeOnly.Parse("17:30:00", CultureInfo.InvariantCulture);

private readonly Lazy<Faker<SystemVolume>> _lazySystemVolumeFaker = new(() =>
new Faker<SystemVolume>()
.UseSeed(GetFakerSeed())
Expand All @@ -18,7 +25,9 @@ internal sealed class ModelStateFakers : FakerContainer
.UseSeed(GetFakerSeed())
.RuleFor(systemFile => systemFile.FileName, faker => faker.System.FileName())
.RuleFor(systemFile => systemFile.Attributes, faker => faker.Random.Enum(FileAttributes.Normal, FileAttributes.Hidden, FileAttributes.ReadOnly))
.RuleFor(systemFile => systemFile.SizeInBytes, faker => faker.Random.Long(0, 1_000_000)));
.RuleFor(systemFile => systemFile.SizeInBytes, faker => faker.Random.Long(0, 1_000_000))
.RuleFor(systemFile => systemFile.CreatedOn, faker => faker.Date.BetweenDateOnly(MinCreatedOn, MaxCreatedOn))
.RuleFor(systemFile => systemFile.CreatedAt, faker => faker.Date.BetweenTimeOnly(MinCreatedAt, MaxCreatedAt)));

private readonly Lazy<Faker<SystemDirectory>> _lazySystemDirectoryFaker = new(() =>
new Faker<SystemDirectory>()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Net;
using FluentAssertions;
using JsonApiDotNetCore.Serialization.Objects;
using Microsoft.Extensions.DependencyInjection;
using TestBuildingBlocks;
using Xunit;

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

testContext.UseController<SystemDirectoriesController>();
testContext.UseController<SystemFilesController>();

testContext.ConfigureServicesBeforeStartup(services =>
{
// Polyfill for missing DateOnly/TimeOnly support in .NET 6 ModelState validation.
services.AddDateOnlyTimeOnlyStringConverters();
});
}

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

[Fact]
public async Task Cannot_create_resource_with_invalid_DateOnly_TimeOnly_attribute_value()
{
// Arrange
SystemFile newFile = _fakers.SystemFile.Generate();

var requestBody = new
{
data = new
{
type = "systemFiles",
attributes = new
{
fileName = newFile.FileName,
attributes = newFile.Attributes,
sizeInBytes = newFile.SizeInBytes,
createdOn = DateOnly.MinValue,
createdAt = TimeOnly.MinValue
}
}
};

const string route = "/systemFiles";

// Act
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(route, requestBody);

// Assert
httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity);

responseDocument.Errors.ShouldHaveCount(2);

ErrorObject error1 = responseDocument.Errors[0];
error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
error1.Title.Should().Be("Input validation failed.");
error1.Detail.Should().StartWith("The field CreatedAt must be between ");
error1.Source.ShouldNotBeNull();
error1.Source.Pointer.Should().Be("/data/attributes/createdAt");

ErrorObject error2 = responseDocument.Errors[1];
error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
error2.Title.Should().Be("Input validation failed.");
error2.Detail.Should().StartWith("The field CreatedOn must be between ");
error2.Source.ShouldNotBeNull();
error2.Source.Pointer.Should().Be("/data/attributes/createdOn");
}

[Fact]
public async Task Can_create_resource_with_valid_attribute_value()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,12 @@ public sealed class SystemFile : Identifiable<int>
[Attr]
[Range(typeof(long), "1", "9223372036854775807")]
public long SizeInBytes { get; set; }

[Attr]
[Range(typeof(DateOnly), "2000-01-01", "2050-01-01")]
public DateOnly CreatedOn { get; set; }

[Attr]
[Range(typeof(TimeOnly), "09:00:00", "17:30:00")]
public TimeOnly CreatedAt { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Net;
using System.Reflection;
using System.Text.Json.Serialization;
Expand Down Expand Up @@ -60,7 +61,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
});

string attributeName = propertyName.Camelize();
string route = $"/filterableResources?filter=equals({attributeName},'{propertyValue}')";
string? attributeValue = Convert.ToString(propertyValue, CultureInfo.InvariantCulture);

string route = $"/filterableResources?filter=equals({attributeName},'{attributeValue}')";

// Act
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
Expand Down Expand Up @@ -88,7 +91,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
await dbContext.SaveChangesAsync();
});

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

// Act
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
Expand Down Expand Up @@ -232,7 +235,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
await dbContext.SaveChangesAsync();
});

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

// Act
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
Expand All @@ -244,6 +247,62 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeSpan").With(value => value.Should().Be(resource.SomeTimeSpan));
}

[Fact]
public async Task Can_filter_equality_on_type_DateOnly()
{
// Arrange
var resource = new FilterableResource
{
SomeDateOnly = DateOnly.FromDateTime(27.January(2003))
};

await _testContext.RunOnDatabaseAsync(async dbContext =>
{
await dbContext.ClearTableAsync<FilterableResource>();
dbContext.FilterableResources.AddRange(resource, new FilterableResource());
await dbContext.SaveChangesAsync();
});

string route = $"/filterableResources?filter=equals(someDateOnly,'{resource.SomeDateOnly:O}')";

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

// Assert
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);

responseDocument.Data.ManyValue.ShouldHaveCount(1);
responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDateOnly").With(value => value.Should().Be(resource.SomeDateOnly));
}

[Fact]
public async Task Can_filter_equality_on_type_TimeOnly()
{
// Arrange
var resource = new FilterableResource
{
SomeTimeOnly = new TimeOnly(23, 59, 59, 999)
};

await _testContext.RunOnDatabaseAsync(async dbContext =>
{
await dbContext.ClearTableAsync<FilterableResource>();
dbContext.FilterableResources.AddRange(resource, new FilterableResource());
await dbContext.SaveChangesAsync();
});

string route = $"/filterableResources?filter=equals(someTimeOnly,'{resource.SomeTimeOnly:O}')";

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

// Assert
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);

responseDocument.Data.ManyValue.ShouldHaveCount(1);
responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeOnly").With(value => value.Should().Be(resource.SomeTimeOnly));
}

[Fact]
public async Task Cannot_filter_equality_on_incompatible_value()
{
Expand Down Expand Up @@ -288,6 +347,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
[InlineData(nameof(FilterableResource.SomeNullableDateTime))]
[InlineData(nameof(FilterableResource.SomeNullableDateTimeOffset))]
[InlineData(nameof(FilterableResource.SomeNullableTimeSpan))]
[InlineData(nameof(FilterableResource.SomeNullableDateOnly))]
[InlineData(nameof(FilterableResource.SomeNullableTimeOnly))]
[InlineData(nameof(FilterableResource.SomeNullableEnum))]
public async Task Can_filter_is_null_on_type(string propertyName)
{
Expand All @@ -308,6 +369,8 @@ public async Task Can_filter_is_null_on_type(string propertyName)
SomeNullableDateTime = 1.January(2001).AsUtc(),
SomeNullableDateTimeOffset = 1.January(2001).AsUtc(),
SomeNullableTimeSpan = TimeSpan.FromHours(1),
SomeNullableDateOnly = DateOnly.FromDateTime(1.January(2001)),
SomeNullableTimeOnly = new TimeOnly(1, 0),
SomeNullableEnum = DayOfWeek.Friday
};

Expand Down Expand Up @@ -342,6 +405,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
[InlineData(nameof(FilterableResource.SomeNullableDateTime))]
[InlineData(nameof(FilterableResource.SomeNullableDateTimeOffset))]
[InlineData(nameof(FilterableResource.SomeNullableTimeSpan))]
[InlineData(nameof(FilterableResource.SomeNullableDateOnly))]
[InlineData(nameof(FilterableResource.SomeNullableTimeOnly))]
[InlineData(nameof(FilterableResource.SomeNullableEnum))]
public async Task Can_filter_is_not_null_on_type(string propertyName)
{
Expand All @@ -358,6 +423,8 @@ public async Task Can_filter_is_not_null_on_type(string propertyName)
SomeNullableDateTime = 1.January(2001).AsUtc(),
SomeNullableDateTimeOffset = 1.January(2001).AsUtc(),
SomeNullableTimeSpan = TimeSpan.FromHours(1),
SomeNullableDateOnly = DateOnly.FromDateTime(1.January(2001)),
SomeNullableTimeOnly = new TimeOnly(1, 0),
SomeNullableEnum = DayOfWeek.Friday
};

Expand Down
Loading

0 comments on commit e4cf9a8

Please sign in to comment.