DEV Community

Cover image for The problem with HTTP PUT method and possible ways to resolve it
Piotr Nieweglowski
Piotr Nieweglowski

Posted on

The problem with HTTP PUT method and possible ways to resolve it

Introduction

In this post I'm going to focus on the problem related to HTTP PUT method. I will show by example Web API HTTP PUT method which can lead to bugs in the code and show you how to handle such scenarios to make your code reliable. At the bottom of the post, there is a github repository with all codebase used here.

How should we handle updating of the data in API?

Probably the most common way to update the data in API is usage of PUT HTTP method.

Let's take a look at the code:



public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string City { get; set; }
    public bool IsPremiumMember { get; set; }
}


Enter fullscreen mode Exit fullscreen mode

Let's assume that we develop a web-shop application. When user spends some particular amount of money, she or he will become a premium member and has the opportunity to purchase some goods with the extra discount. For sake of simplicity we put some good practices like domain-centric logic aside. In our example the shop is very young startup with only critical functions in their system and every update on the data is done manually by stuff. To update the customer's data, we need to trigger a Put method of the controller class:



[ApiController]
[Route("[controller]")]
public class CustomerController : ControllerBase
{
    private static readonly IEnumerable<Customer> _customers = new List<Customer>()
    {
        new Customer { Id = 1, Name = "John", City = "Cracow", IsPremiumMember = false }
    };

    [HttpGet("{id}")]
    public IActionResult Get(int id)
    {
        return Ok(_customers.FirstOrDefault(x => x.Id == id));
    }

    [HttpPut]
    public IActionResult Put(Customer customer)
    {
        var toUpdate = _customers.First(x => x.Id == customer.Id);
        toUpdate.Name = customer.Name;
        toUpdate.City = customer.City;
        toUpdate.IsPremiumMember = customer.IsPremiumMember;
        return NoContent();
    }
}



Enter fullscreen mode Exit fullscreen mode

John has moved recently to Warsaw. He's calling to the shop and tells that fact. Bob, who is a worker of the shop is picking up the phone and is going to update John's address as requested. He opens a web browser with the application. The frontend layer calls backend get method and gets the data:



{
    "id": 1,
    "name": "John",
    "city": "Cracow",
    "isPremiumMember": false
}


Enter fullscreen mode Exit fullscreen mode

Bob is just going to update the data but before that he wants to make a coffee.

At the same time Alice who works in the same team as Bob needs to perform an update operation on Johns account as well. We know that John spends a lot of money and his account needs to be promoted to premium level. Alice opens the browser and gets the same data. She clicks 'Promote account to premium' button and updates the data. After refreshing a browser she gets the data:



{
    "id": 1,
    "name": "John",
    "city": "Cracow",
    "isPremiumMember": true
}


Enter fullscreen mode Exit fullscreen mode

Bob goes back to his PC with a mug of coffee and he's going to finish his task. He amends the city to Warsaw and clicks update button. What will be the result of such scenario? Let's check that and call Get method. The result is:



{
    "id": 1,
    "name": "John",
    "city": "Warsaw",
    "isPremiumMember": false
}


Enter fullscreen mode Exit fullscreen mode

What?! John is not a premium user? You may ask: how it is possible? It can happen because PUT performs update on all fields. We send to backend a set of data and we update every single field except the id. The newly updated field can be overwritten with the old value like in Alice and Bob case. We don't want to have such vulnerability in our code. Let's check what we can do in order to mitigate this issue.

Versioning

The first method to solve the issue is versioning. We need to assign initial version to John's data and increment that after every update. Let's change the code and use this mechanism:



public abstract class VersionedEntity
{
    public long Version { get; set; }
}

public class Customer : VersionedEntity
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string City { get; set; }
    public bool IsPremiumMember { get; set; }
}


Enter fullscreen mode Exit fullscreen mode

We created VersionedEntity abstract class because if we create any new entity it will be easier to maintain solution but it is not required. We could add Version property directly to Customer class.

OK, we have a version stored in the system, let's try to reproduce Alice & Bob scenario and check if the issue still exists.

  1. Bob makes GET call to fetch the data, a result is:


{
    "id": 1,
    "name": "John",
    "city": "Cracow",
    "isPremiumMember": false,
    "version": 1
}


Enter fullscreen mode Exit fullscreen mode
  1. Bob goes to make a coffee
  2. Alice opens a browser, gets the same data from GET method.
  3. Alice updates the data, she sets 'isPremiumMember' to 'true', refreshes the browser to get the data:


{
    "id": 1,
    "name": "John",
    "city": "Cracow",
    "isPremiumMember": true,
    "version": 2 // note that version was incremented
}


Enter fullscreen mode Exit fullscreen mode
  1. Bob is back. He wants to amend the city from 'Cracow' to 'Warsaw'. After clicking 'Update' (his browser has still data with version: 1) button he gets the message:

The data you're trying to update has been changed. Please reload data and try once again.

All right! The issue does not exist! How did we achieved that? Apart from adding the version to the Customer class, we needed to change a controller method as well:



[HttpPut]
public IActionResult Put(Customer customer)
{
    var toUpdate = _customers.First(x => x.Id == customer.Id);
    if (toUpdate.Version != customer.Version) 
    {
        return BadRequest(
            "The data you're trying to update has been changed. " +
            "Please reload data and try once again.");
    }
    toUpdate.Name = customer.Name;
    toUpdate.City = customer.City;
    toUpdate.IsPremiumMember = customer.IsPremiumMember;
    toUpdate.Version++;
    return NoContent();
}


Enter fullscreen mode Exit fullscreen mode

Before updating we check if passed version of the data is the latest. If not, a bad request with a proper message is returned. That's it! Let's go to the next way of handing the 'put issue'.

PATCH instead of PUT

The problem related to PUT is that it updated all fields. Not only changed but all of them. PATCH has different concept it applies only changes without touching unchanged parts. To get more information about patch please take a look at the site: http://jsonpatch.com/. To replace PUT with PATCH in our example we need to add two nuget packages to our solution:



<PackageReference Include="Microsoft.AspNetCore.JsonPatch" Version="5.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.1" />


Enter fullscreen mode Exit fullscreen mode

The next step is to add Newtonsoft Json to our services in Startup class:



public void ConfigureServices(IServiceCollection services)
{

    services.AddControllers()
            .AddNewtonsoftJson();

    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "Blog", Version = "v1" });
    });
}


Enter fullscreen mode Exit fullscreen mode

The final step is replacing Put with Patch method in controller class:



[HttpPatch("{id}")]
public IActionResult Patch(int id, JsonPatchDocument<Customer> patchDoc)
{
    var toUpdate = _customers.First(x => x.Id == id);
    patchDoc.ApplyTo(toUpdate);
    return NoContent();
}


Enter fullscreen mode Exit fullscreen mode

To generate PATCH operation you can use additional libraries for your frontend application (you can find them on linked website). For this article purpose I used online generator https://extendsclass.com/json-patch.html.

Since PATCH does not touch unchanged data we are good to perform our two updates at the same time. The request body to update the city is:



[
 {
  "op": "replace",
  "path": "/city",
  "value": "Warsaw"
 }
]


Enter fullscreen mode Exit fullscreen mode

And promoting to premium account:



[
 {
  "op": "replace",
  "path": "/isPremiumMember",
  "value": true
 }
]


Enter fullscreen mode Exit fullscreen mode

As you can see it is maybe not so simple as PUT method but it provides better user experience - there is no need of refresh before updating if the data has been updated in the meantime.

A summary:

  1. HTTP PUT performs update of all fields, not only on changed ones. That may lead to case that the newly updated field is overwritten with the old value,
  2. To mitigate the issue you can apply versioning of the data. If user tries to update newer data with older data, an error is returned,
  3. To resolve problem you can use PATCH instead of PUT. It is a bit more complicated and requires more code. It is worth doing that if we can have multiple concurrent updates on our data.

Thank you for reading to the end! I hope that my tips will help you to create more reliable code.

If you like my posts, please follow me on Twitter :)

Github repository is available here (a postman collection with all http requests is attached to the repo):




Top comments (0)