@@ -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