← Wszystkie wpisy

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.

Artur Kot 8 min czytania

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.

Tematy: aspnetdotnetnarzedzia

Pobieranie ruszyło

DevJourney_1.0.0_x64-setup.exe · 1.0.0

Jeśli Windows ostrzeże

Komunikat „System Windows ochronił Twój komputer” nie oznacza wykrycia wirusa. SmartScreen ufa certyfikatowi, którym podpisany jest instalator, a ten jeszcze zbiera reputację poprzez licznik pobrań.

W oknie ostrzeżenia: Więcej informacji → Uruchom mimo to.

Sprawdź to sam

Zobacz wynik skanowania w VirusTotal

Albo w PowerShellu, w katalogu z pobranym plikiem:

Get-FileHash .\DevJourney_1.0.0_x64-setup.exe

Powinno wypisać:

7cd001be4463317f601b8bb2eed78537982a3559c92f06e96288434c79931fd6