Criando um CRUD com .NET 5, MySQL, Angular 11, Keycloak, Phometheus e Grafana — Parte 3

Alan Luiz Viana
Escrito por Alan Luiz Viana em
Criando um CRUD com .NET 5, MySQL, Angular 11, Keycloak, Phometheus e Grafana — Parte 3

Nesse artigo vamos criar uma aplicação angular capaz de se autenticar em um servidor keycloak e usar essa autenticação para consumir uma API para listar, incluir, editar e excluir produtos.

Criando o projeto

Para criar o nosso projeto vamos utilizar o Angular CLI, para vamos executar o seguinte comando na raiz do nosso repositório:

ng new frontend --skip-tests

Vamos utilizar o parâmetro –skip-tests no momento pois não vamos escrever casos de teste.

Opções do Angular CLI

Nesse caso aceitei usar Angular Routing e selecionei CSS como nossa ferramenta para estilização.

Configurando ambiente

Podemos configurar o endereço do nosso servidor keycloak e da nossa api nos arquivos environment.prod.ts e invironment.ts:

export const environment = {
  production: true,
  apiUrl: 'http://localhost:5000',
  keycloak: {
        url: 'http://keycloak:8080/auth',
        realm: 'products_api',
        clientId: 'api',
  }
};
export const environment = {
  production: false,
  apiUrl: 'http://localhost:5000',
  keycloak: {
        url: 'http://keycloak:8080/auth',
        realm: 'products_api',
        clientId: 'api',
  }
};

Instalando dependências

Nesse projeto vamos utilizar a biblioteca keycloak-angular, ela vai auxiliar na implementação do nosso AuthGuard e da interceptação das requisições do HttpClient. Podemos realizar a instalação executando o seguinte comando na pasta da nossa aplicação angular(frontend):

npm install keycloak-angular keycloak-js --save

Criando componentes do Keycloak-Angular

Para configurar a nossa autenticação vamos criar uma pasta chamada auth dentro da pasta app da nossa aplicação, o caminho dessa pasta deve ser:

frontend\src\app\auth 

Dentro dessa nova pasta vamos criar o arquivo keycloak-initializer.ts:

import { KeycloakService } from 'keycloak-angular';

import { environment } from '../../environments/environment';

export function keycloakInitializer(keycloak: KeycloakService): () => Promise<any> {
    return (): Promise<any> => {
        return new Promise(async (resolve, reject) => {
            try {
                await keycloak.init({
                    config: environment.keycloak,
                    initOptions: {
                        checkLoginIframe: false
                    },
                    bearerExcludedUrls: []
                });
                resolve(null);
            } catch (error) {
                reject(error);
            }
        });
    };
}

Esse arquivo contém uma função que será usada para configurar o keycloak no inicio da execução da aplicação.

Também na pasta auth vamos criar o arquivo auth-guard.ts:

import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { KeycloakAuthGuard, KeycloakService } from 'keycloak-angular';

@Injectable({
  providedIn: 'root'
})
export class AuthGuard extends KeycloakAuthGuard {
  constructor(protected router: Router, protected keycloakAngular: KeycloakService) {
    super(router, keycloakAngular);
  }
  isAccessAllowed(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean> {
    return new Promise(async (resolve) => {
      if (!this.authenticated) {
        this.keycloakAngular.login();
        resolve(false);
      }
      resolve(true);
    });
  }
}

Essa classe vai ser configurada como uma proteção para nossas rotas, sendo assim, se estivermos autenticados vamos poder seguir para a tela solicitada, caso contrário seremos redirecionados para a tela de login.

Criando estrutura de roteamento de componentes

Para criar nossa estrutura de roteamentos primeiro precisamos que nossos componentes sejam criados. Vamos abrir o terminal na pasta frontend e executar os seguintes comandos:

ng generate component header
ng generate component products-list
ng generate component product-form

Agora podemos configurar nosso arquivo app-routing.module.ts:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AuthGuard } from './auth/auth-guard';
import { ProductFormComponent } from './product-form/product-form.component';
import { ProductsListComponent } from './products-list/products-list.component';

const routes: Routes = [
  { path: '', pathMatch:'full', redirectTo: 'products' },
  { 
    path: 'products',
    component: ProductsListComponent,
    canActivate: [AuthGuard] 
  },
  { 
    path: 'products/:id/edit',
    component: ProductFormComponent,
    canActivate: [AuthGuard] 
  },
  { 
    path: 'products/new',
    component: ProductFormComponent,
    canActivate: [AuthGuard] 
  },
  { path: '**', redirectTo: '' }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

Configurando keycloak no App.Module

Precisamos importar o módulo KeycloakAngularModule, declarar um provider do tipo APP_INITIALIZER para o nosso keycloakInitializer e um provider para nosso AuthGuard.

Também vamos importar os módulos HttpClientModule e FormsModule que são usados ao longo da aplicação, nosso app.module.ts deve estar assim:

import { BrowserModule } from '@angular/platform-browser';
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HeaderComponent } from './header/header.component';
import { ProductsListComponent } from './products-list/products-list.component';
import { ProductFormComponent } from './product-form/product-form.component';
import { KeycloakAngularModule, KeycloakService } from 'keycloak-angular';
import { keycloakInitializer } from './auth/keycloak-initializer';
import { AuthGuard } from './auth/auth-guard';

@NgModule({
  declarations: [
    AppComponent,
    HeaderComponent,
    ProductsListComponent,
    ProductFormComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    KeycloakAngularModule,
    HttpClientModule,
    FormsModule
  ],
  providers: [{
    provide: APP_INITIALIZER,
    useFactory: keycloakInitializer,
    multi: true,
    deps: [KeycloakService]
  },
  AuthGuard
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

Requisitando a API

Vamos criar uma classe que vai representar nosso produto, para isso podemos executar o seguinte comando na pasta frontend:

ng generate class models/product

Podemos adicionar as propriedades do nosso produto, ficando com a classe assim:

export class Product {
    id?: string;
    productName: string = ``;
    sku: string = ``;
    upc: string = ``;
    price: number = 0;
};

E criaremos um service para requisitar nossos endpoints da API, podemos fazer isso executando o comando:

ng generate service api/product

Nosso service pode ser implementado da seguinte forma:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';
import { Product } from '../models/product';


@Injectable({
  providedIn: 'root'
})
export class ProductService {

  apiUrl:string = environment.apiUrl;

  constructor(private http: HttpClient) { }

  getAll(){
    return this.http.get<Product[]>(`${this.apiUrl}/products`);
  }

  getById(id: string){
    return this.http.get<Product>(`${this.apiUrl}/products/${id}`);
  }

  insert(product: Product){
    return this.http.post(`${this.apiUrl}/products/`, product);
  }

  update(product: Product){
    return this.http.put(`${this.apiUrl}/products/${product.id}`, product);
  }

  delete(id: string){
    return this.http.delete(`${this.apiUrl}/products/${id}`);
  }
}

Criando componentes visuais

Vamos começar definindo estilos comuns da aplicação no arquivo styles.css:

/* You can add global styles to this file, and also import other style files */

:root {
    --primary-color: #bf7d06;
    --primary-color-light: rgb(248, 221, 170);
    --default-text-color: #231701;
}

body{
    margin: 0px;
    font-family: sans-serif;
    color: var(--default-text-color)
}

.applicationButton{
    background-color: var(--primary-color);
    border: 1px solid white;
    color: white;
}

O próximo componente que alteraremos é o App.Component, que passa a somente exibir o Header.Component e servir como container para outras telas:

.container{
    display: flex;
    justify-content: center;
}
<div class="nav">
  <app-header></app-header>
</div>

<div class='container'>
  <router-outlet></router-outlet>
</div>

Nosso Header.Component fica sendo responsável por requisitar o logout da aplicação e pode ser implementado da seguinte maneira:

.headerArea{
    background:  var(--primary-color);
    height: 35px;
    display: flex;
    flex-direction: row;
    justify-content: space-between;
    align-items: center;
}

.title{
    color: white;
    font-size: 20px;
    margin: 0px 0px 0px 10px;
}


.headerButton{
    margin-right: 10px;
    width: 100px;
    height: 25px;
}
<div class='headerArea'>
    <h1 class='title'>Product Management</h1>
    <button class='headerButton applicationButton' (click)="logout()">Logout</button>
</div>
import { Component, OnInit } from '@angular/core';
import { KeycloakService } from 'keycloak-angular';

@Component({
  selector: 'app-header',
  templateUrl: './header.component.html',
  styleUrls: ['./header.component.css']
})
export class HeaderComponent implements OnInit {

  constructor(private keycloakAngular: KeycloakService) { }

  ngOnInit(): void {
  }

  async logout() {
    this.keycloakAngular.logout();
  }

}

Podemos agora desenvolver o ProductsList.Component, que é responsável por listar, direcionar para inclusão/edição e apagar produtos.

O componente deve ficar assim:

.componentArea{
    width: 800px;
}

.headerArea{
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
}

.newButton{
    height: 25px;
    width: 100px;
}

.tableStyled{
    width: 100%;
}

.tableStyled thead tr{
    background-color: var(--primary-color);
    color: white;
}

.tableStyled tbody tr:nth-of-type(even) {
    background-color: var(--primary-color-light);
}

.actionButtonsArea{
    display: flex;
    flex-direction: row;
    justify-content: space-around;
}

.actionButton{
    height: 25px;
    width: 75px;
}
<div class="componentArea">
    <div class="headerArea">
        <h1>Product list</h1>
        <button class="newButton applicationButton" (click)='createProduct()'>New</button>
    </div>

    <table class='tableStyled'>
        <thead>
            <tr>              
                <th>Product Name</th>
                <th>UPC</th>
                <th>SKU</th>
                <th>Price</th>
                <th>Actions</th>
            </tr>
        </thead>
        <tbody>
            <tr *ngFor="let product of products">
                <td>{{ product.productName }}</td>
                <td>{{ product.upc }}</td>
                <td>{{ product.sku }}</td>
                <td>{{ numberToCurrencyFormat(product.price) }}
                </td>
                <td>
                    <div class="actionButtonsArea">
                        <button class="actionButton applicationButton" (click)="editProduct(product.id!)" >Edit</button>
                        <button class="actionButton applicationButton" (click)="deleteProduct(product.id!)">Delete</button>
                    </div>
                </td>
            </tr>
        </tbody>
    </table>
    
</div>
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { Product } from '../models/product';
import { ProductService } from '../api/product.service';

@Component({
  selector: 'app-product-list',
  templateUrl: './products-list.component.html',
  styleUrls: ['./products-list.component.css']
})
export class ProductsListComponent implements OnInit {

  products: Product[] = [];

  constructor(private productService: ProductService, private router: Router) { }

  async ngOnInit() {
    await this.loadProducts();
  }

  loadProducts() {
    this.productService.getAll().subscribe(
      (products) => {
        this.products = products;
      },
      (error) => this.errorHandler(error)
    )
  }
  errorHandler(error: any): void {
    console.log(error);
  }

  editProduct(id: string){
    this.router.navigate([`products/${id}/edit`])
  }

  createProduct(){
    this.router.navigate([`products/new`])
  }

  async deleteProduct(id: string){
    if (window.confirm("Are you sure you want to delete this product?")){
      await this.productService.delete(id).toPromise();
      this.loadProducts();
    }
  }

  numberToCurrencyFormat(value: any){
    return new Intl.NumberFormat('en-US',{ style: 'currency', currency: 'USD' }).format(value) 
  }
}

Agora vamos ao componente ProductForm.component, que é responsável pela inclusão e edição de produtos. Ele foi implementado da seguinte maneira:

.componentArea{
    width: 800px;
}

.inputArea{
    margin-bottom: 10px;
}

.inputArea label{
    display: block;
    width: 200px;
}

.inputArea input{
    display: block;;
    height: 30px;
    border: 1px solid var(--primary-color);
    border-radius: 15px;
    padding-left: 15px;
}

.error{
    color: red;
}

.saveButton{
    height: 35px;
    width: 200px;
    margin-right: 10px;
}

.cancelButton{
    height: 35px;
    width: 200px;
}
<div class="componentArea">
    <h1></h1>

    <div *ngIf="product">
        <div class="inputArea">
            <label>Product Name:</label>
            <input type="text"  [(ngModel)]="product.productName"  />
            <span class='error'></span>
        </div>
        <div class="inputArea">
            <label>UPC</label>
            <input type="text" [(ngModel)]="product.upc" />
            <span class='error'></span>
        </div>
        <div class="inputArea">
            <label>SKU</label>
            <input type="text"  [(ngModel)]="product.sku"  />
            <span class='error'></span>
        </div>
        <div class="inputArea">
            <label>Price</label>
            <input type="number"  [(ngModel)]="product.price"  />
            <span class='error'></span>
        </div>

        <div class="buttonSaveArea">
            <button class='saveButton applicationButton' (click)='save()'>Save</button>
            <button class='cancelButton applicationButton' (click)='cancel()'>Cancel</button>
        </div>

    </div>
</div>
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Product } from '../models/product';
import { ProductService } from '../api/product.service';

@Component({
  selector: 'app-product-form',
  templateUrl: './product-form.component.html',
  styleUrls: ['./product-form.component.css']
})
export class ProductFormComponent implements OnInit {

  productId: string = '';
  product: Product = new Product();
  errors: any = [];

  constructor(
    private route: ActivatedRoute,
    private productService: ProductService,
    private router: Router
    ) { }

  ngOnInit() {
    this.route.params.subscribe(async params =>  {
      this.productId = params['id'];
      if (this.productId){
        this.product = await this.productService.getById(this.productId).toPromise();
      }
   });
  }

  async save(){
    if(this.isNewProduct()){
      this.insertProduct();
    }else{
      this.updateProduct();
    }
  }

  private isNewProduct() {
    return !this.product.id;
  }

  insertProduct(){
    this.productService.insert(this.product).subscribe(
      () => this.navigateToProductsList(),
      error => this.handleError(error)
    );
  }

  updateProduct(){
    this.productService.update(this.product).subscribe(
      () => this.navigateToProductsList(),
      error => this.handleError(error)
    );
  }

  handleError(error: any) {
    console.log(error);
    this.errors = error.error?.errors;
  }

  cancel(){
    this.navigateToProductsList();
  }

  navigateToProductsList() {
    this.router.navigate(['products']);
  }

}

Criando uma imagem para nosso frontend

Vamos começar criando o arquivo Dockerfile responsável pela construção da nossa imagem. O arquivo deve ser criado na pasta frontend e deve conter as seguintes instruções:

# Build
FROM node:14-alpine as build-step
RUN mkdir -p /app
WORKDIR /app
COPY package.json /app
RUN npm install
COPY . /app
RUN npm run build -- --prod
 
# Runtime
FROM nginx:1.17.1-alpine as runtime-step
COPY --from=build-step /app/dist/frontend /usr/share/nginx/html
COPY ./nginx-custom.conf /etc/nginx/conf.d/default.conf

Nesse arquivo definimos que usaremos um container node para realizar o build da aplicação e um container nginx para executar nossa aplicação web.

Precisamos configurar o nginx para servir uma Single Page Application (SPA) e faremos isso com o arquivo nginx-custom.conf que deve ser criado na mesma pasta do Dockerfile:

server {
  listen 80;
  location / {
    root /usr/share/nginx/html;
    index index.html index.htm;
    try_files $uri $uri/ /index.html =404;
  }
}

Vamos criar um arquivo para definir arquivos que o docker deve ignorar, esse arquivo se chama .dockerignore e deve estar na pasta frontend:

/node_modules
.gitignore

Configurando o docker-compose

Agora que temos uma imagem pronta para ser construída, podemos criar um service no arquivo docker-compose.yml:

[...]
  frontend:
    depends_on:
      - api
    container_name: frontend
    build: frontend/.
    ports:
      - "4200:80"
    restart: always 

Podemos subir nossa infraestrutura com o comando:

docker-compose up -d --build

Depois de um ou dois minutos nossos serviços subiram e podemos abrir nossa aplicação em http://localhost:4200/.

Lista de Produtos

E assim chegamos ao fim da parte funcional da aplicação, no próximo artigo vamos configurar o prometheus e acompanhar através do grafana o uso do nosso sistema!

Gostou? Tem alguma sugestão ou dúvida? Deixa ai nos comentários!

Segue o link do repositório no estado atual do projeto:

Repositório Products Management

Alan Luiz Viana

Alan Luiz Viana

Autor dos artigos desse blog! :D

Comments

comments powered by Disqus