Search
K
  1. Model Binding

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:

  1. JSON Body
  2. Form Fields
  3. Route Parameters
  4. Query Parameters
  5. User Claims (if property has [FromClaim] attribute)
  6. HTTP Headers (if property has [FromHeader] attribute)
  7. Permissions (if boolean property has [HasPermission] attribute)

Consider the following HTTP call and request DTO:

HTTP Request
  route: /api/user/{UserID}
  url: /api/user/54321
  json: { "UserID": "12345" }
GetUserRequest.cs
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:

json
{
  "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:

UpdateAddressRequest.cs
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:

MyEndpoint.cs
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.

UpdateAddressRequest.cs
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):

json
{
  "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:

Program.cs
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

UpdateAddressCtx.cs
[JsonSerializable(typeof(RequestModel))]
[JsonSerializable(typeof(ResponseModel))]
public partial class UpdateAddressCtx : JsonSerializerContext { }

Step #2 : Specify The Serializer Context For The Endpoint

UpdateAddress.cs
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

Form Field Value
Username Mark
Age 28

Collections

Form Field Value
UserIDs 1
UserIDs 2
VoucherIDs[0] 101
VoucherIDs[1] 102
DiscountCodes[] ABC
DiscountCodes[] DEF

JSON Objects

Form Field Value
User { "Name" : "Betty Elms" , "Age" : 23 }
Address { "Street" : "23 Mulholland Drive" , "City" : "LA" }

JSON Arrays

Form Field Value
ActorNames [ "Tony Curtis" , "Jack Lemon", "Natalie Wood" ]
Users [ { "Name" : "User1" } , { "Name" : "User2" } ]
INFO

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.

MyRequest.cs
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; }
}
Endpoint.cs

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.

GetArticle.cs
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");
    }
}
INFO

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"}]
INFO

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.

GetArticle.cs
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");
    }
}
INFO

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:

GetUserRequest.cs
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

Claim Type Claim Value
UserID X1919
Verified true

Collections

Claim Type Claim Value
Roles Admin
Roles Manager
Emails [email protected]
Emails [email protected]

JSON Objects

Claim Type Claim Value
User { "Name" : "Betty Elms" , "Age" : 23 }
Address { "Street" : "23 Mulholland Drive" , "City" : "LA" }

JSON Arrays

Claim Type Claim Value
Holidays [ "Saturday" , "Tuesday" ]
Phones [ { "Number" : "0283957598" } , { "Number" : "0283957598" } ]
INFO

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.

GetUserRequest.cs
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

Header Value
TenantID X111
IsMobileClient true

Collections

Header Value
Cache-Control no-cache
Cache-Control no-store
Accept-Encoding gzip
Accept-Encoding br

JSON Objects

Header Value
User { "Name" : "Betty Elms" , "Age" : 23 }
Address { "Street" : "23 Mulholland Drive" , "City" : "LA" }

JSON Arrays

Header Value
APIKeys [ "XYZ" , "DEF" ]
Machines [ { "Id" : "564" } , { "Id" : "835" } ]
INFO

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:

UpdateArticleRequest.cs
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:

Point.cs
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:

  1. Register a custom request binder at global level which all endpoints will use unless otherwise specified.
  2. Write a custom request binder per request dto type and register it with one or more endpoints.
  3. Inherit the default model binder and register it either as the global binder or per endpoint.
  4. 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:

MyRequestBinder.cs
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:

Program.cs
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.

MyBinder.cs
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:

MyEndpoint.cs
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:

Program.cs
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:

Request.cs
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.


© FastEndpoints 2024