Pierwsze REST API w ASP.NET Core — od zera do CRUD

Rozumiesz już protokół HTTP — metody, kody statusu, nagłówki. Czas postawić serwer, który tym mówi. W tym poście od pustego katalogu dojdziesz do działającego REST API z pełnym CRUD-em: listowanie, pobieranie po ID, tworzenie, aktualizacja i usuwanie zasobów — z poprawnymi kodami odpowiedzi, nie „wszystko zwraca 200″.
Projekt w 30 sekund
dotnet new web -o SklepApi
cd SklepApi
dotnet runSzablon web daje najcieńszy możliwy start — jeden plik Program.cs, zero kontrolerów, zero ceremonii:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "API działa");
app.Run();WebApplication.CreateBuilder konfiguruje serwer (Kestrel), wstrzykiwanie zależności, logowanie i konfigurację. app.MapGet rejestruje endpoint. app.Run() startuje nasłuch. To wszystko — masz działający serwer HTTP.
Model i magazyn danych
Zasób API to zwykła klasa C#. Zacznijmy od produktu i prostego magazynu w pamięci (bazę podłączymy później przez Entity Framework Core):
public record Produkt(int Id, string Nazwa, decimal Cena);
public class MagazynProduktow
{
private readonly List<Produkt> _produkty = new()
{
new(1, "Klawiatura", 149.99m),
new(2, "Mysz", 79.00m)
};
private int _nastepneId = 3;
public List<Produkt> Wszystkie() => _produkty;
public Produkt? Znajdz(int id) => _produkty.FirstOrDefault(p => p.Id == id);
public Produkt Dodaj(string nazwa, decimal cena)
{
var produkt = new Produkt(_nastepneId++, nazwa, cena);
_produkty.Add(produkt);
return produkt;
}
public bool Usun(int id) => _produkty.RemoveAll(p => p.Id == id) > 0;
}Rejestrujesz magazyn jako singleton, żeby przetrwał między żądaniami:
builder.Services.AddSingleton<MagazynProduktow>();CRUD — pięć endpointów
Teraz mięso. Każdy endpoint mapuje metodę HTTP na operację i zwraca właściwy kod statusu:
// GET wszystkie — 200 z listą
app.MapGet("/api/produkty", (MagazynProduktow magazyn) =>
Results.Ok(magazyn.Wszystkie()));
// GET po ID — 200 albo 404
app.MapGet("/api/produkty/{id:int}", (int id, MagazynProduktow magazyn) =>
{
var produkt = magazyn.Znajdz(id);
return produkt is null ? Results.NotFound() : Results.Ok(produkt);
});
// POST — 201 Created + nagłówek Location
app.MapPost("/api/produkty", (NowyProdukt dane, MagazynProduktow magazyn) =>
{
var produkt = magazyn.Dodaj(dane.Nazwa, dane.Cena);
return Results.Created($"/api/produkty/{produkt.Id}", produkt);
});
// DELETE — 204 albo 404
app.MapDelete("/api/produkty/{id:int}", (int id, MagazynProduktow magazyn) =>
magazyn.Usun(id) ? Results.NoContent() : Results.NotFound());Zwróć uwagę na dwie rzeczy. Po pierwsze — MagazynProduktow pojawia się jako parametr, a ASP.NET Core sam go wstrzykuje z kontenera DI; nie tworzysz go ręcznie. Po drugie — Results.Created, Results.NoContent, Results.NotFound to dokładnie te kody, które omawialiśmy przy statusach HTTP. API mówi kodem, nie polem error w treści.
DTO — nie przyjmuj encji z zewnątrz
Endpoint POST przyjmuje NowyProdukt, a nie Produkt. To celowe:
public record NowyProdukt(string Nazwa, decimal Cena);Gdybyś przyjął Produkt, klient mógłby przesłać własne Id i nadpisać logikę serwera — to podatność zwana *over-posting* (mass assignment). DTO (Data Transfer Object) to osobny model wejścia, który zawiera wyłącznie pola, jakie klient ma prawo ustawić. Serwer sam decyduje o Id.
Nigdy nie binduj żądania wprost do encji bazodanowej. DTO na wejściu to nie ceremonia — to granica, na której odsiewasz pola, których klient nie powinien dotykać.
Model binding — skąd ASP.NET bierze dane
Framework sam wypełnia parametry endpointu z odpowiednich źródeł żądania:
| Parametr | Źródło | Przykład |
|---|---|---|
{id:int} w ścieżce | route | /api/produkty/5 → id = 5 |
Typ złożony (NowyProdukt) | body (JSON) | {"nazwa":"...","cena":9.99} |
| Typ prosty niebędący w route | query string | ?limit=10 → int limit |
| Zarejestrowany serwis | DI | MagazynProduktow magazyn |
Dzięki temu nie parsujesz JSON-a ani nie czytasz Request.Query ręcznie — deklarujesz, czego potrzebujesz, a binding to dostarcza. Szczegóły dopasowania ścieżek i wymuszania źródła ([FromQuery], [FromHeader]) rozwija Minimal API i routing.
Walidacja — 400, zanim dane wejdą głębiej
Puste żądanie nie powinno tworzyć produktu „bez nazwy za 0 zł”. Najprostsza bramka to jawne sprawdzenie i 400 Bad Request:
app.MapPost("/api/produkty", (NowyProdukt dane, MagazynProduktow magazyn) =>
{
if (string.IsNullOrWhiteSpace(dane.Nazwa) || dane.Cena <= 0)
return Results.BadRequest("Nazwa jest wymagana, a cena musi być dodatnia.");
var produkt = magazyn.Dodaj(dane.Nazwa, dane.Cena);
return Results.Created($"/api/produkty/{produkt.Id}", produkt);
});Testowanie API — OpenAPI
W .NET dodasz opis OpenAPI (dawniej Swagger) jednym pakietem, żeby klikalnie testować endpointy zamiast ręcznie sklejać żądania:
builder.Services.AddOpenApi();
// ...
app.MapOpenApi(); // dokument dostępny pod /openapi/v1.jsonPułapki pierwszego API
Pułapka #1 — synchroniczny dostęp do bazy. W tym poście magazyn jest w pamięci, więc metody są synchroniczne. Gdy podłączysz prawdziwą bazę, każda operacja I/O musi być async/await (await db.SaveChangesAsync()) — blokowanie wątku na I/O pod obciążeniem zabija przepustowość serwera.
Pułapka #2 — zwracanie encji zamiast DTO. Ten sam problem co na wejściu, tylko na wyjściu: zwracając encję bazodanową, wyciekasz pola wewnętrzne (hasła, flagi, klucze obce) i wiążesz kształt odpowiedzi ze schematem bazy. Mapuj encję na DTO odpowiedzi.
Pułapka #3 — POST bez Location. Results.Created wymaga URI utworzonego zasobu. Zwrócenie samego 201 bez nagłówka Location łamie kontrakt REST — klient nie wie, gdzie zasób teraz żyje.
Pułapka #4 — zły port / HTTPS w dev. Gdy „nie łączy się”, najczęściej patrzysz na zły port. Porty definiuje Properties/launchSettings.json; dotnet run wypisuje je w konsoli (Now listening on: https://localhost:7xxx).
Minimal API vs kontrolery
Powyższy kod to Minimal API. Alternatywą są kontrolery (klasy z atrybutami [ApiController], [HttpGet]). Który wybrać:
| Minimal API | Kontrolery | |
|---|---|---|
| Ceremonia | Minimalna | Więcej klas/atrybutów |
| Małe API, mikroserwis | ✅ idealne | Nadmiarowe |
| Duże API, wiele akcji | Rozłazi się | ✅ czytelna struktura |
| Filtry, konwencje | Ograniczone | ✅ pełne wsparcie |
Dla pierwszego API i mikroserwisów Minimal API wygrywa prostotą. Przy większym projekcie kontrolery porządkują kod — ale routing i tak działa na tych samych zasadach.
Podsumowanie
Działające REST API w ASP.NET Core to WebApplication, kilka Map* mapujących metody HTTP na operacje, magazyn wstrzykiwany przez DI i — co najważniejsze — poprawne kody statusu zwracane przez Results (200/201+Location/204/404/400). Dwie decyzje odróżniają zabawkę od API produkcyjnego: DTO na wejściu i wyjściu (zamiast gołych encji) oraz walidacja odsiewająca złe żądania na 400, zanim dotrą głębiej. Reszta — baza, routing, pipeline — dokłada się do tego szkieletu, nie zmienia go.
Co dalej
Endpointy już działają, ale skąd ASP.NET wie, że /api/produkty/5 to id = 5, a nie coś innego? Wyjaśnia to Minimal API i routing. Potem Middleware pokaże, co dzieje się z żądaniem, zanim trafi do Twojego endpointu, a Clean Architecture w .NET 10 — REST API — jak to wszystko poukładać w większym projekcie.
🚀 Co dalej?
Zobacz to w praktyce na wideo i pobierz darmową roadmapę, żeby ułożyć naukę w spójną ścieżkę do pierwszej pracy.
- 🗺️ Pobierz darmową roadmapę Junior .NET Developer — 12 kroków od podstaw C# do pierwszej pracy: dev-hobby.pl
- 🎬 Subskrybuj kanał YouTube — nowe filmy co tydzień.
Zamień wiedzę w umiejętności
Pobierz darmową Roadmapę .NET i ułóż takie tematy jak ten w spójną ścieżkę do pierwszej pracy.
Pobieram roadmapę →
3 comments