Twoje pierwsze API ASP.NET Core
Cztery mapowania tras w jednym Program.cs dają działający serwer HTTP. Zobacz realne odpowiedzi, nagłówki i dwa błędy, na które trafisz przy pierwszym POST.
Poniżej masz kompletne, działające API zamknięte w jednym pliku: cztery trasy, zero kontrolerów, zero osobnych warstw serwisowych. Tyle wystarczy na pierwsze API ASP.NET Core, które odpowiada na prawdziwe żądania HTTP, a nie tylko wypisuje jeden napis w konsoli.
Ten wpis pokazuje, jak wygląda pierwsze API ASP.NET Core krok po kroku: od
pustego szablonu, przez cztery trasy operujące na liście zadań, po
sprawdzenie odpowiedzi przez curl i dwa błędy, na które trafisz przy
pierwszym POST.
Krok 0: sprawdź, czy szablon już działa
Zanim dopiszesz cokolwiek własnego, warto zobaczyć pusty szablon w akcji.
Polecenie dotnet new web tworzy projekt z jednym endpointem:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
Sześć linii. To już jest kompletna aplikacja ASP.NET Core. WebApplication
konfiguruje serwer Kestrel, MapGet rejestruje trasę, Run startuje
nasłuchiwanie. Jeżeli to działa u Ciebie identycznie, cała reszta wpisu to
już tylko dopisywanie kolejnych map do tego samego pliku.
Zauważ, czego tu nie ma. Żadnego osobnego pliku Startup.cs, żadnej
konfiguracji w XML-u. Cała aplikacja mieści się w jednym miejscu, które
czytasz od góry do dołu.
Podczas pracy nad kolejnymi trasami wygodniej jest uruchomić dotnet watch run zamiast zwykłego dotnet run. Serwer sam się restartuje po każdym
zapisaniu pliku. Nie trzeba ręcznie zatrzymywać procesu i wpisywać
polecenia od nowa przy każdej zmianie.
Cztery trasy zamiast jednego endpointu
Zamiast zwracać sam tekst, poniższy Program.cs trzyma listę zadań w
pamięci i udostępnia ją przez cztery trasy. Najpierw sama konfiguracja i typ
danych, bez skrótów:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var tasks = new List<TaskItem>
{
new(1, "Skonfigurować projekt", true),
new(2, "Napisać pierwszy endpoint", false),
};
TaskItem to record, czyli typ zbudowany pod dokładnie taki przypadek:
kilka pól tylko do odczytu, bez potrzeby pisania konstruktora ani
przesłaniania Equals. Sama definicja mieści się w jednej linii, na samym
końcu pliku:
record TaskItem(int Id, string Title, bool Done);
A tak wyglądają same trasy, dopisane pod tasks, razem z app.Run(), które
uruchamia nasłuchiwanie:
app.MapGet("/", () => "API działa.");
app.MapGet("/tasks", () => tasks);
app.MapGet("/tasks/{id:int}", (int id) =>
{
var task = tasks.FirstOrDefault(t => t.Id == id);
return task is null ? Results.NotFound() : Results.Ok(task);
});
app.MapPost("/tasks", (TaskItem task) =>
{
tasks.Add(task);
return Results.Created($"/tasks/{task.Id}", task);
});
app.Run();
Każda linia z Map zwraca coś innego. To jest sedno minimal API: nie ma
domyślnego zachowania do nadpisania, jest tylko to, co faktycznie napisałeś.
MapGet("/tasks", ...) zwraca listę, więc ASP.NET Core sam ją serializuje
do JSON-a. MapGet("/tasks/{id:int}", ...) zwraca Results.NotFound()
albo Results.Ok(), czyli jawnie wybiera kod odpowiedzi zamiast zgadywać go
z typu zwracanego. {id:int} w trasie odrzuca od razu wartości, które nie
są liczbą, zanim kod w środku w ogóle się wykona.
Uruchamiasz go tak samo jak szablon:
dotnet run
Wszystkie polecenia z tego wpisu uruchomiono na SDK w wersji 10.0.302, czyli wersji instalowanej razem z .NET 10. A konsola pokazuje realny adres i port, na którym serwer właśnie nasłuchuje:
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5299
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
Content root path: /tmp/minimal-api-demo
Sprawdzanie odpowiedzi bez pisania frontendu
Napisanie interfejsu, który wywołuje API, to osobny projekt. Do sprawdzenia,
czy trasa robi to, co powinna, wystarczy curl z flagą -D -, która
dopisuje nagłówki odpowiedzi przed treścią:
curl -D - http://localhost:5299/tasks
Odpowiedź na to konkretne wywołanie wygląda tak:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Wed, 09 Sep 2026 10:17:32 GMT
Server: Kestrel
Transfer-Encoding: chunked
[{"id":1,"title":"Skonfigurować projekt","done":true},{"id":2,"title":"Napisać pierwszy endpoint","done":false}]
Content-Type: application/json potwierdza, że ASP.NET Core rozpoznał
listę rekordów i sam dobrał format. Warto też spojrzeć na same nazwy pól w
treści: id, title i done zaczynają się małą literą, mimo że we
właściwościach rekordu TaskItem są zapisane wielką, jako Id, Title
i Done. Taki zapis to domyślne ustawienie serializatora JSON w ASP.NET
Core. Nic tu nie trzeba włączać osobno. Ułatwia to później współpracę z
frontendem pisanym w JavaScripcie, gdzie camelCase jest przyjętą konwencją
nazewnictwa pól.
Zapytanie o pojedyncze zadanie, które istnieje, daje 200 OK z jednym
obiektem zamiast tablicy. Zapytanie o numer, którego nie ma na liście,
trafia w gałąź Results.NotFound() i zwraca to:
HTTP/1.1 404 Not Found
Content-Length: 0
Date: Wed, 09 Sep 2026 10:17:32 GMT
Server: Kestrel
Zero w Content-Length mówi tyle samo, co sam kod: ciało odpowiedzi jest
puste, bo Results.NotFound() nic nie zwraca poza samym statusem.
Dodanie zadania przez POST z ciałem JSON zwraca 201 Created i nagłówek
Location wskazujący nowy zasób, tak jak ustawia to Results.Created w
kodzie:
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /tasks/3
Server: Kestrel
{"id":3,"title":"Dodac testy","done":false}
Cztery żądania, cztery różne kody odpowiedzi. Żadne z nich nie wymagało otwierania przeglądarki.
Dwa błędy, na które trafisz przy pierwszym POST
Pierwszy błąd pojawia się, gdy wyślesz to samo ciało JSON, ale bez
nagłówka Content-Type:
curl -X POST http://localhost:5299/tasks -d '{"id":4,"title":"Bez naglowka","done":false}'
HTTP/1.1 415 Unsupported Media Type
Content-Length: 0
Server: Kestrel
Minimal API wiąże parametr TaskItem task z ciała żądania, ale robi to
tylko wtedy, gdy nagłówek jednoznacznie mówi, że ciało jest JSON-em. Bez
niego framework odrzuca żądanie, zanim jeszcze spróbuje sparsować treść,
więc curl musi jawnie dodać -H "Content-Type: application/json".
Drugi błąd wychodzi z niepoprawnego JSON-a, na przykład brakującego nawiasu:
curl -X POST http://localhost:5299/tasks -H "Content-Type: application/json" -d '{"id":5,"title":'
HTTP/1.1 400 Bad Request
Content-Length: 0
Server: Kestrel
Tym razem nagłówek jest poprawny, ale parser JSON-a odrzuca ciało, zanim
którakolwiek linia z MapPost w ogóle zostanie wywołana. Różnica między
kodami nie jest przypadkowa: 415 oznacza, że serwer w ogóle nie chciał
czytać treści, a 400 mówi, że próbował i mu się nie udało. Oba warto
rozpoznawać. Zdarzają się na poziomie frameworku, nie w Twoim kodzie. To
jest coś, czego żaden opis w dokumentacji nie pokazuje bez uruchomienia
prawdziwego żądania.
Kiedy minimal API przestaje wystarczać
Cztery trasy w jednym pliku czyta się bez trudu. Piętnaście już nie,
zwłaszcza gdy trzy z nich potrzebują tej samej walidacji wejścia albo tego
samego odczytu z bazy danych. Wtedy pojawia się pokusa, żeby wydzielić
logikę do osobnej metody, ale sama metoda nadal znajduje się w tym samym
pliku Program.cs. Problem się przenosi, a nie znika.
Jednym krokiem pośrednim, zanim dojdzie do pełnych kontrolerów, jest
MapGroup: pozwala pogrupować kilka tras pod wspólnym przedrostkiem i
wspólnymi ustawieniami, bez przenoszenia logiki do osobnych klas. Wywołanie
app.MapGroup("/tasks") sprawia, że każda trasa zarejestrowana na tej
grupie nie musi już powtarzać segmentu /tasks w swoim adresie. To
rozwiązuje problem powtarzania się w konfiguracji tras, ale nie rozwiązuje
problemu dużego pliku, jeżeli sama logika biznesowa też rośnie. Sam podział
tras to za mało.
Naturalny moment na kontrolery i osobne warstwy przychodzi, gdy trasy zaczynają współdzielić coś więcej niż tylko listę w pamięci: prawdziwą bazę danych przez EF Core, uwierzytelnianie, albo walidację, która musi działać identycznie w pięciu różnych miejscach. Do tego czasu jeden plik jest szybszy do napisania i łatwiejszy do przeczytania niż struktura katalogów zaprojektowana pod projekt, który jeszcze nie istnieje. Lista zadań w pamięci z tego wpisu zniknie zresztą przy pierwszym restarcie serwera, więc to i tak tylko punkt wyjścia do dłuższej rozmowy o prawdziwym przechowywaniu danych, a nie jej koniec.
DevJourney prowadzi przez ASP.NET Core dopiero po tym, jak w programie
kursu pojawią się klasy, LINQ i praca z prawdziwą bazą, bo
dopiero wtedy minimal API ma z czym konkurować. Zanim do tego dojdzie,
warto umieć czytać komunikat błędu
kompilatora i mieć zainstalowane
SDK. Oba przydają się już
przy pierwszym dotnet run.