React s ASP.NET - První část

29.07.2023

Založení projektu s podporou TypeScriptu a příprava backendu.

Slovníček

React JavaScriptová frontend knihovna pro tvorbu single page aplikací (SPA).
SPA Ve zkratce se jedná o typ aplikace, kde server uživateli odešle celou aplikaci napsanou v JavaScriptu a ta pak komunikuje se serverem pouze pomocí API a HTML/CSS si generuje sama. Standardně server posílá celou stránku, kterou prohlížeč uživateli pouze zobrazí. Výhodou z pohledu uživatele může být lepší responsivita stránky.
ASP.NET Open-source a cross-platform webový framework pro .NET
TypeScript Nadstavba nad JavaScriptem, která přidává typování a další funkcionality.
React Query Knihovna, která zjednodušuje načítání a správu dat z API.
Axios JavaScript HTTP client.
NodeJS JavaScriptový runtime. NodeJS používá V8 runtime/engine, který mají prohlížeče (Chrome, Edge) již integrovaný. Proto JavaScript funguje v prohlížeči i pokud uživatel nemá runtime nainstalovaný.
CORS Cross-origin resource sharing. Cross-origin je AJAX požadavek na jinou adresu než je adresa načtené stránky v prohlížeči. Ve výchozím stavu prohlížeč takové požadavky nepovoluje. Webový server ale může prohlížeč instruovat, že má cross-origin požadavky z určitých adres na jeho adresu povolit. CORS kontrola slouží k zabránění CSRF/XSRF útoku.
AJAX Asynchronous JavaScript and XML. Jedná se o výměnu dat mezi prohlížečem a serverem na pozadí stránky bez nutnosti její načtení. JavaScript pošle HTTP požadavek a zpracuje odpověď.
Problem Details RFC specifikace jak má vypadat chybový objekt z HTTP API.

Obsah

Úvod

Use case, který budu popisovat počítá s tím, že frontend a backend poběží na stejném webovém serveru a chceme mít frontend i backend společně v jednom .NET projektu. Nejdříve si založíme projekt a připravíme backend, kde povolíme CORS, upravíme serializaci payloadu a nastavíme pravidla pro error handling. V navazujícím članku bych se pak chtěl věnovat připravě frontendu.

Backend

K založení projektu budeme potřebovat Visual Studio 2022 a NodeJS. Po instalaci NodeJS zaktualizujeme balíčkovacího manažera.

npm install -g npm

Založení projektu

Vytvoříme solution
dotnet new solution --output Suppliers
Přidáme projekt, který pojmenujeme třeba Web a nastavíme podporu pro .NET 7
Vytvořil se nám standardní asp.net projekt, který obsahuje frontend v CientApp složce. Protože projekt obsahuje starou verzi Reactu a nemá podporu TypeScriptu, ClientApp složku prozatím přejmenujeme a vytvoříme nový React projekt.
Supplires\Web> ren ClientApp ClientAppOrig
Supplires\Web> npx create-react-app clientapp --template typescript
Supplires\Web> ren clientapp ClientApp
Původní složku ale využijeme a překopírujeme následující soubory
Z původního package.json také zkopírujeme Scripts sekci a použijeme jí v novém package.json souboru
"scripts": {
  "prestart": "node aspnetcore-https && node aspnetcore-react",
  "start": "rimraf ./build && react-scripts start",
  "build": "react-scripts build",
  "test": "cross-env CI=true react-scripts test --env=jsdom",
  "eject": "react-scripts eject",
  "lint": "eslint ./src/"
}
Smažeme původní ClientAppOrig složku a spustíme projekt z Visual Studia. Měli bychom vidět úvodní React stránku bez chyb v konzoli.

Povolení CORS

Protože nám při vývoji backend i frontend beží na jiných adresách, musíme na serveru v Program.cs povolit cross-origin požadavky.
app.UseRouting();

if (app.Environment.IsDevelopment()) app.UseCors(c => c.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin());

Serializace payloadu

Pokud máme endpoint, který čte HTTP payload pomocí atributu FromBody
[HttpPost]
public IActionResult Post([FromBody] CreateCommand command)
tak ve výchozím stavu se překládá JSON objekt na C# objekt jako case-sensitive. Protože JavaScript většinou posílá JSON vlastnosti malými písmeny, povolíme case-insensitive na true, aby se nám správně vlastnosti mapovali na C# objekty.
builder.Services.AddControllersWithViews().AddJsonOptions(options =>
{
    options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
});

Problem Details

Od verze .NET 7 je k dispozici middleware, který neodchycené výjimky vrací jako JSON objekt podle RFC specifikace. V nižších verzích bylo potřeba si vytvořit vlastní middleware nebo nainstalovat externí nuget balíček. Oba způsoby dobře popisuje Andrew Lock zde a zde 🙃.

Do Program.cs přidáme dvě nové metody. První metoda registruje službu a je upravená tak, aby do odpovědi přidala i TraceId identifikátor, který můžeme zobrazit uživateli a podle kterého pak můžeme dohledat detail chybové hlášky v logu. Druhá metoda pak registruje související middleware.
private static void AddProblemDetails(WebApplicationBuilder builder)
{
    builder.Services.AddProblemDetails(options =>
        options.CustomizeProblemDetails = (context) =>
        {
            // Add traceId property if not present.
            if (!context.ProblemDetails.Extensions.ContainsKey("traceId"))
            {
                string? traceId = Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
                context.ProblemDetails.Extensions.Add(new KeyValuePair<string, object?>("traceId", traceId));
            }
        }
    );
}

private static void UseProblemDetails(WebApplication app)
{
    // By default it will use problem details service to generate a response.
    // If problem details service is not registered, the code will not compile.
    app.UseExceptionHandler();
    // If not used, server does not return response data and browser will display default page.
    // If used, server will return plain text, eg. "Status Code: 404; Not Found".
    // If used and problem details service is registered, it will return problem details JSON object.
    app.UseStatusCodePages(); 
}
Obě metody zavoláme
// Register the service.
AddProblemDetails(builder); 

builder.Services.AddControllersWithViews();

var app = builder.Build();

// Add related middlewares.
UseProblemDetails(app); 
Neodchycená chyba pak má následující JSON formát
{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
  "title": "An error occurred while processing your request.",
  "status": 500,
  "traceId": "00-f295935a77f29d4ad98159f7807cd785-da67c41344fb2161-00"
}
Případně můžeme vyhodit i vlastní chybu pomocí zavolání metody Problem v controlleru, například
public IActionResult Get()
{
    return Problem(detail: "Supplier does not exist.", statusCode: 404);
}
to klientovi vrátí

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
  "title": "Not Found",
  "status": 404,
  "detail": "Supplier does not exist.",
  "traceId": "00-aa127a7034a652229d622556315d1c18-609ce6911edc5f2c-00"
}
Validation Problem Details
Existuje ještě třída ValidationProblemDetails, která umí vygenerovat ProblemDetails z ModelState objektu. Pokud například použijeme atribut Required v input modelu
public class FooCommand
{
    [Required]
    public string Bar { get; set; } = null!;
}

[HttpPost]
public IActionResult Post([FromBody] FooCommand command)
{ 
    // ...
}
tak server, v situaci kdy model není validní (klient nevyplnil vlastnost Bar) automaticky vrátí problem details třídu s vyplněným polem errors
{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-77e6a72d78280bcb2cc05ffb018d9c32-18efd333f17af08b-00",
    "errors": {
        "Bar": [
            "The Name field is required."
        ]
    }
}
Pokud bychom z controlleru chtěli vrátit vlastní seznam chyb, můžeme použít metodu ValidationProblem
ModelState.AddModelError("error key", "error message");
if (!ModelState.IsValid) return ValidationProblem();
která nám vrátí problem details s námi definovaným seznamem chyb. Status kód a další vlastnosti lze upravit v parametrech metody
{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-b0da05020e6d173a2a62ae0dd6c43365-a28deb9cc9f2057e-00",
    "errors": {
        "error key": [
            "error message"
        ]
    }
}

Launch profil

Ve výchozím stavu, pokud spustíme projekt, se nám spustí backend i frontend. To je nepraktické pro vývoj a proto si vytvoříme nový launch profil, který nám spustí pouze backend. Budeme tak moci backend a frontend ovládat zvlášť dle potřeby. Upravíme soubor Web > Properties > launchSettings.json a zkopírujeme stávající Web profil do nového BackendOnly profilu a umažeme "launchBrowser": true, případně nastavíme na false.
"profiles": {
  "Web": {
    "commandName": "Project",
    "launchBrowser": true,
    "applicationUrl": "https://localhost:7085;http://localhost:5160",
    "environmentVariables": {
      "ASPNETCORE_ENVIRONMENT": "Development",
      "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy"
    }
  },
  "BackendOnly": {
    "commandName": "Project",
    "launchBrowser": false,
    "applicationUrl": "https://localhost:7085;http://localhost:5160",
    "environmentVariables": {
      "ASPNETCORE_ENVIRONMENT": "Development",
      "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy"
    }
  }
}
Po spuštění profilu
se backend spustí v samostatném okně konzole
Frontend pak můžeme spustit z ClientApp složky následujícím příkazem
npm run start

Závěr

Ukázali jsme si jak založit a nakonfigurovat ASP.NET projekt s React frameworkem a podporou TypeScriptu. V backendu jsem se hlavně věnoval Problem Details třídě, která zjednodušuje a standardizuje zpracovávání API odpovědí na frontedu. Nastavení Reactu, zpracovávání a volání API se pak pokusím popsat v dalším článku. 🚀

Odkazy