Architektura Aplikacji Internetowych / Programowanie .NET
Rok akademicki: 2026/27
Instrukcja laboratoryjna: Architektura ASP.NET Core Razor Pages (.NET 8/9), Entity Framework Core, Minimal API, Identity oraz Lokalizacja
Prowadzący: Tomasz Goluch
Wersja: 1.0
1. Wprowadzenie i paradygmat Page-Centric Architecture
Cel: Zrozumienie założeń architektury zorientowanej
na strony (Page-Centric), różnic względem wzorca MVC, cyklu
życia klasy PageModel oraz mechanizmów routingu
plikowego.
ASP.NET Core Razor Pages to nowoczesny, wspierany
natywnie przez platformę .NET 8 LTS / .NET 9 model tworzenia
dynamicznych aplikacji webowych generowanych po stronie serwera
(Server-Side Rendering). W przeciwieństwie do klasycznego
wzorca MVC, który rozprasza pojedynczą funkcjonalność pomiędzy trzy
odległe katalogi (Controllers/, Views/,
Models/), Razor Pages grupuje widok (.cshtml)
oraz powiązaną z nim logikę prezentacji (.cshtml.cs – klasa
dziedzicząca po PageModel) w jedną spójną jednostkę
organizacyjną (zgodnie z zasadą High Cohesion).
- Widok Strony (
.cshtml): Rozpoczyna się od obowiązkowej dyrektywy@page, która przekształca plik szablonu bezpośrednio w punkt końcowy HTTP (Endpoint), eliminując konieczność pośrednictwa kontrolera. - Model Strony (
PageModelw.cshtml.cs): Realizuje wzorzec Page Controller / MVVM. Separuje logikę obsługi żądań HTTP (OnGetAsync,OnPostAsync) oraz stan widoku od znaczników HTML, umożliwiając wstrzykiwanie zależności (DI) i bezpośrednie testowanie jednostkowe. - Routing oparty o strukturę katalogów (File-System
Routing): Adres URL strony wynika bezpośrednio z jej
fizycznej lokalizacji wewnątrz katalogu
Pages/(np. plikPages/Rooms/Index.cshtmlodpowiada adresowi/Roomslub/Rooms/Index).
Żądanie HTTP (np. POST /Rooms/Create)
│
▼
[ Potok Middleware ] (HTTPS, StaticFiles, Routing, Localization, AuthN/AuthZ)
│
▼
[ MapRazorPages() ] ──► Dopasowanie ścieżki URL do pliku w katalogu /Pages
│
▼
[ Klasa PageModel (Create.cshtml.cs) ]
│ 1. Wstrzyknięcie serwisów przez konstruktor (DI: DbContext, ILogger)
│ 2. Automatyczny Model Binding właściwości oznaczonych [BindProperty]
│ 3. Walidacja ModelState i wywołanie metody Handlera: OnPostAsync()
▼
[ Warstwa Danych (EF Core DbContext / Serwisy Domenowe) ]
│
├──► Sukces (PRG): RedirectToPage("./Index") ──► HTTP 302 Found
│
└──► Błąd walidacji: return Page()
│
▼
[ Widok Razor (Create.cshtml) ] ──► Renderowanie HTML ──► HTTP 200 OK
🧠 Kiedy wybrać Razor Pages, a kiedy klasyczne MVC lub Minimal API?
- Razor Pages: Rekomendowane przez Microsoft podejście do tworzenia aplikacji zorientowanych na formularze i interfejs użytkownika (systemy rezerwacyjne, panele administracyjne, portale klienckie CRUD). Mniejsza złożoność strukturalna niż w MVC przy zachowaniu pełnej mocy silnika Razor i filtrów ASP.NET Core.
- ASP.NET Core MVC: Sprawdza się w rozbudowanych systemach o skomplikowanym routingu dynamicznym, gdzie jeden kontroler agreguje wiele heterogenicznych widoków lub formatów odpowiedzi.
- Minimal API: Najlżejszy sposób wystawiania punktów końcowych REST/JSON (np. dla aplikacji mobilnych, SPA czy klientów konsolowych), który można bezproblemowo łączyć w jednym projekcie z Razor Pages.
2. Środowisko uruchomieniowe, narzędzia CLI i pakiety NuGet
Cel: Inicjalizacja projektu Razor Pages z użyciem .NET CLI, instalacja narzędzi generatora kodu (Scaffolding) oraz uruchomienie systemu z bazą danych Entity Framework Core.
1. Wymagania narzędziowe
- Środowisko IDE: Visual Studio 2022 (17.8+) z pakietem „Opracowywanie zawartości dla platformy ASP.NET i sieci Web” lub Visual Studio Code z rozszerzeniem C# Dev Kit.
- SDK: .NET 8.0 (LTS) lub .NET 9.0.
- Dokumentacja referencyjna: Microsoft Learn – Razor Pages in ASP.NET Core (.NET 8).
2. Tworzenie projektu i instalacja narzędzi z wiersza poleceń (.NET CLI)
# Utworzenie projektu Razor Pages z indywidualnymi kontami użytkowników (ASP.NET Core Identity)
dotnet new webapp -n RazorPagesLabApp -f net8.0 --auth Individual
cd RazorPagesLabApp
# Instalacja globalnych narzędzi EF Core oraz generatora kodu (Scaffolding)
dotnet tool install --global dotnet-ef
dotnet tool install --global dotnet-aspnet-codegenerator
# Instalacja pakietu wspierającego generowanie kodu i Swaggera
dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design
dotnet add package Swashbuckle.AspNetCore
3. Opcje uwierzytelniania przy tworzeniu projektu
Podczas inicjalizacji szablonu aplikacji webowej w Visual Studio lub
CLI (--auth) dostępne są cztery główne tryby:
- Brak (
None): Brak wbudowanej warstwy logowania; identyczny dostęp dla każdego klienta. - Pojedyncze konta (
Individual): Lokalna baza danych użytkowników i ról oparta o ASP.NET Core Identity (IdentityDbContext), przechowująca bezpieczne skróty haseł (PBKDF2), tokeny resetowania haseł oraz obsługująca weryfikację dwuetapową (2FA) i potwierdzenia e-mail. - Platforma tożsamości Microsoft (
SingleOrg/MultiOrg): Integracja z chmurą Microsoft Entra ID (dawniej Azure Active Directory) oraz Office 365 w oparciu o protokoły OpenID Connect / OAuth 2.0. - Uwierzytelnianie systemu Windows
(
Windows): Przeznaczone dla sieci intranetowych (Active Directory) z wykorzystaniem protokołów Kerberos / NTLM na serwerze IIS lub Kestrel.
3. Architektura Stron Razor, Handlery Żądań i Wiązanie Modelu
Cel: Opanowanie konfiguracji usług Razor Pages w
Program.cs, metod obsługi żądań (Page Handlers),
atrybutu [BindProperty] oraz konwencji przekierowań.
1. Konfiguracja w pliku
Program.cs
Obsługa stron Razor wymaga zarejestrowania usług w kontenerze DI przed zbudowaniem aplikacji oraz zmapowania punktów końcowych w potoku HTTP:
var builder = WebApplication.CreateBuilder(args);
// 1. Rejestracja usług Razor Pages w kontenerze DI
builder.Services.AddRazorPages();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
// 2. Mapowanie punktów końcowych dla plików .cshtml w katalogu /Pages
app.MapRazorPages();
app.Run();
2. Budowa strony Razor
i klasy PageModel
Każda strona składa się z pary plików umieszczonych w katalogu
Pages/ (np. Pages/ServerInfo.cshtml oraz
Pages/ServerInfo.cshtml.cs):
Plik widoku Pages/ServerInfo.cshtml:
@page "{format?}"
@model RazorPagesLabApp.Pages.ServerInfoModel
@{
ViewData["Title"] = "Status Serwera";
}
<div class="card shadow-sm p-3">
<h2>@ViewData["Title"]</h2>
<p class="lead">@Model.Message</p>
</div>
Plik logiki Pages/ServerInfo.cshtml.cs:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace RazorPagesLabApp.Pages;
public class ServerInfoModel : PageModel
{
public string Message { get; private set; } = "Inicjalizacja modelu strony C#.";
// Handler obsługujący żądanie HTTP GET /ServerInfo
public void OnGet(string? format)
{
var currentTime = DateTime.Now.ToString(format ?? "yyyy-MM-dd HH:mm:ss");
Message = $"Czas na serwerze Kestrel: {currentTime}";
}
}
3. Metody obsługi żądań (Page Handlers) i Named Handlers
W przeciwieństwie do kontrolerów MVC, w których nazwy metod akcji są
dowolne, w Razor Pages metody sterujące zachowaniem strony noszą nazwy
powiązane z czasownikiem HTTP i prefiksem On:
OnGet()/OnGetAsync()– inicjalizuje stan danych niezbędny do wyrenderowania strony lub formularza.OnPost()/OnPostAsync()– przetwarza dane przesłane formularzem, weryfikujeModelState.IsValid, zapisuje zmiany w bazie i wykonuje przekierowanie.- Nazwane handlery (Named Handlers): Jeśli
jedna strona zawiera wiele przycisków lub formularzy realizujących różne
operacje POST (np. anulowanie rezerwacji oraz blokowanie terminu),
definiujemy metody
OnPostCancelAsync(int id)orazOnPostBlockAsync(int id), a w widoku wskazujemy je za pomocą tag helperaasp-page-handler:
Wygeneruje to żądanie pod adres:<form method="post" asp-page-handler="Cancel" asp-route-id="@item.Id"> <button type="submit" class="btn btn-warning">Anuluj rezerwację</button> </form>POST /Reservations?id=5&handler=Cancel.
4. Typy zwracane przez Handlery
voidlubTask– automatycznie kończy działanie handlera wyrenderowaniem bieżącej strony.cshtml(kod HTTP 200).IActionResultlubTask<IActionResult>– pozwala dynamicznie decydować o rezultacie:return Page();– renderuje bieżącą stronę (odpowiednikreturn View()w MVC; stosowany przy błędach walidacji formularza).return RedirectToPage("./Index");– wykonuje przekierowanie HTTP 302 do innej strony Razor (wzorzec Post-Redirect-Get). Aby przekierować do strony w innym obszarze (Area), przekazujemy parametrnew { area = "Identity" }.return NotFound();,return Forbid();,return File(...);– standardowe rezultaty HTTP.
5. Wiązanie danych za
pomocą [BindProperty]
W Razor Pages ze względów bezpieczeństwa domyślnie wiązane są
wyłącznie parametry przekazywane do argumentów metody handlera. Aby
automatycznie powiązać właściwości klasy PageModel z polami
formularza HTML:
- Oznaczamy właściwość atrybutem
[BindProperty](działa domyślnie tylko dla żądań POST/PUT/DELETE, chroniąc przed manipulacją stanem w zapytaniach GET). - Jeśli chcemy wiązać parametr paska adresu (QueryString / RouteData)
również podczas żądania
GET(np. filtry wyszukiwania sali lub zakresu dat), jawnie ustawiamy flagęSupportsGet = true:[BindProperty(SupportsGet = true)] public DateOnly? SearchDate { get; set; }
4.
Entity Framework Core, Relacje, Scaffolding i Silnie Typowane Listy
(SelectList)
Cel: Automatyczne generowanie stron CRUD w oparciu o
kontekst bazy danych EF Core, konfiguracja relacji kluczy obcych oraz
prawidłowa implementacja kontrolek <select>.
1. Modelowanie relacji 1:N w Entity Framework Core
Aby generator kodu (Scaffolding) oraz EF Core prawidłowo rozpoznały
relację między encjami (np. Product należący do
Producer lub Reservation przypisana do
Room), w klasie encji podrzędnej definiujemy zarówno
właściwość klucza obcego (ProducerId), jak i właściwość
nawigacyjną (Producer):
using System.ComponentModel.DataAnnotations;
namespace RazorPagesLabApp.Core.Entities;
public class Producer
{
public int Id { get; set; }
[Required, StringLength(100)]
[Display(Name = "Nazwa producenta")]
public string Name { get; set; } = string.Empty;
public ICollection<Product> Products { get; set; } = new List<Product>();
}
public class Product
{
public int Id { get; set; }
[Required, StringLength(150)]
[Display(Name = "Nazwa produktu")]
public string Name { get; set; } = string.Empty;
[Display(Name = "Producent")]
public int ProducerId { get; set; }
// Właściwość nawigacyjna (nullable, aby uniknąć fałszywych błędów ModelState przy samym zbindowaniu ProducerId!)
public Producer? Producer { get; set; }
}
⚠️ Pułapka Nullable Reference Types (
.NET 8/9) w encjach EF Core!
Od wersji .NET 6 projekty mają domyślnie włączoną flagę<Nullable>enable</Nullable>. Jeśli właściwość nawigacyjnapublic Producer Producer { get; set; }nie zostanie oznaczona znakiem zapytania (Producer?), walidator ASP.NET Core uzna ją za pole wymagane ([Required]). Ponieważ formularz przesyła z listy rozwijanej wyłącznie klucz obcyProducerId(np.5), właściwość obiektowaProducerpozostajenull, aModelState.IsValidzawsze zwrócifalse! Oznaczaj właściwości nawigacyjne jako opcjonalne (?) lub stosuj dedykowane modele widoku /[ValidateNever].
2.
Generowanie stron CRUD (Scaffolding) i rejestracja
DbContext
W środowisku Visual Studio klikamy prawym przyciskiem myszy na
utworzonym podfolderze (np. Pages/Products) i wybieramy:
Add → New Scaffolded Item → Razor Pages using Entity Framework
(CRUD). Z wiersza poleceń operację tę wykonujemy
następująco:
dotnet aspnet-codegenerator razorpage -m Product -dc ApplicationDbContext -udl -outDir Pages/Products --referenceScriptLibraries
W pliku Program.cs rejestrowany jest kontekst bazy
danych w kontenerze DI:
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Brak ciągu połączenia 'DefaultConnection'.")));
(Podczas testów można tymczasowo przełączyć się na bazę w pamięci
RAM, wywołując options.UseInMemoryDatabase("TestDb"),
natomiast przy przejściu na fizyczną bazę SQL wymagane jest
wygenerowanie i zaaplikowanie migracji:
dotnet ef migrations add InitialCreate oraz
dotnet ef database update).
3. Dyrektywy i Helpery w wygenerowanych stronach
@Html.DisplayNameFor(model => model.Product.Name)– odczytuje metadane właściwości (nazwę pola C# z rozbiciem PascalCase na spacje lub wartość z atrybutu[Display(Name = "...")]). Wyrażenie lambda jest tu wyłącznie analizowane drzewem wyrażeń (Expression Tree), więc nie zgłosi wyjątkuNullReferenceException, nawet gdy kolekcja jest pusta.@Html.DisplayFor(model => item.Name)– bezpiecznie formatuje i wyświetla wartość właściwości (uwzględniając np.[DataType(DataType.Date)]lub walutę).@RenderBody()– wywoływane w szablonie głównymPages/Shared/_Layout.cshtml; wskazuje miejsce wstrzyknięcia zawartości bieżącej strony Razor.@* Komentarz serwerowy Razor *@– w przeciwieństwie do komentarza HTML<!-- -->, jest całkowicie wycinany na serwerze i nigdy nie trafia do przeglądarki klienta.
4.
Implementacja listy rozwijanej (<select> / ComboBox)
w sposób silnie typowany
Domyślny kod ze scaffoldingu często wykorzystuje niezalecany słownik
ViewData["ProducerId"] lub ViewBag.
Dobrą praktyką inżynierską jest zdefiniowanie silnie
typowanej właściwości SelectList bezpośrednio w klasie
PageModel:
Klasa Pages/Products/Create.cshtml.cs:
public class CreateModel : PageModel
{
private readonly ApplicationDbContext _context;
public CreateModel(ApplicationDbContext context)
{
_context = context;
}
[BindProperty]
public Product Product { get; set; } = default!;
// Silnie typowana kolekcja opcji dla kontrolki <select>
public SelectList ProducerOptions { get; set; } = default!;
public async Task<IActionResult> OnGetAsync()
{
await PopulateProducersDropDownListAsync();
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
// KRYTYCZNE: Przy błędzie walidacji protokół HTTP jest bezstanowy –
// musimy ponownie załadować opcje listy, inaczej <select> będzie pusty!
await PopulateProducersDropDownListAsync(Product.ProducerId);
return Page();
}
_context.Products.Add(Product);
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
private async Task PopulateProducersDropDownListAsync(object? selectedProducer = null)
{
var producersQuery = _context.Producers
.AsNoTracking()
.OrderBy(p => p.Name);
ProducerOptions = new SelectList(
await producersQuery.ToListAsync(),
dataValueField: nameof(Producer.Id),
dataTextField: nameof(Producer.Name),
selectedValue: selectedProducer);
}
}
Widok Pages/Products/Create.cshtml:
@page
@model RazorPagesLabApp.Pages.Products.CreateModel
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
<div class="mb-3">
<label asp-for="Product.Name" class="form-label"></label>
<input asp-for="Product.Name" class="form-control" />
<span asp-validation-for="Product.Name" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Product.ProducerId" class="form-label"></label>
<select asp-for="Product.ProducerId"
asp-items="Model.ProducerOptions"
class="form-select">
<option value="">-- Wybierz producenta z listy --</option>
</select>
<span asp-validation-for="Product.ProducerId" class="text-danger"></span>
</div>
<button type="submit" class="btn btn-primary">Zapisz produkt</button>
</form>
5. Zestawienie najważniejszych Tag Helperów w Razor Pages
| Tag Helper | Przykładowy element HTML | Opis działania |
|---|---|---|
asp-page |
<a asp-page="./Details">...</a> |
Generuje atrybut href
wskazujący na stronę Razor (ścieżka względna ./ lub
bezwzględna /). |
asp-page-handler |
<button asp-page-handler="Cancel"> |
Kieruje żądanie formularza lub linku do
konkretnej metody OnPostCancel /
OnGetCancel. |
asp-route-{key} |
<a asp-page="./Edit" asp-route-id="@item.Id"> |
Przekazuje parametr trasy lub ciągu
zapytania (np. /Products/Edit?id=5 lub
/Products/Edit/5). |
asp-for |
<input asp-for="Product.Name" /> |
Wiąże pole z właściwością modelu: generuje
id, name, type,
value oraz atrybuty walidacji data-val-*. |
asp-items |
<select asp-for="..." asp-items="Model.List"> |
Generuje listę znaczników
<option value="...">Tekst</option> na podstawie
kolekcji IEnumerable<SelectListItem>. |
asp-validation-for |
<span asp-validation-for="Product.Name"></span> |
Stosowany ze znacznikiem
<span> – wyświetla błąd walidacji pojedynczego pola
modelu. |
asp-validation-summary |
<div asp-validation-summary="ModelOnly"></div> |
Stosowany ze znacznikiem
<div> – wyświetla podsumowanie błędów
(All, ModelOnly lub None). |
5. Internacjonalizacja i Lokalizacja (I18n / L10n) w Razor Pages
Cel: Wdrożenie pełnego wsparcia wielojęzycznego
(pl-PL, en-US) z wykorzystaniem plików zasobów
.resx, zestawów satelickich (Satellite Assemblies)
oraz lokalizacji adnotacji danych i formatów dat.
1. Architektura zasobów i Satellite Assemblies
Wszystkie ciągi tekstowe zależne od języka i kultury są podczas
kompilacji przekształcane z plików XML (*.resx) do
binarnych plików zasobów (*.resources) w katalogu
obj/, a następnie osadzane w tzw. zestawach
satelickich (Satellite Assemblies) umieszczanych w
podkatalogach wynikowych
bin/<Configuration>/net8.0/<kod-kultury>/ (np.
bin/Debug/net8.0/pl-PL/RazorPagesLabApp.resources.dll).
2.
Konwencja nazewnictwa plików .resx (Kluczowe
rozróżnienie!)
W ASP.NET Core istnieją dwa sposoby wstrzykiwania tłumaczeń, różniące
się oczekiwaną nazwą pliku .resx w katalogu
Resources/:
Lokalizacja oparta na typie klasy (
IStringLocalizer<T>lub zasoby współdzielone):- Ścieżka pliku
.resxodpowiada przestrzeni nazw i nazwie klasy C# (bez rozszerzenia.cshtml!). - Dla klasy modelu
Pages/Products/IndexModel(namespace RazorPagesLabApp.Pages.Products):Resources/Pages/Products/IndexModel.pl-PL.resxResources/Pages/Products/IndexModel.en-US.resx
- Zasoby współdzielone (Shared Resources):
Aby uniknąć duplikowania słów takich jak „Zapisz”,
„Anuluj”, „Usuń”, tworzymy pustą klasę-znacznik
(Marker Class) w głównym katalogu projektu lub w
Resources/SharedResource.cs:
oraz odpowiadające jej plikinamespace RazorPagesLabApp; public class SharedResource { }Resources/SharedResource.pl-PL.resxiResources/SharedResource.en-US.resx.
- Ścieżka pliku
Lokalizacja oparta na ścieżce widoku (
IViewLocalizer):- Ścieżka pliku
.resxodwzorowuje dokładną ścieżkę pliku szablonu.cshtml:- Widok:
Pages/Products/Index.cshtml - Zasób (PL):
Resources/Pages/Products/Index.cshtml.pl-PL.resx - Zasób (EN):
Resources/Pages/Products/Index.cshtml.en-US.resx
- Widok:
- Ścieżka pliku
💡 Wskazówka: Podczas korzystania z
IStringLocalizer<T>lubIViewLocalizerw .NET 8/9 nie trzeba włączać generowania klas C# w edytorze zasobów (Access Modifier: No code generationjest wystarczające), chyba że chcemy odwoływać się do właściwości statycznych w atrybutach[Display(Name = "RoomName", ResourceType = typeof(...))]. Znacznie wygodniejszym rozwiązaniem dla adnotacji jest automatyczna konfiguracjaDataAnnotationsLocalizationze wspólną klasąSharedResource.
3.
Rejestracja i konfiguracja potoku lokalizacji w
Program.cs
using System.Globalization;
using Microsoft.AspNetCore.Localization;
// 1. Rejestracja usług lokalizacji ze wskazaniem katalogu Resources
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
builder.Services.AddRazorPages()
.AddViewLocalization()
.AddDataAnnotationsLocalization(options =>
{
// Wszystkie atrybuty [Required], [Display], [Range] będą tłumaczone przez jeden plik SharedResource!
options.DataAnnotationLocalizerProvider = (type, factory) =>
factory.Create(typeof(SharedResource));
});
var app = builder.Build();
// 2. Definicja wspieranych kultur (formatowanie dat/liczb oraz interfejs UI)
var supportedCultures = new[]
{
new CultureInfo("pl-PL"),
new CultureInfo("en-US")
};
var localizationOptions = new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture("pl-PL"),
SupportedCultures = supportedCultures,
SupportedUICultures = supportedCultures
};
// Domyślnie RequestLocalizationOptions zawiera już wbudowanych dostawców w kolejności:
// 1. QueryStringRequestCultureProvider (?culture=en-US&ui-culture=en-US)
// 2. CookieRequestCultureProvider (.AspNetCore.Culture)
// 3. AcceptLanguageHeaderRequestCultureProvider (nagłówek przeglądarki)
// WAŻNE: UseRequestLocalization musi znaleźć się PRZED MapRazorPages()!
app.UseRequestLocalization(localizationOptions);
4. Wykorzystanie
w widokach i klasach PageModel
Dodajemy globalne importy w
Pages/_ViewImports.cshtml:
@using Microsoft.Extensions.Localization
@using Microsoft.AspNetCore.Mvc.Localization
@inject IStringLocalizer<SharedResource> SharedLocalizer
W widoku .cshtml lub klasie PageModel:
public class IndexModel : PageModel
{
private readonly IStringLocalizer<SharedResource> _localizer;
public IndexModel(IStringLocalizer<SharedResource> localizer)
{
_localizer = localizer;
}
public void OnGet()
{
ViewData["Title"] = _localizer["AvailableSlotsHeader"];
}
}
6. Minimal API, Dokumentacja OpenAPI (Swagger) i Klient HTTP
Cel: Udostępnienie lekkich punktów końcowych REST
API obok stron Razor Pages, separacja modelu domenowego poprzez obiekty
DTO oraz komunikacja z poziomu aplikacji konsolowej przy użyciu
HttpClient i System.Text.Json.
1. Dlaczego obiekty DTO (Data Transfer Objects) są niezbędne?
Bezpośrednie zwracanie lub przyjmowanie encji bazodanowych EF Core przez API prowadzi do:
- Błędów cyklicznej referencji podczas serializacji JSON
(
Room→Availabilities→Room), - Wycieku wrażliwych struktur bazy danych (np. skrótów haseł z
IdentityUser), - Podatności Over-Posting.
Dlatego we współdzielonej bibliotece klas (.Core)
definiujemy lekkie rekordy lub klasy DTO:
namespace ProjectDefense.Core.DTOs;
public record RoomDto(int Id, string Name, string RoomNumber, int Capacity);
public record AvailableSlotDto(
int SlotId,
string RoomName,
string LecturerName,
DateTime StartTime,
DateTime EndTime);
public record BookSlotRequestDto(int SlotId, string StudentEmail);
2.
Konfiguracja Minimal API oraz Swagger UI w Program.cs
// 1. Rejestracja generatora OpenAPI / Swagger i serwisów biznesowych
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddScoped<IReservationService, ReservationService>();
var app = builder.Build();
// 2. Aktywacja oprogramowania pośredniczącego Swagger
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(); // Dostępne pod adresem: https://localhost:<port>/swagger
}
// 3. Grupowanie i mapowanie punktów końcowych Minimal API z użyciem TypedResults
var api = app.MapGroup("/api").AllowAnonymous();
api.MapGet("/rooms", async (IReservationService service) =>
{
var rooms = await service.GetAllRoomsAsync();
return TypedResults.Ok(rooms);
})
.WithName("GetRooms")
.WithOpenApi();
api.MapGet("/slots/available", async (int? roomId, IReservationService service) =>
{
var slots = await service.GetAvailableSlotsAsync(roomId);
return TypedResults.Ok(slots);
})
.WithName("GetAvailableSlots")
.WithOpenApi();
api.MapPost("/slots/{id:int}/book", async (int id, BookSlotRequestDto request, IReservationService service) =>
{
if (id != request.SlotId)
{
return Results.BadRequest(new { error = "Niezgodność identyfikatora slotu w adresie URL i treści żądania." });
}
var result = await service.BookSlotAsync(id, request.StudentEmail);
if (!result.IsSuccess)
{
return Results.Conflict(new { error = result.ErrorMessage });
}
return Results.Ok(new { message = "Termin został pomyślnie zarezerwowany." });
})
.WithName("BookSlot")
.WithOpenApi();
3.
Nowoczesny klient konsolowy (HttpClient +
System.Net.Http.Json)
W .NET 8/9 nie korzystamy już z zewnętrznej biblioteki
Newtonsoft.Json ani ręcznego kodowania tablic bajtów.
Wbudowana przestrzeń nazw System.Net.Http.Json zapewnia
asynchroniczne metody rozszerzające GetFromJsonAsync oraz
PostAsJsonAsync:
using System.Net.Http.Json;
using ProjectDefense.Core.DTOs;
using var client = new HttpClient
{
BaseAddress = new Uri("https://localhost:7125")
};
// 1. Pobranie listy dostępnych terminów (HTTP GET)
try
{
var slots = await client.GetFromJsonAsync<List<AvailableSlotDto>>("/api/slots/available");
if (slots is { Count: > 0 })
{
foreach (var s in slots)
{
Console.WriteLine($"[ID: {s.SlotId}] Sala: {s.RoomName} | {s.StartTime:dd.MM.yyyy HH:mm} - {s.EndTime:HH:mm} | Prowadzący: {s.LecturerName}");
}
}
else
{
Console.WriteLine("Brak wolnych terminów obron.");
}
// 2. Wysłanie żądania rezerwacji terminu (HTTP POST)
var bookingRequest = new BookSlotRequestDto(SlotId: 12, StudentEmail: "jan.kowalski@student.pg.edu.pl");
HttpResponseMessage response = await client.PostAsJsonAsync($"/api/slots/{bookingRequest.SlotId}/book", bookingRequest);
if (response.IsSuccessStatusCode)
{
Console.WriteLine("Rezerwacja zakończona sukcesem!");
}
else
{
// Prawidłowy asynchroniczny odczyt treści błędu z odpowiedzi HTTP
string errorPayload = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Błąd rezerwacji ({(int)response.StatusCode}): {errorPayload}");
}
}
catch (HttpRequestException ex)
{
Console.WriteLine($"Błąd komunikacji sieciowej z serwerem API: {ex.Message}");
}
7.
Uwierzytelnianie, Autoryzacja oparta na Rolach i Wysyłka E-mail
(IEmailSender)
Cel: Konfiguracja ASP.NET Core Identity z obsługą
ról (Lecturer, Student), zabezpieczenie
folderów i stron Razor oraz implementacja usługi potwierdzania
rejestracji przez e-mail.
1.
Konfiguracja Identity z obsługą Ról i wymuszoną autoryzacją w
Program.cs
Domyślna metoda
AddDefaultIdentity<ApplicationUser> nie aktywuje
menedżera ról (RoleManager<IdentityRole>). Należy
jawnie wywołać .AddRoles<IdentityRole>():
builder.Services.AddDefaultIdentity<ApplicationUser>(options =>
{
options.SignIn.RequireConfirmedAccount = true; // Wymaga potwierdzenia adresu e-mail
options.Password.RequiredLength = 8;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();
// Definicja polityk autoryzacji i konwencji dostępu do folderów Razor Pages
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("RequireLecturerRole", policy => policy.RequireRole("Lecturer"));
options.AddPolicy("RequireStudentRole", policy => policy.RequireRole("Student"));
// FallbackPolicy: każdy niezalogowany użytkownik jest automatycznie przekierowywany
// do strony logowania (z wyjątkiem stron oznaczonych [AllowAnonymous], np. Login/Register/API)
options.FallbackPolicy = options.DefaultPolicy;
});
builder.Services.AddRazorPages(options =>
{
options.Conventions.AuthorizeFolder("/Lecturer", "RequireLecturerRole");
options.Conventions.AuthorizeFolder("/Student", "RequireStudentRole");
});
Alternatywnie pojedynczą klasę PageModel można
zabezpieczyć deklaratywnie za pomocą atrybutu:
[Authorize(Roles = "Lecturer")]
public class ManageRoomsModel : PageModel { ... }
2.
Implementacja IEmailSender (SendGrid / SMTP) i kodowanie
linków HTML
Podczas rejestracji konta z włączoną opcją
RequireConfirmedAccount = true, ASP.NET Core Identity
generuje token weryfikacyjny i wywołuje metodę
SendEmailAsync z interfejsu
Microsoft.AspNetCore.Identity.UI.Services.IEmailSender.
🧠 Dlaczego w linku potwierdzającym pojawia się
&i jak to poprawnie obsłużyć?
Domyślny szablon Identity wRegister.cshtml.cskoduje wygenerowany adres URL za pomocąHtmlEncoder.Default.Encode(callbackUrl), zamieniając znak&pomiędzy parametramiuserIdicodena encję HTML&, i przekazuje gotowy znacznik<a href='...'>jako parametrhtmlMessage.
Jeśli wyślesz tę wiadomość jako zwykły tekst (plain/text) zamiast HTML (text/html), klient pocztowy nie zdekoduje&z powrotem do&, co uszkodzi parametrcodepodczas kliknięcia! Zawsze wysyłaj wiadomość jako treść HTML (lub w przypadku prostych bramek tekstowych zdekoduj encje przezWebUtility.HtmlDecode).
Przykład produkcyjnej, w pełni asynchronicznej implementacji dla usługi SendGrid:
using Microsoft.AspNetCore.Identity.UI.Services;
using SendGrid;
using SendGrid.Helpers.Mail;
namespace ProjectDefense.Web.Services;
public class SendGridEmailSender : IEmailSender
{
private readonly ILogger<SendGridEmailSender> _logger;
private readonly IConfiguration _configuration;
public SendGridEmailSender(ILogger<SendGridEmailSender> logger, IConfiguration configuration)
{
_logger = logger;
_configuration = configuration;
}
public async Task SendEmailAsync(string toEmail, string subject, string htmlMessage)
{
var apiKey = _configuration["SendGrid:ApiKey"];
var fromEmail = _configuration["SendGrid:FromEmail"] ?? "no-reply@projectdefense.local";
if (string.IsNullOrWhiteSpace(apiKey))
{
// W środowisku deweloperskim logujemy link aktywacyjny do konsoli,
// aby umożliwić testowanie bez zewnętrznego klucza API
_logger.LogWarning("[DEV EMAIL FALLBACK] Do: {To} | Temat: {Subject} | Treść HTML: {Body}",
toEmail, subject, htmlMessage);
return;
}
var client = new SendGridClient(apiKey);
var from = new EmailAddress(fromEmail, "System Rezerwacji Project Defense");
var to = new EmailAddress(toEmail);
// Piąty parametr to htmlContent - SendGrid automatycznie ustawi nagłówek MIME text/html
var msg = MailHelper.CreateSingleEmail(from, to, subject, plainTextContent: null, htmlContent: htmlMessage);
var response = await client.SendEmailAsync(msg);
if (response.IsSuccessStatusCode)
{
_logger.LogInformation("Wiadomość e-mail do {Email} została pomyślnie zakolejkowana.", toEmail);
}
else
{
_logger.LogError("Błąd wysyłki e-mail do {Email}. Kod statusu: {StatusCode}", toEmail, response.StatusCode);
}
}
}
Rejestracja serwisu w Program.cs:
builder.Services.AddTransient<IEmailSender, SendGridEmailSender>();
8.
Generowanie i Eksport Raportów Plikowych (.txt,
.xlsx, .pdf)
Cel: Strumieniowe generowanie dokumentów
bezpośrednio w pamięci RAM (MemoryStream) i zwracanie ich z
handlera Razor Pages za pomocą File(...) z użyciem
bibliotek ClosedXML oraz QuestPDF.
dotnet add package ClosedXML
dotnet add package QuestPDF
Przykładowy handler w klasie PageModel zwracający plik
na żądanie prowadzącego:
using System.Text;
using ClosedXML.Excel;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
public async Task<IActionResult> OnGetExportAsync(int roomId, string format)
{
var slots = await _reservationService.GetSlotsForExportAsync(roomId);
switch (format.ToLowerInvariant())
{
case "txt":
var sb = new StringBuilder();
sb.AppendLine($"HARMONOGRAM REZERWACJI - SALA #{roomId}");
sb.AppendLine(new string('=', 50));
foreach (var s in slots)
{
sb.AppendLine($"{s.StartTime:yyyy-MM-dd HH:mm} - {s.EndTime:HH:mm} | Student: {s.StudentEmail ?? "WOLNY TERMIN"}");
}
byte[] txtBytes = Encoding.UTF8.GetBytes(sb.ToString());
return File(txtBytes, "text/plain; charset=utf-8", $"Harmonogram_Sala_{roomId}.txt");
case "xlsx":
using (var workbook = new XLWorkbook())
{
var worksheet = workbook.Worksheets.Add("Rezerwacje");
worksheet.Cell(1, 1).Value = "Początek";
worksheet.Cell(1, 2).Value = "Koniec";
worksheet.Cell(1, 3).Value = "Sala";
worksheet.Cell(1, 4).Value = "Student";
worksheet.Range("A1:D1").Style.Font.Bold = true;
int row = 2;
foreach (var s in slots)
{
worksheet.Cell(row, 1).Value = s.StartTime.ToString("yyyy-MM-dd HH:mm");
worksheet.Cell(row, 2).Value = s.EndTime.ToString("HH:mm");
worksheet.Cell(row, 3).Value = s.RoomName;
worksheet.Cell(row, 4).Value = s.StudentEmail ?? "Wolny";
row++;
}
worksheet.Columns().AdjustToContents();
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return File(
stream.ToArray(),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
$"Harmonogram_Sala_{roomId}.xlsx");
}
case "pdf":
// Wymagana deklaracja licencji Community dla biblioteki QuestPDF
QuestPDF.Settings.License = LicenseType.Community;
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Margin(40);
page.Size(PageSizes.A4);
page.Header().Text($"Harmonogram Obron Projektów – Sala #{roomId}").FontSize(18).Bold();
page.Content().PaddingVertical(15).Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.ConstantColumn(130);
columns.ConstantColumn(80);
columns.RelativeColumn();
});
table.Header(header =>
{
header.Cell().Text("Początek").Bold();
header.Cell().Text("Koniec").Bold();
header.Cell().Text("Przypisany Student").Bold();
});
foreach (var s in slots)
{
table.Cell().Text(s.StartTime.ToString("dd.MM.yyyy HH:mm"));
table.Cell().Text(s.EndTime.ToString("HH:mm"));
table.Cell().Text(s.StudentEmail ?? "--- WOLNY ---");
}
});
});
});
byte[] pdfBytes = document.GeneratePdf();
return File(pdfBytes, "application/pdf", $"Harmonogram_Sala_{roomId}.pdf");
default:
return BadRequest("Nieobsługiwany format eksportu.");
}
}