ASP.NET Core and Ajax

Programming
Published on September 5, 2024
Article Image

Introduction

When changing a program from a regular form submission to an asynchronous process in ASP.NET Core Razor Pages, it's quite common for something that used to work to stop working (is it just me?). This document aims to organize the relationship between ASP.NET Core's model binding and Ajax requests as a personal memo.

The key points are:

  • Request data format and Content-Type header
  • Server-side definitions (meaning of attributes like [BindProperty] and [FromBody])

If the request and server-side definitions are not consistent, data cannot be received correctly. In many cases, it doesn't even result in a runtime error, making it difficult to notice inconsistencies. Let's start by looking at the basics of model binding.

Razor Pages Model Binding

Basic Model Binding Code

Form submission and Razor Pages model binding are handled with code like this:

public class HogeClass
{
    public string Name { get; set; } = "Hoge";
    public int Age { get; set; } = 18;
}

public class HogePageModel : PageModel
{
    [BindProperty]
    public HogeClass Hoge { get; set; } = new();

    public void OnPost()
    {
        Console.WriteLine($"Name: {Hoge.Name}, Age: {Hoge.Age}");
    }
}
@page
@model HogePageModel
<form method="post">
    <input asp-for="@Model.Hoge.Name" />
    <input asp-for="@Model.Hoge.Age" />
    <button type="submit">Post</button>
</form>

The [BindProperty] attribute is used to automatically bind (map) HTTP request POST data (form input values) to the Hoge property.

When executed, a page like this will be displayed (Figure 1. Page display).

Figure 1. Page displayFigure 1. Page display

When launched in debug mode, enter values into the form and click the Post button. You can confirm that the OnPost method is called and Hoge.Name and Hoge.Age are set to the input values.

HTML

Let's check the content of the generated HTML.

<form method="post">
    <input type="text" id="Hoge_Name" name="Hoge.Name" value="Hoge" />
    <input type="number" id="Hoge_Age" name="Hoge.Age" value="18" />
    <button type="submit">Post</button>
    <input name="__RequestVerificationToken" type="hidden" value="XXXXXX・・・(省略)" />
</form>

ASP.NET Core automatically generates id, name, and value attributes.

  • id: Model property names are converted with _ as a delimiter (Hoge.Name → Hoge_Name).
    → Used in JavaScript or label tags (<label for="...">).

  • name: Field name for model binding (Hoge.Name).
    → Maps values to the Hoge.Name property on the server side during POST.

  • value: Current value of the model ("Hoge").
    → Initial value of the form when the page is displayed.

The automatically generated name attribute serves as the key to specify the server-side property name for binding.

  • __RequestVerificationToken
    This is a CSRF protection token automatically generated by ASP.NET Core. It is not directly related to model binding, but without this token, the request will be judged as invalid, the server-side processing will not be executed, and a 400 (Bad Request) response will be returned.

Request Data

Next, let's examine the content of the request data.
As an example, if "Fuga" is entered for Name and 25 for Age:

Content-type: application/x-www-form-urlencoded

Request Body: Hoge.Name=Fuga&Hoge.Age=25&__RequestVerificationToken=CfDJ8PYW-uS7dDZDn6LlNPE8uvosZhCcbGyYoNCYQFcq6heF05q5Q6DN3n6FllCtn9wclH7flJVoHqPZBIj-TC9Q0MunNm1mKLy-0E4p6GOU0zcX9pVTjEQgZvWWJzLJpcn60xHjBytC6p38h83g8WYvFN4
  • Content-type
    The Content-type is a header that indicates the data format of the request body. The server interprets the request body data based on this specification.

  • application/x-www-form-urlencoded
    Despite its long name, this is known as URL-encoded format or form data format, which is the standard encoding format for sending data from a browser. Data is represented as "key=value" pairs, and each pair is concatenated with an & symbol.

  • Request Body
    A URL-encoded string data is sent. If split by the & symbol:

    Hoge.Name=Fuga
    Hoge.Age=25
    __RequestVerificationToken=CfDJ8PYW-uS7dDZDn6LlNPE8uvosZhCcbGyYoNCYQFcq6heF05q5Q6DN3n6FllCtn9wclH7flJVoHqPZBIj-TC9Q0MunNm1mKLy-0E4p6GOU0zcX9pVTjEQgZvWWJzLJpcn60xHjBytC6p38h83g8WYvFN4
    

    The key used is the name attribute from the HTML, and the condition for binding is that Hoge.Name matches the C# property name Hoge.Name.

fetch

Code

Let's change to an Ajax process using fetch.
There is no need to change the .cs file.
Modify the .cshtml file as follows:

@page
@model HogePageModel
<form method="post">
    <input asp-for="@Model.Hoge.Name" />
    <input asp-for="@Model.Hoge.Age" />
    <button type="button" id="button">Post</button>
</form>
<script>
    document.getElementById('button').addEventListener('click', () => {
        const form = document.querySelector('form');
        const formData = new FormData(form);

        fetch('/HogePage', {
            method: 'POST',
            body: formData
        });
    });
</script>

Remember to change the type of the button tag to button. The default is submit, so if you don't change it to button, a normal form submission will occur.

Request Data

Let's check the content of the request data.
Similar to the first example, if "Fuga" is entered for Name and 25 for Age:

Content-type: multipart/form-data; boundary=----WebKitFormBoundary5BiMG5LpjEL9onVI

Request Body:
------WebKitFormBoundary5BiMG5LpjEL9onVI
Content-Disposition: form-data; name="Hoge.Name"

Fuga
------WebKitFormBoundary5BiMG5LpjEL9onVI
Content-Disposition: form-data; name="Hoge.Age"

25
------WebKitFormBoundary5BiMG5LpjEL9onVI
Content-Disposition: form-data; name="__Invariant"

Hoge.Age
------WebKitFormBoundary5BiMG5LpjEL9onVI
Content-Disposition: form-data; name="__RequestVerificationToken"

CfDJ8PYW-uS7dDZDn6LlNPE8uvrM_Ia3TWGCbZ7iqw9j5rLr_WI7a2SIQ-KDi9YDccFmw0UXgnkiuhKZTf_BW61bmTmHJgjGctDkT7leMPas7Z9__pMqsKlEgRyrziT5iN-yO2E1f3vATsGFPJ_UHw1cQew
------WebKitFormBoundary5BiMG5LpjEL9onVI--
  • Content-type: multipart/form-data
    When FormData is used, multipart/form-data is automatically set as the Content-type. 1
    multipart/form-data is a Content-type for sending multiple types of data (such as files) from a form. Although file transmission is not necessary in this example, when FormData is used, the request body's data format becomes multipart/form-data, so the Content-type must be multipart/form-data.
  • Request Body
    This is multipart/form-data formatted data. Data is separated by a boundary string (in this example, "----WebKitFormBoundary5BiMG5LpjEL9onVI").

  • Why can it be received?
    The server-side .cs file has not been changed at all, yet data can be received correctly. This is because the [BindProperty] attribute supports both application/x-www-form-urlencoded and multipart/form-data. You might think that any data can be received if [BindProperty] is attached, but that is not the case. Let's look at an example of sending JSON data next.

fetch (JSON)

Code

Change the format of the transmitted data to JSON.

public class HogeClass
{
    public string Name { get; set; } = "Hoge";
    public int Age { get; set; } = 18;
}

public class HogePageModel : PageModel
{
    public HogeClass Hoge { get; set; } = new();

    public void OnPost([FromBody] HogeClass hoge)
    {
        Console.WriteLine($"Name: {hoge.Name}, Age: {hoge.Age}");
    }
}
  • Stop using [BindProperty].
  • Add [FromBody] HogeClass hoge to the OnPost method's arguments.

[BindProperty] does not support JSON data, so it cannot be used.
To receive JSON data, you must apply the [FromBody] attribute to a method argument.
This way, model binding will occur to the method argument hoge, not to a property.

@page
@model HogePageModel
<form method="post">
    <input asp-for="@Model.Hoge.Name" />
    <input asp-for="@Model.Hoge.Age" />
    <button type="button" id="button">Post</button>
</form>
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery antiforgery
@{
    var token = antiforgery.GetAndStoreTokens(HttpContext).RequestToken;
}
<script>
    document.getElementById('button').addEventListener('click', () => {
        const name = document.querySelector('[name="Hoge.Name"]').value;
        const age = parseInt(document.querySelector('[name="Hoge.Age"]').value, 10);

        const data = {
            Name: name,
            Age: age
        };

        fetch('/HogePage', {
            method: "POST",
            headers: {
                'RequestVerificationToken': `@token`,
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(data)
        });
    });
</script>

The modifications to the .cshtml file are as follows:

  • Set the CSRF token in the request header.

    @inject Microsoft.AspNetCore.Antiforgery.IAntiforgery antiforgery
    @{
        var token = antiforgery.GetAndStoreTokens(HttpContext).RequestToken;
    }
    

    Set the CSRF token in the RequestVerificationToken request header. In regular form submissions and multipart/form-data, it was set in the request body, but for JSON data, it will not be interpreted as a token if set in the body, so it must be set in the header.

  • Set Content-Type to application/json.
    Since JSON data is being sent, application/json must be set. If not specified, it will be sent as the default text/plain. If sent as text/plain, the server will not be able to receive it (a NullReferenceException will occur).

  • Use JSON.stringify to convert the JavaScript object into a JSON formatted string before setting it in the body.
    The request body data must be a string. If it remains an object, the server will not be able to receive it (a NullReferenceException will occur).

Request Data

The request data will be as follows:

Content-type: application/json
requestverificationtoken: CfDJ8PYW-uS7dDZDn6LlNPE8uvosZhCcbGyYoNCYQFcq6heF05q5Q6DN3n6FllCtn9wclH7flJVoHqPZBIj-TC9Q0MunNm1mKLy-0E4p6GOU0zcX9pVTjEQgZvWWJzLJpcn60xHjBytC6p38h83g8WYvFN4

Request Body: {"Name":"Fuga","Age":25}

$.ajax

Next is when using jQuery's $.ajax().

The .cs file can use the code from the first example as is.

public class HogeClass
{
    public string Name { get; set; } = "Hoge";
    public int Age { get; set; } = 18;
}

public class HogePageModel : PageModel
{
    [BindProperty]
    public HogeClass Hoge { get; set; } = new();

    public void OnPost()
    {
        Console.WriteLine($"Name: {Hoge.Name}, Age: {Hoge.Age}");
    }
}

The .cshtml file will be as follows:

@page
@model HogePageModel
<form method="post">
    <input asp-for="@Model.Hoge.Name" />
    <input asp-for="@Model.Hoge.Age" />
    <button type="button" id="button">Post</button>
</form>
@section Scripts {
    <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
    <script>
        $('#button').on('click', () => {
            $.ajax({
                type: 'POST',
                url: '/HogePage',
                data: $('form').serialize()
            });
        })
    </script>

Using jQuery's serialize() converts all input values within the form elements into a application/x-www-form-urlencoded formatted string.

Request Data

Content-type: application/x-www-form-urlencoded

Request Body: Hoge.Name=Fuga&Hoge.Age=25&__Invariant=Hoge.Age&__RequestVerificationToken=CfDJ8PYW-uS7dDZDn6LlNPE8uvqnfpJLTuGH7NvfcuVFLJ5_3LMGcH7tqAUE1RwSyMoFfST7WJmloCyFwehhiYiH2TPxF_dnxSkbWDVXrXqhpJhg7f1QxZwyGSUWUpa24AMxeDtSjqxh-nATdZzHh_oyxzs

The default Content-type for $.ajax is application/x-www-form-urlencoded, so it does not need to be explicitly specified.

$.ajax (JSON)

What if we want to use jQuery's $.ajax() while sending data as JSON?

The .cs file will be exactly the same as in the fetch (JSON) example.

public class HogeClass
{
    public string Name { get; set; } = "Hoge";
    public int Age { get; set; } = 18;
}

public class HogePageModel : PageModel
{
    public HogeClass Hoge { get; set; } = new();

    public void OnPost([FromBody] HogeClass hoge)
    {
        Console.WriteLine($"Name: {hoge.Name}, Age: {hoge.Age}");
    }
}

Stop using [BindProperty] and add [FromBody] HogeClass hoge to the OnPost method's arguments.

@page
@model HogePageModel
<form method="post">
	<input asp-for="@Model.Hoge.Name" />
	<input asp-for="@Model.Hoge.Age" />
	<button type="button" id="button">Post</button>
</form>
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery antiforgery
@{
	var token = antiforgery.GetAndStoreTokens(HttpContext).RequestToken;
}
@section Scripts {
	<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
	<script>
		$('#button').on('click', () => {
			const name = document.querySelector('[name="Hoge.Name"]').value;
			const age = parseInt(document.querySelector('[name="Hoge.Age"]').value, 10);

			const data = {
				Name: name,
				Age: age
			};

			$.ajax({
				type: 'POST',
				url: '/HogePage',
				headers: {
					'RequestVerificationToken': '@token'
				},
				contentType: 'application/json',
				data: JSON.stringify(data)
			});
		})
	</script>
}
  • Setting RequestVerificationToken
  • Setting contentType: 'application/json'
  • Converting the object to a string using JSON.stringify

The necessary modifications are almost identical to the fetch (JSON) case.

Request Data

Content-type: application/json
Requestverificationtoken: CfDJ8PYW-uS7dDZDn6LlNPE8uvpeLThm5_Z8LiRmjO6nQ5eUodlnqHbI-kjmnqhLB_NuagWlOn4PWSDy_JEAnV9gNVyrhAULTXJe2-JbRbcAWkZCz1blHS68eF-RVG67CAc9nBXEEIqbRGW6V9V48Md2Vvs

Request Body: {"Name":"Fuga","Age":25}

It can be seen that JSON data is being sent.

Summary

To summarize, the following applies:

Method .cs Data Reception Method Content-Type Sent Data Format
Form Submission (POST) Bind to [BindProperty] property application/x-www-form-urlencoded URL Encoded
fetch Bind to [BindProperty] property multipart/form-data Multipart
fetch (JSON) Bind to [FromBody] argument application/json JSON
$.ajax Bind to [BindProperty] property application/x-www-form-urlencoded URL Encoded
$.ajax (JSON) Bind to [FromBody] argument application/json JSON

To reiterate, the important points are:

  • Match the sent data format with the Content-Type specification.
  • Change the server-side implementation according to the sent data format and Content-Type.

I hope this is helpful.

References

jQuery.ajax()

contentType (default: 'application/x-www-form-urlencoded; charset=UTF-8') Type: Boolean or String When sending data to the server, use this content type. Default is "application/x-www-form-urlencoded; charset=UTF-8",

Fetch POST request

Note that if the body is a string, Content-Type defaults to text/plain. Therefore, we use the headers option to send application/json instead.