Skip to content

Commit 346e9ed

Browse files
authored
Added support for type based parameter binding (#35496) (#35535)
1 parent 064497e commit 346e9ed

6 files changed

+687
-65
lines changed

src/Http/Http.Extensions/src/RequestDelegateFactory.cs

Lines changed: 159 additions & 42 deletions
Large diffs are not rendered by default.

src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs

Lines changed: 315 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,53 @@ public static bool TryParse(string? value, out MyTryParsableRecord? result)
499499
}
500500
}
501501

502+
private class MyBindAsyncTypeThatThrows
503+
{
504+
public static ValueTask<object?> BindAsync(HttpContext context)
505+
{
506+
throw new InvalidOperationException("BindAsync failed");
507+
}
508+
}
509+
510+
private record MyBindAsyncRecord(Uri Uri)
511+
{
512+
public static ValueTask<object?> BindAsync(HttpContext context)
513+
{
514+
if (!Uri.TryCreate(context.Request.Headers.Referer, UriKind.Absolute, out var uri))
515+
{
516+
return ValueTask.FromResult<object?>(null);
517+
}
518+
519+
return ValueTask.FromResult<object?>(new MyBindAsyncRecord(uri));
520+
}
521+
522+
// TryParse(HttpContext, ...) should be preferred over TryParse(string, ...) if there's
523+
// no [FromRoute] or [FromQuery] attributes.
524+
public static bool TryParse(string? value, out MyBindAsyncRecord? result)
525+
{
526+
throw new NotImplementedException();
527+
}
528+
}
529+
530+
private record struct MyBindAsyncStruct(Uri Uri)
531+
{
532+
public static ValueTask<object?> BindAsync(HttpContext context)
533+
{
534+
if (!Uri.TryCreate(context.Request.Headers.Referer, UriKind.Absolute, out var uri))
535+
{
536+
return ValueTask.FromResult<object?>(null);
537+
}
538+
539+
return ValueTask.FromResult<object?>(new MyBindAsyncStruct(uri));
540+
}
541+
542+
// TryParse(HttpContext, ...) should be preferred over TryParse(string, ...) if there's
543+
// no [FromRoute] or [FromQuery] attributes.
544+
public static bool TryParse(string? value, out MyBindAsyncStruct result) =>
545+
throw new NotImplementedException();
546+
}
547+
548+
502549
[Theory]
503550
[MemberData(nameof(TryParsableParameters))]
504551
public async Task RequestDelegatePopulatesUnattributedTryParsableParametersFromRouteValue(Delegate action, string? routeValue, object? expectedParameterValue)
@@ -560,6 +607,84 @@ public async Task RequestDelegatePopulatesUnattributedTryParsableParametersFromR
560607
Assert.Equal(42, httpContext.Items["tryParsable"]);
561608
}
562609

610+
[Fact]
611+
public async Task RequestDelegatePrefersBindAsyncOverTryParseString()
612+
{
613+
var httpContext = new DefaultHttpContext();
614+
615+
httpContext.Request.Headers.Referer = "https://example.org";
616+
617+
var requestDelegate = RequestDelegateFactory.Create((HttpContext httpContext, MyBindAsyncRecord tryParsable) =>
618+
{
619+
httpContext.Items["tryParsable"] = tryParsable;
620+
});
621+
622+
await requestDelegate(httpContext);
623+
624+
Assert.Equal(new MyBindAsyncRecord(new Uri("https://example.org")), httpContext.Items["tryParsable"]);
625+
}
626+
627+
[Fact]
628+
public async Task RequestDelegatePrefersBindAsyncOverTryParseStringForNonNullableStruct()
629+
{
630+
var httpContext = new DefaultHttpContext();
631+
632+
httpContext.Request.Headers.Referer = "https://example.org";
633+
634+
var requestDelegate = RequestDelegateFactory.Create((HttpContext httpContext, MyBindAsyncStruct tryParsable) =>
635+
{
636+
httpContext.Items["tryParsable"] = tryParsable;
637+
});
638+
639+
await requestDelegate(httpContext);
640+
641+
Assert.Equal(new MyBindAsyncStruct(new Uri("https://example.org")), httpContext.Items["tryParsable"]);
642+
}
643+
644+
[Fact]
645+
public async Task RequestDelegateUsesTryParseStringoOverBindAsyncGivenExplicitAttribute()
646+
{
647+
var fromRouteRequestDelegate = RequestDelegateFactory.Create((HttpContext httpContext, [FromRoute] MyBindAsyncRecord tryParsable) => { });
648+
var fromQueryRequestDelegate = RequestDelegateFactory.Create((HttpContext httpContext, [FromQuery] MyBindAsyncRecord tryParsable) => { });
649+
650+
var httpContext = new DefaultHttpContext
651+
{
652+
Request =
653+
{
654+
RouteValues =
655+
{
656+
["tryParsable"] = "foo"
657+
},
658+
Query = new QueryCollection(new Dictionary<string, StringValues>
659+
{
660+
["tryParsable"] = "foo"
661+
}),
662+
},
663+
};
664+
665+
await Assert.ThrowsAsync<NotImplementedException>(() => fromRouteRequestDelegate(httpContext));
666+
await Assert.ThrowsAsync<NotImplementedException>(() => fromQueryRequestDelegate(httpContext));
667+
}
668+
669+
[Fact]
670+
public async Task RequestDelegateUsesTryParseStringOverBindAsyncGivenNullableStruct()
671+
{
672+
var fromRouteRequestDelegate = RequestDelegateFactory.Create((HttpContext httpContext, MyBindAsyncStruct? tryParsable) => { });
673+
674+
var httpContext = new DefaultHttpContext
675+
{
676+
Request =
677+
{
678+
RouteValues =
679+
{
680+
["tryParsable"] = "foo"
681+
},
682+
},
683+
};
684+
685+
await Assert.ThrowsAsync<NotImplementedException>(() => fromRouteRequestDelegate(httpContext));
686+
}
687+
563688
public static object[][] DelegatesWithAttributesOnNotTryParsableParameters
564689
{
565690
get
@@ -629,11 +754,169 @@ void TestAction([FromRoute] int tryParsable, [FromRoute] int tryParsable2)
629754
Assert.Equal(LogLevel.Debug, logs[0].LogLevel);
630755
Assert.Equal(@"Failed to bind parameter ""Int32 tryParsable"" from ""invalid!"".", logs[0].Message);
631756

632-
Assert.Equal(new EventId(3, "ParamaterBindingFailed"), logs[0].EventId);
633-
Assert.Equal(LogLevel.Debug, logs[0].LogLevel);
757+
Assert.Equal(new EventId(3, "ParamaterBindingFailed"), logs[1].EventId);
758+
Assert.Equal(LogLevel.Debug, logs[1].LogLevel);
634759
Assert.Equal(@"Failed to bind parameter ""Int32 tryParsable2"" from ""invalid again!"".", logs[1].Message);
635760
}
636761

762+
[Fact]
763+
public async Task RequestDelegateLogsBindAsyncFailuresAndSets400Response()
764+
{
765+
// Not supplying any headers will cause the HttpContext TryParse overload to fail.
766+
var httpContext = new DefaultHttpContext()
767+
{
768+
RequestServices = new ServiceCollection().AddSingleton(LoggerFactory).BuildServiceProvider(),
769+
};
770+
771+
var invoked = false;
772+
773+
var requestDelegate = RequestDelegateFactory.Create((MyBindAsyncRecord arg1, MyBindAsyncRecord arg2) =>
774+
{
775+
invoked = true;
776+
});
777+
778+
await requestDelegate(httpContext);
779+
780+
Assert.False(invoked);
781+
Assert.False(httpContext.RequestAborted.IsCancellationRequested);
782+
Assert.Equal(400, httpContext.Response.StatusCode);
783+
784+
var logs = TestSink.Writes.ToArray();
785+
786+
Assert.Equal(2, logs.Length);
787+
788+
Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[0].EventId);
789+
Assert.Equal(LogLevel.Debug, logs[0].LogLevel);
790+
Assert.Equal(@"Required parameter ""MyBindAsyncRecord arg1"" was not provided.", logs[0].Message);
791+
792+
Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[1].EventId);
793+
Assert.Equal(LogLevel.Debug, logs[1].LogLevel);
794+
Assert.Equal(@"Required parameter ""MyBindAsyncRecord arg2"" was not provided.", logs[1].Message);
795+
}
796+
797+
[Fact]
798+
public async Task BindAsyncExceptionsThrowException()
799+
{
800+
// Not supplying any headers will cause the HttpContext TryParse overload to fail.
801+
var httpContext = new DefaultHttpContext()
802+
{
803+
RequestServices = new ServiceCollection().AddSingleton(LoggerFactory).BuildServiceProvider(),
804+
};
805+
806+
var requestDelegate = RequestDelegateFactory.Create((MyBindAsyncTypeThatThrows arg1) => { });
807+
808+
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => requestDelegate(httpContext));
809+
Assert.Equal("BindAsync failed", ex.Message);
810+
}
811+
812+
[Fact]
813+
public async Task BindAsyncWithBodyArgument()
814+
{
815+
Todo originalTodo = new()
816+
{
817+
Name = "Write more tests!"
818+
};
819+
820+
var httpContext = new DefaultHttpContext();
821+
822+
var requestBodyBytes = JsonSerializer.SerializeToUtf8Bytes(originalTodo);
823+
var stream = new MemoryStream(requestBodyBytes); ;
824+
httpContext.Request.Body = stream;
825+
826+
httpContext.Request.Headers["Content-Type"] = "application/json";
827+
httpContext.Request.Headers["Content-Length"] = stream.Length.ToString();
828+
httpContext.Features.Set<IHttpRequestBodyDetectionFeature>(new RequestBodyDetectionFeature(true));
829+
830+
var jsonOptions = new JsonOptions();
831+
jsonOptions.SerializerOptions.Converters.Add(new TodoJsonConverter());
832+
833+
var mock = new Mock<IServiceProvider>();
834+
mock.Setup(m => m.GetService(It.IsAny<Type>())).Returns<Type>(t =>
835+
{
836+
if (t == typeof(IOptions<JsonOptions>))
837+
{
838+
return Options.Create(jsonOptions);
839+
}
840+
return null;
841+
});
842+
843+
httpContext.RequestServices = mock.Object;
844+
httpContext.Request.Headers.Referer = "https://example.org";
845+
846+
var invoked = false;
847+
848+
var requestDelegate = RequestDelegateFactory.Create((HttpContext context, MyBindAsyncRecord arg1, Todo todo) =>
849+
{
850+
invoked = true;
851+
context.Items[nameof(arg1)] = arg1;
852+
context.Items[nameof(todo)] = todo;
853+
});
854+
855+
await requestDelegate(httpContext);
856+
857+
Assert.True(invoked);
858+
var arg = httpContext.Items["arg1"] as MyBindAsyncRecord;
859+
Assert.NotNull(arg);
860+
Assert.Equal("https://example.org/", arg!.Uri.ToString());
861+
var todo = httpContext.Items["todo"] as Todo;
862+
Assert.NotNull(todo);
863+
Assert.Equal("Write more tests!", todo!.Name);
864+
}
865+
866+
[Fact]
867+
public async Task BindAsyncRunsBeforeBodyBinding()
868+
{
869+
Todo originalTodo = new()
870+
{
871+
Name = "Write more tests!"
872+
};
873+
874+
var httpContext = new DefaultHttpContext();
875+
876+
var requestBodyBytes = JsonSerializer.SerializeToUtf8Bytes(originalTodo);
877+
var stream = new MemoryStream(requestBodyBytes); ;
878+
httpContext.Request.Body = stream;
879+
880+
httpContext.Request.Headers["Content-Type"] = "application/json";
881+
httpContext.Request.Headers["Content-Length"] = stream.Length.ToString();
882+
httpContext.Features.Set<IHttpRequestBodyDetectionFeature>(new RequestBodyDetectionFeature(true));
883+
884+
var jsonOptions = new JsonOptions();
885+
jsonOptions.SerializerOptions.Converters.Add(new TodoJsonConverter());
886+
887+
var mock = new Mock<IServiceProvider>();
888+
mock.Setup(m => m.GetService(It.IsAny<Type>())).Returns<Type>(t =>
889+
{
890+
if (t == typeof(IOptions<JsonOptions>))
891+
{
892+
return Options.Create(jsonOptions);
893+
}
894+
return null;
895+
});
896+
897+
httpContext.RequestServices = mock.Object;
898+
httpContext.Request.Headers.Referer = "https://example.org";
899+
900+
var invoked = false;
901+
902+
var requestDelegate = RequestDelegateFactory.Create((HttpContext context, CustomTodo customTodo, Todo todo) =>
903+
{
904+
invoked = true;
905+
context.Items[nameof(customTodo)] = customTodo;
906+
context.Items[nameof(todo)] = todo;
907+
});
908+
909+
await requestDelegate(httpContext);
910+
911+
Assert.True(invoked);
912+
var todo0 = httpContext.Items["customTodo"] as Todo;
913+
Assert.NotNull(todo0);
914+
Assert.Equal("Write more tests!", todo0!.Name);
915+
var todo1 = httpContext.Items["todo"] as Todo;
916+
Assert.NotNull(todo1);
917+
Assert.Equal("Write more tests!", todo1!.Name);
918+
}
919+
637920
[Fact]
638921
public async Task RequestDelegatePopulatesFromQueryParameterBasedOnParameterName()
639922
{
@@ -1669,6 +1952,26 @@ public async Task RequestDelegateHandlesBodyParamOptionality(Delegate @delegate,
16691952
}
16701953
}
16711954

1955+
[Fact]
1956+
public async Task RequestDelegateDoesSupportBindAsyncOptionality()
1957+
{
1958+
var httpContext = new DefaultHttpContext()
1959+
{
1960+
RequestServices = new ServiceCollection().AddSingleton(LoggerFactory).BuildServiceProvider(),
1961+
};
1962+
1963+
var invoked = false;
1964+
1965+
var requestDelegate = RequestDelegateFactory.Create((MyBindAsyncRecord? arg1) =>
1966+
{
1967+
invoked = true;
1968+
});
1969+
1970+
await requestDelegate(httpContext);
1971+
1972+
Assert.True(invoked);
1973+
}
1974+
16721975
public static IEnumerable<object?[]> ServiceParamOptionalityData
16731976
{
16741977
get
@@ -1843,6 +2146,16 @@ private class Todo : ITodo
18432146
public bool IsComplete { get; set; }
18442147
}
18452148

2149+
private class CustomTodo : Todo
2150+
{
2151+
public static async ValueTask<object?> BindAsync(HttpContext context)
2152+
{
2153+
var body = await context.Request.ReadFromJsonAsync<CustomTodo>();
2154+
context.Request.Body.Position = 0;
2155+
return body;
2156+
}
2157+
}
2158+
18462159
private record struct TodoStruct(int Id, string? Name, bool IsComplete) : ITodo;
18472160

18482161
private interface ITodo

0 commit comments

Comments
 (0)