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/productssind alle Produkte,/api/products/2ist Produkt 2. In der Adresse stehen keine Verben wiegetProductoderdelete. - Was du mit der Ressource tust, sagt die HTTP-Methode:
GETliest,POSTerstellt,PUTersetzt,DELETElö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
- Die Ressource ist /api/products: 4 Produkte auf dem Server. Der Client kennt nur die Adresse.
- GET liest Produkt 3 (Sencha). Es ist da: 200 OK und das Produkt als JSON.
- POST an die Sammlung erstellt ein neues Produkt. Der Server gibt ihm die id 5: 201 Created.
- PUT ersetzt Produkt 2: Der Preis wird 11.90 €. Die Antwort hat keinen Body: 204 No Content.
- DELETE löscht Produkt 4: 204 No Content.
- GET für Produkt 4: Es ist jetzt weg, also 404 Not Found.
- 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).GetAllgibt eine Liste zurück, und daraus wird JSON.Getgibt einActionResult<Product>zurück: entweder ein Produkt (200 mit JSON) oder ein anderes Ergebnis, etwaNotFound().return product;ist erlaubt, weil C# das Produkt von selbst in einActionResult<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);[{"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 -> 404GetFromJsonAsync<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));}201, Location: http://localhost:7263/api/Products/5Das neue Produkt hat die id 5400Name: 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 selbstif (!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 nurerrorsaus. NotFound()gibt in einem API-Controller ebenfalls ein wenig JSON zurück (eineProblemDetailsmit 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);PUT /api/products/2 -> 20411.90 €DELETE /api/products/4 -> 204DELETE /api/products/4 -> 404PUT /api/products/4 -> 404PUT 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,PUTundDELETEsind idempotent: Sie zu wiederholen ist sicher, und das Ergebnis auf dem Server ist dasselbe.POSTist 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);Das PUT auf Produkt 3 klappt: 204. Das DELETE ebenfalls: 204. Danach findet das GET es nicht: 404. Das letzte POST hat einen leeren Namen, und [Required] akzeptiert keinen leeren Text, also gibt [ApiController] 400 zurück, bevor Create überhaupt erreicht wird.
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.
Ohne geschweifte Klammern ist "id" wörtlicher Text in der Adresse: Die Action antwortet nur auf /api/products/id. Dann ist der Parameter int id kein Teil des Pfads, und [ApiController] sucht ihn in der Query nach ?. Wenn er dort nicht steht, ist er 0, und das Produkt wird nicht gefunden. Die Adresse /api/products/2 passt nur zu PUT und DELETE, also ist ein GET darauf 405. Die Lösung ist [HttpGet("{id:int}")].
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:
201 201 201 201 201 201 201 201 201 201 409 409Gleichzeitig 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]; } }}syncist ein gewöhnliches Objekt, das nur als Schlüssel dient.new()ist kurz fürnew 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));201 Created: 10409 Conflict: 90Übrig: 0Mit 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
lockdarf keinawaitstehen, und der Compiler erlaubt es nicht.lockhält den Schlüssel für den Thread, der hineingegangen ist, und nach einemawaitkann der restliche Code in einem anderen Thread laufen. Wenn du auf etwas Asynchrones warten und dabei die Daten schützen musst, nimmst duSemaphoreSlimmitawait semaphore.WaitAsync(). - Der Block in einem
locksollte 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 wieTryUpdateundAddOrUpdateprüfen und ändern in einem Schritt, ohne eigeneslock. - 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);}Hinzugefügt mit id 5Colombia Supremo: 12.50 €Guatemala Antigua: 13.20 €Ethiopia Yirgacheffe: 14.90 €Abgelehnt: 400Genauer, 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 (
AddOpenApiundMapOpenApi), und die Seite zum Ausprobieren kommt aus einem eigenen Paket, etwaScalar.AspNetCoreoderSwashbuckle.AspNetCore. - CORS: Standardmäßig lässt der Browser JavaScript von
shop.example.bgkeine Antworten vonapi.example.bglesen. Der Server erlaubt ausdrücklich, welche Websites es dürfen, mitbuilder.Services.AddCors(...)undapp.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/xmlverlangt und der ServerAddXmlSerializerFormatters()hat. Das Format nach dem Header zu wählen heißt Content Negotiation. - Ein
HttpClientwird 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:
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 -> 400PUT 1 -> 204GET 1 -> 200 {"id":1,"title":"Filter für die Kaffeemaschine bestellen","done":true}DELETE 2 -> 204GET 2 -> 404GET -> 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
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Wie das Internet funktioniert · Ein Webserver mit Minimal API · MVC: Model, View, Controller · Views mit Razor
- Geht weiter in: Hotelbuchungen
- Aufgaben zum Thema: Web und Desktop