Model Binding
Built-In Request Binding
Endpoint handlers are supplied with fully populated request DTOs where the property values are automatically bound from the incoming request without any effort from the developer. This behavior can be overridden as explained here.
Binding Order
The DTOs are populated from the below binding sources in the exact given order:
- JSON Body
- Form Fields
- Route Parameters
- Query Parameters
- User Claims (if property has [FromClaim] attribute)
- HTTP Headers (if property has [FromHeader] attribute)
- Permissions (if boolean property has [HasPermission] attribute)
Consider the following HTTP call and request DTO:
route: /api/user/{UserID}
url: /api/user/54321
json: { "UserID": "12345" }
public class GetUserRequest
{
public string UserID { get; set; }
}
When the handler receives the DTO, the value of UserID will be 54321 because route parameters have higher priority than JSON body. Likewise, if you decorate the UserID property with a [FromClaim] attribute, the value of UserID will be whatever claim value the user has for the claim type UserID in their claims.
From JSON Body
Any incoming HTTP request with a "application/json" content-type header will be automatically bound to the request DTO if it has matching properties.
JSON To Complex Types
As long as the incoming request body contains valid JSON such as the following:
{
"UserID": 111,
"Address": {
"Street": "123 road",
"City": "New York",
"Country": "USA"
}
}
Would be auto bound to a complex type with matching property names such as this:
public class UpdateAddressRequest
{
public int UserID { get; set; }
public Address Address { get; set; }
public class Address
{
public string Street { get; set; }
public string City { get; set; }
public string Country { get; set; }
}
}
Binding JSON Arrays
JSON arrays in the request body can be bound to models by specifying the request DTO type of the endpoint as List<T> like so:
public class MyEndpoint : Endpoint<List<Address>>
{
...
}
Binding To A DTO Property
In cases where you need to bind the incoming JSON body to a property of the request DTO, simply decorate the property to be bound to with a [FromBody] attribute like below.
public class UpdateAddressRequest
{
[FromBody]
public Address Address { get; set; }
}
In which case the JSON request body must be as follows (with the address object at the root level):
{
"Street": "123 road",
"City": "new york",
"Country": "usa"
}
Mismatched Property Names
When the incoming JSON field name does not match with the request DTO property name, you can use the [JsonPropertyName] attribute to instruct the serializer on how to match fields.
[JsonPropertyName("address")]
public Address UserAddress { get; set; }
Serializer Options
Custom Converters
Most complex types can be bound as long as the System.Text.Json serializer can handle it. If it's not supported out of the box, please see the STJ documentation on how to implement custom converters for your types. Those converters can be registered in startup as follows:
app.UseFastEndpoints(c =>
{
c.Serializer.Options.Converters.Add(new CustomConverter());
});
Serialization Casing
By default the serializer uses camel casing for serializing/deserializing. You can change the casing as shown in the configuration settings section.
Source Generators
The System.Text.Json source generator support can be easily enabled with a simple 2 step process:
Step #1 : Create A Serializer Context
[JsonSerializable(typeof(RequestModel))]
[JsonSerializable(typeof(ResponseModel))]
public partial class UpdateAddressCtx : JsonSerializerContext { }
Step #2 : Specify The Serializer Context For The Endpoint
public class UpdateAddress : Endpoint<RequestModel, ResponseModel>
{
public override void Configure()
{
Post("user/address");
SerializerContext(UpdateAddressCtx.Default);
}
}
If you'd like to use the same set of SerializerOptions for the context, simply use the overload that takes the type of the context instead of supplying an instance like above.
SerializerContext<UpdateAddressCtx>();
From Form Fields
Form fields from the incoming request can be bound automatically to the request DTO properties. See here for the types of properties supported and how to add support for your own types.
Make sure to enable form data submissions by doing the following:
public override void Configure()
{
...
AllowFormData();
}
Doing so would allow incoming requests with a content-type header value of "multipart/form-data" to be processed. For sending url encoded form content, simply do the following:
AllowFormData(urlEncoded: true);
The AllowFormData() method is nothing more than a convenient shortcut for the following which adds Accepts metadata to the endpoint:
public override void Configure()
{
...
Description(x => x.Accepts<MyRequest>("application/x-www-form-urlencoded"));
}
File/binary content uploads are explained here.
Accepted Form Data Formats
The incoming form data can be in any of the following formats:
Scalar Values
Collections
JSON Objects
JSON Arrays
Collections, JSON arrays and objects are deserialized using the System.Text.Json.JsonSerializer. Values can be bound to any DTO property type that STJ supports deserializing to. If the target DTO property type is not supported out of the box, you can register a custom converter.
Mismatched Property Names
If the incoming field name is different from the name of the DTO property being bound to, simply specify the name of the incoming field using the [BindFrom] attribute like so:
[BindFrom("customer_id")]
public string CustomerID { get; set; }
Binding Nested Complex Form Data
Deeply nested complex DTOs can be bound from incoming form-data given that the form-fields are named correctly. Assume we have a request DTO structure such as the following:
sealed class UpdateBookRequest
{
[FromForm]
public Book Book { get; set; } // complex type to bind from form data
}
sealed class Book
{
public string Title { get; set; } // one primitive value
public List<int> BarCodes { get; set; } // multiple primitive values
public IFormFile Cover { get; set; } // one file
public IFormFileCollection AlternateCovers { get; set; } // multiple files
public IEnumerable<Author> Authors { get; set; } // multiple complex values
}
sealed class Author
{
public string Name { get; set; }
public IFormFile ProfilePicture { get; set; }
public ICollection<IFormFile> Agreements { get; set; }
}
A single root level DTO property must be annotated with the attribute [FromForm] which hints to the binder to look for the source data in the incoming form. The form-data must be constructed like so:
curl --location 'http://localhost:5000/api/book' \
--form 'Title="book title"' \
--form 'BarCodes="12345"' \
--form 'BarCodes="54321"' \
--form 'Cover=@"/cover.jpg"' \
--form 'AlternateCovers=@"/alt-cover-1.jpg"' \
--form 'AlternateCovers=@"/alt-cover-2.jpg"' \
--form 'Authors[0].Name="author 1 name"' \
--form 'Authors[0].ProfilePicture=@"/author-1-profile.jpg"' \
--form 'Authors[0].Agreements=@"/author-1-agreement-1.pdf"' \
--form 'Authors[0].Agreements=@"/author-1-agreement-2.pdf"'
Field Naming Convention:
- Use multiple form fields with the same name for binding to collection properties of scalar values such as the BarCodes property.
- Use indexing for referring to properties of complex types of a collection, such as in the case of the Authors property.
- Indexing should not be used for files, and should use duplicate field names if they are to be bound to a IFormFile collection property such as with the AlternateCovers, and Author.Agreements properties.
From Route Parameters
Route parameters can be bound to properties on the DTO using route templates like you'd typically do.
public class MyRequest
{
public string MyString { get; set; }
public bool MyBool { get; set; }
public int MyInt { get; set; }
public long MyLong { get; set; }
public double MyDouble { get; set; }
public decimal MyDecimal { get; set; }
}
public class MyEndpoint : Endpoint<MyRequest>
{
public override void Configure()
{
Get("/api/{MyString}/{MyBool}/{MyInt}/{MyLong}/{MyDouble}/{MyDecimal}");
}
}
If a GET request is made to the url: /api/hello world/true/123/12345678/123.45/123.4567
The request DTO would have the following property values:
MyString: "hello world"
MyBool: true
MyInt: 123
MyLong: 12345678
MyDouble: 123.45
MyDecimal: 123.4567
Mismatched Property Names
If route param name is different from the name of the DTO property being bound to for whatever reason, simply specify the name of the param using the [BindFrom] attribute like so:
[BindFrom("my_string")]
public string MyString { get; set; }
Route Values Without a DTO
If your endpoint doesn't have a request DTO, you can easily read route parameters using the Route<T>() endpoint method as shown below.
public class GetArticle : EndpointWithoutRequest
{
public override void Configure() => Get("/article/{ArticleID}");
public override Task HandleAsync(CancellationToken ct)
{
//http://localhost:5000/article/123
int articleID = Route<int>("ArticleID");
}
}
Route<T>() method is only able to handle types that have a static TryParse() method. See here on how to add parsing support for your own types.
If there's no static TryParse() method or if parsing fails, an automatic validation failure response is sent to the client. This behavior can be turned off with the following overload:
Route<Guid>("ArticleID", isRequired: false);
From Query Parameters
Query string parameters from the incoming request are bound automatically to the request DTO properties. See here for the types of properties supported and how to add support for your own types. Even though attribute annotations are not necessary for binding from query parameters, generating the correct Swagger spec does however require an attribute.
Accepted Query Formats
The incoming query params can be in any of the following formats:
Scalar Values
?UserID=123&Age=45
Collections
?UserIDs=123&UserIDs=456
?VoucherIDs[0]=101&VoucherIDs[1]=102
JSON Objects
?User={"Name":"Betty","Age":23}
?Address={"Street":"23 Mulholland Drive","City":"LA"}
JSON Arrays
?ActorNames=["Tony Curtis","Jack Lemon","Natalie Wood"]
?Users=[{"Name":"User1"},{"Name":"User2"}]
Collections, JSON arrays and objects are deserialized using the System.Text.Json.JsonSerializer. Values can be bound to any DTO property type that STJ supports deserializing to. If the target DTO property type is not supported out of the box, you can register a custom converter.
Mismatched Property Names
If the incoming query param name is different from the name of the DTO property being bound to, simply specify the name of the param using the [BindFrom] attribute like so:
[BindFrom("customer_id")]
public string CustomerID { get; set; }
Query Params Without a DTO
If your endpoint doesn't have a request DTO, you can easily read query parameters using the Query<T>() endpoint method as shown below.
public class GetArticle : EndpointWithoutRequest
{
public override void Configure() => Get("/article");
public override Task HandleAsync(CancellationToken ct)
{
//http://localhost:5000/article?id=123
int articleID = Query<int>("id");
}
}
Query<T>() method is only able to handle types that have a static TryParse() method. See here on how to add parsing support for your own types.
If there's no static TryParse() method or if parsing fails, an automatic validation failure response is sent to the client. This behavior can be turned off with the following overload:
Query<int>("id", isRequired: false);
From User Claims
User claim values can be bound to request DTO properties simply by decorating it with the [FromClaim] attribute like so:
public class GetUserRequest
{
[FromClaim]
public string UserID { get; set; }
}
Optional Claims
By default if the user does not have a claim type called UserID, then a validation error will be sent automatically to the client. You can make the claim optional by using the following overload of the attribute:
[FromClaim(IsRequired = false)]
Doing so will allow the endpoint handler to execute even if the current user doesn't have the specified claim and model binding will take the value from the highest priority source of the other binding sources mentioned above.
Accepted Claim Value Formats
The claim values can be in any of the following formats:
Scalar Values
Collections
JSON Objects
JSON Arrays
Collections, JSON arrays and objects are deserialized using the System.Text.Json.JsonSerializer. Values can be bound to any DTO property type that STJ supports deserializing to. If the target DTO property type is not supported out of the box, you can register a custom converter.
Mismatched Property Names
If the user's Claim Type is different from the name of the DTO property being bound to, simply specify the Claim Type using the following overload of the attribute like so:
[FromClaim("user-id")]
public string UserID { get; set; }
From Headers
HTTP header values can be bound to request DTO properties by decorating it with the [FromHeader] attribute.
public class GetUserRequest
{
[FromHeader]
public string TenantID { get; set; }
}
Optional Headers
By default if the request does not have a header called TenantID, then a validation error will be sent automatically to the client. You can make the header optional by using the following overload of the attribute:
[FromHeader(IsRequired = false)]
Doing so will allow the endpoint handler to execute even if the current request doesn't have the specified header and model binding will take the value from the highest priority source of the other binding sources mentioned above.
Accepted Header Formats
Header values can be in any of the following formats:
Scalar Values
Collections
JSON Objects
JSON Arrays
Collections, JSON arrays and objects are deserialized using the System.Text.Json.JsonSerializer. Values can be bound to any DTO property type that STJ supports deserializing to. If the target DTO property type is not supported out of the box, you can register a custom converter.
Mismatched Property Names
If header name is different from the name of the DTO property being bound to, simply specify the name of the header using the following overload of the attribute like so:
[FromHeader("client-id")]
public string ClientID { get; set; }
Has Permissions
The [HasPermission] attribute can be used on boolean properties to check if the current user principal has a particular permission like so:
public class UpdateArticleRequest
{
[HasPermission("Article_Update")]
public bool AllowedToUpdate { get; set; }
}
The property value will be set to true if the current principal has the Article_Update permission. An automatic validation error will be sent in case the principal does not have the specified permission. You can disable the automatic validation error by doing the following:
[HasPermission("Article_Update", IsRequired = false)]
Supported DTO Property Types
Only applies to form fields, route/query params, claims & headers. Is irrelevant for JSON binding.
Simple scalar values can be bound automatically to any of the primitive/CLR non-collection types such as the following that has a static TryParse() method:
- bool
- double
- decimal
- DateTime
- Enum
- Guid
- int
- long
- string
- TimeSpan
- Uri
- Version
In order to support binding your custom types from route/query/claims/headers/form fields, simply add a static TryParse() method to your type like in the example below:
public class Point
{
public double X { get; set; }
public double Y { get; set; }
public static bool TryParse(string? input, out Point? output) //adhere to this signature
{
output = null;
if (string.IsNullOrEmpty(input))
{
return false;
}
var parts = input.Split(',');
if (!double.TryParse(parts[0], out var x) ||
!double.TryParse(parts[1], out var y))
{
return false;
}
output = new Point
{
X = x,
Y = y
};
return true;
}
}
If the Point is a struct type, you'd have to slightly change the method signature to make the out parameter non-nullable like so:
public static bool TryParse(string? input, out Point output)
Custom Value Parsers
As an alternative approach to adding a static TryParse() method to your types in order to support binding from route/query/claims/headers/form fields, it is possible to register a custom value parser function per type at startup.
app.UseFastEndpoints(c =>
{
c.Binding.ValueParserFor<Guid>(MyParsers.GuidParser);
});
public static class MyParsers
{
public static ParseResult GuidParser(object? input)
{
bool success = Guid.TryParse(input?.ToString(), out var result);
return new (success, result);
}
}
The parser is a function that takes in a nullable object and returns a ParseResult struct, which is just a DTO that holds a boolean indicating whether the parsing was successful or not as well as the parsed result object.
This method can be used for any type which, if configured will take precedence over the static TryParse() method. This can be considered analogous to registering a custom converter in STJ. Do note these value parsers do not apply to JSON deserialization and only applies to non-STJ operations.
DIY Request Binding
You can override the request model binding behavior at a few different levels and methods as follows:
- Register a custom request binder at global level which all endpoints will use unless otherwise specified.
- Write a custom request binder per request dto type and register it with one or more endpoints.
- Inherit the default model binder and register it either as the global binder or per endpoint.
- Specify a binding modifier function to be applied to endpoints of your choice.
Global Request Binder
In order to configure a global request binder, implement the interface IRequestBinder<TRequest> and create an open generic custom binder like below:
public class MyRequestBinder<TRequest> : IRequestBinder<TRequest> where TRequest : notnull, new()
{
public async ValueTask<TRequest> BindAsync(BinderContext ctx, CancellationToken ct)
{
if (ctx.HttpContext.Request.HasJsonContentType())
{
var req = await JsonSerializer.DeserializeAsync<TRequest>(
ctx.HttpContext.Request.Body, ctx.SerializerOptions, ct);
if (req is IHasTenantId r)
r.TenantId = ctx.HttpContext.Request.Headers["x-tenant-id"];
return req!;
}
return new();
}
}
then register it with the IOC container before the AddFastEndpoints() call like so:
bld.Services.AddSingleton(typeof(IRequestBinder<>), typeof(MyRequestBinder<>));
bld.Services.AddFastEndpoints(); //this must come after
Doing the above will replace the default request binder globally and all endpoints will use your binder unless they specify their own binders as explained below.
Endpoint Level Binders
You can create your own concrete binder like below (or even generic binders) and instruct the endpoint to use that instead of the global level request binder.
public class MyBinder : IRequestBinder<MyRequest>
{
public async ValueTask<MyRequest> BindAsync(BinderContext ctx, CancellationToken ct)
{
// populate and return a request dto object however you please...
return new MyRequest
{
Id = ctx.HttpContext.Request.RouteValues["id"]?.ToString()!,
CustomerID = ctx.HttpContext.Request.Headers["CustomerID"].ToString()!,
Product = await JsonSerializer.DeserializeAsync<Product>(
ctx.HttpContext.Request.Body,
new JsonSerializerOptions(),
ct)
};
}
}
In order to use the above binder with your endpoint, simply register it during configuration like so:
public class Endpoint : Endpoint<Request, Response>
{
public override void Configure()
{
Post("/my-endpoint");
RequestBinder(new MyBinder());
}
}
Inherit The Default Binder
Instead of implementing your own binder from scratch as above, it's possible to inherit the built-in default binder like so:
public class MyBinder : RequestBinder<Request>
{
public async override ValueTask<Request> BindAsync(BinderContext ctx, CancellationToken ct)
{
await base.BindAsync(ctx, ct);
// do your binding here
}
}
Binding Modifier Function
A global binding modifier function can be applied to request DTOs of your choice which will be run after the registered request binder has done it's work:
app.UseFastEndpoints(c => c.Binding.Modifier = (req, tReq, ctx, ct) =>
{
if (req is IHasRole r)
{
r.Role = ctx.HttpContext.User.ClaimValue(ClaimTypes.Role) ?? "Guest";
}
});
This function is called for each HTTP request during model binding with the following input parameters:
object: the request dto instance
Type: the type of the request dto
BinderContext: request binder context
CancellationToken: cancellation token
Binding Raw Request Content
If you need to access the raw request content as a string, you can achieve that by implementing the interface IPlainTextRequest on your DTO like so:
public class Request : IPlainTextRequest
{
public string Content { get; set; }
}
When your DTO implements IPlainTextRequest interface, JSON model binding will be skipped. Instead, the Content property is populated with the body content of the request. Other properties can also be added to your DTO in case you need to access some other values like route/query/form fields/headers/claim values.