1

I've looked at this question about DateTimeOffset query parameters, but I explicitly want to be able to pass a DateTimeOffset as a route attribute, not a query parameter (will likely need further routing after the date). In addition I want a route that does not include the date, like so:

[Route("api/Controller/Action/")]
[HttpGet]
public async Task<ActionResult> ControllerAction() 
{
    //blah
}

[Route("api/Controller/Action/{dateParam:DateTimeOffset}")]
[HttpGet]
public async Task<ActionResult> ControllerAction(DateTimeOffset dateParam)
{
    //blah
}

These routes both return an InvalidOperationException: The constraint reference 'DateTimeOffset' could not be resolved to a type. Register the constraint type with 'Microsoft.AspNetCore.Routing.RouteOptions.ConstraintMap'.

To try and fix this I added and registered a constraint:

//Constraint
public class DateTimeOffsetConstraint : IRouteConstraint
{
    public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values,
        RouteDirection routeDirection)
    {
        if (values.TryGetValue(routeKey, out var value) && value != null)
        {
            return value is DateTimeOffset;
        }
        return false;
    }
}

//In startup configure services
services.Configure<RouteOptions>(opt => opt.ConstraintMap.Add("DateTimeOffsetConstraint", typeof(DateTimeOffsetConstraint)));

//In controller, altered second route to use constraint
[Route("api/Controller/Action/{dateParam:DateTimeOffsetConstraint}")]
[HttpGet]
public async Task<ActionResult> ControllerAction(DateTimeOffset dateParam)
{
    //blah
}

After that change calling the first route returns InvalidOperationException: Unable to resolve service for type 'System.DateTimeOffset', while the second with the DateTimeOffset (in the proper Zulu time json format e.g. 2019-10-02T05:04:18.070Z) returns a 404.

Following questions by @Kirk Larkin... The controller takes an IDatePeriodRepository, this is defined in another project. This at one point has a DateTimeOffset passed into the constructor

public interface IDatePeriodRepository
{
    Task<int> GetDatePeriod();
    Task<int> GetDatePeriod(DateTimeOffset date);
}

//Defined in seperate file
internal class DatePeriodRepository: IDatePeriodRepository
{
    private readonly DateTimeOffset _dateCycleStart;

    public DatePeriodRepository(DateTimeOffset dateCycleStart)
    {
        _dateCycleStart = dateCycleStart;
    }

    public Task<int> GetDatePeriod()
    {
        return GetDatePeriod(DateTimeOffset.Now);
    }

    public Task<int> GetDatePeriod(DateTimeOffset date)
    {
        var yearDiff = (date.Year - _billingCycleStart.Year) * 12;
        var monthDiff = yearDiff + date.Month - _dateCycleStart.Month;
        return Task.FromResult(monthDiff);
    }
}

This is constructed with a service collection extension

public static class ServiceCollectionExtension
{
    public static IServiceCollection AddDatePeriodRepository(this IServiceCollection services, Action<Options> configuration)
    {
        var options = new Options();
        configuration(options);
        services.AddSingleton(options);
        services.Configure(configuration);

        return services.AddScoped<IDatePeriodRepository, DatePeriodRepository>();
    }
}

//Used in startup ConfigureServices
services.AddBillingPeriodRepository(opt =>
        opt.BillingPeriodCycleStart = Configuration.GetValue<DateTimeOffset>("BillingPeriodCycleStart"));

How can I have a DateTimeOffset as a route attribute?

Smudge202'a answer worked, but I also had to alter the constrinat match method like so:

public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values,
            RouteDirection routeDirection)
{
    if (values.TryGetValue(routeKey, out var value) && value != null)
    {
        return DateTimeOffset.TryParse(value.ToString(), out _);
    }
    return false;
}
J Lewis
  • 462
  • 1
  • 4
  • 15
  • What happens if you ditch the constraint altogether? i.e. `[Route("api/Controller/Action/{dateParam}")]`. Do you _want_ a 404 if the format is invalid or would you want a 400? – Kirk Larkin Oct 03 '19 at 09:10
  • I get the Unable to resolve service for type 'System.DateTimeOffset' again – J Lewis Oct 03 '19 at 09:12
  • And as for the format, I want it to 400 if it's not a valid date – J Lewis Oct 03 '19 at 09:12
  • Do you have a `DateTimeOffset` parameter in a constructor for that controller? That's not an error message I'd expect from your `ControllerAction` method you've shown. – Kirk Larkin Oct 03 '19 at 09:19
  • No I don't. There's an interface whose implementation takes one, but that is constructed in a different project and injected using a service collection extension, so no direct need of a DateTimeOffset – J Lewis Oct 03 '19 at 09:25
  • So in constructing the controller in question, is this service involved somewhere along the chain? How would you expect the DI container to be able to give that service an instance of `DateTimeOffset`? What value would it have? – Kirk Larkin Oct 03 '19 at 09:27

1 Answers1

2

You're injecting a DateTimeOffset into DatePeriodRepository, however your DI setup is configuring an Options class.

Change DatePeriodRepository to expect the configured Options class:

internal class DatePeriodRepository: IDatePeriodRepository
{
    private readonly DateTimeOffset _dateCycleStart;

    public DatePeriodRepository(Options options)
    {
        _dateCycleStart = options.BillingPeriodCycleStart;
    }

    public Task<int> GetDatePeriod()
    {
        return GetDatePeriod(DateTimeOffset.Now);
    }

    public Task<int> GetDatePeriod(DateTimeOffset date)
    {
        var yearDiff = (date.Year - _billingCycleStart.Year) * 12;
        var monthDiff = yearDiff + date.Month - _dateCycleStart.Month;
        return Task.FromResult(monthDiff);
    }
}
Smudge202
  • 4,689
  • 2
  • 26
  • 44