WebAPI: data for other programs
Problem
An app, not a browser
The shop is launching a phone app, and a partner café wants to order from the catalog automatically. The pages from the previous lessons are HTML for people: menus, tables, buttons. The café's program has to dig through that HTML to find the price. As soon as the designer moves a column, the program stops working.
Programs want only the data, in a predictable shape. So the server has a second door: WebAPI, addresses that return JSON instead of pages. One API serves the phone app, the website, the desktop program at the till and the partners.
The idea
Resources, methods and statuses
APIs today usually follow REST. There are only a few rules:
- The address names a resource, a thing:
/api/productsis all the products,/api/products/2is product 2. There are no verbs likegetProductordeletein the address. - What you do with the resource is said by the HTTP method:
GETreads,POSTcreates,PUTreplaces,DELETEdeletes. - How it went is said by the status:
| Status | When |
|---|---|
| 200 OK | GET found the resource; the body is JSON |
| 201 Created | POST created a new resource; Location is its address |
| 204 No Content | PUT or DELETE succeeded; there is no body |
| 400 Bad Request | the data is not valid; the body says why |
| 404 Not Found | there is no such resource |
| 409 Conflict | the request is correct, but it cannot be carried out right now (for example, nothing is in stock) |
An API that follows these rules is called a REST API. A client can use it without reading the server's code: the address and the method are enough.
Five requests to the same resource and the statuses they return. How to write the controller and the client is on the next cards:
var http = new HttpClient { BaseAddress = new Uri("http://localhost:6292") };var matcha = new Product { Name = "Matcha", Price = 18.00m, Category = "tea" };var cheaper = new Product { Name = "Colombia Supremo", Price = 11.90m, Category = "coffee" };
Show(await http.GetAsync("/api/products/3"));Show(await http.PostAsJsonAsync("/api/products", matcha));Show(await http.PutAsJsonAsync("/api/products/2", cheaper));Show(await http.DeleteAsync("/api/products/4"));Show(await http.GetAsync("/api/products/4"));
static void Show(HttpResponseMessage r) => Console.WriteLine(r.RequestMessage.Method + " " + r.RequestMessage.RequestUri.AbsolutePath + " -> " + (int)r.StatusCode);Step by step, in words
- The resource is /api/products: 4 products on the server. The client knows only the address.
- GET reads product 3 (Sencha). It is there: 200 OK and the product as JSON.
- POST to the collection creates a new product. The server gives it id 5: 201 Created.
- PUT replaces product 2: the price becomes €11.90. The response has no body: 204 No Content.
- DELETE deletes product 4: 204 No Content.
- GET for product 4: it is gone now, so 404 Not Found.
- The address says which resource, the method says what you do with it, and the status says how it went.
See it
A controller for data
An API controller inherits from ControllerBase: that is Controller without the views. The two attributes at the top make it an API:
[ApiController][Route("api/[controller]")]public class ProductsController : ControllerBase{ private readonly IProductService products;
public ProductsController(IProductService products) => this.products = products;
[HttpGet] public List<Product> GetAll() => products.All();
[HttpGet("{id:int}")] public ActionResult<Product> Get(int id) { Product product = products.Find(id); if (product == null) return NotFound(); return product; }}[Route("api/[controller]")]puts the controller's name in the address:api/Products. Uppercase and lowercase in the address do not matter.[ApiController]turns on API behavior: parameters come from the JSON body, and invalid data returns 400 by itself (more in a moment).GetAllreturns a list, and it becomes JSON.Getreturns anActionResult<Product>: either a product (200 with JSON) or another result, such asNotFound().return product;is allowed because C# turns the product into anActionResult<Product>by itself.
In Program.cs, the controllers are added with AddControllers (no views) and wired up with MapControllers (the routes are in the attributes). AddJsonOptions is the setting for readable JSON from the MVC lesson:
var builder = WebApplication.CreateBuilder();builder.Logging.ClearProviders();builder.Services.AddControllers() .AddJsonOptions(o => o.JsonSerializerOptions.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All));builder.Services.AddSingleton<IProductService, ProductService>();
var app = builder.Build();app.Urls.Add("http://localhost:6261");app.MapControllers();await app.StartAsync();The client sets the common start of the addresses with BaseAddress and then writes only the path:
var http = new HttpClient { BaseAddress = new Uri("http://localhost:6261") };
Console.WriteLine(await http.GetStringAsync("/api/products"));
Product tea = await http.GetFromJsonAsync<Product>("/api/products/3");Console.WriteLine(tea.Name + ", €" + tea.Price);
HttpResponseMessage missing = await http.GetAsync("/api/products/99");Console.WriteLine("GET /api/products/99 -> " + (int)missing.StatusCode);[{"id":1,"name":"Ethiopia Yirgacheffe","price":14.90,"category":"coffee"},{"id":2,"name":"Colombia Supremo","price":12.50,"category":"coffee"},{"id":3,"name":"Sencha","price":8.40,"category":"tea"},{"id":4,"name":"Earl Grey","price":6.90,"category":"tea"}]Sencha, €8.40GET /api/products/99 -> 404GetFromJsonAsync<Product> reads JSON and makes a Product object from it. The properties in the JSON start with a lowercase letter (name), and in C# with a capital (Name). Reading does not care about uppercase and lowercase.
See it
POST: a new product
Product has the same properties, and the attributes say what is valid:
public class Product{ public int Id { get; set; }
[Required(ErrorMessage = "A name is required.")] public string Name { get; set; }
[Range(0.01, 1000, ErrorMessage = "The price must be from €0.01 to €1000.")] public decimal Price { get; set; }
public string Category { get; set; }}The action that creates it:
[HttpPost] public ActionResult<Product> Create(Product product) { products.Add(product); return CreatedAtAction(nameof(Get), new { id = product.Id }, product); }CreatedAtAction returns 201, puts the address of the new product in Location (built from the Get action and the id), and sends the product in the body. nameof(Get) is the name of the method as text, "Get", but the compiler checks it. The client sends one good product and one empty one:
var http = new HttpClient { BaseAddress = new Uri("http://localhost:6263") };
var matcha = new Product { Name = "Matcha", Price = 18.00m, Category = "tea" };HttpResponseMessage created = await http.PostAsJsonAsync("/api/products", matcha);Product saved = await created.Content.ReadFromJsonAsync<Product>();Console.WriteLine((int)created.StatusCode + ", Location: " + created.Headers.Location);Console.WriteLine("The new product has id " + saved.Id);
HttpResponseMessage rejected = await http.PostAsJsonAsync("/api/products", new Product { Price = 0 });Console.WriteLine((int)rejected.StatusCode);ValidationProblemDetails problem = await rejected.Content.ReadFromJsonAsync<ValidationProblemDetails>();foreach (KeyValuePair<string, string[]> error in problem.Errors.OrderBy(e => e.Key)){ Console.WriteLine(error.Key + ": " + string.Join(" ", error.Value));}201, Location: http://localhost:6263/api/Products/5The new product has id 5400Name: A name is required.Price: The price must be from €0.01 to €1000.The second product has no name, and its price is 0. Create is never called: [ApiController] checks the attributes and returns 400 by itself. The body is a ValidationProblemDetails: standard JSON with an errors field that has a list of errors for each property.
More precisely, if you are curious
- Without
[ApiController], you have to writeif (!ModelState.IsValid) return ValidationProblem();yourself at the start of every action, and[FromBody]in front of the parameter, so it is read from the JSON. - The body of the 400 also has a
traceId, the number of the request. You use it to find the error in the server's logs. It is different for every request, so the program prints onlyerrors. NotFound()in an API controller also returns a little JSON (aProblemDetailswith status 404), not an empty body.
See it
PUT and DELETE
[HttpPut("{id:int}")] public IActionResult Update(int id, Product product) { if (!products.Update(id, product)) return NotFound(); return NoContent(); }
[HttpDelete("{id:int}")] public IActionResult Delete(int id) { if (!products.Remove(id)) return NotFound(); return NoContent(); }The service is the one from the MVC lesson, with three more methods: Add, Update and Remove. Update and Remove return false if there is no product with that id, and the action turns that into a 404:
public interface IProductService{ Product Find(int id); List<Product> All(); void Add(Product product); bool Update(int id, Product product); bool Remove(int id);}var http = new HttpClient { BaseAddress = new Uri("http://localhost:6264") };
var cheaper = new Product { Name = "Colombia Supremo", Price = 11.90m, Category = "coffee" };HttpResponseMessage updated = await http.PutAsJsonAsync("/api/products/2", cheaper);Console.WriteLine("PUT /api/products/2 -> " + (int)updated.StatusCode);Console.WriteLine("€" + (await http.GetFromJsonAsync<Product>("/api/products/2")).Price);
HttpResponseMessage deleted = await http.DeleteAsync("/api/products/4");Console.WriteLine("DELETE /api/products/4 -> " + (int)deleted.StatusCode);HttpResponseMessage again = await http.DeleteAsync("/api/products/4");Console.WriteLine("DELETE /api/products/4 -> " + (int)again.StatusCode);HttpResponseMessage nowhere = await http.PutAsJsonAsync("/api/products/4", cheaper);Console.WriteLine("PUT /api/products/4 -> " + (int)nowhere.StatusCode);PUT /api/products/2 -> 204€11.90DELETE /api/products/4 -> 204DELETE /api/products/4 -> 404PUT /api/products/4 -> 404PUT sends the whole product, and it replaces the old one. The second delete of product 4 is 404: it is already gone. The same goes for a PUT to it.
More precisely, if you are curious
- To change only part of a resource (for example, only the price), there is the
PATCHmethod. It is less common, because you have to agree on what a "part" looks like. GET,PUTandDELETEare idempotent: repeating them is safe, and the result on the server is the same.POSTis not: two POSTs make two products. That is why the browser asks "Send the form again?" only for a POST.
Try it
Four requests
The server is the one from the previous cards, with the four products. What does the client print?
var http = new HttpClient { BaseAddress = new Uri("http://localhost:6262") };
var sencha = new Product { Name = "Sencha", Price = 9.20m, Category = "tea" };HttpResponseMessage a = await http.PutAsJsonAsync("/api/products/3", sencha);HttpResponseMessage b = await http.DeleteAsync("/api/products/3");HttpResponseMessage c = await http.GetAsync("/api/products/3");HttpResponseMessage d = await http.PostAsJsonAsync("/api/products", new Product { Name = "", Price = 5 });Console.WriteLine((int)a.StatusCode + " " + (int)b.StatusCode + " " + (int)c.StatusCode + " " + (int)d.StatusCode);The PUT to product 3 succeeds: 204. So does the DELETE: 204. After that, the GET does not find it: 404. The last POST has an empty name, and [Required] does not accept empty text, so [ApiController] returns 400 before Create is ever reached.
Try it
The product cannot be found
GET /api/products/2 returns 405, and GET /api/products/id returns 404. Product 2 shows up only at the odd address /api/products/id?id=2. Which line is the bug on?
Pick the line with the bug.
Without curly braces, "id" is literal text in the address: the action answers only /api/products/id. Then the int id parameter is not part of the path, and [ApiController] looks for it in the query after ?. If it is not there, it is 0, and the product is not found. The address /api/products/2 matches only PUT and DELETE, so a GET to it is 405. The fix is [HttpGet("{id:int}")].
Problem
The last ten bags
The server does not handle requests one at a time. If a hundred people press "Buy" in the same second, ASP.NET Core handles them at the same time. All of them use the same object: the service is a singleton.
The stock room has 10 bags of the coffee on sale. A reservation checks and lowers the count:
public class Stock{ private readonly Dictionary<int, int> left = new Dictionary<int, int> { [1] = 10 };
public bool TryTake(int productId) { if (!left.TryGetValue(productId, out int count) || count == 0) return false; left[productId] = count - 1; return true; }
public int Left(int productId) => left[productId];}One at a time, the requests go through correctly, 201 ten times and then 409:
201 201 201 201 201 201 201 201 201 201 409 409At the same time, though, two requests can read count = 1 at the same instant. Both decide there is one left, both write 0, and both return 201. That way you sell more than you have. How many more depends on chance: it is different every time you run it. It is the same with the last room in a hotel and the last seat in a hall.
The idea
lock: one at a time in the dangerous spot
The check and the change have to be indivisible: nobody may get in between them. In C#, lock does this:
public class Stock{ private readonly Dictionary<int, int> left = new Dictionary<int, int> { [1] = 10 }; private readonly object sync = new();
public bool TryTake(int productId) { lock (sync) { if (!left.TryGetValue(productId, out int count) || count == 0) return false; left[productId] = count - 1; return true; } }
public int Left(int productId) { lock (sync) { return left[productId]; } }}syncis an ordinary object that serves only as a key.new()is short fornew object(): the type is taken from the field on the left.lock (sync) { ... }lets only one request into the block at a time. The others wait at the door until the first one leaves. That way the check and the decrease happen together.- Reading is also in a
lock: otherwise you could read the dictionary right while another request is changing it.
The controller is ordinary: 201 on success, 409 Conflict if nothing is in stock:
public class Reservation{ public int ProductId { get; set; }}
[ApiController][Route("api/[controller]")]public class ReservationsController : ControllerBase{ private readonly Stock stock;
public ReservationsController(Stock stock) => this.stock = stock;
[HttpPost] public IActionResult Reserve(Reservation reservation) { if (!stock.TryTake(reservation.ProductId)) return Conflict("Out of stock."); return StatusCode(201, reservation); }}The proof: a hundred requests at once with Task.WhenAll against 10 bags:
var http = new HttpClient { BaseAddress = new Uri("http://localhost:6266") };
var requests = new List<Task<HttpResponseMessage>>();for (int i = 0; i < 100; i++){ requests.Add(http.PostAsJsonAsync("/api/reservations", new Reservation { ProductId = 1 }));}HttpResponseMessage[] responses = await Task.WhenAll(requests);
Console.WriteLine("201 Created: " + responses.Count(r => (int)r.StatusCode == 201));Console.WriteLine("409 Conflict: " + responses.Count(r => (int)r.StatusCode == 409));Console.WriteLine("Left: " + app.Services.GetRequiredService<Stock>().Left(1));201 Created: 10409 Conflict: 90Left: 0With lock, the result is the same every time you run it. The same holds for every service registered with AddSingleton that keeps data in memory: the ProductService from the cards above also has to lock in Add, Update and Remove if many clients work with it.
More precisely, if you are curious
- There cannot be an
awaitinside alock, and the compiler does not allow it.lockholds the key for the thread that went in, and after anawaitthe rest of the code may run on a different thread. If you need to wait for something asynchronous while protecting the data, you useSemaphoreSlimwithawait semaphore.WaitAsync(). - The block in a
lockshould be short: everyone else is waiting. Do not make requests to other servers or to the disk inside it. - For a dictionary that is used from many places at the same time, there is the ready-made
ConcurrentDictionary<K, V>: methods likeTryUpdateandAddOrUpdatedo the check and the change in one go, without your ownlock. - For a counter,
Interlockedis enough:Interlocked.Increment(ref count)increases the number indivisibly.count++is not indivisible: it is "read, add, write". - In a real application, the stock is in a database. There the same problem is solved by transactions and by a condition in the query itself: "decrease only if it is above 0".
See it
The program on the other side
The partner café knows nothing about the controllers. It knows the addresses and the shape of the JSON. It is handy to keep the requests in one class:
public class CatalogClient{ private readonly HttpClient http;
public CatalogClient(string address) { http = new HttpClient { BaseAddress = new Uri(address) }; }
public async Task<List<Product>> AllAsync() => await http.GetFromJsonAsync<List<Product>>("/api/products");
public async Task<int> AddAsync(Product product) { HttpResponseMessage response = await http.PostAsJsonAsync("/api/products", product); response.EnsureSuccessStatusCode(); Product saved = await response.Content.ReadFromJsonAsync<Product>(); return saved.Id; }}EnsureSuccessStatusCode() throws an HttpRequestException if the status is not 2xx. That way an error from the server does not go unnoticed. The café adds a product, gets the coffees sorted by price, and tries an invalid product:
var catalog = new CatalogClient("http://localhost:6267");
int id = await catalog.AddAsync(new Product { Name = "Guatemala Antigua", Price = 13.20m, Category = "coffee" });Console.WriteLine("Added with id " + id);
List<Product> coffee = (await catalog.AllAsync()).Where(p => p.Category == "coffee").OrderBy(p => p.Price).ToList();foreach (Product p in coffee){ Console.WriteLine(p.Name + ": €" + p.Price);}
try{ await catalog.AddAsync(new Product { Name = "No price" });}catch (HttpRequestException e){ Console.WriteLine("Refused: " + (int)e.StatusCode);}Added with id 5Colombia Supremo: €12.50Guatemala Antigua: €13.20Ethiopia Yirgacheffe: €14.90Refused: 400More precisely, if you are curious
- Swagger / OpenAPI describes the API in a standard JSON file: addresses, methods, shapes of the bodies, statuses. From it you get a page where you can try the API in the browser, and you can generate clients in other languages. In .NET 10 the description is built in (
AddOpenApiandMapOpenApi), and the try-it page comes from a separate package, such asScalar.AspNetCoreorSwashbuckle.AspNetCore. - CORS: by default, the browser does not let JavaScript from
shop.example.bgread responses fromapi.example.bg. The server explicitly allows which sites can, withbuilder.Services.AddCors(...)andapp.UseCors(...). For programs outside the browser, like this one, CORS does not matter. - ASP.NET Core can also answer with XML, if the client asks for it with the header
Accept: application/xmland the server hasAddXmlSerializerFormatters(). Picking the format by the header is called content negotiation. - One
HttpClientis used for many requests. A new one for every request uses up the computer's connections under heavy load.
Exercise
An API for tasks
Solve this in your editor, in a project from dotnet new webapi --use-controllers or in one file as in the lesson.
An API for tasks (todo). TodoItem has an Id, a Title (required, with [Required]) and Done. TodosController at api/todos has the five actions: all of them, one by id (or 404), create (201 with CreatedAtAction), replace with PUT (204 or 404) and delete (204 or 404). The tasks are in a singleton service TodoList that locks with lock on every read and write. A new task gets the next number, even if older ones were deleted.
The client calls, in order: two tasks, a task with no title, marks the first one as done, reads it, deletes the second one, looks for it, and finally reads all of them. It prints the method, the status and the body, if the request succeeded and has a body:
POST -> 201 {"id":1,"title":"Order filters for the coffee machine","done":false}POST -> 201 {"id":2,"title":"Call the tea supplier","done":false}POST without a title -> 400PUT 1 -> 204GET 1 -> 200 {"id":1,"title":"Order filters for the coffee machine","done":true}DELETE 2 -> 204GET 2 -> 404GET -> 200 [{"id":1,"title":"Order filters for the coffee machine","done":true}]Then add GET /api/todos?done=false, which returns only the tasks that are not done: a bool? done parameter on GetAll and a Where.
Test