ASP.NET Core Web API · REST Foundations

HTTP Methods

Each HTTP method is a promise. GET never changes data, PUT and DELETE are safe to retry, POST is not. ShopSmart’s mobile app retries requests on bad networks, so keeping these promises matters. We implement all five on one resource.

Beginner 8 min Lesson 3 of 37Examples target .NET 9.
Before you start:REST Principles
0/37 in ASP.NET Core Web API
On this page · 8 min · Beginner

In simple words#

HTTP methods are verbs for a library: GET (read a book), POST (donate a new book), PUT (replace a book), PATCH (fix a page), DELETE (remove a book).

GET
Read — no changes.
POST / PUT / PATCH
Create / replace / partly update.
Idempotent
Repeating it has the same effect (GET, PUT, DELETE).

Learning objectives#

  • Implement GET, POST, PUT, PATCH and DELETE actions
  • Explain safe and idempotent methods
  • Do partial updates with PATCH
  • Recognise 405 Method Not Allowed

Explanation#

[HttpGet] reads (200), [HttpPost] creates (201 + Location), [HttpPut] replaces the whole resource (204 or 200), [HttpPatch] changes some fields (200 or 204), [HttpDelete] removes (204).

Safe methods (GET, HEAD, OPTIONS) do not change data. Idempotent methods (GET, PUT, DELETE) have the same effect whether called once or five times — so clients can retry them after a timeout. POST is neither: two POSTs create two orders.

PUT expects the complete representation; validation should require all fields. PATCH sends only what changes — in .NET 9 a simple approach is a DTO with nullable properties (null = “not sent”). JSON Patch documents are another option.

If a URL exists but not for that method (DELETE /api/products), ASP.NET Core answers 405 Method Not Allowed with an Allow header listing what is supported.

For non-idempotent operations that must be retry-safe (payments, orders), accept an Idempotency-Key header and remember which keys were already processed.

Syntax#

C# Illustrative
[HttpGet("{id:int}")]    public ActionResult<ProductDto> Get(int id) => …;
[HttpPost]               public ActionResult<ProductDto> Create(CreateProductDto dto) => …;  // 201
[HttpPut("{id:int}")]    public IActionResult Replace(int id, ReplaceProductDto dto) => …;   // 204
[HttpPatch("{id:int}")]  public ActionResult<ProductDto> Update(int id, PatchProductDto dto) => …;
[HttpDelete("{id:int}")] public IActionResult Delete(int id) => …;                           // 204

Code example: All five methods on /api/products#

C#Controllers/ProductsController.cs Illustrative
// Program.cs
using System.Net.Http.Json;


var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();                                  // keep the demo output clean
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Urls.Add("http://127.0.0.1:5203");
await app.StartAsync();


using var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5203") };
async Task Send(HttpMethod method, string path, object? body = null)
{
    var request = new HttpRequestMessage(method, path) { Content = body is null ? null : JsonContent.Create(body) };
    var response = await http.SendAsync(request);
    var allow = response.Content.Headers.Allow.Count > 0 ? 
quot; (Allow:
{string.Join(", ", response.Content.Headers.Allow)})" : "";
Console.WriteLine(
quot;
{method,-6} {path,-18} {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}".TrimEnd() + allow);
} await Send(HttpMethod.Post, "/api/products", new { name = "Webcam", price = 2199, stock = 5 }); await Send(HttpMethod.Post, "/api/products", new { name = "Webcam", price = 2199, stock = 5 }); // POST twice = two products await Send(HttpMethod.Patch, "/api/products/1", new { price = 1999 }); // only the price await Send(HttpMethod.Put, "/api/products/1", new { name = "HD Webcam", price = 2499, stock = 8 }); await Send(HttpMethod.Get, "/api/products/1"); await Send(HttpMethod.Delete, "/api/products/2"); await Send(HttpMethod.Delete, "/api/products/2"); // same state, different response await Send(HttpMethod.Delete, "/api/products"); // no DELETE on the collection await app.StopAsync(); // Controllers/ProductsController.cs using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc; public record ProductDto(int Id, string Name, decimal Price, int Stock); public record SaveProductDto([Required] string Name, [Range(1, 1_000_000)] decimal Price, [Range(0, 10_000)] int Stock); public record PatchProductDto(string? Name, decimal? Price, int? Stock); // null = "not sent" [ApiController] [Route("api/[controller]")] public class ProductsController : ControllerBase { private static readonly Dictionary<int, ProductDto> Store = []; private static int _nextId = 1; [HttpGet] public IEnumerable<ProductDto> GetAll() => Store.Values; [HttpGet("{id:int}")] public ActionResult<ProductDto> Get(int id) => Store.TryGetValue(id, out var p) ? p : NotFound(); [HttpPost] // not idempotent public ActionResult<ProductDto> Create(SaveProductDto dto) { var product = new ProductDto(_nextId++, dto.Name, dto.Price, dto.Stock); Store[product.Id] = product; return CreatedAtAction(nameof(Get), new { id = product.Id }, product); } [HttpPut("{id:int}")] // idempotent: replaces everything public IActionResult Replace(int id, SaveProductDto dto) { if (!Store.ContainsKey(id)) return NotFound(); Store[id] = new ProductDto(id, dto.Name, dto.Price, dto.Stock); return NoContent(); } [HttpPatch("{id:int}")] // partial update public ActionResult<ProductDto> Update(int id, PatchProductDto dto) { if (!Store.TryGetValue(id, out var p)) return NotFound(); Store[id] = p with { Name = dto.Name ?? p.Name, Price = dto.Price ?? p.Price, Stock = dto.Stock ?? p.Stock }; return Store[id]; } [HttpDelete("{id:int}")] // idempotent: the product stays deleted public IActionResult Delete(int id) => Store.Remove(id) ? NoContent() : NotFound(); }
Expected output
POST   /api/products      201 {"id":1,"name":"Webcam","price":2199,"stock":5}
POST   /api/products      201 {"id":2,"name":"Webcam","price":2199,"stock":5}
PATCH  /api/products/1    200 {"id":1,"name":"Webcam","price":1999,"stock":5}
PUT    /api/products/1    204
GET    /api/products/1    200 {"id":1,"name":"HD Webcam","price":2499,"stock":8}
DELETE /api/products/2    204
DELETE /api/products/2    404 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"0HNPL7JP34I10:00000007"}
DELETE /api/products      405 (Allow: GET, POST)
(the traceId varies per request)

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. Two identical POSTs create products 1 and 2 — POST is not idempotent.
  2. PATCH sends only the price; Name and Stock keep their values.
  3. PUT replaces the whole product and returns 204 No Content; GET shows the result.
  4. The second DELETE of product 2 returns 404, but the server state is the same — DELETE is idempotent.
  5. DELETE on the collection URL is not mapped, so the API answers 405 with an Allow header.

Real-world use case#

ShopSmart’s mobile app uses PATCH to update a single cart quantity, PUT to save the full delivery address, and sends an Idempotency-Key with POST /api/orders so a retried checkout never creates two orders.

Common mistakes#

Changing data in GET

GET /api/products/7/delete can be triggered by crawlers, link previews or caches. Use DELETE.

Using PUT for partial updates

A PUT body missing fields would, by definition, clear them. Use PATCH for partial changes.

Treating a 404 on repeated DELETE as a failure

For retries, “already gone” is fine. Clients can treat 404 on DELETE as success.

Best practices#

  • 201 + Location for POST, 204 for PUT/DELETE
  • PATCH DTOs with nullable fields
  • Idempotency keys for retry-sensitive POSTs
  • Let 405 tell clients what is allowed

Remember this#

Safe: GET. Idempotent: GET, PUT, DELETE.

PUT = replace all. PATCH = change some.

Wrong method on a valid URL → 405 + Allow.

Practice exercise#

Your task: Add HEAD support for GET /api/products/{id} by also marking the action with [HttpHead("{id:int}")], so clients can check existence without downloading the body.

Need a hint?
Stack attributes: [HttpGet("{id:int}")] [HttpHead("{id:int}")].
Show solution
C# Illustrative
// Controllers/ProductsController.cs
using Microsoft.AspNetCore.Mvc;


public record ProductDto(int Id, string Name);


[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private static readonly ProductDto[] Products = [new(1, "Wireless Mouse")];


    [HttpGet("{id:int}")]
    [HttpHead("{id:int}")]
    public ActionResult<ProductDto> Get(int id) =>
        Products.FirstOrDefault(p => p.Id == id) is { } product ? product : NotFound();
}

Mini quiz#

HTTP Methods quizMultiple choice Question 1 of 3

Which method should a client use to change only the stock of a product?

Score so far: 0/0

Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.