Rejestracja użytkowników

Jak to zwykle w życiu bywa, czas weryfikuje słuszność naszych planów. Chciałem dokładnie opisywać implementację każdej warstwy naszej aplikacji, ale się nie da. Głównie dlatego, że w większości przypadków prezentacja użycia danego „komponentu” wymagała wywołania z warstwy niższej, a wpis wyglądał jak spaghetti z odnośnikami gdzie aktualnie się znajdujemy. Po drugie, najważniejsze rzeczy zostały już opisane, i tam siłą rzeczy pojawiały się chociażby serwisy domenowe. Po trzecie, w razie wątpliwości lub niedomówień zawsze możecie odwiedzić githuba projektu, lub napisać do mnie w komentarzu lub przez formularz kontaktowy 😛 W związku z tym postanowiłem, że zabierzemy się za implementację i opis funkcjonalności systemu. Dziś zajmiemy się rejestracją użytkowników. W tym celu musimy przygotować kilka rzeczy:

  • Strukturę plików na kliencie
  • Widok rejestracji użytkownika wraz z ViewModelem
  • DTO odpowiedzialne za przesłanie danych na serwer
  • serisu wykującego żądania na serwer
  • metody na serwerze, które stworzą nowego użytkownika

Pierwszą rzeczą jaką zrobimy to ogarnięcie strony klienckiej. W paczce skeleton dostarczonej przez zespół Aureli znajdował się folder src, który ma zawierać skrypty. Moglibyśmy zrobić z tego wielki worek, do którego byśmy wrzucali co popadnie, ale nie dalej jak za dwa tygodnie nie byłoby wiadomo o co w tym wszystkim chodzi. Dlatego na wzór „starego” ASP.NET stworzymy sobie podkatalog areas, który będzie nam ładnie wydzielał poszczególne obszary naszej aplikacji. Myślę, że najłatwiej będzie to po prostu pokazać 😉

 

files-structure

 

Jak widać każdy obszar (póki co admin i user) zawiera pięć folderów:

  • config – zawiera konfigurację routingu dla obszaru. Cały proces konfiguracji opisałem we wpisie o routingu w Aureli.io
  • models – zawiera wszelkie modele/DTO wykorzystywane w danym obszarze
  • services – tu znajdują się serwisy, które komunikują się z naszym serwerem
  • static-view-models – statyczne view modele bez widoku
  • view-models –  a tu mamy nasze widoki wraz z view modelami\

Generalnie praktyka nakazuje aby struktura plików po stronie klienta była jednopoziomowa, ale dla mnie taka wersja jest czytelniejsza, wobec czego potraktujcie to jako propozycję. Zwróćmy uwagę, że część plików nie zawiera się w katalogu areas. Te pliki są częścią „architektury” części klienckiej i nie dotyczą bezpośrednio logiki biznesowej aplikacji. Przy okazji kolejnych wpisów będę je stopniowo omawiał, a jeden nawet teraz 😀 Mowa o data-service.ts. Implementacja wygląda następująco:

 

import auth = require('auth-service');
import {HttpClient,json} from 'aurelia-fetch-client';
import {inject} from 'aurelia-framework';
 
export interface IDataService
{
    http: HttpClient;
    authService: auth.AuthService;
    get<TResponse>(url: string, isAccessTokenRequired: boolean) : Promise<TResponse>;
    post<TResponse>(url: string, data: any, isAccessTokenRequired: boolean) : Promise<TResponse>;
 
}
 
export class DataService implements IDataService
{
    http: HttpClient;
    authService: auth.AuthService;
 
    constructor(http: HttpClient, authService: auth.AuthService)
    {
        this.authService = authService;
        this.http = http;
 
        this.http.configure(config =>
        {
            config.withBaseUrl('http://localhost:49849/api/');
        });
    }
 
    get<TResponse>(url: string, isAccessTokenRequired: boolean) : Promise<TResponse>
    {
        var requestConfig : any = {};
 
        if (isAccessTokenRequired)
        {
            var accessToken = this.authService.getAccessToken();
            requestConfig.headers = { 'Authorization': `Bearer ${accessToken}` };
        }
 
        return this.http.fetch(url, requestConfig).then<TResponse>(response => response.json());
    }
 
    post<TResponse>(url: string, data : any, isAccessTokenRequired: boolean) : Promise<TResponse>
    {
        var requestConfig: any =
        {
            method: 'post',
            body: json(data)
        };
 
        if (isAccessTokenRequired) {
            var accessToken = this.authService.getAccessToken();
            requestConfig.headers = { 'Authorization': `Bearer ${accessToken}` };
        }
 
        return this.http.fetch(url, requestConfig).then<TResponse>(response => response.json());
    }
} 

 

Jest to serwis bazowy po którym będą dziedziczyć wszystkie serwisy znajdujące się w areas. W konstruktorze przyjmuje dwa obiekty: jeden typu HttpClient, który pozwoli nam na tworzenie żądań do serwera, drugi typu AuthService, który zajmuje się zarządzaniem access tokenem użytkownika (opiszę przy okazji logowania). W samym konstruktorze konfigurowany jest także host naszej aplikacji. Wiem, że jest wpisany na sztywno, ale mam zamiar wynieść go do pliku konfiguracyjnego. Sama klasa DataService posiada dwie metody generyczne: get oraz post . Metoda get posiada dwa parametry. Pierwszy jest adresem, pod który wykonamy żądanie, drugi parametr decyduje czy w nagłówku żądania ma zostać dodany access token. Metoda post działa analogicznie, z tym, że posiada jeszcze parametr data typu nieokreślonego (any), tak aby móc wysyłać dane na serwer.

Przejdźmy w końcu do samej rejestracji. Na początek nasze DTO, które prześlemy na serwer:

export class UserLoginDto
{
    userName: string;
    password: string;
    rememberMe: boolean;
 
    constructor()
    {
        this.rememberMe = true;
    }
}
 
 
export class UserRegisterDto extends  UserLoginDto
{
    email: string;
    confirmPassword: string;
}

 

Myślę, że nie ma co tłumaczyć. Jedyne co może zaskoczyć o fakt, że DTO do rejestracji użytkownika zawiera pole remeberMe (niewykorzystywane). Nie chciałem zbytnio redundować kodu dlatego postawiłem na dziedziczenie, ale w mojej ocenie tonie jest jakiś rażący zabieg. Dobra jedziemy dalej, serwis wygląda tak:

 

import models = require("../models/user-models");
import app = require("../../../data-service");
import data = require("../../../data");
import auth = require("../../../auth-service");
import {HttpClient} from "aurelia-fetch-client";
import {inject} from 'aurelia-framework';
 
 
export interface IUserService
{
    register(userRegisterDto: models.UserRegisterDto): Promise<data.IResult>;
    login(userLoginDto: models.UserLoginDto): Promise<string>;
    getUserSelfInfo(): Promise<auth.IUser>;
    logout(): Promise<data.IResult>;
}
 
@inject(HttpClient, auth.AuthService)
export class UserService extends app.DataService implements IUserService 
{
    constructor(http: HttpClient, authService: auth.AuthService)
    {
        super(http, authService);
    }
 
    register(userRegisterDto: models.UserRegisterDto): Promise<data.IResult>
    {
        return super.post('Accounts/Register', userRegisterDto, false);
    }
    //Inne metody
}

 

Pierwsze linijki stanowią wszelkie importy, które są nam niezbędne do widzenia typów. Jak widać nasz serwis faktycznie dziedziczy po bazowym serwisie, a ponadto implementuje swój interfejs IUserService. W konstruktorze zostają wstrzyknięte dwie zależności, które przekazujemy serwisowi bazowemu poprzez wywołanie super(hhtp, authService). Jest to odpowiednik base w C#. Metoda register przyjmuje nasze DTO, a zwracać ma Promise na typ IResult (zostanie opisany później). Sama metoda sprowadza się do wywołania bazowej metody post, której przekazujemy ścieżkę, obiekt rejestracji użytkownika oraz informujemy, że access token nie ma zostać dołączony, gdyż na tym etapie użytkownik go nie posiada. Ok, zabieramy się za view model:

 

import userServices = require("../services/user-service");
import models = require("../models/user-models");
import data = require("../../../data");
import {inject} from 'aurelia-framework';
import {Router} from 'aurelia-router';
 
@inject(userServices.UserService, Router)
export class RegisterViewModel
{
    userService: userServices.IUserService;
    userRegisterDto: models.UserRegisterDto;
 
    constructor(userService: userServices.UserService, private router: Router)
    {
        this.userService = userService;
        this.userRegisterDto = new models.UserRegisterDto();
    }
 
    register()
    {  
        this.userService.register(this.userRegisterDto).then((result :data.IResult) =>
        {
            if (result.state === data.ResultStateEnum.Succeed)
            {
                this.router.navigate('#/user/login');
                Materialize.toast('Registration completed. Login now !', 4000, 'btn');
            }
            else
            {
                for (let error of result.errors)
                    Materialize.toast(error, 4000, 'btn orange');
            }                
        });
    }
}

 

Nasz RegisterViewModel posiada dwa pola: userService, poprzez który wyślemy nasze dane, oraz userRegisterDto. Serwis użytkowników zostaje wstrzyknięty i przypisany do pola w konstruktorze. Wstrzyknięty zostaje także router, który posłuży nam na przeniesienie użytkownika na widok logowania po udanej akcji rejestracji. Metoda register zostanie wywołana z poprzez guzik na widoku. Wywołuje ona metodę register naszego serwisu użytkownika po czym obsługuje callback. Jeżeli akcja powiodła się (o czym informuje pole state w IResult) tak jak wspomniałem przenosimy użytkownika na widok logowania i wyświetlamy mu komunikat o powodzeniu akcji przy pomocy toastra dostarczonego przez Materialize CSS. Jeżeli jednak proces się nie powiedzie np. z powodu złej długości hasła, albo pustego emaila, wtedy wypiszemy wszystkie błędy zawarte w tablicy errors, aby ułatwić użytkownikowi ich korektę. Czas na widok:

 

<template>
    <div class="container">
        <div class="row">
            <form class="col s8 offset-s2 form-position ">
                <div class="row form-header">                   
                    <div class="col s2 offset-s3">
                        <h3>Register</h3>
                    </div>
                </div>
                <div class="row">
                    <div class="input-field col s6 offset-s3">
                        <input id="register-username" value.bind="userRegisterDto.userName" type="text">
                        <label for="register-username">Username</label>
                    </div>
                </div>
                <div class="row">
                    <div class="input-field col s6 offset-s3">
                        <input id="register-email" value.bind="userRegisterDto.email" type="text">
                        <label for="register-email">Email</label>
                    </div>
                </div>
                <div class="row">
                    <div class="input-field col s6 offset-s3">
                        <input id="register-password" value.bind="userRegisterDto.password" type="password">
                        <label for="register-password">Password</label>
                    </div>
                </div>
                <div class="row">
                    <div class="input-field col s6 offset-s3">
                        <input id="register-confirm-password" value.bind="userRegisterDto.confirmPassword" type="password">
                        <label for="register-confirm-password">Confirm password</label>
                    </div>
                </div>
                <div class="row">
                    <div class="col s6 offset-s3">
                        <a class="waves-effect waves-light btn" click.trigger="register()">Register</a>
                    </div>
                </div>
            </form>
        </div>
    </div>
</template>

 

Jest to najzwyklejszy formularz zawierający cztery pola do uzupełnienia oraz guzik wywołujący metodę register w RegisterViewModel. Jeżeli nie jesteście zaznajomieni z data-bindem w Aureli to zapraszam do moje wpisu gnie przedstawiłem podstawy tego frameworka.  Dobra, czas na serwer. Na początek DTO:

 

public class UserLoginDto
    {
        [Required]
        public string UserName { get; set; }
 
        [Required]
        [StringLength(100, ErrorMessage = "The {0} must be at least {2} characters long.", MinimumLength = 6)]
        [DataType(DataType.Password)]
        public string Password { get; set; }
 
        
        public bool RememberMe { get; set; }
    }
 
    public class UserRegisterDto : UserLoginDto
    {
        [Required]
        [EmailAddress]
        public string Email { get; set; }
 
        [Required]
        [DataType(DataType.Password)]
        [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
        public string ConfirmPassword { get; set; }
    }

 

Temat analogiczny do tego po stronie klienta, z tą różnicą, że property posiadają atrybuty na podstawi, który zostanie określona poprawność DTO. Teraz kontroler:

[Route("api/Accounts")]
    public class AccountController : BaseController
    {
        private readonly IUserAuthDomainServiceProxy _userAuthDomainServiceProxy;
        private readonly IOAuthService _oAuthService;
 
        public AccountController(IUserAuthDomainServiceProxy userAuthDomainServiceProxy, IOAuthService oAuthService)
        {
            _userAuthDomainServiceProxy = userAuthDomainServiceProxy;
            _oAuthService = oAuthService;
        }
 
        [HttpPost("Register"), AllowAnonymous]
        public async Task<IResult> RegisterUserAsync([FromBody] UserRegisterDto userRegisterDto)
        {
            if(!ModelState.IsValid)
                return new Result
                {
                    State = ResultStateEnum.Failed,
                    Errors = ModelState.Values.SelectMany(v => v.Errors).Select(v => v.ErrorMessage).ToArray()
                };
 
            var registerResult = await _userAuthDomainServiceProxy.CreateUserAsync(userRegisterDto);
 
            if (!registerResult.Succeeded)
            {
                return new Result
                {
                    State = ResultStateEnum.Failed,
                    Errors = registerResult.Errors.Select(e => e.Description).ToArray()
                };
            }
            return new Result();
        }       
        //Inne akcje kontrolera 
    }

 

Pierwszą rzeczą jest sprawdzenie poprawności DTO poprzez sprawdzenie wartości pola ModelState.IsValid. Jeżeli coś jest nie tak, zwracamy rezultat w postaci obiektu Result, którego stan informuje klienta o niepowodzeniu akcji, a wszystkie błędy przypisujemy do tablicy Errors. Następnym krokiem jest wywołanie metody asynchronicznej CreateUserAsync z warstwy Proxy, która zwraca obiekt IdentityResult. Jeżeli na tym etapie wydarzy się coś czego nie mogliśmy sprawdzić w naszym DTO (np. nazwa użytkownika jest zajęta), to pole Succeeded będzie równe false, a my analogicznie zwrócimy obiekt zawierający wszelkie błędy. W przypadku gdy wszystko poszło po naszej myśli zwracamy nową instancję Result, która domyślnie posiada State ustawiony na Succeed. No dobra szybko przechodzimy do warstwy Proxy. Metoda CreateUserAsync w proxy:

 

public class UserAuthDomainServiceProxy : BaseProxy, IUserAuthDomainServiceProxy
    {
        private readonly IUnitOfWorkFactory _unitOfWorkFactory;
        private readonly IDomainServiceFactory<IUserAuthDomainService> _userAuthDomainServiceFactory; 
 
        public UserAuthDomainServiceProxy(IUnitOfWorkFactory unitOfWorkFactory, IDomainServiceFactory<IUserAuthDomainService> userAuthDomainServiceFactory)
        {
            _unitOfWorkFactory = unitOfWorkFactory;
            _userAuthDomainServiceFactory = userAuthDomainServiceFactory;
        }
 
 
        public async Task<IdentityResult> CreateUserAsync(UserRegisterDto userRegisterDto)
        {
            using (var unitOfWork = _unitOfWorkFactory.Get())
            {
                var userAuthDomainService = _userAuthDomainServiceFactory.Get(unitOfWork);
                var userCreateDomainObject = userRegisterDto.AsDomainObject();
 
                return await userAuthDomainService.CreateUserAsync(userCreateDomainObject);
            }
        } 
    }

 

Jeżeli nie wiecie skąd tu się wzięły te fabryki to wszystko wyjaśniłem we wpisie o wzorcu Unit Of Work. Jedyna „akcja”, która się tu odbywa to zamiana DTO na DomainObject poprzez Automappera. Przejdźmy do serwisu domenowego:

 

public sealed class UserAuthDomainService : IUserAuthDomainService
    {
        private readonly UserManager<UserEntity> _userManager;
        private readonly RoleManager<IdentityRole> _roleManager; 
        private readonly SignInManager<UserEntity> _signInManager;
 
        public UserAuthDomainService(UserManager<UserEntity> userManager, SignInManager<UserEntity> signInManager, RoleManager<IdentityRole> roleManager)
        {
            _userManager = userManager;
            _signInManager = signInManager;
            _roleManager = roleManager;
        }
 
        public async Task<IdentityResult> CreateUserAsync(UserCreateDomainObject userCreateDomainObject)
        {
            var userEntity = userCreateDomainObject.AsEntity();
            return await _userManager.CreateAsync(userEntity, userCreateDomainObject.Password);
        } 
    }

 

Jak widać, pierwszą rzeczą jest zamiana obiektu domenowego do postaci encji użytkownika. Pytanie brzmi dlaczego nie zrobiłem tego w od razu w proxy? Moim zdaniem encja jest ściśle domenowa, a proxy powinno zapewnić jedynie komunikację pomiędzy warstwą Web oraz Domain zapewniając przy tym, aby domena otrzymywała jedynie DomainObjects, a kontroler otrzymywał rezultaty metod domenowych w postaci DTO. Nie jest to najpiękniejsze rozwiązanie, ale nie wpadłem na nic leszego, tak więc jeśli ktoś ma pomysł niech napisze 😛 Cała metoda domenowa sprowadza się tak naprawdę do wywołania metody UserManagera o nazwie CreateAsync. No dobra zobaczmy jak to działa:

 

Animation

Po kliknięciu, będzie się ruszać :O

 

Jest git 🙂 Dobra, wpis wyszedł długi ,ale nie chciałem się rozdrabniać na 10 postów bo to nie ma sensu. Kolejne będą dotyczyć logowania i tu już pewnie podzielę to na stronę klienta i serwer. Jeżeli chcecie być na bieżąco wbijajcie na mojego twittera 😉 Adios !

 

You may also like...