Zum Inhalt
wannacode

WebAPI: Daten für andere Programme

Kurz gesagt: Eine WebAPI ist ein Server, der statt Seiten Daten als JSON zurückgibt; bei REST benennt die Adresse eine Ressource, und GET, POST, PUT und DELETE sagen, was mit ihr geschehen soll.

Problem

Eine App, kein Browser

Der Shop bringt eine Handy-App heraus, und ein Partnercafé will automatisch aus dem Katalog bestellen. Die Seiten aus den vorigen Lektionen sind HTML für Menschen: Menüs, Tabellen, Buttons. Das Programm des Cafés muss sich durch dieses HTML wühlen, um den Preis zu finden. Sobald die Designerin eine Spalte verschiebt, funktioniert das Programm nicht mehr.

Programme wollen nur die Daten, in einer vorhersehbaren Form. Also bekommt der Server eine zweite Tür: WebAPI, Adressen, die statt Seiten JSON zurückgeben. Eine API bedient die Handy-App, die Website, das Desktopprogramm an der Kasse und die Partner.

Die Idee

Ressourcen, Methoden und Status

APIs folgen heute meist REST. Es gibt nur wenige Regeln:

  • Die Adresse benennt eine Ressource, ein Ding: /api/products sind alle Produkte, /api/products/2 ist Produkt 2. In der Adresse stehen keine Verben wie getProduct oder delete.
  • Was du mit der Ressource tust, sagt die HTTP-Methode: GET liest, POST erstellt, PUT ersetzt, DELETE löscht.
  • Wie es gelaufen ist, sagt der Status:
Status Wann
200 OK GET hat die Ressource gefunden; der Body ist JSON
201 Created POST hat eine neue Ressource erstellt; Location ist ihre Adresse
204 No Content PUT oder DELETE hat geklappt; es gibt keinen Body
400 Bad Request die Daten sind nicht gültig; der Body sagt, warum
404 Not Found diese Ressource gibt es nicht
409 Conflict die Anfrage ist richtig, lässt sich aber gerade nicht ausführen (zum Beispiel ist nichts auf Lager)

Eine API, die diesen Regeln folgt, heißt REST-API. Ein Client kann sie verwenden, ohne den Code des Servers zu lesen: Adresse und Methode reichen.

Fünf Anfragen an dieselbe Ressource und die Status, die sie zurückgeben. Wie man den Controller und den Client schreibt, steht auf den nächsten Karten:

var http = new HttpClient { BaseAddress = new Uri("http://localhost:7292") };
var matcha = new Product { Name = "Matcha", Price = 18.00m, Category = "tee" };
var cheaper = new Product { Name = "Colombia Supremo", Price = 11.90m, Category = "kaffee" };
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);
Schritt für Schritt, in Worten
  1. Die Ressource ist /api/products: 4 Produkte auf dem Server. Der Client kennt nur die Adresse.
  2. GET liest Produkt 3 (Sencha). Es ist da: 200 OK und das Produkt als JSON.
  3. POST an die Sammlung erstellt ein neues Produkt. Der Server gibt ihm die id 5: 201 Created.
  4. PUT ersetzt Produkt 2: Der Preis wird 11.90 €. Die Antwort hat keinen Body: 204 No Content.
  5. DELETE löscht Produkt 4: 204 No Content.
  6. GET für Produkt 4: Es ist jetzt weg, also 404 Not Found.
  7. Die Adresse sagt, welche Ressource, die Methode, was du mit ihr tust, und der Status, wie es gelaufen ist.

Sieh es dir an

Ein Controller für Daten

Ein API-Controller erbt von ControllerBase: Das ist Controller ohne die Views. Die beiden Attribute oben machen ihn zu einer 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]")] setzt den Namen des Controllers in die Adresse: api/Products. Groß- und Kleinschreibung in der Adresse spielen keine Rolle.
  • [ApiController] schaltet das Verhalten einer API ein: Parameter kommen aus dem JSON-Body, und ungültige Daten geben von selbst 400 zurück (gleich mehr dazu).
  • GetAll gibt eine Liste zurück, und daraus wird JSON. Get gibt ein ActionResult<Product> zurück: entweder ein Produkt (200 mit JSON) oder ein anderes Ergebnis, etwa NotFound(). return product; ist erlaubt, weil C# das Produkt von selbst in ein ActionResult<Product> umwandelt.

In Program.cs werden die Controller mit AddControllers hinzugefügt (ohne Views) und mit MapControllers angebunden (die Routen stehen in den Attributen). AddJsonOptions ist die Einstellung für lesbares JSON aus der MVC-Lektion:

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:7261");
app.MapControllers();
await app.StartAsync();

Der Client legt mit BaseAddress den gemeinsamen Anfang der Adressen fest und schreibt dann nur noch den Pfad:

var http = new HttpClient { BaseAddress = new Uri("http://localhost:7261") };
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);
Ausgabe
[{"id":1,"name":"Ethiopia Yirgacheffe","price":14.90,"category":"kaffee"},{"id":2,"name":"Colombia Supremo","price":12.50,"category":"kaffee"},{"id":3,"name":"Sencha","price":8.40,"category":"tee"},{"id":4,"name":"Earl Grey","price":6.90,"category":"tee"}]
Sencha, 8.40 €
GET /api/products/99 -> 404

GetFromJsonAsync<Product> liest JSON und macht daraus ein Objekt Product. Die Eigenschaften im JSON beginnen mit einem Kleinbuchstaben (name), in C# mit einem Großbuchstaben (Name). Beim Lesen spielt Groß- und Kleinschreibung keine Rolle.

Sieh es dir an

POST: ein neues Produkt

Product hat dieselben Eigenschaften, und die Attribute sagen, was gültig ist:

public class Product
{
public int Id { get; set; }
[Required(ErrorMessage = "Ein Name ist erforderlich.")]
public string Name { get; set; }
[Range(0.01, 1000, ErrorMessage = "Der Preis muss zwischen 0.01 und 1000 € liegen.")]
public decimal Price { get; set; }
public string Category { get; set; }
}

Die Action, die es erstellt:

[HttpPost]
public ActionResult<Product> Create(Product product)
{
products.Add(product);
return CreatedAtAction(nameof(Get), new { id = product.Id }, product);
}

CreatedAtAction gibt 201 zurück, setzt die Adresse des neuen Produkts in Location (gebaut aus der Action Get und der id) und schickt das Produkt im Body. nameof(Get) ist der Name der Methode als Text, "Get", aber der Compiler prüft ihn. Der Client schickt ein gutes Produkt und ein leeres:

var http = new HttpClient { BaseAddress = new Uri("http://localhost:7263") };
var matcha = new Product { Name = "Matcha", Price = 18.00m, Category = "tee" };
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("Das neue Produkt hat die 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));
}
Ausgabe
201, Location: http://localhost:7263/api/Products/5
Das neue Produkt hat die id 5
400
Name: Ein Name ist erforderlich.
Price: Der Preis muss zwischen 0.01 und 1000 € liegen.

Das zweite Produkt hat keinen Namen, und sein Preis ist 0. Create wird nie aufgerufen: [ApiController] prüft die Attribute und gibt von selbst 400 zurück. Der Body ist eine ValidationProblemDetails: Standard-JSON mit einem Feld errors, das für jede Eigenschaft eine Liste von Fehlern hat.

Genauer, für Neugierige
  • Ohne [ApiController] musst du am Anfang jeder Action selbst if (!ModelState.IsValid) return ValidationProblem(); schreiben und [FromBody] vor den Parameter, damit er aus dem JSON gelesen wird.
  • Der Body der 400 hat auch eine traceId, die Nummer der Anfrage. Damit findest du den Fehler in den Logs des Servers. Sie ist bei jeder Anfrage anders, deshalb gibt das Programm nur errors aus.
  • NotFound() gibt in einem API-Controller ebenfalls ein wenig JSON zurück (eine ProblemDetails mit Status 404), keinen leeren Body.

Sieh es dir an

PUT und 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();
}

Der Service ist der aus der MVC-Lektion, mit drei Methoden mehr: Add, Update und Remove. Update und Remove geben false zurück, wenn es kein Produkt mit dieser id gibt, und die Action macht daraus eine 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:7264") };
var cheaper = new Product { Name = "Colombia Supremo", Price = 11.90m, Category = "kaffee" };
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);
Ausgabe
PUT /api/products/2 -> 204
11.90 €
DELETE /api/products/4 -> 204
DELETE /api/products/4 -> 404
PUT /api/products/4 -> 404

PUT schickt das ganze Produkt, und es ersetzt das alte. Das zweite Löschen von Produkt 4 ist 404: Es ist schon weg. Dasselbe gilt für ein PUT darauf.

Genauer, für Neugierige
  • Um nur einen Teil einer Ressource zu ändern (zum Beispiel nur den Preis), gibt es die Methode PATCH. Sie ist seltener, weil man sich einigen muss, wie ein „Teil“ aussieht.
  • GET, PUT und DELETE sind idempotent: Sie zu wiederholen ist sicher, und das Ergebnis auf dem Server ist dasselbe. POST ist es nicht: Zwei POSTs machen zwei Produkte. Deshalb fragt der Browser „Formular erneut senden?“ nur bei einem POST.

Probier es aus

Vier Anfragen

Der Server ist der von den vorigen Karten, mit den vier Produkten. Was gibt der Client aus?

var http = new HttpClient { BaseAddress = new Uri("http://localhost:7262") };
var sencha = new Product { Name = "Sencha", Price = 9.20m, Category = "tee" };
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);
Antwort

Probier es aus

Das Produkt ist nicht zu finden

GET /api/products/2 gibt 405 zurück, und GET /api/products/id gibt 404. Produkt 2 erscheint nur unter der seltsamen Adresse /api/products/id?id=2. In welcher Zeile ist der Fehler?

Wähle die Zeile mit dem Fehler.

Problem

Die letzten zehn Tüten

Der Server bearbeitet Anfragen nicht einzeln. Wenn hundert Leute in derselben Sekunde auf „Kaufen“ drücken, bearbeitet ASP.NET Core sie gleichzeitig. Alle verwenden dasselbe Objekt: Der Service ist ein Singleton.

Im Lager sind 10 Tüten des Kaffees im Angebot. Eine Reservierung prüft und verringert die Zahl:

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];
}

Einzeln gehen die Anfragen richtig durch, zehnmal 201 und dann 409:

Ausgabe (12 Anfragen nacheinander)
201 201 201 201 201 201 201 201 201 201 409 409

Gleichzeitig können aber zwei Anfragen im selben Augenblick count = 1 lesen. Beide entscheiden, dass noch eine da ist, beide schreiben 0, und beide geben 201 zurück. So verkaufst du mehr, als du hast. Wie viel mehr, hängt vom Zufall ab: Es ist bei jedem Start anders. Genauso ist es mit dem letzten Zimmer im Hotel und dem letzten Platz im Saal.

Die Idee

lock: einer nach dem anderen an der gefährlichen Stelle

Prüfen und Ändern müssen unteilbar sein: Niemand darf dazwischenkommen. In C# macht das lock, das du aus der Lektion „Threads“ kennst. Hier sind die Threads die Anfragen:

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 ist ein gewöhnliches Objekt, das nur als Schlüssel dient. new() ist kurz für new object(): Der Typ wird vom Feld links genommen.
  • lock (sync) { ... } lässt immer nur eine Anfrage in den Block. Die anderen warten an der Tür, bis die erste hinausgeht. So passieren Prüfen und Verringern zusammen.
  • Auch das Lesen steht in einem lock: Sonst könntest du das Dictionary genau dann lesen, wenn eine andere Anfrage es ändert.

Der Controller ist gewöhnlich: 201 bei Erfolg, 409 Conflict, wenn nichts auf Lager ist:

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("Nicht auf Lager.");
return StatusCode(201, reservation);
}
}

Der Beweis: hundert Anfragen auf einmal mit Task.WhenAll gegen 10 Tüten:

var http = new HttpClient { BaseAddress = new Uri("http://localhost:7266") };
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("Übrig: " + app.Services.GetRequiredService<Stock>().Left(1));
Ausgabe
201 Created: 10
409 Conflict: 90
Übrig: 0

Mit lock ist das Ergebnis bei jedem Start dasselbe. Dasselbe gilt für jeden Service, der mit AddSingleton registriert ist und Daten im Speicher hält: Auch der ProductService von den Karten oben muss in Add, Update und Remove sperren, wenn viele Clients damit arbeiten.

Genauer, für Neugierige
  • In einem lock darf kein await stehen, und der Compiler erlaubt es nicht. lock hält den Schlüssel für den Thread, der hineingegangen ist, und nach einem await kann der restliche Code in einem anderen Thread laufen. Wenn du auf etwas Asynchrones warten und dabei die Daten schützen musst, nimmst du SemaphoreSlim mit await semaphore.WaitAsync().
  • Der Block in einem lock sollte kurz sein: Alle anderen warten. Mach darin keine Anfragen an andere Server oder an die Festplatte.
  • Für ein Dictionary, das von vielen Stellen gleichzeitig verwendet wird, gibt es das fertige ConcurrentDictionary<K, V>: Methoden wie TryUpdate und AddOrUpdate prüfen und ändern in einem Schritt, ohne eigenes lock.
  • Für einen Zähler reicht Interlocked: Interlocked.Increment(ref count) erhöht die Zahl unteilbar. count++ ist nicht unteilbar: Es ist „lesen, addieren, schreiben“.
  • In einer echten Anwendung steht das Lager in einer Datenbank. Dort löst man dasselbe Problem mit Transaktionen und mit einer Bedingung in der Abfrage selbst: „verringere nur, wenn es über 0 ist“.

Sieh es dir an

Das Programm auf der anderen Seite

Das Partnercafé weiß nichts von den Controllern. Es kennt die Adressen und die Form des JSON. Praktisch ist es, die Anfragen in einer Klasse zu halten:

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() wirft eine HttpRequestException, wenn der Status nicht 2xx ist. So bleibt ein Fehler des Servers nicht unbemerkt. Das Café fügt ein Produkt hinzu, holt die Kaffeesorten nach Preis sortiert und versucht ein ungültiges Produkt:

var catalog = new CatalogClient("http://localhost:7267");
int id = await catalog.AddAsync(new Product { Name = "Guatemala Antigua", Price = 13.20m, Category = "kaffee" });
Console.WriteLine("Hinzugefügt mit id " + id);
List<Product> coffee = (await catalog.AllAsync()).Where(p => p.Category == "kaffee").OrderBy(p => p.Price).ToList();
foreach (Product p in coffee)
{
Console.WriteLine(p.Name + ": " + p.Price + " €");
}
try
{
await catalog.AddAsync(new Product { Name = "Ohne Preis" });
}
catch (HttpRequestException e)
{
Console.WriteLine("Abgelehnt: " + (int)e.StatusCode);
}
Ausgabe
Hinzugefügt mit id 5
Colombia Supremo: 12.50 €
Guatemala Antigua: 13.20 €
Ethiopia Yirgacheffe: 14.90 €
Abgelehnt: 400
Genauer, für Neugierige
  • Swagger / OpenAPI beschreibt die API in einer Standard-JSON-Datei: Adressen, Methoden, Form der Bodys, Status. Daraus bekommst du eine Seite, auf der du die API im Browser ausprobieren kannst, und du kannst Clients in anderen Sprachen erzeugen. In .NET 10 ist die Beschreibung eingebaut (AddOpenApi und MapOpenApi), und die Seite zum Ausprobieren kommt aus einem eigenen Paket, etwa Scalar.AspNetCore oder Swashbuckle.AspNetCore.
  • CORS: Standardmäßig lässt der Browser JavaScript von shop.example.bg keine Antworten von api.example.bg lesen. Der Server erlaubt ausdrücklich, welche Websites es dürfen, mit builder.Services.AddCors(...) und app.UseCors(...). Für Programme außerhalb des Browsers, wie dieses, spielt CORS keine Rolle.
  • ASP.NET Core kann auch mit XML antworten, wenn der Client es mit dem Header Accept: application/xml verlangt und der Server AddXmlSerializerFormatters() hat. Das Format nach dem Header zu wählen heißt Content Negotiation.
  • Ein HttpClient wird für viele Anfragen verwendet. Ein neuer für jede Anfrage verbraucht unter hoher Last die Verbindungen des Computers.

Übung

Eine API für Aufgaben

Löse das in deinem Editor, in einem Projekt aus dotnet new webapi --use-controllers oder in einer Datei wie in der Lektion.

Eine API für Aufgaben (Todo). TodoItem hat eine Id, einen Title (Pflicht, mit [Required]) und Done. TodosController unter api/todos hat die fünf Actions: alle, eine nach id (oder 404), erstellen (201 mit CreatedAtAction), mit PUT ersetzen (204 oder 404) und löschen (204 oder 404). Die Aufgaben liegen in einem Singleton-Service TodoList, der bei jedem Lesen und Schreiben mit lock sperrt. Eine neue Aufgabe bekommt die nächste Nummer, auch wenn ältere gelöscht wurden.

Der Client ruft der Reihe nach auf: zwei Aufgaben, eine Aufgabe ohne Titel, markiert die erste als erledigt, liest sie, löscht die zweite, sucht sie und liest zum Schluss alle. Er gibt die Methode, den Status und den Body aus, wenn die Anfrage geklappt hat und einen Body hat:

Ausgabe
POST -> 201 {"id":1,"title":"Filter für die Kaffeemaschine bestellen","done":false}
POST -> 201 {"id":2,"title":"Den Teelieferanten anrufen","done":false}
POST ohne Titel -> 400
PUT 1 -> 204
GET 1 -> 200 {"id":1,"title":"Filter für die Kaffeemaschine bestellen","done":true}
DELETE 2 -> 204
GET 2 -> 404
GET -> 200 [{"id":1,"title":"Filter für die Kaffeemaschine bestellen","done":true}]

Dann füge GET /api/todos?done=false hinzu, das nur die nicht erledigten Aufgaben zurückgibt: einen Parameter bool? done an GetAll und ein Where.

Test

Teste dich

1. Welche Adresse und Methode folgen REST für „Produkt 7 löschen“?
2. Welchen Status gibt ein erfolgreiches POST zurück, das ein Produkt erstellt?
3. Product hat [Required] an Name. Was passiert mit einem POST ohne Namen an einen Controller mit [ApiController]?
4. Hundert Anfragen reservieren gleichzeitig die letzten 10 Stück. Warum kann ohne lock mehr verkauft werden?
5. Was gibt DELETE zurück, wenn das Löschen geklappt hat?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·