ASP.NET s podporou knihovny React

29.07.2023

Založení ASP.NET projektu s podporou React knihovny a TypeScriptu, včetně nastavení error handlingu pomocí ProblemDetails objektu na straně backendu.

Slovníček

React JavaScriptová frontend knihovna pro tvorbu single-page aplikací (SPA).
SPA Frontend aplikace napsaná v JavaScriptu, kterou webový server odešle uživateli. Tato aplikace pak komunikuje se serverem výhradně prostřednictvím API a sama si generuje HTML a CSS. Jednou z výhod oproti přístupu, kdy server posílá celou stránku, je rychlejší odezva.
ASP.NET Open-source a multiplatformní webový framework pro .NET.
TypeScript Nadstavba nad JavaScriptem, která přidává typování a rozšiřuje jeho funkcionalitu.
React Query Knihovna, která zjednodušuje načítání a správu dat z API.
Axios JavaScriptový HTTP klient pro komunikaci s API.
Node.js JavaScriptový runtime. Node.js používá V8 runtime engine, který je integrován v prohlížečích, například v Chrome a Edge. Díky tomu JavaScript funguje v prohlížeči, i když uživatel nemá runtime nainstalovaný v operačním systému.
CORS CORS (Cross-Origin Resource Sharing) je mechanismus, který umožňuje provádět AJAX požadavky 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, aby povolil cross-origin požadavky z určitých adres na jeho vlastní adresu. CORS kontrola slouží proti neoprávněným cross-origin požadavkům a přispívá k ochraně před CSRF/XSRF útoky.
AJAX Asynchronous JavaScript and XML (AJAX) je technologie, která umožňuje výměnu dat mezi prohlížečem a serverem na pozadí stránky, aniž by bylo nutné ji znovu načítat. JavaScript odešle HTTP požadavek a zpracuje odpověď.
Problem Details Specifikace RFC 7807: Problem Details for HTTP APIs, jak by měl vypadat chybový objekt v 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. Založíme projekt a připravíme backend, kde povolíme CORS, upravíme serializaci payloadu a následně nastavíme pravidla pro error handling.

Backend

K založení projektu budeme potřebovat Visual Studio 2022 a Node.js. Po instalaci Node.js 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 Web, a nastavíme podporu pro .NET 7.
Vytvořil se nám standardní ASP.NET projekt, který obsahuje frontend ve složce CientApp. Protože projekt obsahuje starší verzi Reactu a nemá podporu TypeScriptu, složku CientApp 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 využijeme a zkopírujeme následující soubory:
Z původního package.json souboru také zkopírujeme sekci Scripts a použijeme ji 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í složku ClientAppOrig 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 během vývoje běží backend i frontend na různý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 JSON objekt překládá na C# objekt jako case-sensitive. Protože JavaScript obvykle posílá JSON vlastnosti malými písmeny, povolíme case-insensitive na true, aby se vlastnosti správně mapovaly 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 nutné 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í zaregistruje službu a zároveň upraví odpověď tak, aby obsahovala identifikátor TraceId. Ten lze zobrazit uživateli a použít k dohledání chybové hlášky v logu. Druhá metoda 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 následovně:
// Register the service.
AddProblemDetails(builder); 

builder.Services.AddControllersWithViews();

var app = builder.Build();

// Add related middlewares.
UseProblemDetails(app); 
Neodchycená chyba bude mít 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 vyvolat vlastní chybu zavoláním metody Problem v controlleru, například:
public IActionResult Get()
{
    return Problem(detail: "Supplier does not exist.", statusCode: 404);
}
ta klientovi vrátí následující JSON objekt:

{
  "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 také třída ValidationProblemDetails, která dokáže vygenerovat ProblemDetails z objektu ModelState. 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 pokud model není validní (uživatel nevyplnil vlastnost Bar), server automaticky vrátí instanci třídy ProblemDetails 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 chceme z controlleru 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í ProblemDetails objekt s 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 se při spuštění projektu spustí jak backend, tak frontend. To však není praktické pro vývoj, a proto si vytvoříme nový launch profil, který spustí pouze backend. Díky tomu budeme moci backend a frontend ovládat samostatně podle potřeby. Upravíme soubor Web > Properties > launchSettings.json, zkopírujeme stávající profil Web a vytvoříme nový profil BackendOnly. V něm odstraníme položku "launchBrowser": true nebo ji 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í a otevře v samostatném okně konzole:
Frontend pak můžeme spustit ze složky ClientApp pomocí následujícího příkazu:
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 zaměřil především na třídu Problem Details, která zjednodušuje a standardizuje zpracování chybových API odpovědí na frontendu. Nastavení Reactu, zpracování a volání API se pak pokusím popsat v dalším článku. 🚀

Odkazy