Skip to content
wannacode

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/products is all the products, /api/products/2 is product 2. There are no verbs like getProduct or delete in the address.
  • What you do with the resource is said by the HTTP method: GET reads, POST creates, PUT replaces, DELETE deletes.
  • 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
  1. The resource is /api/products: 4 products on the server. The client knows only the address.
  2. GET reads product 3 (Sencha). It is there: 200 OK and the product as JSON.
  3. POST to the collection creates a new product. The server gives it id 5: 201 Created.
  4. PUT replaces product 2: the price becomes €11.90. The response has no body: 204 No Content.
  5. DELETE deletes product 4: 204 No Content.
  6. GET for product 4: it is gone now, so 404 Not Found.
  7. 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).
  • GetAll returns a list, and it becomes JSON. Get returns an ActionResult<Product>: either a product (200 with JSON) or another result, such as NotFound(). return product; is allowed because C# turns the product into an ActionResult<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);
Output
[{"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.40
GET /api/products/99 -> 404

GetFromJsonAsync<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));
}
Output
201, Location: http://localhost:6263/api/Products/5
The new product has id 5
400
Name: 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 write if (!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 only errors.
  • NotFound() in an API controller also returns a little JSON (a ProblemDetails with 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);
Output
PUT /api/products/2 -> 204
€11.90
DELETE /api/products/4 -> 204
DELETE /api/products/4 -> 404
PUT /api/products/4 -> 404

PUT 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 PATCH method. It is less common, because you have to agree on what a "part" looks like.
  • GET, PUT and DELETE are idempotent: repeating them is safe, and the result on the server is the same. POST is 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);
Answer

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.

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:

Output (12 requests, one after another)
201 201 201 201 201 201 201 201 201 201 409 409

At 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];
}
}
}
  • sync is an ordinary object that serves only as a key. new() is short for new 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));
Output
201 Created: 10
409 Conflict: 90
Left: 0

With 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 await inside a lock, and the compiler does not allow it. lock holds the key for the thread that went in, and after an await the rest of the code may run on a different thread. If you need to wait for something asynchronous while protecting the data, you use SemaphoreSlim with await semaphore.WaitAsync().
  • The block in a lock should 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 like TryUpdate and AddOrUpdate do the check and the change in one go, without your own lock.
  • For a counter, Interlocked is 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);
}
Output
Added with id 5
Colombia Supremo: €12.50
Guatemala Antigua: €13.20
Ethiopia Yirgacheffe: €14.90
Refused: 400
More 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 (AddOpenApi and MapOpenApi), and the try-it page comes from a separate package, such as Scalar.AspNetCore or Swashbuckle.AspNetCore.
  • CORS: by default, the browser does not let JavaScript from shop.example.bg read responses from api.example.bg. The server explicitly allows which sites can, with builder.Services.AddCors(...) and app.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/xml and the server has AddXmlSerializerFormatters(). Picking the format by the header is called content negotiation.
  • One HttpClient is 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:

Output
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 -> 400
PUT 1 -> 204
GET 1 -> 200 {"id":1,"title":"Order filters for the coffee machine","done":true}
DELETE 2 -> 204
GET 2 -> 404
GET -> 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

Check yourself

1. Which address and method follow REST for "delete product 7"?
2. What status does a successful POST that creates a product return?
3. Product has [Required] on Name. What happens to a POST without a name sent to a controller with [ApiController]?
4. A hundred requests reserve the last 10 units at the same time. Why can more be sold without lock?
5. What does DELETE return when the delete succeeded?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·