Your first server
Problem
The catalog has to be visible from anywhere
The shop's catalog is already a list of products in your program, and you can filter it with LINQ. But it lives in the memory of your computer. One customer opens the shop in a browser, another opens it in the app on their phone. Both programs speak HTTP: they send a request like GET /products/3 and wait for a response with a status and a body.
You need a program that listens on a port, receives HTTP requests and answers them. That is a web server.
See it
Server and client in one program
In C#, the web server is ASP.NET Core. The simplest way to use it is the Minimal API: the server and its addresses are described in a few lines. This program starts a server and then asks it a question as a client:
var builder = WebApplication.CreateBuilder();builder.Logging.ClearProviders();var app = builder.Build();app.Urls.Add("http://localhost:6201");
app.MapGet("/", () => "Coffee and tea: open");await app.StartAsync();
using var http = new HttpClient();string text = await http.GetStringAsync("http://localhost:6201/");Console.WriteLine(text);
await app.StopAsync();Coffee and tea: openWebApplication.CreateBuilder()andBuild()make the application.app.Urls.Addsays which address and port to listen on.MapGet("/", lambda)says: for aGETrequest to the path/, answer with what the lambda returns. The text goes into the body of the response.await app.StartAsync()starts the server and does not wait: it moves on to the next line.await app.StopAsync()stops it at the end.HttpClientis the client.GetStringAsync(address)sends aGETrequest and gives you the body of the response as a string.
There is var because the types here are long (WebApplicationBuilder, WebApplication). using var http means the client closes by itself when the program ends.
More precisely, if you are curious
builder.Logging.ClearProviders()removes the messages ASP.NET writes to the console, such as "Now listening on: http://localhost:6201". In a real server they are useful. Here we remove them so the output has only our text.- A real server ends with
app.Run(): it listens until you stop it with Ctrl+C. Then the client is another program: the browser (openhttp://localhost:6201/) or the app on the phone. Here the server and the client are in one program, so the code can be checked without a second computer. - You make a project with
dotnet new web. The program is inProgram.cs, and theusings for the web are included by default. - One
HttpClientis for many requests. Do not make a new one for each request: it keeps the connections to the server open and reuses them.
See it
Routes: which address returns what
The server compares the path of each request with its routes and calls the lambda of the one that matches. Part of the path can be a parameter:
app.MapGet("/products/{id:int}", (int id) =>{ Product product = catalog.FirstOrDefault(p => p.Id == id); return product == null ? "No such product" : product.Name + ": €" + product.Price;});app.MapGet("/search", (string q) => string.Join(", ", catalog.Where(p => p.Name.Contains(q)).Select(p => p.Name)));using var http = new HttpClient();Console.WriteLine(await http.GetStringAsync("http://localhost:6202/products/3"));Console.WriteLine(await http.GetStringAsync("http://localhost:6202/products/9"));Console.WriteLine(await http.GetStringAsync("http://localhost:6202/search?q=beans"));Colombia beans: €31.50No such productEspresso beans, Colombia beans{id:int}in the route is a parameter in the path:/products/3matches, andidis 3.:intis a constraint: only numbers match.- The lambda has a parameter with the same name,
int id. ASP.NET takes the value from the path and turns it into anintby itself. (string q)has no place in the path, so it is taken from the query after?:/search?q=beansgivesq = "beans".catalogis the list of products from the LINQ lesson. The lambdas see it because they are in the same code, after it.
More precisely, if you are curious
- The whole file. The
Productclass comes after the code, as in the LINQ lesson:
var builder = WebApplication.CreateBuilder();builder.Logging.ClearProviders();var app = builder.Build();app.Urls.Add("http://localhost:6202");
List<Product> catalog = new List<Product>{ new Product { Id = 1, Name = "Espresso beans", Price = 24.90m, Category = "coffee" }, new Product { Id = 2, Name = "Sencha green tea", Price = 9.80m, Category = "tea" }, new Product { Id = 3, Name = "Colombia beans", Price = 31.50m, Category = "coffee" }, new Product { Id = 4, Name = "Peppermint", Price = 4.20m, Category = "tea" },};
app.MapGet("/products/{id:int}", (int id) =>{ Product product = catalog.FirstOrDefault(p => p.Id == id); return product == null ? "No such product" : product.Name + ": €" + product.Price;});app.MapGet("/search", (string q) => string.Join(", ", catalog.Where(p => p.Name.Contains(q)).Select(p => p.Name)));await app.StartAsync();
using var http = new HttpClient();Console.WriteLine(await http.GetStringAsync("http://localhost:6202/products/3"));Console.WriteLine(await http.GetStringAsync("http://localhost:6202/products/9"));Console.WriteLine(await http.GetStringAsync("http://localhost:6202/search?q=beans"));
await app.StopAsync();
public class Product{ public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public string Category { get; set; }}- If the address has no
?q=, thenqhere isnull,Contains(null)throws an exception, and the server answers with 500. A project fromdotnet new webhas thenullcheck turned on (<Nullable>enable</Nullable>), and then ASP.NET answers with 400 by itself if a required parameter is missing. - Other constraints:
{price:decimal},{code:alpha}(letters only),{id:int:min(1)}. A parameter with?is optional:/products/{page:int?}.
See it
Request and response
app.MapGet("/products/{id:int}", (int id) =>{ Product product = catalog.FirstOrDefault(p => p.Id == id); return product == null ? Results.NotFound() : Results.Ok(product);});await app.StartAsync();
using var http = new HttpClient();HttpResponseMessage response = await http.GetAsync("http://localhost:6219/products/2");Console.WriteLine((int)response.StatusCode);Console.WriteLine(await response.Content.ReadAsStringAsync());Step by step, in words
- The server registers a route: the address /products/<number> leads to this method.
- StartAsync: the server starts listening on port 6219.
- The client sends a request: method GET, path /products/2.
- The path matches the route. 2 is a number, so id = 2.
- The product is in the catalog, so the method returns Results.Ok with it. The server turns it into JSON.
- The response comes back: status 200 and a JSON body.
- The client prints the status and the body.
A lambda can return not only a value but a whole response. Results.Ok(object) is status 200 with the object in the body, and Results.NotFound() is 404 with no body. The server turns the product into JSON, text that describes the object, by itself.
On the client side, GetAsync gives you the whole response as an HttpResponseMessage. StatusCode is the status ((int) shows it as a number), and Content.ReadAsStringAsync() reads the body. GetStringAsync from the earlier cards throws an exception on any status other than 2xx. GetAsync does not throw, so with it you can see the 404 too.
The same for the catalog, with a product that is there and one that is not:
app.MapGet("/products/{id:int}", (int id) =>{ Product product = catalog.FirstOrDefault(p => p.Id == id); return product == null ? Results.NotFound() : Results.Ok(product);});using var http = new HttpClient();foreach (string path in new string[] { "/products/2", "/products/9" }){ HttpResponseMessage response = await http.GetAsync("http://localhost:6203" + path); Console.WriteLine(path + " -> " + (int)response.StatusCode + " " + response.StatusCode); Console.WriteLine(await response.Content.ReadAsStringAsync());}/products/2 -> 200 OK{"id":2,"name":"Sencha green tea","price":9.80,"category":"tea"}/products/9 -> 404 NotFoundTry it
Text instead of a number
The route is the same, /products/{id:int}. The client asks for a product by name:
What will it print?
HttpResponseMessage response = await http.GetAsync("http://localhost:6204/products/espresso");Console.WriteLine((int)response.StatusCode);espresso is not a number, so {id:int} does not match. There is no other route for this path, and the server answers with 404: "there is no such address". The lambda is never called.
See it
JSON: objects over the network
What travels over the network is text, not objects. JSON (JavaScript Object Notation) is the most common way to write an object as text: {"id":2,"name":"Sencha green tea","price":9.80,"category":"tea"}. The properties are "name":value pairs in curly braces, and a list is in square brackets: [{…},{…}]. Every language and every browser understands it.
If the lambda returns a list, the server makes it a JSON array. In the client, GetFromJsonAsync<List<Product>> reads the JSON and turns it back into a list of products:
app.MapGet("/products", () => catalog);using var http = new HttpClient();string json = await http.GetStringAsync("http://localhost:6205/products");Console.WriteLine(json.Substring(0, 70) + "...");
List<Product> products = await http.GetFromJsonAsync<List<Product>>("http://localhost:6205/products");foreach (Product p in products.Where(p => p.Category == "tea")){ Console.WriteLine(p.Name + ": €" + p.Price);}[{"id":1,"name":"Espresso beans","price":24.90,"category":"coffee"},{"...Sencha green tea: €9.80Peppermint: €4.20- In the JSON the names start with a lowercase letter (
name), and in C# with a capital (Name). The server andHttpClientsort this out by themselves. decimalis a number in JSON, with the digits after the decimal point:24.90.
The programs in this lesson that print JSON have two usings at the top:
using System.Text.Encodings.Web;using System.Text.Unicode;and one more line after ClearProviders(). The reason is in "More precisely":
builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All));More precisely, if you are curious
- Letters outside basic Latin in JSON. The JSON library in .NET (
System.Text.Json) writes everything outside basic Latin as\uXXXXby default, soébecomesé. It is still valid JSON and every program reads it correctly, but a person cannot read it. Here isJsonSerializer.Serializewithout the web server. The property names stay capitalized here, because the lowercase names are a web setting:
using System.Text.Json;
Product cafe = new Product { Id = 6, Name = "Café crème", Price = 3.80m, Category = "coffee" };Console.WriteLine(JsonSerializer.Serialize(cafe));
public class Product{ public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public string Category { get; set; }}{"Id":6,"Name":"Caf\u00E9 cr\u00E8me","Price":3.80,"Category":"coffee"}- The web server in .NET 10 encodes less by default: accented letters come out as they are, but the characters
<,>,&and'are not encoded either. That is convenient, but it is dangerous if the JSON ends up in an HTML page. That is why the name of this setting isUnsafeRelaxedJsonEscaping. The line withConfigureHttpJsonOptionssays explicitly what happens: all the letters in Unicode (UnicodeRanges.All) stay as they are, and the characters that are dangerous for HTML are encoded (<becomes<). That way the output is readable and does not depend on the default setting. - The reverse conversion, from JSON to an object, does not care about these settings:
éandéare read the same way.
See it
POST: add a product
GET reads. To add a product, the client sends a POST with the product as JSON in the body. On the server, this is MapPost:
app.MapPost("/products", (Product product) =>{ product.Id = catalog.Max(p => p.Id) + 1; catalog.Add(product); return Results.Created($"/products/{product.Id}", product);});using var http = new HttpClient();Product tea = new Product { Name = "Assam black tea", Price = 7.40m, Category = "tea" };HttpResponseMessage response = await http.PostAsJsonAsync("http://localhost:6207/products", tea);Console.WriteLine((int)response.StatusCode + " " + response.Headers.Location);
Product created = await response.Content.ReadFromJsonAsync<Product>();Console.WriteLine("New product " + created.Id + ": " + created.Name);List<Product> all = await http.GetFromJsonAsync<List<Product>>("http://localhost:6207/products");Console.WriteLine("In the catalog: " + all.Count);201 /products/5New product 5: Assam black teaIn the catalog: 5- The lambda takes
Product product. It is not in the path, so ASP.NET reads it from the JSON in the body of the request. Max(p => p.Id)from LINQ gives the largest number, so the new one is one more.Results.Created(address, object)is status 201 Created: something was created. TheLocationheader gives the address of the new resource, and the body is the product itself with its new number.- In the client,
PostAsJsonAsyncturns the object into JSON and sends aPOST.ReadFromJsonAsync<Product>reads the body of the response as a product.
More precisely, if you are curious
- The catalog lives in the server's memory: on a restart, the new products disappear. A real shop keeps them in a database.
- A real server handles many requests at once, and
Listis not made for changes from several requests at the same time. That is one more reason for the data to be in a database, which takes care of that. - There are also
MapPut(replace) andMapDelete(delete). They are in the WebAPI lesson.
Try it
500 instead of 404
The client asks for /products/9, and there is no such product. It expects 404 but gets 500. Which line is the bug on?
Pick the line with the bug.
First throws InvalidOperationException when it finds nothing. The server does not stop, but it turns the exception into a 500 response: "server error". Yet the error is in the request: there is no such product. The fix is FirstOrDefault, a check for null and Results.NotFound(), as on the "Request and response" card.
Exercise
The shop's server
Solve these in your editor. Each task is one program: a server, a client that calls it, and stopping. If you run several at once, give each one its own port.
1. What time is it. GET /time returns the time as text like 14:05 (DateTime.Now.ToString("HH:mm")). The time changes, so the client checks only its shape: length 5 and a : at position 2:
The time looks like HH:mm: True2. A catalog with search. GET /products?category=tea returns the products in the category, sorted by price, and GET /search?q=… searches the names regardless of uppercase and lowercase (ToLower). The client reads the first two with GetFromJsonAsync and prints the third as JSON:
Tea: Peppermint €4.20, Sencha green tea €9.80Found: 2[{"id":4,"name":"Peppermint","price":4.20,"category":"tea"}]3. A missing product. GET /products/{id:int} returns the product, or 404 with a message in the body: Results.NotFound("No product with number 42"). The client asks for 1 and 42 and prints the status and the body:
1: 200 {"id":1,"name":"Espresso beans","price":24.90,"category":"coffee"}42: 404 "No product with number 42"4. Add a product. POST /products adds the product and returns 201 with its address. If the name is empty (string.IsNullOrWhiteSpace) or the price is not positive, it returns Results.BadRequest("A name and a price are required") and adds nothing. The client adds rooibos for €6.90, reads it by the address from Location, and tries a product with no name:
POST: 201 /products/5GET: 5 Rooibos €6.90POST without a name: 400 "A name and a price are required"Test