Skip to content
wannacode

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:

Program.cs
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();
Output
Coffee and tea: open
  • WebApplication.CreateBuilder() and Build() make the application. app.Urls.Add says which address and port to listen on.
  • MapGet("/", lambda) says: for a GET request 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.
  • HttpClient is the client. GetStringAsync(address) sends a GET request 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 (open http://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 in Program.cs, and the usings for the web are included by default.
  • One HttpClient is 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"));
Output
Colombia beans: €31.50
No such product
Espresso beans, Colombia beans
  • {id:int} in the route is a parameter in the path: /products/3 matches, and id is 3. :int is 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 an int by itself.
  • (string q) has no place in the path, so it is taken from the query after ?: /search?q=beans gives q = "beans".
  • catalog is 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 Product class comes after the code, as in the LINQ lesson:
Program.cs
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=, then q here is null, Contains(null) throws an exception, and the server answers with 500. A project from dotnet new web has the null check 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
  1. The server registers a route: the address /products/<number> leads to this method.
  2. StartAsync: the server starts listening on port 6219.
  3. The client sends a request: method GET, path /products/2.
  4. The path matches the route. 2 is a number, so id = 2.
  5. The product is in the catalog, so the method returns Results.Ok with it. The server turns it into JSON.
  6. The response comes back: status 200 and a JSON body.
  7. 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());
}
Output
/products/2 -> 200 OK
{"id":2,"name":"Sencha green tea","price":9.80,"category":"tea"}
/products/9 -> 404 NotFound

Try 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);
Answer

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);
}
Output
[{"id":1,"name":"Espresso beans","price":24.90,"category":"coffee"},{"...
Sencha green tea: €9.80
Peppermint: €4.20
  • In the JSON the names start with a lowercase letter (name), and in C# with a capital (Name). The server and HttpClient sort this out by themselves.
  • decimal is 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 \uXXXX by default, so é becomes é. It is still valid JSON and every program reads it correctly, but a person cannot read it. Here is JsonSerializer.Serialize without 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; }
}
Output
{"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 is UnsafeRelaxedJsonEscaping. The line with ConfigureHttpJsonOptions says 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);
Output
201 /products/5
New product 5: Assam black tea
In 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. The Location header gives the address of the new resource, and the body is the product itself with its new number.
  • In the client, PostAsJsonAsync turns the object into JSON and sends a POST. 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 List is 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) and MapDelete (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.

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:

Output
The time looks like HH:mm: True

2. 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:

Output
Tea: Peppermint €4.20, Sencha green tea €9.80
Found: 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:

Output
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:

Output
POST: 201 /products/5
GET: 5 Rooibos €6.90
POST without a name: 400 "A name and a price are required"

Test

Check yourself

1. What does app.MapGet("/products/{id:int}", (int id) => ...) do?
2. The lambda returns Results.NotFound(). What does the client get?
3. The lambda returns a Product object. How does it get to the client?
4. Why do the programs in this lesson have await app.StartAsync() and await app.StopAsync()?
5. The lambda finds the product with catalog.First(p => p.Id == id), and there is no such product. What does the client get?

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