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 committed 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.
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
@@ -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
@@ -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
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; }
}
@@ -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 @@ public async Task Can_filter_equality_on_type(string propertyName, object proper
});

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 @@ public async Task Can_filter_equality_on_type_Decimal()
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 @@ public async Task Can_filter_equality_on_type_TimeSpan()
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 @@ public async Task Can_filter_equality_on_type_TimeSpan()
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 @@ public async Task Cannot_filter_equality_on_incompatible_value()
[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 @@ public async Task Can_filter_is_null_on_type(string propertyName)
[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

0 comments on commit e4cf9a8

Please sign in to comment.