Zwracanie błędów z API, czyli ProblemDetails

Zwracanie błędów z API, czyli ProblemDetails

Wstęp

Często zdarza się, że tworzymy własne modele, którymi przesyłamy błędy z API do klienta API. Osobiście często miałem z tym rozkminy, jak to zrobić uniwersalnie. Na szczęście w 2016 roku w życie wszedł standard RFC7807, który opisuje ProblemDetails – uniwersalny sposób przesyłania błędów.

Standard ProblemDetails

Po co powstał ten standard? Przede wszystkim REST ma być uniwersalny. Twórcy dostrzegli problem typu „każdy wuj na swój strój” – każdy robił własne modele i standardy przesyłania informacji o błędach. Chodziło też o usprawnienie automatyzacji.

Przykładowo, człowiek siedzący za monitorem często z kontekstu może wydedukować, czy błąd 404 jest związany z brakiem strony, czy może z brakiem szukanego rekordu w bazie danych. Automaty niekoniecznie.

Czym jest ProblemDetails?

Z punktu widzenia dokumentacji to jest ustandaryzowany model, który wygląda tak:

{
    "type": "https://example.com/probs/out-of-credit",
    "title": "You do not have enough credit.",
    "detail": "Your current balance is 30, but that costs 50.",
    "status": 400,
    "instance": "/account/12345/msgs/abc",
 }

Teraz omówię każde z tych pól:

Type

To pole jest wymagane i powinno posiadać adres do strony HTML na temat danego błędu. Informacje mają być w formie, w której człowiek może je przeczytać. Jeśli nie możesz dać żadnego adresu, daj domyślną wartość „about:blank”.

Czy to znaczy, że musisz stworzyć strony HTML z opisem swoich błędów? Nie. Wystarczy, że wpiszesz tam adres standardowy opisujący dany błąd, np.: https://www.rfc-editor.org/rfc/rfc7231#section-6.5.4. Generalnie możesz odnieść się do poszczególnych sekcji ze strony: https://www.rfc-editor.org/rfc/rfc7231, na której są opisane już te błędy.

Klient API musi traktować to pole jako główne pole opisu błędu. Tzn. powinien wiedzieć, jak zareagować na przykładową wartość: https://www.rfc-editor.org/rfc/rfc7231#section-6.5.4.

Title

To ma być jakieś podsumowanie problemu czytelne dla człowieka. I tutaj uwaga – zawartość tego pola nie może się zmieniać. Tzn. że kilka „wywołań” tego samego problemu musi dać ten sam tytuł. Czyli nie zmieniaj tego w zależności od kontekstu.

Wyjątkiem od tej reguły może być lokalizowanie treści. Czyli inną możesz dać dla Niemca (po niemiecku), inną dla Polaka (po polsku).

Detail

Tutaj powinien znaleźć się opis konkretnej sytuacji czytelny dla człowieka. O ile pole title nie powinno się zmieniać w zależności od danych, to pole detail jak najbardziej może – tak jak widzisz w przykładzie. Pole jest opcjonalne.

Klient API nie powinien w żaden sposób parsować informacji zawartych w tym polu. Są inne możliwości, które pomagają w automatyzacji, o których piszę później (rozszerzenia).

Status

To pole jest opcjonalne. Jego wartością powinien być kod oryginalnego błędu. Powiesz – „ale po co, skoro kod błędu jest w odpowiedzi na żądanie?”. No niby jest. Ale niekoniecznie musi być oryginalny. Jeśli np. ruch przechodzi przez jakieś proxy albo w jakiś inny sposób jest modyfikowany, wtedy kod w odpowiedzi może być inny niż w oryginalnym wystąpieniu błędu. Dlatego w polu status zawsze podawaj oryginalny kod błędu.

Instance

Tutaj powinno znaleźć się miejsce, które spowodowało błąd. To też powinien być adres URI.

Rozszerzenia

Standard pozwala rozszerzać ProblemDetails. Tzn. możesz dodać do modelu inne właściwości. Teraz możesz powiedzieć: „I tu się kończy uniwersalność, dziękuję, do widzenia.”. I w pewnym sensie będziesz miał rację, ale tylko w pewnym sensie. Zauważ, że cały czas podstawowy model jest taki sam.

Przykład modelu z rozszerzeniem

{
   "type": "https://example.net/validation-error",
   "title": "Your request parameters didn't validate.",
   "status": 400,
   "invalid_params": [ {
                         "name": "age",
                         "reason": "must be a positive integer"
                       },
                       {
                         "name": "color",
                         "reason": "must be 'green', 'red' or 'blue'"}
                     ]
   }

Jak widzisz w przykładzie powyżej, doszło pole „invalid_params„. Takie rozszerzenia mogą być użyteczne dla klientów API lub po prostu posiadać dodatkowe informacje. Pamiętaj, że pole detail nie powinno być w żaden sposób parsowane. Więc, wracając do pierwszego przykładu, moglibyśmy dać dwa rozszerzenia, którymi klient API może się w jakiś sposób posłużyć:

{
    "type": "https://example.com/probs/out-of-credit",
    "title": "You do not have enough credit.",
    "detail": "Your current balance is 30, but that costs 50.",
    "status": 400,
    "instance": "/account/12345/msgs/abc",
    "balance": 30,
    "price": 50
 }

Generalnie rozszerzenia umożliwiają przekazanie większej ilości informacji w sposób, w jaki chcesz.

Czy muszę używać ProblemDetails?

Nie musisz. Jeśli masz już swój mechanizm, który działa, to go zostaw. ProblemDetails nie ma zamieniać istniejących mechanizmów. Ma dawać uniwersalność i brak konieczności tworzenia własnych rozwiązań.

Jeśli jednak zwracasz jakiś konkretny model, zawsze masz opcję żeby zrobić z niego swój ProblemDetails. Po prostu potraktuj swój model jako rozszerzenie. Wystarczy, że dodasz pola wymagane i będzie git.

Ale powtarzam – jeśli masz już swój działający mechanizm, niczego nie musisz robić. Nie musisz tego implementować na siłę. Jednak w nowych projektach – doradzam używanie ProblemDetails.

ProblemDetails w .NET

Na szczęście .NET ma już ogarnięte ProblemDetails i w zasadzie nie musisz tworzyć własnego mechanizmu. Różnice występują między .NET6 i .NET7. Nie piszę o wcześniejszych wersjach jako że już nie są wspierane. Jeśli utrzymujesz jakieś apki poniżej .NET6, powinieneś zmigrować do nowszej wersji.

Zwracanie problemu

W .NET standardowo możesz zwracać Problem w kontrolerze:

[HttpGet]
public IActionResult ReturnProblem()
{
    return Problem();
}

Oczywiście możesz przekazać mu wszystkie pola z modelu standardowego (jako parametry metody Problem). Jeśli tego nie zrobisz, .NET sam je uzupełni domyślnymi wartościami. Z powyższego przykładu uzyskasz taką zwrotkę:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
  "title": "An error occurred while processing your request.",
  "status": 500,
  "traceId": "00-4139aae6364cb68d2235c576689bb359-88964c6328a1d834-00"
}

Zauważ, że:

  • type odnosi do opisu błędu 500 (bo taki jest domyślnie zwracany przy problemie)
  • title – to jakaś ogólna domyślna wartość
  • status – jak pisałem wyżej, zwraca oryginalny kod błędu
  • traceId – to jest rozszerzenie .NET, które ułatwia szukanie błędów w logach

Piszemy rozszerzenie

Tutaj wszystko sprowadza się do zwrócenia obiektu ProblemDetails zamiast wywołania metody Problem(). Niestety metoda Problem() nie umożliwia dodawania rozszerzeń. Ale nic się nie bój – nie musisz wszystkiego tworzyć sam. Jest dostępna fabryka, którą wykorzystamy. Musisz ją wstrzyknąć do kontrolera, a potem użyć jej, żeby stworzyć instancję ProblemDetails:

public class ProblemController : ControllerBase
{
    private readonly ProblemDetailsFactory _problemDetailsFactory;
    public ProblemController(ProblemDetailsFactory problemDetailsFactory)
    {
        _problemDetailsFactory = problemDetailsFactory;
    }

    [HttpGet]
    public IActionResult ReturnProblem()
    {
        var result = _problemDetailsFactory.CreateProblemDetails(HttpContext);

        var extObject = new
        {
            errorType = "LoginProblem",
            systemCode = 10
        };
        result.Extensions["additionalData"] = extObject;

        return Unauthorized(result);
    }
}

Wszystko powinno być dość jasne. Tylko zwróć uwagę na jedną rzecz. Właściwość Extensions z klasy ProblemDetails to jest słownik. Zwykły Dictionary<string, object>. Co nam to daje? Kluczem musi być string (to będzie nazwa pola), ale za to wartością może być już cokolwiek. Może być string, liczba jakaś, a nawet cały obiekt – to, co zrobiliśmy tutaj.

Utworzyłem jakiś anonimowy obiekt i dodałem go jako rozszerzenie. W efekcie ten obiekt zostanie zserializowany (domyślnie do JSON).

Oczywiście na koniec musimy zwrócić problem posługując się jedną z metod w stylu BadRequest, NotFound, Unauthorized itd. Ja przykładowo wybrałem Unauthorized, ale pewnie w rzeczywistym projekcie, najchętniej użyjesz BadRequest.

W rezultacie otrzymamy coś takiego:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
  "title": "An error occurred while processing your request.",
  "status": 500,
  "traceId": "00-c721ebc1a2be6484c53a9cdd54307e5c-b3ebd2cf05a27d0c-00",
  "additionalData": {
    "errorType": "LoginProblem",
    "systemCode": 10
  }
}

Zwróć uwagę na jedną rzecz. W response będziesz miał kod 401 (bo zwróciliśmy Unauthorized), natomiast w modelu ProblemDetails masz 500 – bo z takim kodem jest tworzony domyślnie ProblemDetails. Oczywiście wypadałoby to ujednolicić, przekazując do fabryki odpowiedni kod błędu:

var result = _problemDetailsFactory.CreateProblemDetails(HttpContext, statusCode: (int)HttpStatusCode.Unauthorized);

Zobacz też, że nigdzie nie musisz podawać właściwości type. I super! .NET sam ma wszystko ładnie zmapowane do strony https://tools.ietf.org/html/rfc7231, na której są już opisane wszystkie błędy.

Właściwość type jest uzupełniana na podstawie przekazanego statusCode. Oczywiście, jeśli potrzebujesz, możesz dać swoje type w parametrze metody Problem() lub fabryki.

Obsługa błędów

Cały mechanizm umożliwia też elegancką obsługę błędów. Jeśli gdziekolwiek zostanie rzucony nieprzechwycony wyjątek, standardowe działanie jest takie, że zwracany jest błąd 500. Natomiast mechanizm ProblemDetails w .NET tworzy odpowiedź z użyciem tego standardu. Niestety… dopiero w .NET7. Tu jest ta różnica. W .NET7 jest to dostępne na „dzień dobry”, natomiast w .NET6 trzeba sobie samemu to dopisać. Chociaż istnieje NuGet Hellang.Middleware.ProblemDetails którego możesz użyć we wcześniejszych wersjach. Ja go nie używałem, więc tylko daję Ci znać, że jest.

Wyjątki i ProblemDetails w .NET6

W .NET6 musisz do tego napisać własny mechanizm (lub posłużyć się wspomnianym Nugetem). Możesz np. napisać middleware, który Ci to ogarnie. Taki middleware w najprostszej postaci mógłby wyglądać tak:

public class ProblemDetailsMiddleware : IMiddleware
{
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        try
        {
            await next(context);
        }
        catch (Exception ex)
        {
            await HandleProblemDetails(context, ex);
        }
    }

    private async Task HandleProblemDetails(HttpContext context, Exception ex)
    {
        var factory = context.RequestServices.GetRequiredService<ProblemDetailsFactory>();
        var problem = factory.CreateProblemDetails(context,
            title: "Unhandled exception",
            detail: ex.GetType().ToString());

        problem.Extensions["message"] = ex.Message;
        if (IsDevelopement(context))
            problem.Extensions["stackTrace"] = ex.StackTrace;

        context.Response.StatusCode = problem.Status.Value;
        await context.Response.WriteAsJsonAsync(problem, null, "application/problem+json");
    }

    private bool IsDevelopement(HttpContext context)
    {
        var hostEnv = context.RequestServices.GetRequiredService<IWebHostEnvironment>();
        return hostEnv.IsDevelopment();
    }
}

Jak widzisz, po prostu zapisuję problem do odpowiedzi. Sprawdzam też środowisko. Jeśli jest deweloperskie, to nie waham się pokazać stack trace 🙂 W innym przypadku – ze względów bezpieczeństwa lepiej tego nie robić. W takim przypadku musisz też uważać na to, jakie komunikaty dajesz, gdy rzucasz jakiś wyjątek – nie mogą być drogowskazem dla hackerów. Pisałem o tej „podatności” w książce „Zabezpieczanie aplikacji internetowych w .NET – podstawy„.

To jest najprostszy możliwy middleware. Dobrze byłoby również zalogować taki błąd.

Oczywiście musisz pamiętać o dodaniu tego middleware do pipeline:

builder.Services.AddScoped<ProblemDetailsMiddleware>();

var app = builder.Build();
app.UseMiddleware<ProblemDetailsMiddleware>();

Pamiętaj, że middleware’y obsługujące wyjątki powinny być uruchamiane jak najwcześniej w pipeline.

Wyjątki i ProblemDetails w .NET7

Jak już pisałem, w .NET7 to masz „out-of-the-box”. Wystarczy zrobić dwie rzeczy. Podczas rejestracji serwisów dodaj serwisy od ProblemDetails:

builder.Services.AddProblemDetails();

A przy konfiguracji middleware pipeline włóż obsługę wyjątków:

app.UseExceptionHandler();

A jeśli byś chciał dodać jakieś swoje rozszerzenie do ProblemDetails przy niezłapanym wyjątku, wystarczy dodać serwisy z odpowiednim przeciążeniem, np:

builder.Services.AddProblemDetails(c =>
{
    c.CustomizeProblemDetails = (ctx) =>
    {
        ctx.ProblemDetails.Extensions.Add("user_logged", ctx.HttpContext.User?.Identity?.IsAuthenticated);
    };
});

W tym przykładzie, do każdego ProblemDetails zostanie dołożone moje rozszerzenie; pole „user_logged„, mówiące czy użytkownik jest zalogowany, czy nie. W obiekcie ctx (ProblemDetailsContext) mamy do dyspozycji m.in. HttpContext z żądania. Więc jeśli potrzebujesz jakiś dodatkowych informacji, to proszę bardzo.

Bezpieczeństwo

Pamiętaj, że komunikat błędu może tworzyć jakiś drogowskaz dla hackerów. A tym bardziej cały stack trace. Na szczęście mechanizm ProblemDetails w .NET7 jest bezpieczny. Dopóki nie zrobisz niczego bardzo głupiego, to będzie dobrze. Stack Trace zostanie dołączone do modelu ProblemDetails tylko na środowisku Developerskim. Natomiast na każdym innym nie zobaczysz go. I to dobrze.

Pamiętaj jednak, żeby samemu nie podawać zbyt dużo informacji w ProblemDetails, bo to może pomóc hackerom. Zresztą pisałem o tym w książce Zabezpieczenia aplikacji internetowych w .NET – podstawy


Dzięki za przeczytanie artykułu. To tyle jeśli chodzi o podstawy użycia ProblemDetails. Jeśli znalazłeś w artykule jakiś błąd albo czegoś nie rozumiesz, koniecznie daj znać w komentarzu.

Obrazek wyróżniający: Obraz autorstwa gpointstudio na Freepik

Podziel się artykułem na:
Pobranie modelu json razem z plikiem czyli custom model binding

Pobranie modelu json razem z plikiem czyli custom model binding

Wstęp

W tym artykule przedstawię Ci mechanizm własnego wiązania obiektów, czyli po ludzku „Custom model binding” w .NET.

.NET w standardzie ma sporo wbudowanych binderów, których zadaniem jest po prostu utworzenie jakiegoś konkretnego obiektu na podstawie danych z żądania HTTP. W zasadzie rzadko zdarza się potrzeba napisania własnego bindera, bo te domyślne załatwiają prawie 100% potrzeb. Prawie.

Postaram przedstawić Ci ten cały mechanizm od zupełnego początku. Jeśli szukasz konkretnego rozwiązania przejdź na koniec artykułu.

Do artykułu powstał przykładowy projekt. Możesz go pobrać z GitHuba.

Czym jest binder?

Wyobraź sobie taki kod w kontrolerze:

[Route("api/[controller]")]
[ApiController]
public class UserController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult GetById(int id)
    {

    }
}

Tutaj dzieje się dość sporo magii. Jak zapewne wiesz, żądanie HTTP to nic innego jak standaryzowany tekst wysłany z jednego komputera do drugiego.

W tym przypadku takie proste żądanie mogłoby wyglądać tak:

GET /api/user/5 HTTP/1.1
Host: localhost

Już pomijam w jaki sposób z czegoś takiego .NET umie wywołać konkretną metodę z konkretnej klasy. Ale spójrz, że w żądaniu masz ścieżkę /api/user/5. I tę piątkę dostaniesz w parametrze id w swojej metodzie GetById. Cały myk polega na tym, żeby Twoja nazwa parametru w procedurze (int id) była taka sama jak w atrybucie HttpGet ({id}).

Zanim wywołana zostanie metoda GetById, do roboty rusza specjalny binder, który patrzy i widzi:

„Aha, tutaj w atrybucie mam zmienną o nazwie id. W parametrze procedury też mam zmienną o nazwie id. To teraz wezmę sobie wartość z żądania („5”) i skonwertuję to na żądany typ (int) i zwrócę inta o odpowiedniej wartości (5)”.

Być może brzmi to nieco skomplikowanie, ale w rzeczywistości jest banalnie proste, ponieważ sam framework robi już dużo roboty za Ciebie. Spróbujmy napisać coś podobnego.

Binder działa dla konkretnego parametru. Spójrz na taki przykład:

[HttpPost("{id}")]
public IActionResult SaveData(int id, [FromBody]Author authorData)
{

}

Tutaj do pracy ruszą dwa różne bindery. Pierwszy – który powiąże id, drugi który odczyta jsona z ciała żądania i stworzy z niego obiekt Author.

Prosty binder

Opis zadania

Będziemy chcieli przesłać jakiś prosty rekord w żądaniu i po drugiej stronie odebrać to jako obiekt.

Model będzie wyglądał tak:

public class SimplePost
{
    public int Id { get; set; }
    public string Title { get; set; }
}

A metoda w kontrolerze:

[HttpPost("{post}")]
public IActionResult AddPost(SimplePost post)
{

}

Żądanie, które będzie wysłane to:

POST /api/posts/id:2;title:Tytuł postu

Weź pod uwagę, że to tylko przykład. W rzeczywistości prawdopodobnie przesłałbyś te dane po prostu jsonem. Ale chcę Ci pokazać, jak działa binder.

Zauważ, że tutaj analogicznie jak w pierwszym przykładzie, nazwa zmiennej w atrybucie jest taka sama jak nazwa zmiennej w parametrze metody. Do tej zmiennej dowiążemy te dane.

Wybór bindera

Musisz dać znać frameworkowi, jakiego bindera ma użyć dla konkretnego przypadku. Można to zrobić na dwa sposoby. Swój model możesz opatrzyć atrybutem ModelBinder, np:

[ModelBinder(typeof(SimplePostBinder))]
public class SimplePost
{
    public int Id { get; set; }
    public string Title { get; set; }
}

albo jeśli nie chcesz lub nie możesz posłużyć się atrybutem ModelBinder, zawsze możesz napisać swojego providera.

Provider dla bindera

UWAGA! SimplePostBinder przedstawiony w tym akapicie, to binder, którego piszemy w następnym akapicie 🙂

Wystarczy zaimplementować interfejs IModelBinderProvider:

public class SimplePostBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context.Metadata.ModelType == typeof(SimplePost))
            return new BinderTypeModelBinder(typeof(SimplePostBinder));
        else
            return null;
    }
}

Teraz pewnie zachodzisz w głowę co to do cholery jest BinderTypeModelBinder i dlaczego nie możesz po prostu zrobić new SimplePostBinder().

Otóż możesz. Natomiast BinderTypeModelBinder to jest fabryka dla bindera. Umożliwia wykorzystanie dependency injection. Jeśli chciałbyś zwrócić swój obiekt: new SimplePostBinder, a konstruktor wymagałby różnych serwisów, no to wtedy mamy problem. Na szczęście BinderTypeModelBinder go rozwiązuje i sam się zatroszczy o wstrzyknięcie odpowiednich obiektów.

Na koniec musisz tego providera zarejestrować przy rejestracji kontrolerów:

builder.Services.AddControllers(options =>
{
    options.ModelBinderProviders.Insert(0, new SimplePostBinderProvider());
});

UWAGA!

Providerzy są sprawdzani wg kolejności na liście ModelBinderProviders. Jest prawie pewne, że jeśli dodasz swojego providera na końcu (metodą Add), wtedy framework wybierze jakiegoś innego, zarejestrowanego wcześniej, który spełni swój warunek. Dlatego też zawsze dodawaj swojego bindera na początek listy – metodą Insert.

Jeśli otrzymujesz błędy w stylu 415: Unsupported media type lub widzisz że Twój binder nie chodzi, upewnij się w pierwszej kolejności, że dodałeś go na początek listy.

Piszemy bindera

W pierwszym kroku musisz stworzyć klasę, która implementuje interfejs IModelBinder. Interfejs ma tylko jedną metodę do napisania:

public class SimplePostBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        
    }
}

Pierwsza uwaga – pisząc własnego bindera, jak najbardziej możesz posługiwać się dependency injection. Możesz do konstruktora wrzucić sobie to, co potrzebujesz.

Parametr ModelBindingContext to jest klasa abstrakcyjna, której implementacja (DefaultModelBindingContext) daje Ci sporo informacji.

Przede wszystkim musimy pobrać nazwę modelu – czyli parametr z atrybutu HttpPost:

public Task BindModelAsync(ModelBindingContext bindingContext)
{
    var paramName = bindingContext.ModelName;

    return Task.CompletedTask;
}

BindingContext ma jeszcze jedną podobną właściwość: FieldName – to jest nazwa parametru w Twojej metodzie.

Mając nazwę parametru z atrybutu (pamiętaj – z atrybutu), możemy teraz pobrać jego wartość:

public Task BindModelAsync(ModelBindingContext bindingContext)
{
    var paramName = bindingContext.ModelName;
    ValueProviderResult value = bindingContext.ValueProvider.GetValue(paramName);

    return Task.CompletedTask;
}

ValueProviderResult to w pewnym uproszczeniu tablica stringów. ValueProvider, jeśli zwróci jakąś wartość, to zawsze będą to stringi. I dopiero te stringi możemy konwertować na poszczególne typy.

Tutaj dostaniemy stringa: „id:2;title:Tytuł postu” i teraz musimy go skonwertować na nasz typ. Tu już jest prosto:

public Task BindModelAsync(ModelBindingContext bindingContext)
{
    var paramName = bindingContext.ModelName;
    ValueProviderResult value = bindingContext.ValueProvider.GetValue(paramName);

    if(value == ValueProviderResult.None)
        return Task.CompletedTask;

    var strValue = value.First();
    if(string.IsNullOrWhiteSpace(strValue))
        return Task.CompletedTask;

    SimplePost result = ConvertFromString(strValue);
    bindingContext.Result = ModelBindingResult.Success(result);

    return Task.CompletedTask;
}

Najpierw w linijce 6 upewniłem się, że faktycznie otrzymaliśmy jakąś wartość. Potem w linijce 10 upewniłem się, że wartość, którą mamy nie jest pustym stringiem. Na koniec konwertuję tego stringa do obiektu i zwracam za pomocą bindingContext.Result.

Domyślnie bindingContext.Result jest ustawiony na Failed, dlatego ustawiam go tylko, gdy konwersja się powiodła.

Jeśli z ciekawości chciałbyś zobaczyć metodę ConvertFromString, to może wyglądać tak:

private SimplePost ConvertFromString(string data)
{
    Dictionary<string, string> keyValues = new();

    string[] fields = data.Split(';');
    foreach(var field in fields)
    {
        string[] kv = field.Split(':');
        keyValues[kv[0]] = kv[1];   
    }

    SimplePost result = new SimplePost();
    result.Id = int.Parse(keyValues["id"]);
    result.Title = keyValues["title"];

    return result;
}

Walidacja modelu w binderze

W sytuacji, gdzie użytkownik przekazuje dane, wszystko może pójść nie tak. Tak samo w naszym przypadku. Dlatego zawsze warto jest zabezpieczyć się przed problemami.

Jeśli przekażemy złe dane, np.: „idik:2;title:Tytuł postu” – idik zamiast id, nie będziemy w stanie utworzyć obiektu. Po prostu metoda ConvertFromString się wykrzaczy. Najprościej poradzić sobie z tym w taki sposób:

try
{
    SimplePost result = ConvertFromString(strValue);
    bindingContext.Result = ModelBindingResult.Success(result);
}catch(Exception ex)
{
    bindingContext.ModelState.AddModelError(paramName, $"Could not convert from specified data, error: {ex.Message}");
}

return Task.CompletedTask;

W aplikacji webowej np. RazorPages musisz teraz sprawdzić stan modelu w standardowy sposób:

if(!ModelState.IsValid)
    return View()

W przypadku WebApi (klasa opatrzona atrybutem ApiController) stan modelu jest sprawdzany automatem przed uruchomieniem metody w kontrolerze. W naszym przykładzie po prostu dostaniesz taką zwrotkę:

400: BadRequest z przykładową zawartością:

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-528c9aba1d62ac1e013a1b841d57c8b0-e13ad6c0c13efb6f-00",
    "errors": {
        "post": [
            "Could not convert from specified data, error: The given key 'id' was not present in the dictionary."
        ]
    }
}

Tak samo z automatu zadziała sprawdzenie pól, które mają dodatkowe wymagania np. za pomocą adnotacji. Przykładowo:

[Range(10, 1000)]
public int Id { get; set; }

Piszemy bindera dla Jsona w formularzu

To już właściwie formalność, bo poznałeś już zasadę działania bindera. I w tym punkcie będzie więcej zabawy refleksją niż binderem. Niemniej jednak uczulę Cię na jedną rzecz.

Jeśli chcesz przesłać plik wraz z jakimiś danymi w JSONie, prawdopodobnie chciałbyś mieć analogiczny model:

public class Data
{
    public IEnumerable<IFormFile> Files { get; set; }
    public Author AuthorData { get; set; }
}

Czyli plik i obiekt w jednej klasie. Da się to zrobić i pokażę Ci jak. Jednak uczulam Cię, że zdecydowanie lepiej jest mieć pliki i dane osobno. Dlaczego? Bo binder do pobierania plików już istnieje w standardzie i wcale nie jest taki oczywisty, jakby się mogło wydawać.

Model generyczny

Stworzymy model generyczny, który będzie mógł mieć dowolny obiekt, który będzie pochodził z JSONa. Model jest banalnie prosty:

[ModelBinder(typeof(FormJsonBinder))]
public class FormJsonData<T>
    where T: class, new()
{
    public T Model { get; set; }
}

Teraz przyjmiemy model i plik w osobnych parametrach:

[HttpPost]
public IActionResult AddAuthor(IEnumerable<IFormFile> files, FormJsonData<Author> author)
{
    return Ok();
}

Klasa Author niech wygląda tak:

public class Author
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateTimeOffset Birthday { get; set; }
    public string Note { get; set; }
}

Jak już wiesz – binder działa na konkretnym parametrze. A więc w tym przypadku pliki z parametru files przyjdą ze standardowego bindera .NET. Więc zajmiemy się tylko FormJsonData.

Z PostMana można by wysłać takie żądanie:

Jak widzisz, wysyłamy formularz, gdzie w polu author mam JSONa z danymi autora, a w polu files mamy pliki. Zwróć uwagę, że te pola nazywają się tak samo jak parametry w naszej procedurze. To ważne, bo tak będziemy to wiązać.

Binder

Teraz napiszemy sobie bindera do tego FormJsonData. Początek już znasz:

public class FormJsonBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var fieldName = bindingContext.FieldName;
        var fieldValue = bindingContext.ValueProvider.GetValue(fieldName);
        if (fieldValue == ValueProviderResult.None)
            return Task.CompletedTask;

        return Task.CompletedTask;
    }
}

Teraz w fieldValue mamy całego przekazanego JSONa. Musimy stworzyć już tylko odpowiedni obiekt. No właśnie. Ale jaki? FormJsonData jest klasą generyczną. Dlatego musimy się nieco pobawić refleksją. Nie będę tego omawiał, bo to nie jest artykuł na ten temat:

public Task BindModelAsync(ModelBindingContext bindingContext)
{
    var fieldName = bindingContext.FieldName;
    var fieldValue = bindingContext.ValueProvider.GetValue(fieldName);
    if (fieldValue == ValueProviderResult.None)
        return Task.CompletedTask;

    try
    {
        Type modelType = GetTypeForModel(bindingContext);
        var result = ConvertFromJson(modelType, fieldValue.FirstValue);
        bindingContext.Result = ModelBindingResult.Success(result);
    }catch(Exception ex)
    {
        bindingContext.ModelState.AddModelError(fieldName, $"Could not convert from specified data, error: {ex.Message}");
    }
    
    return Task.CompletedTask;
}

private Type GetTypeForModel(ModelBindingContext context)
{
    Type modelType = context.ModelType;
    Type[] genericArgs = modelType.GenericTypeArguments;
    if (genericArgs.Length == 0)
        throw new InvalidOperationException("Invalid class! Expected generic type!");

    return genericArgs[0];
}

private object ConvertFromJson(Type modelType, string jsonData)
{
    Type outputType = typeof(FormJsonData<>).MakeGenericType(modelType);

    JsonSerializerOptions opt = new JsonSerializerOptions
    {
        PropertyNameCaseInsensitive = true
    };

    var model = JsonSerializer.Deserialize(jsonData, modelType, opt);

    var result = Activator.CreateInstance(outputType);
    var modelProp = outputType.GetProperty("Model");
    modelProp.SetValue(result, model);

    return result;
}

Dzięki takiemu podejściu moglibyśmy mieć kilka różnych obiektów w jednym formularzu! W różnych polach formularza możesz umieścić różne Jsony i odbierać je w kontrolerze stosując odpowiednie nazwy parametrów.

Pobranie pliku w binderze

Tak naprawdę do pobierania plików w binderze służy… osobny binder. I ma on dużo więcej kodu niż, to co pokazuję Ci niżej:

private async Task GetFormFilesAsync(ModelBindingContext bindingContext,
    ICollection<IFormFile> postedFiles)
{
    var request = bindingContext.HttpContext.Request;
    if (request.HasFormContentType)
    {
        var form = await request.ReadFormAsync();

        foreach (var file in form.Files)
        {
            if (file.Length == 0 && string.IsNullOrEmpty(file.FileName))
                continue;

            postedFiles.Add(file);
        }
    }
}

Ten kod zadziała, ale zwracam uwagę po raz trzeci – binder do pobierania plików robi więcej rzeczy niż tylko to. Dlatego najlepiej pobieraj pliki osobnym parametrem, tak jak zrobiliśmy to wyżej.


Dzięki za przeczytanie tego artykułu. To tyle jeśli chodzi o custom model binding w .NET. Temat dość prosty i przyjemny, ale za to rzadko stosowany 🙂 Jeśli czegoś nie zrozumiałeś lub znalazłeś błąd w artykule, koniecznie daj znać w komentarzu.

Podziel się artykułem na:
Przesyłanie plików

Przesyłanie plików

Wstęp

W tym artykule pokazuję, jak przesyłać pliki między aplikacjami webowymi. Myślę, że wiedza tutaj zawarta jest kompletna i dużo obszerniejsza niż w innych artykułach. Zobaczysz tutaj kompletne mechanizmy do przesyłania plików pożądane w każdej aplikacji.

UWAGA! Ze względu na prostotę i czytelność, kody przedstawione w tym artykule nie są zbyt czyste. Miej to na uwadze.

Na GitHubie znajdują się przykładowe projekty. W pliku readme.md jest instrukcja, która pokazuje jak je poprawnie uruchamiać.

Jeśli szukasz informacji jak przesłać formularzem plik z danymi w JSON, przeczytaj ten artykuł.

Spis treści

Przesyłanie pliku na serwer w Razor Pages

Na początek zajmiemy się przesyłaniem plików w obrębie aplikacji Razor Pages. Analogicznie będzie w przypadku MVC.

Formularz

Przede wszystkim musisz pamiętać, że plik jest zawsze przesyłany formularzem. A więc na początek trzeba stworzyć odpowiedni formularz:

<form method="post" enctype="multipart/form-data">
    <div>
        <label for="file-input">Wybierz plik</label>
        <input type="file" id="file-input" accept=".jpg" />
    </div>
</form>

Po stronie HTML posługujemy się zwykłym inputem o typie file. Atrybut accept pozwala ograniczyć typy plików tylko do tych, które chcemy zobaczyć w okienku do wyboru plików.

Ale ważna uwaga – pamiętaj że musisz ustawić atrybut enctype dla formularza na multipart/form-data. W innym przypadku plik po prostu nie przejdzie.

Teraz pytanie, jak odebrać plik po stronie serwera? Służy do tego specjalny interfejs IFormFile. Ten interfejs znajduje się w Nugecie: Microsoft.AspNetCore.Http, więc zainstaluj go najpierw.

Model

Napiszmy teraz klasę, która będzie modelem dla tego formularza i prześle plik wraz z jakimiś innymi danymi:

public class FormData
{
    [Required(ErrorMessage = "Musisz wybrać plik")]
    public IFormFile? FileToUpload { get; set; }
    [Required(ErrorMessage = "Musisz wpisać wiadomość")]
    public string? Message { get; set; }
}

Zwróć uwagę, że oba pola są oznaczone jako Required – będą wymagane. I tu mała dygresja.

Wszystkie pola, które nie są oznaczone jako nullable – znakiem zapytania (a nullable używane jest w projekcie) domyślnie są uznane za wymagane. Z tego wynika, że powyższy kod mógłbym zapisać tak:

public class FormData
{
    public IFormFile FileToUpload { get; set; }
    public string Message { get; set; }
}

Zauważ, że w drugim przykładzie przy typach pól nie ma znaku zapytania, który oznacza pole jako nullable. W tym momencie te pola są domyślnie wymagane. I jeśli któreś z nich nie będzie wypełnione, formularz nie przejdzie walidacji.

Przesyłanie

Jeśli nie wiesz co robią atrybuty Required i jak walidować formularz, koniecznie przeczytaj artykuł Walidacja danych w MVC.

To teraz dodamy ten model do strony i zaktualizujemy formularz:

index.cshtml.cs:

public class IndexModel : PageModel
{
    [BindProperty]
    public FormData Data { get; set; }

    public async Task OnPostAsync()
    {

    }
}

index.cshtml:

<form method="post" enctype="multipart/form-data">
    <div>
        <label asp-for="Data.FileToUpload">Wybierz plik</label>
        <input type="file" asp-for="Data.FileToUpload" accept=".jpg" />
        <span asp-validation-for="Data.FileToUpload" class="text-danger"/>
    </div>

    <div>
        <label asp-for="Data.Message">Wiadomość</label>
        <input type="text" asp-for="Data.Message" />
        <span asp-validation-for="Data.Message" class="text-danger" />
    </div>
    <div>
        <button type="submit">Wyślij mnie</button>
    </div>
</form>

@section Scripts
{
    <partial name="_ValidationScriptsPartial" />
}

Jeśli nie wiesz, czym jest atrybut asp-validation-for lub _ValidationScriptsPartial, koniecznie przeczytaj artykuł o walidacji. Krótko mówiąc – jeśli jakiś warunek walidacyjny nie zostanie spełniony (np. [Required]), to w tym spanie pojawi się komunikat błędu.

Pamiętaj też, że _ValidationScriptsPartial musisz umieścić na końcu strony. W przeciwnym razie mechanizm walidacji po stronie klienta może nie zadziałać poprawnie.

Odbieranie danych

Teraz już wystarczy tylko odebrać te dane. Robimy to w metodzie OnPostAsync.

public async Task<IActionResult> OnPostAsync()
{
    if (!ModelState.IsValid)
        return Page();

    EnsureFilesDirExists();
    var secureFileName = Path.GetFileName(Data.FileToUpload.FileName);
    var filePath = Path.Combine("files", secureFileName);
    
    using var stream = new FileStream(filePath, FileMode.CreateNew);
    await Data.FileToUpload.CopyToAsync(stream);

    return Page();
}


private void EnsureFilesDirExists()
{
    if (!Directory.Exists("files"))
        Directory.CreateDirectory("files");
}

Najpierw sprawdzam, czy stan modelu jest prawidłowy – po stronie serwera też musisz o to zadbać.

Następnie tworzę obiekt klasy FileStream, do którego wkopiowuję otrzymany plik. Plik za sprawą tego strumienia ląduje na dysku.

Jeśli przesyłane jest kilka plików, zamiast pojedynczej właściwości IFormFile użyj jakiejś formy kolekcji w swoim modelu, np. IEnumerable<IFormFile>, czy też List<IFormFile>.

Nazwa właściwości w modelu musi być taka sama jak wartość atrybutu name w elemencie input. Oczywiście, jeśli posługujesz się tylko RazorPages i domyślnym bindingiem, to masz to zagwarantowane.

Niebezpieczeństwa

Zapchanie pamięci i atak DoS

UWAGA! Nie używaj MemoryStream do łapania plików po stronie serwera. Pamiętaj, że wszystko co jest w MemoryStream znajduje się w pamięci. Jeśli na Twój serwer przesyłane są pliki, wtedy użycie MemoryStream może doprowadzić do wyczerpania pamięci i BUM! Wszystko wybuchnie. Stworzysz też podatność na atak Denial Of Service.

Dlatego też zawsze w takiej sytuacji używaj FileStream lub lepiej – jeśli masz taką możliwość – przesyłaj plik bezpośrednio na blob storage – o tym za chwilę.

Niebezpieczna nazwa pliku i przejęcie kontroli nad komputerem

Drugim zagrożeniem jest nazwa pliku. Zdziwiony? Nazwa pliku i plik mogą zostać tak spreparowane, żeby dobrać się do danych znajdujących się na serwerze. A nawet przejąć nad nim całkowitą kontrolę. Jeśli posłużysz się gołą nazwą pliku, która przychodzi w IFormFile.FileName stworzysz sobie podatność na atak Path Traversal. Piszę o tym więcej w swojej książce Zabezpieczanie aplikacji internetowych w .NET – podstawy. Jest tam wyjaśniony ten atak wraz z przykładem. A także kilka innych. Polecam zerknąć 🙂

Aby uniknąć podatności, zastosuj po prostu nazwę pliku, którą zwróci Ci Path.GetFileName().

Dobre praktyki

Poza tym, co opisałem wyżej, warto jeszcze sprawdzić rozmiar przesyłanego pliku. Ten rozmiar masz we właściwości IFormFile.Length. Daj jakieś maksimum na wielkość pliku. Jeśli przesłany plik jest za duży, to po prostu zwróć błąd.

Dobrze jest też uruchomić jakiś skaner antywirusowy na przesyłanym pliku, jeśli masz taką możliwość.

No i pamiętaj, żeby zawsze sprawdzać po stronie serwera co do Ciebie przychodzi, bo walidacje po stronie klienta można łatwo obejść.

Jeśli przesyłany plik jest naprawdę duży, to zapisuj go w osobnym tasku. Zwróć odpowiedź do klienta w stylu, że plik jest procesowany i wkrótce się pojawi.

Ochrona przed ponownym przesłaniem

Może się zdarzyć tak, że w przeglądarce ktoś zdąży zupełnym przypadkiem wcisnąć przycisk do wysyłania pliku dwa razy. Można się przed tym uchronić w prosty sposób. Wystarczy zablokować guzik po przesłaniu. Wymaga to jednak nieco JavaScriptu. W standardowym mechanizmie walidacji można to osiągnąć w taki sposób:

<form method="post" enctype="multipart/form-data" id="form" onsubmit="return submitHandler()">
    <!-- treść formularza -->
    <div>
        <button id="submit-btn" type="submit">Wyślij mnie</button>
    </div>
</form>

@section Scripts
    {
    <partial name="_ValidationScriptsPartial" />

    <script type="text/javascript">
        function submitHandler() {
            let form = $("#form");
            if (!form.validate().checkForm())
                return false;

            $("#submit-btn").disabled = true;

            return true;
        }
    </script>
}

Tutaj nadałem ID formularzowi i przyciskowi do wysyłania. Dodatkowo dodałem obsługę zdarzenia onsubmit dla formularza. W tym handlerze najpierw sprawdzam, czy formularz jest poprawnie zwalidowany. Wywołanie form.validate().checkForm() nie powoduje kolejnej walidacji, tylko sprawdza, czy formularz jest zwalidowany. Jeśli nie jest (bo np. nie podałeś wszystkich wymaganych danych), wtedy zwracam false – formularz wtedy nie zostaje przesłany.

W przeciwnym razie blokuję guzik do wysyłania i zwracam true. Nie ma opcji podwójnego wciśnięcia tego przycisku. Taki mechanizm powinieneś stosować w każdym formularzu. Możesz też schować taki formularz i wyświetlić komunikat, że formularz jest wysyłany. Po prostu pokaż zamiast niego jakiegoś diva z taką informacją.

Walidacja rozmiaru pliku

Tak jak pisałem wyżej – warto dać jakiś maksymalny rozmiar pliku. Można to dość łatwo zwalidować zarówno po stronie klienta jak i serwera – wystarczy użyć własnego atrybutu walidacyjnego 🙂 Przede wszystkim muszę Cię odesłać do tego artykułu, w którym opisałem jak tworzyć własne atrybuty walidacyjne.

Nie będę tutaj powtarzał tej wiedzy, więc warto żebyś go przeczytał, jeśli czegoś nie rozumiesz. Tutaj dam Ci gotowe rozwiązanie.

Atrybut może wyglądać tak:

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, 
    AllowMultiple = false)]
public class MaxFileSizeAttribute : ValidationAttribute, IClientModelValidator
{
    private readonly int _maxFileSize;
    public MaxFileSizeAttribute(int maxFileSize)
    {
        _maxFileSize = maxFileSize;
    }

    public override bool IsValid(object value)
    {
        var file = value as IFormFile;
        if (file == null)
            return true;
        return file.Length <= _maxFileSize;
    }

    public void AddValidation(ClientModelValidationContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        MergeAttribute(context.Attributes, "data-val", "true");
        MergeAttribute(context.Attributes, "data-val-filesize", ErrorMessageString);
        MergeAttribute(context.Attributes, "data-val-filesize-maxsize", _maxFileSize.ToString());
    }

    private static void MergeAttribute(IDictionary<string, string> attributes, string key, string value)
    {
        if (!attributes.ContainsKey(key))
        {
            attributes.Add(key, value);
        }
    }
}

Dodatkowo trzeba dodać walidację po stronie klienta. Ja robię to w pliku _ValidationScriptsPartial.cs. Cały plik może wyglądać tak:

<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>

<script type="text/javascript">
    $.validator.addMethod("filesize", function (value, element, param) {
        if (element.type != "file")
            return false;

        for(let i = 0; i < element.files.length; i++)
            if(element.files[i].size > param)
                return false;

        return true;
    });

    $.validator.unobtrusive.adapters.addSingleVal("filesize", "maxsize");
</script>

Teraz wystarczy już tylko użyć tego w modelu:

public class FormData
{
    [Required(ErrorMessage = "Musisz wybrać plik")]
    [MaxFileSize(1024 * 1024, ErrorMessage = "Plik jest za duży, maksymalna wielkość pliku to 1 MB")]
    public IFormFile? FileToUpload { get; set; }
    [Required(ErrorMessage = "Musisz wpisać wiadomość")]
    public string? Message { get; set; }
}

Ważne! Pamiętaj że żeby walidacja działała, model na stronie musi być opatrzony atrybutem BindProperty:

public class IndexModel : PageModel
{
        [BindProperty]
        public FormData Data { get; set; } = new();    

        public async Task<IActionResult> OnPostAsync()
        {
        }
}

Jeśli tego nie będzie, formularz może zadziałać poprawnie i przesłać dane, ale walidacja nie zostanie uruchomiona.

Przesyłanie pliku bezpośrednio na blob storage

BlobStorage jest to usługa Azure’owa do przechowywania plików. Czasem lepiej/łatwiej umieścić pewne pliki właśnie tam niż u siebie na serwerze. To nie jest artykuł o tym, więc nie piszę tutaj więcej w szczególności jak obsługować i tworzyć taki storage. Jeśli wiesz o co chodzi, to ten akapit może Ci się przydać. Jeśli nie – pewnego dnia na pewno to opiszę.

Generalnie wszystko sprowadza się do wywołania odpowiedniego przeciążenia metody UploadBlobAsync z BlobContainerClient:

await blobContainerClient.UploadBlobAsync(
    trustedFilename, Data.FileToUpload.OpenReadStream());

W takiej sytuacji plik ląduje bezpośrednio na Twoim blob storage.

Przesyłanie pliku w Blazor

Tutaj sprawa wygląda nieco inaczej. Generalnie mamy gotowy komponent do pobrania pliku: InputFile. Co więcej, ten komponent nie musi być częścią formularza. To jednak nieco zmienia sposób podejścia do zadania. Zacznijmy od tego jak w ogóle działa przesyłanie plików w Blazor:

<InputFile OnChange="FileChangeHandler" accept=".jpg" />

@code{
    private async Task FileChangeHandler(InputFileChangeEventArgs args)
    {
        using var stream = args.File.OpenReadStream();
    }
}

Po pierwsze mamy komponent InputFile, którego kluczowym zdarzeniem jest OnChange. Trzeba tutaj podać handlera, który dostanie przekazany plik.

Parametr metody posiada pole File (typu IBrowserFile). Znajduje się tam pierwszy dostępny plik. Jeśli pozwoliłeś na dodanie kilku plików, wtedy możesz je odczytać z metody GetMultipleFiles, która zwraca Ci listę IBrowserFile. To wszystko znajduje się w klasie InputFileChangeEventArgs w przesłanym parametrze.

Interfejs IBrowserFile zawiera metodę OpenReadStream, która zwraca strumień z danymi pliku. I teraz uwaga nr 1. OpenReadStream przyjmuje w parametrze maksymalny rozmiar pliku, który może przekazać. Domyślnie ten parametr wskazuje 512 KB. Jeśli plik w strumieniu będzie większy, wtedy metoda wybuchnie wyjątkiem.

UWAGA! Blazor WASM pracuje tylko po stronie klienta – w jego przeglądarce. Nie ma tutaj żadnego serwera. Daje nam to pewne ograniczenie – nie możesz zapisać pobranego pliku – Blazor nie ma dostępu do systemu plików. Dlatego też w przypadku Blazor nie możesz posłużyć się FileStream, jak to było wyżej. Tutaj musisz po prostu przechować plik albo w tablicy bajtów, albo w MemoryStream.

Zobaczmy teraz w jaki sposób można zrobić pobieranie pliku z progress barem.

Przesyłanie pliku z progress barem

<InputFile OnChange="FileChangeHandler" accept=".jpg" />

@if(ReadingFile)
{
    <div>
        <progress value="@DataRead" max="@FileSize" />
    </div>
}

@code{
    private long FileSize { get; set; }
    private long DataRead { get; set; }
    private bool ReadingFile { get; set; } = false;

    private async Task FileChangeHandler(InputFileChangeEventArgs args)
    {
        try
        {
            FileSize = args.File.Size;
            DataRead = 0;
            ReadingFile = true;

            byte[] buffer = System.Buffers.ArrayPool<byte>.Shared.Rent(1024); //pobranie buforu min. 1024 bajty
            using var stream = args.File.OpenReadStream(1024 * 1024); //ograniczenie wielkości pliku do 1 MB
            using MemoryStream fileContent = new MemoryStream();

            while(await stream.ReadAsync(buffer) is int read && read > 0)
            {
                DataRead += read;
                fileContent.Write(buffer);
                StateHasChanged();
            }
        }finally
        {
            ReadingFile = false;
        }
    }
}

Wszystko rozbija się o odczyt pliku w porcjach po 1024 bajty. Kolejne części pliku są zapisywane w strumieniu fileContent. Przy każdym zapisywanym fragmencie aktualizuję ilość odczytanych danych (DataRead), która jest aktualną wartością w progress barze. Aby progress bar się odświeżył, trzeba zawołać StateHasChanged.

Na koniec odczytu cały plik będzie w strumieniu fileContent.

Pokazanie pobranego obrazka

W związku z tym, że Blazor pracuje po stronie klienta, nie możemy pliku zapisać gdzieś i go pokazać. Najprościej zatem pokazać taki obrazek w postaci zakodowanej do Base64. Robi się to w taki sposób, że odczytujemy dane obrazka (np. przekazujemy go do MemoryStream lub tablicy bajtów), następnie zamieniamy na Base64, a na koniec do tagu IMG przekazujemy ten obrazek w takiej postaci:

<img src="data:image/jpeg;base64,==data==" />

Ten tajemniczy ciąg w atrybucie src możemy rozbić na poszczególne elementy:

data:image/jpeg;base64,==data==

  • data – to określenie, że obrazek będzie w formie danych, a nie ścieżki do pliku
  • image/jpeg – to określenie typu tych danych
  • base64 – format danych i dane

Zwróć uwagę na różne separatory konkretnych elementów – po data jest dwukropek, po content-type jest średnik, a po base64 jest przecinek. Jasne, prawda? 🙂 Jeśli nie widzisz faktycznego obrazka tylko ikonkę zastępczą, sprawdź te separatory w pierwszej kolejności.

Jak to może wyglądać w kodzie?

<InputFile OnChange="FileChangeHandler" accept=".jpg;.png" />
@if(FileRead)
{
    <div>
        <img src="@Img64Data" width="128"/>
    </div>
}

@code{
    private bool FileRead { get; set; } = false;
    private string Img64Data { get; set; }

    private async Task FileChangeHandler(InputFileChangeEventArgs args)
    {
        using MemoryStream ms = new MemoryStream();
        var stream = args.File.OpenReadStream(1024 * 1024);
        await stream.CopyToAsync(ms);

        var data = Convert.ToBase64String(ms.GetBuffer());
        Img64Data = $"data:{args.File.ContentType};base64,{data}";
        FileRead = true;
    }
}

Zwróć uwagę, że content type przekazywanego pliku wziąłem z parametru InputFileChangeEventArgs. Mógłbym go wpisać na sztywno, ale wtedy byłby problem jeśli użytkownik wybrałby inny typ pliku. Png, svg, czy chociażby bmp.

Walidacja pliku po stronie klienta

W związku z tym, że plik nie musi być w Blazor częścią formularza i tak naprawdę nie ma żadnej walidacji, musimy posłużyć się małym mykiem.

Tak jak w kodzie powyżej, odczytamy plik do jakiejś zmiennej i będziemy walidować tę zmienną. To oznacza, że trzeba zmienić model widoku dla Blazor:

public class FormDataViewModel
{
    [Required(AllowEmptyStrings = false, ErrorMessage = "Wybierz plik")]
    [MinLength(1, ErrorMessage = "Plik nie może być pusty")]
    [MaxLength(1024 * 1024, ErrorMessage = "Plik jest za duży")]
    public byte[]? FileContent { get; set; }
    [Required(ErrorMessage = "Musisz wpisać wiadomość")]
    public string? Message { get; set; }
}

I zasadniczo taka walidacja jest prawie dobra. Problem w tym, że jeśli przekażemy zbyt duży plik, to nie będzie o tym informacji. Dlaczego? Przypomnij sobie, jak odczytujemy plik:

var stream = args.File.OpenReadStream(1024 * 1024);

Jeśli będzie większy niż 1MB, to wtedy OpenReadStream rzuci wyjątek. Dlatego też powinniśmy sprawdzić w tym miejscu, czy plik nie jest za duży.

Zgłoszenie błędu można zrobić na kilka sposobów. Najprostszym z nich jest po prostu pokazanie jakiegoś spana przy InputFile z odpowiednim tekstem. Np.:

<label>
    Wybierz plik:
    <InputFile OnChange="FileChangeHandler" accept=".jpg;.png" />
    <ValidationMessage For="@(() => Data.FileContent)" class="text-danger" />
    <span class="text-danger" style="@ErrorSpanVisibility">@ErrorSpanText</span>
</label>

@code{
    private string ErrorSpanVisibility { get; set; } = "visibility:hidden";
    private string ErrorSpanText { get; set; }

    private async Task FileChangeHandler(InputFileChangeEventArgs args)
    {
        const int maxFileSize = 1024 * 1024;
        if(args.File.Size > maxFileSize)
        {
            ErrorSpanVisibility = "visibility:visible";
            ErrorSpanText = "Plik jest za duży";
            return;
        }

        ErrorSpanVisibility = "visibility:hidden";
        //odczyt pliku
    }
}

To jest najprostsze rozwiązanie, które pewnie sprawdzi się w większości przypadków. Jednak można to też zrobić inaczej, za pomocą ValidationMessage. Wymaga to jednak zmiany sposobu walidacji formularza i wykracza poza ramy tego artykułu. Obiecuję, że napiszę osobny artykuł o dodatkowej walidacji w Blazor.

Przesyłanie pliku na serwer do WebApi

Skoro mamy już fajny przykład w Blazorze, wykorzystamy go do przesłania pliku do WebApi. A właściwie całego modelu. Pokażę Ci jak przesłać dane wraz z plikiem.

Przede wszystkim musisz pamiętać, że tak jak w przypadku MVC / Razor Pages, plik jest wysyłany formularzem. Zasadniczo – przesyłanie pliku na serwer zawsze sprowadza się do umieszczenia go w formularzu i wysłaniu tego formularza.

Odczyt po stronie Api

Na początek stwórzmy projekt WebApi. Następnie dodajmy model, który będzie wyglądał tak:

public class WebApiFormData
{
    public IFormFile File { get; set; }
    public string Message { get; set; }
}

Zwróć uwagę na starego znajomego – IFormFile.

Teraz najlepsze, jedyne co musimy zrobić w kontrolerze w WebApi to:

[Route("api/[controller]")]
[ApiController]
public class FormController : ControllerBase
{
    [HttpPost]
    public IActionResult PostHandler([FromForm]WebApiFormData data)
    {
        if (!ModelState.IsValid)
            return BadRequest();
        
        return Ok();
    }
}

I to wystarczy, żeby WebApi odpowiednio odebrało dane! Po tej stronie już niczego nie musimy robić. Czy to nie jest piękne?

UWAGA! Jeśli podczas wysyłania danych do WebApi otrzymujesz błąd 415 (Unsupported Media Type) sprawdź, czy na pewno pobierasz w WebApi dane z formularza – atrybut FromForm.

Wysyłanie do API

Rejestracja HttpClientFactory

Teraz musimy te dane wysłać formularzem za pomocą HttpClient. Zrobimy to z aplikacji Blazor, która była tworzona wyżej w tym artykule. Zacznijmy od rejestracji fabryki dla HttpClient przy rejestracji serwisów:

builder.Services.AddHttpClient("api", client =>
{
    client.BaseAddress = new Uri("https://localhost:7144/");
});

Tego klienta pobierzemy sobie w metodzie, która jest wywoływana po poprawnym przesłaniu formularza w Blazor:

[Inject]
public IHttpClientFactory HttpClientFactory { get; set; }

private async Task ValidSumbitHandler(EditContext ctx)
{
    var client = HttpClientFactory.CreateClient("api");
}

Jeśli nie rozumiesz tego kodu, przeczytaj artykuł o tym jak posługiwać się HttpClient.

Protokół MultipartForm data

Teraz musimy utworzyć całe ciało wiadomości, którą będziemy wysyłać. Będzie to formularz złożony z części.

Każda część to tzw. content. C# daje nam różne takie klasy jak np. StreamContent, do którego możemy wrzucić strumień, ByteArrayContent, do którego możemy podać tablicę, StringContent, do którego wrzucamy stringa.

I tutaj uwaga. Pojedyncze żądanie może mieć tylko jeden content. Ale jest specjalny typ – MultipartFormDataContent, który składa się z innych contentów. Ostatecznie wszystko jest przesyłane oczywiście jako strumień bajtów. A skąd wiadomo gdzie kończy się jedna zawartość a zaczyna druga?

Istnieje coś takiego jak Boundary. Jest to string, który oddziela poszczególne części między sobą. Jest ustawiany automatycznie, ale sam też go możesz podać. Pamiętaj tylko, że taki string powinien być unikalny.

Tak może wyglądać takie żądanie:

POST http://localhost:7144/api/form/1 HTTP/1.1
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=----------moje-unikalne-boundary

----------moje-unikalne-boundary
Content-Disposition: form-data; name="caption"

Summer vacation
----------moje-unikalne-boundary
Content-Disposition: form-data; name="image1"; filename="GrandCanyon.jpg"
Content-Type: image/jpeg

(dane binarne)
----------moje-unikalne-boundary

Najpierw stwórzmy i dodajmy zawartość pliku. Posłużymy się tutaj ByteArrayContent, ponieważ trzymamy plik w tablicy bajtów:

var request = new HttpRequestMessage(HttpMethod.Post, "api/form");

var form = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(Data.FileContent);
form.Add(fileContent, nameof(WebApiFormData.File), "uploaded.jpg");

Na początku utworzyłem HttpRequestMessage, który ostatecznie zostanie wysłany. To on będzie miał zawarty w sobie formularz.

Potem tworzę formularz, do którego będą dodawane poszczególne zawartości.

I tak tworzymy fileContent z zawartością pliku, który został odczytany. Podczas dodawania tego contentu (form.Add) ważne jest wszystko – nazwa tego contentu – file – taka sama jak w modelu, który będzie przyjmowany przez WebApi. No i nazwa pliku – „uploaded.jpg”. Nazwa pliku musi być dodana w przypadku przesyłania pliku. Tak naprawdę powinieneś dodać prawdziwe rozszerzenie. Ja tylko z czystego lenistwa i prostoty wpisałem na sztywno „jpg”. Pamiętaj, żeby dodawać faktyczne rozszerzenie.

To teraz zajmiemy się pozostałymi właściwościami:

var msgContent = new StringContent(Data.Message, new MediaTypeHeaderValue("text/plain"));
form.Add(msgContent, nameof(WebApiFormData.Message));

Tutaj wszystko wygląda analogicznie, z tym że posługuję się StringContent zamiast ByteArrayContent.

I tutaj istotna uwaga. Zawsze dobrze jest podać media-type dla stringa, który przesyłasz. Może to być czysty tekst (text/plain), może to być string z json’em (application/json), cokolwiek. Może być też tak, że WebApi nie chce mieć przekazanego żadnego mime-type albo wręcz przeciwnie – jakiś konkretny. W takim przypadku zastosuj to, co jest napisane w dokumentacji konkretnego WebApi.

Teraz można już dodać nasz formularz do żądania i wysłać je:

request.Content = form;
await client.SendAsync(request);

I tyle. Formularz został wysłany do API.

Przesłanie większej ilości danych z plikiem

Często trzeba przesłać większą ilość danych wraz z plikiem. Napisałem osobny artykuł o tym jak przesłać dane w JSON razem z plikiem. Zachęcam do przeczytania, bo mówię też tam o mechanizmie bindingu.


Dzięki za przeczytanie tego artykułu. Mam nadzieję, że wszystko jest jasne. Jeśli jednak znalazłeś jakiś błąd lub czegoś nie rozumiesz, koniecznie daj znać w komentarzu.

Obrazek artykułu: Obraz autorstwa upklyak na Freepik

Podziel się artykułem na:
Ten cholerny CORS dogłębnie

Ten cholerny CORS dogłębnie

Wstęp

Pewnie nie raz spotkałeś się z sytuacją, gdzie próba wywołania API z Blazor albo JavaScript zakończyła się radosnym błędem

XMLHttpRequest cannot load http://….
No ‘Access-Control-Allow-Origin’ header is present on the requested resource. Origin ‘http://…’ is therefore not allowed access.

Czym jest CORS, dlaczego jest potrzebne i jak się z nim zaprzyjaźnić? O tym dzisiaj.

Co to CORS

Cross Origin Request Sharing to mechanizm bezpieczeństwa wspierający politykę same-origin. Same-origin polega na tym, że JavaScript z jednej domeny (a konkretnie origin) nie może komunikować się z serwerem z innej domeny (origin).

Innymi słowy, jeśli masz stronę pod adresem: https://example.com i chciałbyś z niej wywołać za pomocą JavaScript coś ze strony https://mysite.com, to musisz mieć na to specjalne pozwolenie wydane przez mysite.com.

Czyli jeśli u siebie lokalnie z końcówki https://localhost:3000 będziesz chciał zawołać jakieś API z końcówki: https://localhost:5001, to też się to nie uda bez specjalnego pozwolenia. Tym wszystkim zarządza przeglądarka.

Czym jest ORIGIN

Już wiemy, że żeby nie było problemów, obydwie strony żądania muszą należeć do tego samego originu. Czym jest zatem origin?

To połączenie: protocol + host + port, czyli np:

https://example.com i https://example.com:443 – należą do tego samego originu. Pomimo, że w pierwszym przypadku nie podaliśmy jawnie portu, to jednak protokół https domyślnie działa na porcie 443. A więc został tam dodany niejawnie.

http://example.com i https://example.com – nie należą już do tego samego originu. Różnią się protokołem i portem (przypominam, że https działa domyślnie na porcie 443, a http na 80).

https://example.com:5000 i https://example.com:5001 – też nie należą do tego samego originiu, ponieważ różnią się portem.

https://api.example.com i https://example.com – też nie należą do tego samego originu, bo różnią się hostem. Zasadniczo origin definiuje aplikację internetową.

Polityka same-origin

Jak już pisałem wcześniej, polityka same-origin zakazuje jednej aplikacji korzystać z elementów innej aplikacji. Skryptów js, arkuszy css i innych… Ale…

No, ale jak to? A CDN? A linkowanie bootstrapa itd?

No właśnie. Przede wszystkim przeglądarki nie są zbyt rygorystyczne pod tym względem. Głównie ze względu na kompatybilność wsteczną. Pół Internetu przestałoby działać. Jednak to „rozluźnienie” niesie za sobą pewne zagrożenia. Np. może dawać podatność na atak XSS lub CSRF (pisałem o Cross Site Request Forgery w książce o podstawach zabezpieczania aplikacji internetowych).

Wyjątki polityki same-origin

Skoro przeglądarki niezbyt rygorystycznie podchodzą do polityki same-origin, to znaczy że są pewne luźniejsze jej elementy. Oczywiście, że tak. Przeglądarki pozwalają ogólnie na:

  • zamieszczanie obrazków z innych originów
  • wysyłanie formularzy do innych originów
  • zamieszczanie skryptów z innych originów – choć tutaj są już pewne ograniczenia

Na co same-origin nie pozwoli

Przede wszystkim nie pozwoli Ci na dostęp do innych originów w nowych technologiach takich jak chociażby AJAX. Czyli strzały HTTP za pomocą JavaScriptu. Co to oznacza? Zacznijmy od najmniejszego problemu – jeśli piszesz aplikację typu SPA w JavaScript lub Blazor, to chcesz się odwoływać do jakiegoś API. W momencie tworzenia aplikacji prawdopodobnie serwer stoi na innym originie niż front. Na produkcji może być podobnie. W takiej sytuacji bez obsługi CORS po stronie serwera, nie połączysz się z API.

Idąc dalej, jeśli chcesz na swojej stronie udostępnić dane pobierane z innego źródła – np. pobierasz AJAXem kursy walut – to też może nie zadziałać. W prawdzie użyłem tych kursów walut jako być może nieszczęśliwy przykład. Jeśli to działa to tylko ze względu na luźną politykę CORS. W przeciwnym razie musiałbyś się kontaktować z dostawcą danych, żeby pozwolił Ci na ich pobieranie. I tak też często się dzieje. I on może to zrobić właśnie dzięki CORS.

Więc jak działa ten CORS?

Pobierz sobie przykładową solucję, którą przygotowałem na GitHub. Jest kam kilka projektów:

  • WebApiWithoutCors – api, które w żaden sposób nie reaguje na CORS – domyślnie uniemożliwi wszystko
  • WebApiWithCors – api z obsługą CORS
  • ClientApiConfig – podstawowy klient, który chciałby pobrać dane i zrobić POST
  • DeletableClient – klient, któremu polityka CORS pozwala jedynie na zrobienie DELETE
  • BadClient – klient, któremu żadne API na nic nie pozwala

Każdy projekt pracuje w HTTP (nie ma SSL/TLS) specjalnie, żeby umożliwić w łatwy sposób podsłuchiwanie pakietów w snifferze.

Przede wszystkim działanie CORS (Cross Origin Request Sharing) jest domeną przeglądarki. Jeśli uruchomisz teraz przykładowe projekty: ClientApp (aplikacja SPA pisana w Blazor) i WebApiWithoutCors i wciśniesz guzik Pobierz dane, to zobaczysz taki komunikat:

A teraz wywołaj tę samą końcówkę z PostMan, to zobaczysz że dane zostały pobrane:

Co więcej, jeśli posłużysz się snifferem, np. WireShark, zobaczysz że te dane do przeglądarki przyszły:

To znaczy, że to ta małpa przeglądarka Ci ich nie dała. Co więcej, wywaliła się wyjątkiem HttpRequestException przy pobieraniu danych:

var client = HttpClientFactory.CreateClient("api");
try
{
    var response = await client.GetAsync("weatherforecast");
    if (!response.IsSuccessStatusCode)
        ErrorMsg = $"Nie można było pobrać danych, błąd: {response.StatusCode}";
    else
    {
        var data = await response.Content.ReadAsStringAsync();
        WeatherData = new(JsonSerializer.Deserialize<WeatherForecast[]>(data));
    }
}catch(HttpRequestException ex)
{
    ErrorMsg = $"Nie można było pobrać danych, błąd: {ex.Message}";
}

Co z tą przeglądarką nie tak?

Przeglądarka odebrała odpowiedź z serwera, ale Ci jej nie pokazała. Dlaczego? Ponieważ nie dostała z serwera odpowiedniej informacji. A konkretnie nagłówka Access-Control-Allow-Origin, o czym informuje w konsoli:

XMLHttpRequest cannot load http://....
No ‘Access-Control-Allow-Origin’ header is present on the requested resource. Origin ‘http://...’ is therefore not allowed access.

To poniekąd serwer zdecydował, że nie chce klientowi danych pokazywać. A dokładniej – serwer nie zrobił niczego, żeby te dane pokazać. Po prostu na dzień dobry obsługa CORS na serwerze jest wyłączona. Można powiedzieć, że jest zaimplementowana jedynie w przeglądarce.

Niemniej jednak przeglądarka wysłała do serwera zapytanie GET, które odpowiednie dane pobrało i zwróciło. Czyli jakaś operacja na serwerze się wykonała. Pamiętaj, że zapytanie GET nie powinno mieć żadnych skutków ubocznych. Czyli nie powinno zmieniać żadnych danych. Powinno dane jedynie pobierać. A więc teoretycznie nic złego nie może się stać.

A gdyby tak przeglądarka wysłała POST? Zadziała czy nie? No właśnie nie w każdej sytuacji.

Jeśli teraz w przykładowej aplikacji uruchomisz narzędzia dewelopera (Shift + Ctrl + i) i wciśniesz guzik Wywołaj POST, to zobaczysz coś takiego:

Zanim przeglądarka wyśle to żądanie, najpierw wykona specjalne zapytanie, tzw. preflight. Czyli niejako zapyta się serwera: „Hej serwer, jestem z takiego originu i chciałbym wysłać do Ciebie POST z takimi nagłówkami. Mogę?”

To specjalne żądanie wysyłane jest na adres, na który chcesz rzucić POSTem. Z tym że tutaj metodą jest OPTIONS. Poza tym w nagłówkach są zaszyte informacje:

  • Access-Control-Request-Headers – lista nagłówków z jakimi chcesz wysłać POST
  • Access-Control-Request-Method – metoda, jaką chcesz wywołać (POST, DELETE itd)
  • Origin – origin, z którego żądanie będzie wysłane

Możesz to podejrzeć zarówno w narzędziach dewelopera jak i w Wireshark:

A co zrobił serwer? Zwrócił błąd: 405 - Method not allowed. Co znaczy, że pod takim endpointem serwer nie obsługuje zapytań typu OPTIONS. Co dla przeglądarki daje jasny komunikat: „Nie wysyłaj mi tego, nie obsługuję CORS”. Przeglądarka więc zaniecha i nie wyśle takiego zapytania.

Wyjątkowe formularze

Jak już pisałem wcześniej, formularze są pewnym wyjątkiem. Przeglądarka i tak je wyśle. To kwestia kompatybilności wstecznej. Jeśli będziesz chciał wysłać metodę POST z Content-Type ustawionym na multipart/form-data, to takie zapytanie zostanie wykonane bez żadnego preflight'u. Takich wyjątków jest więcej i są bardzo dobrze opisane na stronie Sekuraka, więc nie będę tego powielał. Jeśli masz ochotę zgłębić temat, to polecam.

Obsługa CORS w .NET

Skoro już wiesz z grubsza czym jest CORS i, że to serwer ostatecznie musi dać jawnie znać, że zgadza się na konkretne zapytanie, to teraz zaimplementujmy ten mechanizm po jego stronie. Spójrz na projekt WebApiWithCors z załączonej solucji.

Jeśli pracujesz na .NET < 6, to pewnie będziesz musiał dorzucić Nugeta: Microsoft.AspNetCore.Cors.

Przede wszystkim musisz dodać serwisy obsługujące CORS podczas rejestracji serwisów:

builder.Services.AddCors();

a także wpiąć obsługę CORS w middleware pipeline. Jeśli nie wiesz, czym jest middleware pipeline, przeczytaj ten artykuł.

app.UseRouting();
app.UseCors();
app.UseAuthorization();

Pamiętaj, że UseCors musi zostać wpięte po UseRouting, ale przed UseAuthorization.

Takie dodanie jednak niczego nie załatwi. CORS do odpowiedniego funkcjonowania potrzebuje polityki. I musimy mu tę politykę ustawić.

Polityka CORS

CORS Policy mówi jakich dokładnie klientów i żądania możesz obsłużyć. Składa się z trzech części:

  • origin – obsługuj klientów z tych originów
  • method – obsługuj takie metody (POST, GET, DELETE itd…)
  • header – obsługuj takie nagłówki

To znaczy, że klient aby się dobić do serwera musi spełnić wszystkie trzy warunki – pochodzić ze wskazanego originu, wywołać wskazaną metodę i posiadać wskazany nagłówek.

Wyjątkiem jest tu POST. Co wynika z wyjątkowości formularzy. Jeśli będziesz chciał wysłać POST, przeglądarka zapyta się o to jedynie w przypadku, gdy Content-Type jest odpowiedni (np. nie wskazuje na formularz). Co to dalej oznacza? Jeśli stworzysz na serwerze politykę, która nie dopuszcza POST, ale dopuszcza wszystkie nagłówki (AllowAnyHeader), to ten POST i tak zostanie wysłany. Kwestia kompatybilności wstecznej.

Najprostszą politykę utworzysz w taki sposób:

builder.Services.AddCors(setup =>
{
    setup.AddDefaultPolicy(p =>
    {
        p.AllowAnyOrigin()
        .AllowAnyMethod()
        .AllowAnyHeader();
    });
});

Ona zezwala na połączenia z dowolnego originu, wykonanie dowolnej metody z dowolnymi nagłówkami. Czy to dobrze? To zależy od projektu.

Co więcej, metoda AddDefaultPolicy doda domyślną politykę. Wpięty w pipeline UseCors będzie używał tej domyślnej polityki do sprawdzenia, czy żądanie od klienta może pójść dalej.

Za pomocą metody AddPolicy możesz dodać politykę z jakąś nazwą. Tylko wtedy do UseCors musisz przekazać w parametrze nazwę polityki, której chcesz używać domyślnie. UseCors wywołany bez parametrów będzie używał polityki dodanej przez AddDefaultPolicy. Jeśli jej nie dodasz, wtedy CORS nie będzie obsługiwany.

Konkretna polityka

Oczywiście możesz w polityce wskazać konkretne wartości, np.:

builder.Services.AddCors(setup =>
{
    setup.AddDefaultPolicy(p =>
    {
        p.WithOrigins("http://localhost:5001")
        .AllowAnyMethod()
        .WithHeaders("X-API-KEY");
    });
});

To spowoduje, że polityka dopuści strzały tylko z originu http://localhost:5001 z jakąkolwiek metodą i dozwolonym nagłówkiem X-API-KEY.

I tutaj dwie uwagi. Po pierwsze – pamiętaj, żeby originu nie kończyć slashem: / . Jeśli tak wpiszesz http://localhost:5001/, wtedy origin się nie zgodzi i mechanizm CORS nie dopuści połączeń. Czyli – brak slasha na końcu originu. Idąc dalej, nie podawaj pełnych adresów w stylu: https://localhost:5001/myapp – to nie jest origin.

A teraz pytanie za milion punktów. Co się stanie, gdy mając taką politykę z poprawnego originu wywołasz:

var data = new WeatherForecast
{
    Date = DateTime.Now,
    Summary = "Cold",
    TemperatureC = 5
};

var client = HttpClientFactory.CreateClient("api");
client.DefaultRequestHeaders.Add("X-API-KEY", "abc");
var response = await client.PostAsJsonAsync("weatherforecast", data);

Dodałeś nagłówek X-API-KEY do żądania i wysyłasz JSONa za pomocą post (dowolna metoda).

Zadziała?

Przemyśl to.

Jeśli powiedziałeś „nie”, to zgadza się. Gratulacje 🙂 A teraz pytanie dlaczego to nie zadziała. Spójrz jaki przeglądarka wysyła preflight:

O co pyta przeglądarka w tym żądaniu?

„Hej serwer, czy mogę ci wysłać POST z nagłówkami contenty-type i x-api-key? A co odpowiada serwer?

„Ja się mogę zgodzić co najwyżej na metodę POST i nagłówek X-API-KEY„.

Przeglądarka teraz patrzy na swoje żądanie i mówi: „Ojoj, to nie mogę ci wysłać content-type. Więc nie wysyłam”. To teraz pytanie skąd się wzięło to content type? Spójrz jeszcze raz na kod:

var response = await client.PostAsJsonAsync("weatherforecast", data);

Wysyłasz JSONa. A to znaczy, że gdzieś w metodzie PostAsJsonAsync został dodany nagłówek: Content-Type=application/json. Ponieważ w zawartości żądania (content) masz json (czyli typ application/json).

Uważaj na takie rzeczy, bo mogą doprowadzić do problemów, z którymi będziesz walczył przez kilka godzin. Ale w tym wypadku już powinieneś wiedzieć, jak zaktualizować politykę CORS:

builder.Services.AddCors(setup =>
{
    setup.AddDefaultPolicy(p =>
    {
        p.WithOrigins("http://localhost:5001")
        .AllowAnyMethod()
        .WithHeaders("X-API-KEY", "Content-Type");
    });
});

Musisz dodać ten „Content-Type”.

Jeśli wydaje Ci się, że CORS powinien zadziałać, a nie działa, w pierwszej kolejności zawsze zobacz jaki preflight jest wysyłany, jakie nagłówki idą w żądaniu i czy są zgodne z polityką.

Pamiętaj też, że w żądaniu nie muszą znaleźć się wszystkie nagłówki. Jeśli nie będzie w tej sytuacji X-API-KEY nic złego się nie stanie. Analogicznie jak przy polityce dotyczącej metod. Możesz wysłać albo GET, albo POST, albo DELETE… Nie możesz wysłać kilku metod jednocześnie, prawda? 🙂

CORS tylko dla jednego endpointu

Corsy możesz włączyć tylko dla konkretnych endpointów. Możesz to zrobić za pomocą atrybutów. I to na dwa sposoby. Jeśli chcesz umożliwić większość operacji, możesz niektóre endpointy wyłączyć spod opieki CORS. Spójrz na kod kontrolera:

[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
    return Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        Date = DateTime.Now.AddDays(index),
        TemperatureC = Random.Shared.Next(-20, 55),
        Summary = Summaries[Random.Shared.Next(Summaries.Length)]
    })
    .ToArray();
}

[HttpPost]
[DisableCors]
public IActionResult PostTest([FromBody]WeatherForecast data)
{
    return Ok();
}

Atrybut DisableCors spowoduje, że mechanizm CORSów uniemożliwi wywołanie tej końcówki. Jeśli przeglądarka użyje preflight, wtedy serwer odpowie, ale nie pozwalając na nic.

Kilka polityk CORS

Skoro można zablokować CORS na pewnych końcówkach, to pewnie można też odblokować na innych. No i tak. Zgadza się. Zróbmy sobie takie dwie polityki:

builder.Services.AddCors(setup =>
{
    setup.AddPolicy("get-policy", setup =>
    {
        setup.WithOrigins("http://localhost:5001")
        .AllowCredentials()
        .AllowAnyHeader()
        .AllowAnyMethod();
    });

    setup.AddPolicy("set-policy", setup =>
    {
        setup.WithOrigins("http://localhost:5001")
        .WithMethods("POST")
        .WithHeaders("Content-Type");
    });
});

Zwróć uwagę na to, że nie dodajemy teraz żadnej domyślnej polityki (AddDefaultPolicy), tylko dwie, które jakoś nazwaliśmy. Teraz każdy endpoint może mieć swoją własną politykę:

[HttpGet]
[EnableCors("get-policy")]
public IEnumerable<WeatherForecast> Get()
{
    return Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        Date = DateTime.Now.AddDays(index),
        TemperatureC = Random.Shared.Next(-20, 55),
        Summary = Summaries[Random.Shared.Next(Summaries.Length)]
    })
    .ToArray();
}

[HttpPost]
[EnableCors("set-policy")]
public IActionResult PostTest([FromBody]WeatherForecast data)
{
    return Ok();
}

Każdy endpoint dostał swoją własną politykę za pomocą atrybutu EnableCors. Jako parametr przekazujemy nazwę polityki. Jeśli w takim przypadku nie podasz atrybutu EnableCors, to końcówka będzie zablokowana. Dlaczego? Spójrz na middleware:

app.UseCors();

Taki middleware po prostu będzie chciał użyć domyślnej polityki (AddDefaultPolicy), której jednak nie ma. Dlatego też zablokuje wszystko. Oczywiście możesz w tym momencie podać konkretną politykę, jaka ma być używana przez middleware:

app.UseCors("get-policy");

Wtedy każdy endpoint bez atrybutu [EnableCors] będzie używał tej polityki.

Dynamiczna polityka CORS

Czasem możesz potrzebować bardziej płynnej polityki, która może zależeć od konkretnego originu. Możesz chcieć wpuszczać tylko te originy, które są zarejestrowane w bazie albo dla różnych originów mieć różne polityki.

Wtedy sam musisz zadbać o to, żeby mechanizm CORS dostał odpowiednią politykę. Na szczęście w .NET6 jest to banalnie proste. Wystarczy zaimplementować interfejs ICorsPolicyProvider, np. w taki sposób:

public class OriginCorsPolicyProvider : ICorsPolicyProvider
{
    private readonly CorsOptions _corsOptions;
    public OriginCorsPolicyProvider(IOptions<CorsOptions> corsOptions)
    {
        _corsOptions = corsOptions.Value;
    }

    public Task<CorsPolicy?> GetPolicyAsync(HttpContext context, string? policyName)
    {
        var origin = context.Request.Headers.Origin;
        var policy = _corsOptions.GetPolicy(origin);

        if (policy == null)
            policy = _corsOptions.GetPolicy(policyName ?? _corsOptions.DefaultPolicyName);
        
        return Task.FromResult(policy);
    }
}

Interfejs wymaga tylko jednej metody – GetPolicyAsync. Najpierw jednak zobacz w jaki sposób zarejestrowałem odpowiednie polityki podczas rejestracji serwisów CORS:

setup.AddPolicy("http://localhost:5001", setup =>
{
    setup.WithOrigins("http://localhost:5001")
    .AllowAnyHeader()
    .WithMethods("GET", "POST");
});

setup.AddPolicy("http://localhost:5011", setup =>
{
    setup.WithOrigins("http://localhost:5011")
    .WithMethods("DELETE")
    .WithHeaders();
    
});

Nazwa polityki to po prostu origin, dla którego ta polityka jest utworzona. A teraz wróćmy do providera. Spójrz najpierw na metodę GetPolicyAsync.

Najpierw pobieram origin z requestu, następnie pobieram odpowiednią politykę. Metoda GetPolicy z obiektu _corsOptions zwraca politykę po nazwie. Te polityki są tam dodawane przez setup.AddPolicy. Gdzieś tam pod spodem są tworzone jako dodatkowe opcje, co widzisz w konstruktorze – w taki sposób możesz pobrać zarejestrowane polityki.

Oczywiście nic nie stoi na przeszkodzie, żebyś w swoim providerze połączył się z bazą danych i na podstawie jakiś wpisów sam utworzył odpowiednią politykę dynamicznie i ją zwrócił.

Teraz jeszcze tylko musimy zarejestrować tego providera:

builder.Services.AddTransient<ICorsPolicyProvider, OriginCorsPolicyProvider>();

I tyle.

Słowem zakończenia, pamiętaj żeby nie podawać w middleware kilka razy UseCors, bo to nie ma sensu. Pierwszy UseCors albo przepuści żądanie dalej w middleware albo je sterminuje.


To tyle jeśli chodzi o CORSy. Dzięki za przeczytanie artykułu. Mam nadzieję, że teraz będziesz się poruszał po tym świecie z większą pewnością. Jeśli znalazłeś błąd w artykule albo czegoś nie rozumiesz, koniecznie daj znać w komentarzu.

Obrazek wyróżniający: Obraz autorstwa wayhomestudio na Freepik

Podziel się artykułem na:
Własny szablon dla dotnet new

Własny szablon dla dotnet new

Wstęp

W poprzednim artykule pokazałem jak tworzyć klasyczny szablon projektów dla Visual Studio. Natomiast z tego artykułu dowiesz się jak stworzyć szablon dla dotnet new. Taki szablon możesz zaimportować na każdym komputerze i systemie, w którym zainstalowany jest dotnet.

Klasyczne szablony (dla Visual Studio) są obsługiwane tylko przez VisualStudio dla Windows. Natomiast te nowe dla „dotnet new” już nie mają tego ograniczenia. Ponadto pozwalają duuużo prościej zrobić bardziej skomplikowane rzeczy. Minus? Nie da się tego wyklikać…jeszcze…

Pamiętaj, że ten artykuł to nie jest kompletny podręcznik szablonów. O nie! To jest dość duży temat. Artykuł pokazuje jak zacząć i jak zrobić coś trudniejszego niż „hello world”. Na koniec jednak daję Ci kilka linków, które uzupełniają artykuł. Jeśli czegoś tutaj nie znajdziesz, być może znajdziesz w oficjalnej dokumentacji (podlinkowanej na końcu).

Z czego składa się szablon

Są dwie rzeczy:

  • struktura projektu – wrzucasz tutaj dowolną zawartość (pliki, foldery, inne projekty), która ma się znaleźć na dzień dobry w projekcie wynikowym – od tego zaczynasz, po prostu stwórz nowy projekt, który będzie wyjściowym projektem dla Twojego szablonu
  • plik konfiguracyjny template.json – opisuje parametry szablonu
  • opcjonalne pliki np. do obsługi Wizarda, czy też ikonka

W przeciwieństwie do klasycznych szablonów pod VisualStudio, nie musisz stosować specjalnych tokenów w formie parametrów w swoich projektach. Nie ma też problemu z kompilowaniem ich – właśnie ze względu na brak tych tokenów.

Musisz jednak zapewnić odpowiednią strukturę katalogów, np. taką:

Jak widzisz, w katalogu z projektem musisz utworzyć folder .template.config, w którym umieścisz całą konfigurację swojego szablonu. Ja utworzyłem projekt WebApplication (RazorPages), jako projekt wyjściowy, któremu nadałem nazwę WebAppWithApiTemplate.

Tylko uwaga – będzie Cię korciło, żeby dodać folder .template.config do projektu w Visual Studio. Nie rób tego. Wszystko, co zrobisz z projektem będzie miało skutek w Twoim szablonie. W prawdzie można potem zastosować pewien mechanizm, żeby pozbyć się tego folderu w wynikowym projekcie, ale moim zdaniem można to zrobić lepiej…

Dodawanie plików konfiguracyjnych do solucji

To nie znaczy jednak, że nie możesz pracować na plikach konfiguracyjnych w Visualu. Byłoby to mocno uciążliwe. Utwórz zatem ręcznie katalog .template.config, a w nim plik o nazwie template.json. A następnie w Visual Studio kliknij prawym klawiszem myszy na solucję i wybierz Add -> New Solution Folder:

To utworzy Ci folder (tzw. „filtr”) na poziomie solucji. Jednak żaden folder na dysku nie powstanie. Ja swój „filtr” nazwałem template-files – to jak nazwiesz swój nie ma żadnego znaczenia.

Następnie kliknij go prawym klawiszem myszy i wybierz Add -> Existing Item:

W pickerze wybierz pliki konfiguracyjne szablonu, które utworzyłeś wcześniej. To doda je do solucji, ale nie zrobi żadnych zmian na poziomie projektów. Dzięki temu będziesz mógł pracować na plikach konfiguracyjnych w Visual Studio:

Plik template.json

To właściwie serce Twojego szablonu. Niestety na dzień dzisiejszy nie da się go wyklikać, ale dość łatwo się go tworzy. Zwłaszcza, jeśli wykorzystasz możliwości VisualStudio. Spójrz na okno z zawartością pliku:

Plik oczywiście jest pusty, ale w edytorze kodu widzisz combobox oznaczony jako Schema. Możesz w nim wybrać schemat pliku json, który chcesz tworzyć – dzięki temu cały Intellisense zadziała i będziesz mieć eleganckie podpowiedzi w kodzie. Odnajdź na tej liście https://json.schemastore.org/template.json – to jest opisany schemat szablonu.

UWAGA! Widoczny na powyższym screenie schemat to jakiś przykładowy pierwszy lepszy z listy. Pamiętaj, żeby odnaleźć tam konkretny: https://json.schemastore.org/template.json

VS dzięki temu opisowi może dawać Ci podpowiedzi. A teraz zacznijmy wypełniać plik template.json. Na początek trzeba mu wskazać wybrany schemat. Robimy to za pomocą właściwości $schema:

{
  "$schema": "https://json.schemastore.org/template.json"
}

OK, teraz zupełnie podstawowa zawartość tego pliku może wyglądać tak:

{
  "$schema": "https://json.schemastore.org/template.json",
  "author": "Adam Jachocki",
  "classifications": [ "WebApp", "ApiClient" ],
  "identity": "Jachocki.WebAppWithApiTemplate",
  "name": "Web application with API Client",
  "shortName": "waapi",
  "tags": {
    "type": "project",
    "language": "c#"
  }
}

To są minimalne wymagane właściwości. Rozpoznasz je po tym, że w Intellisense są pogrubione. A teraz przelecimy je po kolei:

  • author – to oczywiście autor szablonu
  • classifications – to klasyfikuje szablon. Te wartości będą potem używane w filtrach np. Visual Studio. Podajesz tutaj tablicę wartości, które powinny kategoryzować Twój szablon. W VisualStudio to te właściwości podczas dodawania nowego projektu. W narzędziu dotnet new --list będą się pojawiały w rubryce tags.
  • identity – to jest coś w rodzaju ID Twojego szablonu. To musi być unikalne, dlatego daj tutaj jakąś unikalną wartość, ale niech to będzie coś opisowego a nie np. GUID – później się przyda
  • name – pełna nazwa Twojego szablonu (przykłady istniejących: „Console App”, „ASP.NET Core Web App”)
  • shortName – nazwa skrócona szablonu, której będziesz mógł używać, tworząc projekt na jego podstawie
  • tags – podstawowy opis szablonu – wskazujesz, że szablon odnosi się do projektu (type: project) i jaki jest główny język projektu. Szablon może być jeszcze dla całej solucji (kilka projektów) lub nowym item’em – czyli poszczególnym typem pliku.

Dość istotną właściwością jest sourceName. SourceName to ciąg znaków zarówno w zawartości plików jak i w ich nazwach, który podczas tworzenia projektu zostanie zastąpiony nazwą projektu, którą poda użytkownik. Przykładowo, jeśli mój kod wygląda tak:

I jeśli we właściwości sourceName podam: „WebAppWithApiTemplate” wtedy ten ciąg zostanie zamieniony na to, co wpisze użytkownik podając nazwę swojego projektu.

Czyli zarówno namespace’y, jak i nazwa projektu głównego zostaną zamienione na ten żądany przez użytkownika. A więc dodajmy to:

{
  "$schema": "https://json.schemastore.org/template.json",
  "author": "Adam Jachocki",
  "classifications": [ "WebApp", "ApiClient" ],
  "identity": "Jachocki.WebAppWithApiTemplate",
  "name": "Web application with API Client",
  "shortName": "waapi",
  "tags": {
    "type": "project",
    "language": "c#"
  },
  "sourceName": "WebAppWithApiTemplate"
}

Pewnie są sytuacje, w których nie chciałbyś takiej podmiany. Wtedy po prostu tego nie stosujesz, gdyż nie jest to właściwość wymagana.

To jest już pełnoprawny projekt szablonu. Teraz musisz go tylko zapakować w nuget i zainstalować. O tym później. Najpierw zrobimy inne fajne rzeczy.

Parametry

Szablon często będzie zawierał jakieś parametry, które mogą być ustawione przez użytkownika. W pliku template.json lądują one we właściwości symbols. Parametry są jednym z typów symboli.

Parametr składa się z nazwy (ID), a także typu danych. Może zawierać wartość domyślną, a także ciąg, który będzie zamieniony na wartość parametru.

Jak to działa? Silnik szablonów analizuje plik template.json. Jeśli teraz znajdzie jakieś parametry (symbole) do podmiany, to każdy taki ciąg znaków w kodzie zamieni na konkretną wartość podaną przez użytkownika. Analogicznie do właściwości sourceName.

Dodajmy do naszego szablonu folder ApiClient, a nim prostą klasę:

I teraz chcę, żeby taka klasa znalazła się w katalogu ApiClient, ale to użytkownik będzie decydował o tym, jak ten klient ma się nazywać. Dlatego też posłużę się symbolem:

"symbols": {
  "ApiClientName": { //nazwa parametru (jego ID)
    "type": "parameter",
    "datatype": "text",
    "description": "Podaj nazwę klasy dla swojego klienta API",
    "displayName": "Nazwa klasy dla Api Client",
    "defaultValue": "MyApiClient",
    "isRequired": true,
    "replaces": "varApiClientName",
    "fileRename": "varApiClientName"
  }
}

Uznaj właściwość symbols za klasę, która posiada inne właściwości – konkretne symbole. I teraz tak. Każdy symbol ma swoją nazwę (ID). Tutaj to APIClientName. Nazwą symbolu możesz posługiwać się w kodzie swojego szablonu, o tym za chwilę.

Każdy symbol to też swego rodzaju klasa, która posiada konkretne właściwości:

  • type – typ symbolu. To może być parametr, generator itd. Na razie skupmy się na parametrach
  • datatype – typ danych, jakie ten parametr będzie przechowywał. Inne typy to np:
    • bool – true/false – na GUI równoznaczne z checkboxem (np. Use Https)
    • choice – lista wartości do wyboru – na GUI równoznaczne z combo (np. wybór Identity)
    • float, hex, int, text – no te wartości same się opisują. Przy czym text jest wartością domyślną
  • description – opis parametru. Pokaże się w konsoli w poleceniu dotnet new, ale też w VisualStudio jako dodatkowa informacja (chociaż dla IDE jest dodatkowy/osobny sposób pokazywania parametrów opisany niżej)
  • defaultValue – wartość domyślna
  • isRequired – czy parametr jest wymagany. Jeśli podczas tworzenia projektu przez polecenie dotnet new użytkownik nie poda tej wartości, to dotnet new zwróci błąd
  • replaces – to jest ten magiczny string w plikach, który zostanie zamieniony na wartość wprowadzoną przez użytkownika. U mnie to varApiClientName. Ten prefix „var” jest tylko pewnym udogodnieniem dla mnie. Nie ma tutaj żadnych wytycznych ani obostrzeń. Ale pamiętaj, że zostaną zamienione WSZYSTKIE STRINGI w kodzie. Łącznie z tymi w plikach projektów, komentarze, a nawet wszystkie wartości tekstowe. Jeśli więc miałbym w kodzie coś takiego:
public string Id { get; set; } = "varApiClientName";

to też będzie zamienione. Dlatego ja posługuję się tym przedrostkiem var. To trochę chroni przed zrobieniem głupoty.

  • fileRename – analogicznie jak replace z tą różnicą, że taki ciąg zostanie podmieniony w nazwach plików.

Stwórzmy teraz bardzo prostego i prymitywnego klienta dla API. To jest tylko bardzo prymitywny przykład. Jak fajnie zrobić takiego klienta pisałem w tym artykule. Nie chcę zaciemniać obrazu, dlatego zrobię najprościej jak się da. Ten klient powstał tylko dla przykładu. I naprawdę nie jest istotne, że nie ma większego sensu 😉

Sparametryzowany klient

Co chcemy uzyskać?

  1. Użytkownik ma mieć opcję do podania nazwy klienta – to już nam załatwił parametr varApiClientName.
  2. Użytkownik ma mieć możliwość wyłączenia lub włączenia rejestracji serwisów – czyli domyślnej konfiguracji klienta.
  3. W przypadku włączenia rejestracji serwisów, użytkownik ma mieć możliwość podania BaseAddress dla API. Zarówno dla środowiska produkcyjnego jak i deweloperskiego.

Tworzenie pełnego kodu

Tak, czy inaczej musimy stworzyć cały kod. W pliku template.json nie możemy dodawać plików ani linijek kodu. Dlatego też musimy wyjść od pełnego obrazu.

Przede wszystkim jest klasa, która umożliwia wysyłanie żądań – prosty klient w pliku varApiClientName.cs:

namespace WebAppWithApiTemplate.ApiClient
{
	public class varApiClientName
	{
		private readonly HttpClient _client;

		public varApiClientName(HttpClient client)
		{
			_client = client;
		}

		public async Task<HttpResponseMessage> GetData(string endpoint)
		{
			return await _client.GetAsync(endpoint);			
		}

		public async Task<HttpResponseMessage> PostData<TOut>(TOut data, string endpoint)
		{
			return await _client.PostAsJsonAsync(endpoint, data);
		}
	}
}

Do tego trzeba zrobić rejestrację HttpClienta. Dlaczego w taki sposób? Opisywałem to w tym artykule.

W folderze ApiClient dodałem plik ServiceCollectionsExtensions.cs:

public class varApiClientNameOptions
{
	public const string CONFIG_SECTION = "varApiClientName";
	public string BaseAddress { get; set; }
}
public static class ServiceCollectionExtensions
{
	public static IServiceCollection AddvarApiClientNameIntegration(this IServiceCollection services, 
		IConfiguration config)
	{
		varApiClientNameOptions options = new varApiClientNameOptions();
		config.Bind(varApiClientNameOptions.CONFIG_SECTION, options);

		services.AddHttpClient<varApiClientName>(client =>
		{
			client.BaseAddress = new Uri(options.BaseAddress);
		});

		return services;
	}
}

W tym kodzie po prostu rejestrujemy HttpClient dla naszego klienta. Jeśli nie wiesz co robi AddHttpClient, koniecznie przeczytaj ten artykuł.

Najpierw pobieramy ustawienia, w których będzie wpisany adres bazowy api. To nam zapewnia, że adres bazowy będzie pobrany w zależności od środowiska. Nasępnie konfigurujemy klienta.

Spójrz w jaki sposób przemycam wszędzie varApiClientName – te wszystkie miejsca zostaną zamienione na faktyczną nazwę klienta.

Na koniec wywołamy jeszcze tę metodę podczas rejestracji serwisów w pliku Program.cs:

using WebAppWithApiTemplate.ApiClient;
//...

builder.Services.AddRazorPages();
builder.Services.AddvarApiClientNameIntegration(builder.Configuration);

Dodawanie parametrów

W związku z tym, że chcemy aby użytkownik mógł podać adres bazowy dla API, musimy dodać takie parametry do template.json. Oczywiście dodajemy to dalej w sekcji symbols:

"ApiProdBaseAddress": {
  "type": "parameter",
  "datatype": "text",
  "description": "Bazowy adres dla API dla środowiska produkcyjnego",
  "replaces": "varApiProdBaseAddress",
  "defaultValue": "https://api.example.com/"
},
"ApiDevBaseAddress": {
  "type": "parameter",
  "datatype": "text",
  "description": "Bazowy adres dla API dla środowiska deweloperskiego",
  "replaces": "varApiDevBaseAddress",
  "defaultValue": "https://api.dev.example.com"
}

Dodałem dwa parametry – jeden to adres bazowy dla środowiska produkcyjnego, drugi dla deweloperskiego. Jeśli nie wiesz, czym jest appsettings.json, czym się różni od appsettings.Development.json i masz małe pojęcie o konfiguracji .NET, koniecznie przeczytaj ten artykuł.

Dodawanie ustawień w aplikacji

OK, możemy teraz te dane dodać do plików appsettings.

Teraz mój plik appsettings.json wygląda tak:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "varApiClientName": {
    "BaseAddress": "varApiProdBaseAddress"
  } 
}

Spójrz w jaki sposób przemycam tutaj znów nazwy tego klienta i parametr z bazowym adresem dla API. Analogicznie zrobimy w pliku appsettings.Development.json:

"varApiClientName": {
  "BaseAddress": "varApiDevBaseAddress"
}

Wycinanie fragmentów kodu

OK, teraz mamy ogarnięte punkty 1 i 3 z naszych założeń. Czyli użytkownik może podać nazwę klienta, a także adresy bazowe do API. To teraz musimy się zatroszczyć o punkt 2 – użytkownik ma mieć możliwość wyłączenia jakiejkolwiek konfiguracji klienta.

Najpierw dodajmy taki parametr w pliku template.json:

"ConfigureApiClient": {
  "type": "parameter",
  "datatype": "bool",
  "description": "Czy klient API ma być domyślnie skonfigurowany",
  "defaultValue": "true"
}

Zauważ, że tutaj nie posługuję się właściwością replaces, ponieważ niczego nie będziemy podmieniać. Albo wykonamy fragment kodu, albo nie.

Zatem wytnijmy warunkowo fragment kodu, który wywołuje rejestrację klienta, czyli to:

using WebAppWithApiTemplate.ApiClient;
//...

builder.Services.AddRazorPages();
builder.Services.AddvarApiClientNameIntegration(builder.Configuration);

Robimy to w bardzo prosty sposób – dyrektywami kompilatora:

#if ConfigureApiClient
using WebAppWithApiTemplate.ApiClient;
#endif

//...

			builder.Services.AddRazorPages();
#if ConfigureApiClient
			builder.Services.AddvarApiClientNameIntegration(builder.Configuration);
#endif

W taki sposób możesz sterować fragmentami kodu w plikach. Po prostu sprawdzasz, czy parametr o podanym ID ma wartość true.

Usuwanie plików

Jeśli chodzi jednak o plik ServiceCollectionExtensions.cs nie jest on w ogóle potrzebny, gdy użytkownik nie chce automatycznej konfiguracji. Nie ma sensu wycinać kodu w tym pliku, bo zostałby nam zupełnie pusty. Dlatego warunkowo możemy go usunąć. Z pomocą przychodzi nowa sekcja w pliku template.jsonsources – w niej możemy stosować modyfikatory plików źródłowych.

Sekcja sources zawiera modyfikatory. Każdy modyfikator może mieć warunek lub wykonać się bezwarunkowo (zawsze). Napiszmy więc taki modyfikator, który usunie plik ServiceCollectionsExtensions.cs, gdy parametr ConfigureApiClient nie będzie ustawiony (jego wartość będzie na false):

"sources": [
  {
    "modifiers": [
      {
        "condition": "(!ConfigureApiClient)",
        "exclude": "ApiClient/ServiceCollectionExtensions.cs"
      }
    ]
  }
],

Jak widzisz, żeby sprawdzić wartość jakiegoś parametru, po prostu wpisujemy jego nazwę w nawias. Możemy też go zanegować wykrzyknikiem. Czyli w tym przypadku, gdybyśmy chcieli ten warunek przepisać na kod, wyglądałoby to akoś tak:

if(!ConfigureApiClient)
{
    exclude("ApiClient/ServiceCollectionExtensions.cs");
}

Następnie musimy wskazać, co ma się zadziać, jeśli warunek będzie spełniony. A więc pozbywamy się pliku, wykluczamy go (exclude) z całości.

Teraz może pojawić się pytanie – skąd silnik template’ów wie, gdzie jest konkretny plik. Ścieżką wyjściową (bazową) dla silnika jest folder, w którym znajduje się folder .template.config.

I tutaj odnosimy się do folderu ApiClient i pliku, który się w nim znajduje. Możesz stosować tutaj symbole wieloznaczne, np: „**/*Extensions.cs” – usunęłoby wszystkie pliki z wszystkich podkatalogów, których nazwy kończą się na Extensions.cs.

Jeśli jednak chciałbyś wykluczyć większość plików, a zostawić tylko jeden, łatwiej będzie posłużyć się modyfikatorem include. Domyślnie include włącza wszystkie pliki, które znajdują się w projekcie (oczywiście poza katalogiem .template.config).

Możesz też chcieć warunkowo zmienić nazwy plików. Do tego możesz zastosować modyfikator rename.

Warunkowe zawartości plików

Pliki *.cs

Jak już pisałem wcześniej, w plikach cs możemy posługować się dyrektywami kompilatora, żeby warunkowo mieć jakąś zawartość, np:

#if ConfigureApiClient
			builder.Services.AddvarApiClientNameIntegration(builder.Configuration);
#endif

Możesz stosować również dyrektywę #elif. A co jeśli chcesz jednak, żeby jakaś dyrektywa była widoczna na koniec w pliku? Np:

#if DEBUG
			logger.LogWarning("UWAGA! Tryb deweloperski!");
#endif

Jest i na to sposób. Nazywa się to processing flag. To już jednak wygląda jak czary:

//-:cnd:noEmit
#if DEBUG
			logger.LogWarning("UWAGA! Tryb deweloperski!");
#endif
//+:cnd:noEmit

No cóż… Grunt, że jest taka opcja 😉 Pamiętaj – „processing flag”.

Pliki JSON

Tutaj nie możemy się posłużyć bezczelnie dyrektywą, ale możemy posłużyć się specjalnym komentarzem. W plikach json komentarz jest rozpoczynany znakami // i trwa do końca linii. To wytnijmy teraz ustawienia związane z konfiguracją api clienta z plików appsettings:

  //#if ConfigureApiClient
  "varApiClientName": {
    "BaseAddress": "varApiProdBaseAddress"
  }
  //#endif

To spowoduje dokładnie to, czego się spodziewasz – sekcja varApiClientName pojawi się w pliku appsettings tylko wtedy, gdy parametr ConfigureApiClient będzie miał wartość TRUE. Zrób analogiczną operację w pliku appsettings.Development.json.

Zmiany w plikach projektów i innych XMLach

Tutaj analogicznie posłużymy się komentarzami XMLowymi. Możesz bez problemu je stosować w plikach projektów. Dodajmy do naszych wymagań jeszcze jedno – niech użytkownik wybierze, czy chce korzystać z biblioteki System.Text.Json, czy ze starego dobrego Newtonsoft.Json. Na początek dodajmy taki parametr do pliku template.json. Niech to będzie combobox.

"JsonLibrary": {
  "type": "parameter",
  "datatype": "choice",
  "choices": [
    {
      "choice": "Default",
      "description": "Używaj domyślnej biblioteki do obsługi JSON",
      "displayName": "Domyślny System.Text.Json"
    },
    {
      "choice": "Newtonsoft",
      "description": "Używaj starego, dobrego Newtonsoft",
      "displayName": "Newtonsoft.Json"
    }
  ],
  "defaultValue": "Default"
}

Jak widzisz, choices składa się z tablicy obiektów (choice, description, displayName). Ich właściwości same się opisują. I co ciekawe, przy choice też możesz stosować właściwość replaces, co później pokażę. Pamiętaj też, żeby wartością datatype było ustawione na choice.

To teraz zmieńmy plik projektu. Jak już wspomniałem robimy to specjalnym komentarzem:

<!--#if (JsonLibrary == 'Newtonsoft')-->
<ItemGroup>
  <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
</ItemGroup>
<!--#endif -->

Musisz tutaj zwrócić uwagę na dwie rzeczy:

  1. Pomiędzy znakiem komentarza <!-- a hashem nie może być spacji. Inaczej silnik szablonów nie uzna tego za dyrektywę, tylko za zwykły komentarz.
  2. Wartość, którą porównujesz musi być w apostrofach. Inaczej warunek nie zostanie uznany jako spełniony.

Można to zrobić jeszcze inaczej – bez porównywania konkretnych wartości. Przydać się to może w sytuacji, gdzie miałbyś do sprawdzenia kilka tych samych warunków w kilku miejscach. Ale o tym za chwilę.

Trochę magii – czyli czego nie powie Ci dokumentacja

Dokumentacje szablonów są całkiem nieźle opisane, ale mają miejscami sporo braków. Jak np. magiczny parametr Framework. Na jakimś video z Microsoftu słyszałem, że jest on zalecany, ale nigdzie nie jest opisany. Hurrra!

Generalnie parametr Framerowk daje wybór frameworka, na którym ma być stworzony projekt oparty na szablonie. Tworzy się go analogicznie jak inne parametry:

"Framework": {
  "type": "parameter",
  "description": "The target framework for the project.",
  "datatype": "choice",
  "choices": [
    {
      "choice": "net6.0",
      "description": "Target net6.0"
    },
    {
      "choice": "netcoreapp3.1",
      "description": ".NetCore 3.1"
    },
    {
      "choice": "net7.0",
      "description": ".NET 7.0"
    }
  ],
  "replaces": "net6.0",
  "defaultValue": "net6.0"
}

Zwróć uwagę tutaj na dwie rzeczy:

  • wartości w choice muszą być dokładnymi „monikerami” wersji .NET, np.:
    • net48 – .NetFramework 4.8
    • netstandard2.1
    • netcoreapp3.1
    • net5.0
    • net6.0
    • net7.0

Jeśli wrzucisz inne wartości, to magiczny mechanizm nie zadziała.

  • we właściwości replaces wstawiasz, jak to z innymi parametrami, string do podmiany. Pamiętaj, że to podmieni wszystkie znalezione stringi „net6.0” na wybraną przez użytkownika wartość – w szczególności w pliku projektu.
Zobacz, jak sprytnie Visual Studio rozkminił wersje

Symbole wyliczane

Innym typem symbolu są symbole wyliczane. To coś w rodzaju parametru. Tylko nie podaje go użytkownik, a wyliczamy go na jakiejś podstawie. Możesz to stosować gdy w kilku miejscach stosujesz jakieś warunki. Np:

"IsLTS": {
  "type": "computed",
  "value": "(Framework == net6.0 || Framework == netcoreapp3.1)"
}

Taki kodzik stworzy Ci coś w rodzaju zmiennej o nazwie IsLTS, która przyjmie odpowiednią wartość na podstawie innych parametrów. W tym konkretnym przypadku możesz sprawdzić, czy wybrana wersja .net jest długowieczną (long time support), czy też nie. Później na tej podstawie możesz zadecydować o czymś innym, stosując w innych warunkach zmienną IsLTS – dokładnie w taki sam sposób jak inne parametry. Np. możesz gdzieś w kodzie wpisać warning:

#if (!IsLTS)
#warning "Caution! Your framework is not LTS version!"
#endif

UWAGA! Symbole typu computed mogą przyjmować jedynie wartości typu bool.

Instalowanie szablonu

Właściwie wszystkie wytyczne mamy już ogarnięte. Teraz możemy zainstalować taki szablon. Oczywiście nie można zrobić z VisualStudio tego automatem… jeszcze… za to można to zrobić na kilka sposobów. Najpierw podam Ci sposób lokalny – to jest wystarczające jeśli tworzysz jakiś szablon dla siebie i raczej nie będziesz w nim już grzebał.

Uruchom terminal i przejdź do katalogu głównego Twojej aplikacji. Tam, gdzie masz katalog z projektem. U mnie plik projektu znajduje się w projekty\MasterBranch\SingleNewTemplate\src\WebAppWithApiTemplate\WebAppWithApiTemplate.csproj dlatego muszę ustawić się w katalogu src: projekty\MasterBranch\SingleNewTemplate\src\

Teraz wystarczy zainstalować szablon, wykonując polecenie:

dotnet new --install .\WebAppWithApiTemplate

czyli podajemy nazwę katalogu, w którym jest projekt z szablonem.

Następnie możemy przejrzeć sobie listę szablonów:

dotnet new --list

U mnie wygląda to tak:

Jak widzisz, mój szablon został zainstalowany.

Jeśli będziesz chciał go odinstalować, to dotnet new podpowie Ci dokładnie co zrobić, ale zasadniczo powinieneś podać pełną ścieżkę dostępu do katalogu z projektem szablonu, np.:

dotnet new --uninstall d:\projekty\MasterBranch\SingleNewTemplate\src\WebAppWithApiTemplate

Jeśli chciałbyś utworzyć sobie projekt na podstawie tego szablonu, to możesz to zrobić podając króką nazwę szablonu. A wywołując instrukcję help, dostaniesz pełną pomoc dla swojego szablonu:

dotnet new waapi --help

Jak widzisz, dotnet świetnie sobie poradził z Twoimi parametrami i pokazuje Ci dokładnie jak ich użyć. Przykładowe utworzenie projektu:

dotnet new waapi -J Default -A ExampleApiClient -o .\NewProject

Tutaj jednak pamiętaj, że parametry polecenia dotnet new mieszają się z Twoimi parametrami. Stąd np. -o (--output) – parametr dotnet new, który mówi gdzie stworzyć projekt.

Możesz też taki projekt stworzyć bezpośrednio z Visual Studio (zrestartuj Visual Studio po zainstalowaniu szablonu).

Instalowanie szablonu NuGetem

Ten sposób umożliwia Ci podzielenie się szablonem z innymi, a także łatwe jego wersjonowanie. Wymaga to utworzenia nugetowej paczki. Ale zadanie jest dość proste.

Utwórz gdzieś plik template.nuspec. Możesz to zrobić w katalogu .template.config, ale nie musisz. Utwórz go gdzieś, gdzie uznasz za słuszne. Jego przykładowa zawartość powinna wyglądać tak:

<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd">
  <metadata>
    <id>Jachocki.ApiClient</id>
    <version>1.0.0</version>
    <description>Przykładowy projekt szablonu</description>
    <authors>Adam Jachocki</authors>
    <requireLicenseAcceptance>false</requireLicenseAcceptance>
    <license type="expression">MIT</license>
    <tags>dotnet templates apiclient webapi</tags>
	<packageTypes>
      <packageType name="Template" />
    </packageTypes>
  </metadata>
  <files>
    <file src="..\**\*.*" exclude="..\**\bin\**\*.*;..\**\obj\**\*.*;" />
  </files>
</package>

Ten plik sam się opisuje. Musisz pamiętać jedynie o tym, żeby sekcja files odnosiła się do konkretnych plików Twojego szablonu. Ja akurat plik nuspec umieściłem w .template.config – dlatego też w sekcji files idę po pliki „piętro wyżej”.

Następnie musisz wywołać na nim:

nuget pack template.nuspec

Jeśli wszystko jest ok, to otrzymałeś plik z rozszerzeniem nupkg. I to jest Twój pakiet, którym możesz się już dzielić. Żeby teraz zainstalować taki szablon, wystarczy:

dotnet new install .\twoj-plik.nupkg

Odinstalowanie jest analogiczne. Tyle, że podajesz tylko nazwę pakietu – bez ścieżki do niego.

Przygotowanie szablonu dla środowisk IDE

dotnetcli.host.json

Oprócz pliku template.json, który jest podstawą, możesz mieć jeszcze dodatkowe pliki, w których swój szablon możesz podkręcić. Najprostszym jest dotnetcli.host.json. Jego rolą jest ustawienie aliasów do parametrów w narzędziu dotnet new. Domyślnie są one tworzone jakoś automagicznie. Jeśli wyświetlisz pomoc dla swojego szablonu, zobaczysz taki mniej więcej opis:

Parametr na długą nazwę, np. --ApiClientName, ale może mieć też krótki alias, np: -a. Zauważ, że pełna nazwa parametru rozpoczyna się podwójnym myślnikiem [–], natomiast aliast pojedynczym [-]. Jest to naturalne zachowanie. Jak widzisz powyżej, dla każdego parametru istnieją jakieś domyślne aliasy. Ale możesz je sam utworzyć, stosując plik dotnetcli.host.json, np. tak:

{
  "$schema": "https://json.schemastore.org/dotnetcli.host.json",
  "symbolInfo": {
    "ApiClientName": {
      "longName": "ApiClientName",
      "shortName": "cn"
    }
  }
}

Najpierw podajesz id symbolu takie jak w pliku template.json. Następnie masz parametry:

  • longName – długa nazwa – czyli ta prawilna cała nazwa parametru prefiksowana podwójnym myślnikiem
  • shortName – alias – krótka nazwa parametru. Jeśli podasz pusty string „”, wtedy parametr nie będzie miał aliasu.
  • isHidden – jeśli ustawisz na true, to ta opcja nie będzie widoczna z poziomu dotnet new --help. Jednak nadal będziesz mógł z niej korzystać (po prostu nie zobaczysz jej w podpowiedziach).

ide.host.json

Ten plik z kolei pomaga ogarnąć dodatkowe rzeczy w Visual Studio (i pewnie w innych IDE). We wcześniejszych wersjach (poniżej 2022) był wymagany. Teraz jest opcjonalny, jednak umożliwia dodatkowe czary mary.

Zacznijmy od zupełnej podstawy:

{
  "$schema": "https://json.schemastore.org/ide.host.json",
  "icon": "icon.png"
}

Tutaj ustawiamy ikonkę dla szablonu. To zakłada, że w katalogu .template.config masz ikonkę o nazwie icon.png. Żeby ikonka była dobrze widoczna, powinna być w rozmiarze 32×32 piksele o głębokości 32 bitów.

UWAGA! Jeśli tego nie zrobisz, ale w katalogu .template.config będziesz miał ikonkę o nazwie icon.png, to VisualStudio od wersji 2022 też to ogarnie.

Parametry

Informacje o parametrach przechowywane są we właściwości symbolInfo. To po prostu tablica parametrów, mówiących VisualStudio jak ma je obsługiwać. Podobnie jak w template.json

Podstawowa budowa obiektu jest taka:

"symbolInfo": [
  {
    "id": "ApiClientName",
    "name": {
      "text": "Nazwa klienta API"
    },
    "description": {
      "text": "Podaj nazwę klienta dla swojego API. Tak będzie nazywać się wygenerowana klasa."
    },
    "isVisible": true
  }
]

Id to nazwa parametru z pliku template.json. Właściwości name i description pokażą się przy tym parametrze jako jego nazwa i opis, który będzie w hincie:

Jeśli nie wypełnisz właściwości name i description, zostanę one odczytane z pliku template.json.

Parametr isVisible określa, czy właściwość ma być widoczna.

Jeśli używasz pliku ide.host.json, to pamiętaj że domyślnie wszystkie parametry są UKRYTE. Stąd właściwość isVisible. Ustawiasz nią, które parametry mają być widoczne. Jeśli chcesz żeby wszystkie były widoczne, to jest od tego właściwość defaultSymbolVisibility, np:

{
  "$schema": "https://json.schemastore.org/ide.host.json",
  "icon": "icon.png",
  "supportsDocker": true,
  "defaultSymbolVisibility": true
}

Ten kod sprawi, że wszystkie parametry z template.json będą widoczne podczas tworzenia nowego projektu w Visual Studio. Czyli zasadniczo w VisualStudio 2022 równie dobrze ten plik (ide.host.json) mógłby nie istnieć.

Plik ide.host.json ma jeszcze kilka parametrów:

  • description – opis szablonu – jeśli go nie wypełnisz, zostanie wzięty z pliku template.json
  • name – nazwa szablonu – analogicznie jak wyżej
  • order – kolejność w jakiej pojawi się szablon na liście tworzenia nowego projektu
  • supportsDocker – jeśli ustawione na true, to podczas tworzenia projektu zobaczysz opcję (checkbox) pozwalającą zdokeryzować taki projekt. I tutaj uwaga! Jeśli chcesz żeby ta opcja była widoczna w VisualStudio, musisz do pliku template.json dodać parametr Framework (ten magiczny). Inaczej nie zadziała.
  • unsupportedHosts – możesz tutaj podać listę wersji, dla których ten szablon ma być niewidoczny na dialogu tworzenia nowego projektu, np. taka konfiguracja ukryje szablon w VisualStudio:
"unsupportedHosts": [
  {
    "id": "vs"
  }
]

Zaawansowane

Specjalne operacje

Jeśli silnik szablonów nie potrafi czegoś zrobić standardowo, to być może da Ci taką możliwość za pomocą specjalnych operacji. Te operacje pozwalają na zdefiniowanie dodatkowych akcji podczas tworzenia projektu na podstawie szablonu. Akcje mogą być globalne (dla wszystkich plików) lub ograniczone tylko do niektórych plików.

{
  "customOperations": { //odnosi się do wszystich plików - globalnie
   },

  "specialCustomOperations": { //tylko do niektórych plików
  }
}

Ja w jednym swoim szablonie potrzebowałem mieć różne grupy usingów. Szablon został utworzony pod konkretną solucję, w której te specjalne projekty (namespacey) już są. Jednak z nimi projekt szablonu się nie kompilował. Owszem, mogłem utworzyć jakiś plik z konkretnymi namespaceami i potem go usuwać, ale wpadłem na inny pomysł. Wyobraź sobie taki plik *.cs:

/*add-usings
using Nerdolando.BS.Common.Abstractions;
using Nerdolando.BS.Integrations;
add-usings*/
namespace WebAppWithApiTemplate.ApiClient
{
	public class SpecialOperations
	{
	}
}

Zauważ, że usingi mam tutaj wykomentowane. Ale chcę, żeby pojawiły się w wynikowym projekcie. Dlatego posłużyłem się customową operacją z pliku template.json:

"SpecialCustomOperations": {
  "**/*.cs": {
    "flagPrefix": "/*add-usings",
    "operations": [
      {
        "type": "replacement",
        "configuration": {
          "original": "/*add-usings",
          "replacement": ""
        }
      },
      {
        "type": "replacement",
        "configuration": {
          "original": "add-usings*/",
          "replacement": ""
        }
      }
    ]
  }
}

Najpierw podaję do jakich plików odnoszą się akcje. Tutaj – wszystkie pliki z rozszerzeniem *.cs. Następnie definuję operacje. Jest kilka predefiniowanych operacji, m.in. „replacement„. Każda z takich operacji ma swoją konfigurację. Niestety na listopad 2022 w schemacie template.json nie ma tego zdefiniowanego, więc nie będziesz miał podpowiedzi w Intellisense.

W związku z tym, że ten artykuł nie jest kompletnym przewodnikiem, masz tutaj stronę z oficjalnej dokumentacji z opisem tych operacji: https://github.com/dotnet/templating/wiki/Reference-for-template.json#global-custom-operations-and-special-custom-operations

Porady

W tym momencie kończę już ten nieco przydługi artykuł. Dam Ci jeszcze kilka porad na koniec.

  1. Przede wszystkim zainstaluj sobie narzędzie do analizy szablonów:
dotnet tool install --global sayedha.template.command

Następnie możesz przeanalizować swój szablon pod kątem problemów:

templates analyze -f <path-to-folder>

Gdzie <path-to-folder> to ścieżka do katalogu z szablonem (katalogu, w którym jest .template.config)

  1. Dodawaj parametr Framework do template.json.
  2. Jeśli masz jakiś problem ze swoim szablonem – Visual Studio widzi starą wersję albo nie widzi go wcale:
    • odinstaluj szablon
    • usuń zawartość katalogu C:\Users\<nazwa użytkownika>\.templateengine\
    • zainstaluj szablon na nowo
    • jeśli to nie pomaga, czasem pomaga restart Visuala, a czasem restart komputera
  3. Jeśli chcesz wejść mocniej w temat, koniecznie odwiedź:

Dzięki za przeczytanie tego artykułu. Jeśli znalazłeś jakiś błąd albo czegoś nie rozumiesz, koniecznie daj znać w komentarzu. Daj też znać w komentarzu, jeśli uważasz, że taki artykuł jest za długi i powinien zostać podzielony na dwa 🙂

Podziel się artykułem na: