Search code examples
c#asp.net-core-mvctaskaction

415 Status When Passing Model into Controller Action in ASP.NET Core 3.1 MVC


I've seen many tutorials and documentation pass the model as an argument in an Action in a controller. Every time I do this I get a 415 status error (incorrect media type) when the action is called. This is problematic for me because my fields clear after the action occurs. Many have suggested calling the model when I return the View, but that has not been working for me. Does anyone know why that is and how I can fix it? I'm so frustrated I've tried so many things and it just never works :(

Example of how I want to pass the model as an argument:

[HttpGet("[action]")]
public async Task<IActionResult> Search(Movies model, int ID, string titleSearch, 
    string genreSearch)
{

    return View(model);
}

My View:

@model IEnumerable<MyApp.Models.Movies>

@{ 
    ViewData["Title"] = "Movies";
}

<form method="get" role="form" asp-controller="MoviesList" asp-action="Index">
    <label>Movie Genre</label>
    <select name="movieGenre" asp-items="@(new SelectList(ViewBag.genre, "ID", "Genre"))"></select>

    <label>Movie Title</label>
    <input type="search" value="@ViewData["movieTitle"]" name="movieTitle" />

    <input type="submit" value="Search" asp-controller="MoviesList" asp-action="Search" />
</form>

<input type="hidden" name="ID" value="@ViewBag.pageID"

<table>
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(m => m.Title)
            </th>
            <th>
                @Html.DisplayNameFor(m => m.Genre)
            </th>
        </tr>
    </thead>
    <tbody>
        @foreach(var item in Model)
        {
            <tr>
                <th>
                    @Html.DisplayFor(modelItem => item.Title)
                </th>
                <th>
                    @Html.DisplayFor(modelItem => item.Genre)
                </th>
            </tr>
        }
    </tbody>
</table>

My Controller:

//This action is called when the page is first called
[HttpGet("[action]")]
[Route("/MoviesList/Index/id")]
public async Task<IActionResult> Index(int id)
{
    //using ViewBag to set the incoming ID and save it in the View
    //so that I can access it from my search action
    ViewBag.pageID = id;
    //calling a query to load data into the table in the View
    //var query = query

    return View(await query);
}

//searching the movies list with this action
[HttpGet("[action]")]
public async Task<IActionResult> Search(int ID, string titleSearch, string genreSearch)
{
    int id = ID;
    ViewData["titleSearch"] = titleSearch;

    //do some necessary conversions to the incoming data (the dropdowns for example come in as 
    //integers that match their value in the DB

    var query = from x in _db.Movies
                .Where(x => x.Id == id)
                select x;

    //some conditionals that check for null values
    //run the search query
    query = query.Where(x =>
    x.Title.Contains(titleSearch) &&
    x.Genre.Contains(genreSearch));

    //when this return happens, I do get all of my results from the search,
    //but then all of the fields reset & my hidden ID also resets
    //this is problematic if the user decides they want to search again with 
    //different entries
    return View("Index", await query.AsNoTracking().ToListAsync());
}

Overall, my goal is to not have any of the fields clear after my action is complete, and allow the user to re-call the action with new entries. From my understanding, passing the model as an argument can help me achieve my goal, but I haven't had any luck. Please let me know how I can achieve this goal. Thank you for your time!


Solution

  • There are so many things wrong in your code. I am not sure where to start but will try my best to list out a few:

    1. Use of [HttpGet]
    2. Use of Attribute Routing, [Route]
    3. Form post
    4. Overuse of ViewBag

    1. Use of [HttpGet]

    I don't want to say the way you used [HttpGet] passing a name as the parameter is wrong, but your setup will always ignore the controller name!

    The [action] you passed in is call token replacement, which will be replaced with the value of the action name so:

    /*
     * [HttpGet("[action]")] on Search action  =>  [HttpGet("search")]  =>  matches /search
     * [HttpGet("[action]")] on Index action   =>  [HttpGet("index")]   =>  matches /index
     */
    

    See how wrong that is! You're missing the controller name!

    A request /moviesList/index will not call the Index method from the MoviesList controller, but a request /index will!

    Just take out the template/token replacement parameter. And by default, if you don't mark the controller action with any HTTP verb templates, i.e., [HttpGet], they're default to handle HTTP GET requests.


    2. Use of Attribute Routing, [Route]

    I don't want to say using attribute routing in a Model-View-Controller application is wrong, but attribute routing is used mostly when you're building a RESTful API application.

    By default, the app is setup to use the conventional routing, which should come with the template when you first create your application:

    namespace DL.SO.SearchForm.WebUI
    {
        public class Startup
        {
            public void ConfigureServices(IServiceCollection services)
            {
                ...
            }
    
            public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
            {
                ...
    
                app.UseEndpoints(endpoints =>
                {
                    endpoints.MapControllerRoute(
                        name: "default",
                        pattern: "{controller=Home}/{action=Index}/{id?}");
                });            
            }
        }
    }
    

    The way you used [Route] attribute gives me an impression that you don't know what they're or at least you are confused. With the conventional routing, even if you don't put [Route] on the controllers, the following requests should arrive to their corresponding controller actions by the "default" routing:

    /*
     * /moviesList/index     GET    =>    MoviesList controller, Index action
     * /moviesList/search    GET    =>    MoviesList controller, Search action
     */
    

    By the way, a controller named MoviesListController is awful. I will just call it MovieController.


    3. Form Post

    Within the form, you can't specify a controller and the action on the submit button. It's not an anchor tag anyway.

    And <input type="hidden" name="ID" value="@ViewBag.pageID" is outside the form. How would the form know what that is and post the correct value back?


    4. Overuse of ViewBag / ViewData

    Technically you can only use ViewBag to transfer data between controller to view. ViewData is only valid in the current request, and you can only transfer data from controller to view, not vice-versa.

    In additional, they're so-called weakly typed collections. They're designed to transfer small amount of data in and out of controllers and views, like the page title. If you overuse them, your applications will become so hard to maintain as you have to remember what type the data is when using it.

    By overusing ViewBag / ViewData, you're basically removing one of the best features about C# & Razor - strongly typed.

    The best approach is to specify a view model in the view. You pass an instance of the view model to the view from the controller action. The view model defines only the data the view needs! You should not pass your entire database model to the view so that users can use your other important information!



    My approach

    Instead of using a single method to handle listing all the movies as well as the search filters, I would like to separate them. The search form will be using [HttpPost] instead of [HttpGet].

    That way I will only need to post back the search filters data, and I can now define custom parameters on the Index action and have the Post action redirect to the Index action.

    I will show you what I mean.

    View Models

    First I will define all the view models I need for the view:

    namespace DL.SO.SearchForm.WebUI.Models.Movie
    {
        // This view model represents each summarized movie in the list.
        public class MovieSummaryViewModel
        {
            public int MovieId { get; set; }
    
            public string MovieTitle { get; set; }
    
            public string MovieGenre { get; set; }
    
            public int MovieGenreId { get; set; }
        }
    
        // This view model represents the data the search form needs
        public class MovieListSearchViewModel
        {
            [Display(Name = "Search Title")]
            public string TitleSearchQuery { get; set; }
    
            [Display(Name = "Search Genre")]
            public int? GenreSearchId { get; set; }
    
            public IDictionary<int, string> AvailableGenres { get; set; }
        }
    
        // This view model represents all the data the Index view needs
        public class MovieListViewModel
        {
            public MovieListSearchViewModel Search { get; set; }
    
            public IEnumerable<MovieSummaryViewModel> Movies { get; set; }
        }
    }
    

    The Controller

    Next, here comes the controller:

    One thing to pay attention here is that you have to name the POST action parameter the same way as you define it in the view model, like so MovieListSearchViewModel search.

    You can't name the parameter name something else because we're posting partial view model back to MVC, and by default, the model binding will only bind the data for you if it matches the name.

    namespace DL.SO.SearchForm.WebUI.Controllers
    {
        public class MovieController : Controller
        {
            // See here I can define custom parameter names like t for title search query,
            // g for searched genre Id, etc
            public IActionResult Index(string t = null, int? g = null)
            {
                var vm = new MovieListViewModel
                {
                    Search = new MovieListSearchViewModel
                    {
                        // You're passing whatever from the query parameters
                        // back to this search view model so that the search form would
                        // reflect what the user searched!
                        TitleSearchQuery = t,
                        GenreSearchId = g,
    
                        // You fetch the available genres from your data sources, although
                        // I'm faking it here.
                        // You can use AJAX to further reduce the performance hit here
                        // since you're getting the genre list every single time.
                        AvailableGenres = GetAvailableGenres()
                    },
    
                    // You fetch the movie list from your data sources, although I'm faking
                    // it here.
                    Movies = GetMovies()
                };
    
                // Filters
                if (!string.IsNullOrEmpty(t))
                {
                    // Filter by movie title
                    vm.Movies = vm.Movies
                        .Where(x => x.MovieTitle.Contains(t, StringComparison.OrdinalIgnoreCase));
                }
    
                if (g.HasValue)
                {
                    // Filter by movie genre Id
                    vm.Movies = vm.Movies
                        .Where(x => x.MovieGenreId == g.Value);
                }
    
                return View(vm);
            }
    
            [HttpPost]
            [ValidateAntiForgeryToken]
            // You have to name the paramter "Search" as you named so in its parent
            // view model MovieListViewModel
            public IActionResult Search(MovieListSearchViewModel search)
            {
                // This is the Post method from the form.
                // See how I just put the search data from the form to the Index method.
                return RedirectToAction(nameof(Index), 
                    new { t = search.TitleSearchQuery, g = search.GenreSearchId });
            }
    
            #region Methods to get fake data
    
            private IEnumerable<MovieSummaryViewModel> GetMovies()
            {
                return new List<MovieSummaryViewModel>
                {
                    new MovieSummaryViewModel
                    {
                        MovieId = 1,
                        MovieGenreId = 1,
                        MovieGenre = "Action",
                        MovieTitle = "Hero"
                    },
                    new MovieSummaryViewModel
                    {
                        MovieId = 2,
                        MovieGenreId = 2,
                        MovieGenre = "Adventure",
                        MovieTitle = "Raiders of the Lost Ark (1981)"
                    },
                    new MovieSummaryViewModel
                    {
                        MovieId = 3,
                        MovieGenreId = 4,
                        MovieGenre = "Crime",
                        MovieTitle = "Heat (1995)"
                    },
                    new MovieSummaryViewModel
                    {
                        MovieId = 4,
                        MovieGenreId = 4,
                        MovieGenre = "Crime",
                        MovieTitle = "The Score (2001)"
                    }
                };
            }
    
            private IDictionary<int, string> GetAvailableGenres()
            {
                return new Dictionary<int, string>
                {
                    { 1, "Action" },
                    { 2, "Adventure" },
                    { 3, "Comedy" },
                    { 4, "Crime" },
                    { 5, "Drama" },
                    { 6, "Fantasy" },
                    { 7, "Historical" },
                    { 8, "Fiction" }
                };
            }
    
            #endregion
        }
    }
    

    The View

    Finally here comes the view:

    @model DL.SO.SearchForm.WebUI.Models.Movie.MovieListViewModel
    @{ 
        ViewData["Title"] = "Movie List";
    
        var genreDropdownItems = new SelectList(Model.Search.AvailableGenres, "Key", "Value");
    }
    
    <h2>Movie List</h2>
    <p class="text-muted">Manage all your movies</p>
    <div class="row">
        <div class="col-md-4">
            <div class="card">
                <div class="card-body">
                    <form method="post" asp-area="" asp-controller="movie" asp-action="search">
                        <div class="form-group">
                            <label asp-for="Search.GenreSearchId"></label>
                            <select asp-for="Search.GenreSearchId"
                                    asp-items="@genreDropdownItems"
                                    class="form-control">
                                <option value="">- select -</option>
                            </select>
                        </div>
                        <div class="form-group">
                            <label asp-for="Search.TitleSearchQuery"></label>
                            <input asp-for="Search.TitleSearchQuery" class="form-control" />
                        </div>
                        <button type="submit" class="btn btn-success">Search</button>
                    </form>
                </div>
            </div>
        </div>
        <div class="col-md-8">
            <div class="table-responsive">
                <table class="table table-hover">
                    <thead>
                        <tr>
                            <th>#</th>
                            <th>Title</th>
                            <th>Genre</th>
                        </tr>
                    </thead>
                    <tbody>
                        @if (Model.Movies.Any())
                        {
                            foreach (var movie in Model.Movies)
                            {
                                <tr>
                                    <td>@movie.MovieId</td>
                                    <td>@movie.MovieTitle</td>
                                    <td>@movie.MovieGenre</td>
                                </tr>
                            }
                        }
                        else
                        {
                            <tr>
                                <td colspan="3">No movie matched the searching citiria!</td>
                            </tr>
                        }
                    </tbody>
                </table>
            </div>
        </div>
    </div>
    

    Screenshots

    When you first land on the Movies page:

    enter image description here

    The available Genre list as well as the movie list is shown correctly:

    enter image description here

    Search by Genre:

    enter image description here

    Search by Title:

    enter image description here