Andrii Kondratiuk
Andrii Kondratiuk

Reputation: 23

What configurations required to make NodaTime.Instant work as query/path/form parameter in AspNetCore?

I'm trying to use NodaTime in my AspNetCore project.

JSON serialization works fine. But I can't get working for model binding of form/query/path params.As I saw in similar questions there are no TypeConverter implementation in NodaTime. Maybe there are some work around for this?

.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp2.2</TargetFramework>
    <AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.App" />
    <PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.2.0" PrivateAssets="All" />
    <PackageReference Include="NodaTime" Version="2.4.6" />
    <PackageReference Include="NodaTime.Serialization.JsonNet" Version="2.2.0" />
  </ItemGroup>

</Project>

My Startup services config:

public void ConfigureServices(IServiceCollection services)
{
     services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2)
            .AddJsonOptions(o => o.SerializerSettings.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb));
}

Api controller to reproduce the problem:

[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
    // works
    [HttpGet("Serialize")]
    public ActionResult Get()
    {
        return Ok(new { Now = SystemClock.Instance.GetCurrentInstant() });
    }

    // error
    [HttpGet("Ping")]
    public ActionResult Get([FromQuery] Instant t)
    {
        return Ok(new { Pong = t });
    }
}

URL: http://localhost:55555/api/values/ping?t=2019-09-02T06:55:52.7077495Z

Stack trace:

System.InvalidOperationException: Could not create an instance of type 'NodaTime.Instant'. Model bound complex types must not be abstract or value types and must have a parameterless constructor. Alternatively, give the 't' parameter a non-null default value. at Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexTypeModelBinder.CreateModel(ModelBindingContext bindingContext) at Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexTypeModelBinder.BindModelCoreAsync(ModelBindingContext bindingContext) at Microsoft.AspNetCore.Mvc.ModelBinding.ParameterBinder.BindModelAsync(ActionContext actionContext, IModelBinder modelBinder, IValueProvider valueProvider, ParameterDescriptor parameter, ModelMetadata metadata, Object value)
at Microsoft.AspNetCore.Mvc.Internal.ControllerBinderDelegateProvider.<>c__DisplayClass0_0.<g__Bind|0>d.MoveNext() --- End of stack trace from previous location where exception was thrown --- at Microsoft.AspNetCore.Mvc.Internal.ControllerActionInvoker.InvokeInnerFilterAsync() at Microsoft.AspNetCore.Mvc.Internal.ResourceInvoker.InvokeNextResourceFilter() at Microsoft.AspNetCore.Mvc.Internal.ResourceInvoker.Rethrow(ResourceExecutedContext context) at Microsoft.AspNetCore.Mvc.Internal.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted) at Microsoft.AspNetCore.Mvc.Internal.ResourceInvoker.InvokeFilterPipelineAsync() at Microsoft.AspNetCore.Mvc.Internal.ResourceInvoker.InvokeAsync()
at Microsoft.AspNetCore.Routing.EndpointMiddleware.Invoke(HttpContext httpContext) at Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware.Invoke(HttpContext httpContext) at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware.Invoke(HttpContext context)

Upvotes: 2

Views: 1352

Answers (1)

Jon Skeet
Jon Skeet

Reputation: 1503290

I'm far from an expert on ASP.NET Core model binders, but I've managed to hack up a way of doing it, as shown below.

We do now have TypeConverter support in NodaTime 3.0, which is present in the 3.0.0-beta01 build. I'm not expecting breaking changes between now and the 3.0 GA release, but I'd prefer not to make any guarantees :) The implementation code is pretty stable though - I wouldn't be worried on that front.

To bind manually, you can create a model binder provider and a model binder:

public class InstantModelBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context) =>
        context.Metadata.ModelType == typeof(Instant) ? new InstantModelBinder(context.Metadata.ParameterName) : null;
}

public class InstantModelBinder : IModelBinder
{
    private readonly string parameterName;

    public InstantModelBinder(string parameterName)
    {
        this.parameterName = parameterName;
    }

    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var text = bindingContext.ActionContext.HttpContext.Request.Query[parameterName];
        if (text.Count != 1)
        {
            bindingContext.Result = ModelBindingResult.Failed();
        }
        else
        {
            var result = InstantPattern.ExtendedIso.Parse(text);
            bindingContext.Result = result.Success
                ? ModelBindingResult.Success(result.Value)
                : ModelBindingResult.Failed();
        }
        return Task.CompletedTask;
    }
}

Then register the provider as the first provider in MVC options:

services.AddMvc(options => options.ModelBinderProviders.Insert(0, new InstantModelBinderProvider()))
    .SetCompatibilityVersion(CompatibilityVersion.Version_2_2)
    .AddJsonOptions(o => o.SerializerSettings.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb));

If you need to do this for multiple Noda Time types, I'd expect you do be able to use a single provider that works out which binder to use based on the ModelBinderProviderContext.Metadata.ModelType.

Currently the binder assumes that you're binding from the query string. I haven't investigated how easy it is to make that more general.

Upvotes: 3

Related Questions