rxjs, http
Hoy partiremos de la aplicación del día 3 (repositorio) para aprender sobre Observables y los servicios que ofrece Angular para consumir datos a través de la red.
Outline
Introducción a rxjs observable
En Angular muchas librerías están implementadas utilizando rxjs ya que devuelven observables.
En lugar de devolver promesas implementan la asincronía mediante un flujo expresado en la emisión de eventos de un observable.
RxJs permite operar sobre los observables y sus emisiones mediante una amplia librería de operadores que permiten codificar las reglas de negocia de una manera funcional. Se permite implementar un paradigma reactivo haciendo que nuestra aplicación funcione orientada a eventos en lugar de imperativamente. Es como lodash orientado a eventos.
Un primer ejemplo de cómo funcionan sería la traducción de
document.addEventListener("click", () => console.log("Clicked!"));
al paradigma de los observables
import { fromEvent } from "rxjs";
fromEvent(document, "click").subscribe(() => console.log("Clicked!"));
Usamos el operador de creación fromEvent
para crear un observable a partir del evento click
. Para empezar a escuchar esos eventos hace falta subscribirse a ese observable.
Podríamos modificar nuestro app.component.ts
para implementar la funcionalidad de arriba:
import { Component, OnInit } from "@angular/core";
import { fromEvent } from "rxjs";
@Component({
selector: "app-root",
templateUrl: "./app.component.html",
styleUrls: ["./app.component.scss"],
})
export class AppComponent implements OnInit {
title = "angular-101-day-3";
ngOnInit(): void {
fromEvent(document, "click").subscribe(() => console.log("Clicked!"));
}
}
y cada vez que pulsemos sobre la página web, veríamos en nuestra consola un mensaje de Clicked!
.
Características de los observables
Purity
RxJs permite producir valores mediante funciones puras. Esto hace que sea menos propenso a errores ya que el código (trozos de código) son definidos sólo en términos de sus inputs
.
Si quisiéramos contar el número de clicks realizados sobre la página, podríamos codificar lo siguiente en nuestro app.component.html
:
import { Component, OnInit } from "@angular/core";
// import { fromEvent } from 'rxjs';
@Component({
selector: "app-root",
templateUrl: "./app.component.html",
styleUrls: ["./app.component.scss"],
})
export class AppComponent implements OnInit {
title = "angular-101-day-3";
count = 0;
ngOnInit(): void {
// fromEvent(document, 'click').subscribe(() => console.log('Clicked!'));
document.addEventListener("click", () =>
console.log(`Clicked ${this.count++}`)
);
}
}
En cambio, mediante RxJs haríamos:
import { Component, OnInit } from "@angular/core";
import { fromEvent, scan } from "rxjs";
@Component({
selector: "app-root",
templateUrl: "./app.component.html",
styleUrls: ["./app.component.scss"],
})
export class AppComponent implements OnInit {
title = "angular-101-day-3";
// count = 0;
ngOnInit(): void {
fromEvent(document, "click")
.pipe(scan((count) => count + 1, 0))
.subscribe((count) => console.log(`Clicked ${count}`));
}
}
Vemos que NO dependemos de una variable externa (count
) a nuestra función para mantener el valor. Pero el cambio de paradigma al principio es confuso. En el código de arriba estamos haciendo uso del operador scan
que guarda las emisiones anteriores para poderlas usar en las siguientes emisiones. Es una especie de Array.reduce
para eventos.
Flow
RxJs permite controlar y manejar el flujo de eventos. Como ejemplo vamos a evitar alguno de ellos. Vamos a evitar los clicks que se den antes de que haya pasado un segundo desde el último click. En este caso, una implementación sin observables podría ser:
import { Component, OnInit } from "@angular/core";
@Component({
selector: "app-root",
templateUrl: "./app.component.html",
styleUrls: ["./app.component.scss"],
})
export class AppComponent implements OnInit {
title = "angular-101-day-3";
ngOnInit(): void {
let count = 0;
let rate = 1000;
let lastClick = Date.now() - rate;
document.addEventListener("click", () => {
if (Date.now() - lastClick >= rate) {
console.log(`Clicked ${++count} times`);
lastClick = Date.now();
}
});
}
}
Con observables:
import { Component, OnInit } from "@angular/core";
import { fromEvent, scan, throttleTime } from "rxjs";
@Component({
selector: "app-root",
templateUrl: "./app.component.html",
styleUrls: ["./app.component.scss"],
})
export class AppComponent implements OnInit {
title = "angular-101-day-3";
ngOnInit(): void {
fromEvent(document, "click")
.pipe(
throttleTime(1000),
scan((count) => count + 1, 0)
)
.subscribe((count) => console.log(`Clicked ${count} times`));
}
}
Como se comprueba, mientras que la primera implementación necesita de variables variables mantener el invariante del algoritmo, en la segunda no, ya que RxJs permite la implementación pura de la función. Hemos tenido que hacer uso, eso sí, del operador throttleTime
que evita la emisión del observable fuente los segundos que se pasen por parámetro.
Valores
RxJs ayuda a modificar los valores según se van emitiendo. Como ejemplo vamos a mapear cada click con su clientX
y además evitar mapeos consecutivos de menos de un segundo de diferencia.
En una implementación clásica
import { Component, OnInit } from "@angular/core";
@Component({
selector: "app-root",
templateUrl: "./app.component.html",
styleUrls: ["./app.component.scss"],
})
export class AppComponent implements OnInit {
title = "angular-101-day-3";
ngOnInit(): void {
let count = 0;
const rate = 1000;
let lastClick = Date.now() - rate;
document.addEventListener("click", (event) => {
if (Date.now() - lastClick >= rate) {
count += event.clientX;
console.log(count);
lastClick = Date.now();
}
});
}
}
Con RxJs
import { Component, OnInit } from "@angular/core";
import { fromEvent, throttleTime, map, scan } from "rxjs";
@Component({
selector: "app-root",
templateUrl: "./app.component.html",
styleUrls: ["./app.component.scss"],
})
export class AppComponent implements OnInit {
title = "angular-101-day-3";
ngOnInit(): void {
fromEvent(document, "click")
.pipe(
throttleTime(1000),
map((event: any) => event.clientX),
scan((count, clientX) => count + clientX, 0)
)
.subscribe((count) => console.log(count));
}
}
A las anteriores implementaciones le hemos añadido el operador map
que es muy usada. map
es el símil de Array.map
pero para eventos (cosas a lo largo del tiempo).
Definición de un observable
Si dividimos los sistemas de comunicación productor / consumidor podemos categorizarlos entre los que son pull y los que son push. Un sistema pull va dirigido por el consumidor mientras que un push por el productor. Podríamos obtener esta tabla:
Una función puede ser un sistema de comunicación (pide datos y devuelve datos). En su caso, es un sistema pull ya que la función ha de ser llamada (el consumidor se activa) para comunicar los datos. Además transita un único dato en el sistema. Un iterador es como una función solo que produce más datos, en el sistema transitan múltiples datos. En contrapartida, una promise es una sistema push porque los datos transitan porque un producer (el resolver de la función) mete datos en el sistema. Pues bien, un observable
es una colección retardada de múltiples datos activados por un productor (un subscriber que llama al método next
para introducir datos). Se puede resumir este párrafo en
Así que a modo de resumen, un observable
es:
- Entidad base de RxJs
- Representa una colección invocable de futuros valores o eventos.
- Se pueden crear con
new Observable
o con un operador de creación. - Tiene el método
subscribe
para empezar a escuchar los valores que emite. - Es una generalización de las funciones, que permite codificar una implementación que se ejecutará cuando se llame a
subscribe
y que además puede devolver múltiples valores (como un iterador).
Observer
Un observer es el consumidor de los valores emitidos por un observable. Un objeto observer es de la forma:
const observer = {
next: (x) => console.log("Observer got a next value: " + x),
error: (err) => console.error("Observer got an error: " + err),
complete: () => console.log("Observer got a complete notification"),
};
Es lo que se le pasa al método observable.subscribe
para poder leer los valores que emite el observable.
Subscription
Lo que produce la llamada a observable.subscribe()
. Básicamente un objeto con el método unsubscribe
que permite liberar los recursos de un observable o cancelar la ejecución del mismo.
Subject
Un Subject en RxJs es un tipo especial de Observable que permite multicasting a múltiples Observers. Esto significa que un Subject puede emitir datos a varios suscriptores simultáneamente. A diferencia de los Observables básicos, un Subject mantiene un registro de sus suscriptores y puede enviarles notificaciones en cualquier momento. Esto lo hace muy útil en situaciones donde necesitas que múltiples partes de tu aplicación reaccionen a los mismos datos o eventos.
Operadores
Los operadores son piezas que permiten realizar complejas operaciones asíncronas de forma declarativa. Hay de varios tipos: creational, join creational, transformation, filtering.
Además están los pipeable operators. Un pipeable operator es un operador que mediante una función pura toma un observable
como entrada y devuelve otro como salida. De esta forma, se facilita la composición de operadores sobre los streams de eventos o valores que producen los observables
Map
Permite modificar los valores emitidos según la función del callback.
const numbers = Rx.Observable.from([10, 100, 1000]);
numbers.map((num) => Math.log(num)).subscribe((x) => console.log(x));
// 2.3 .. 4.6 .. 6.9
apiCall.map((json) => JSON.parse(json)).subscribe();
// emit as JS object, rather than useless JSON string
Filter
Filtra los valores emitidos en función de si el valor pasa el test que indica la función que se le pasa por parámetro
const tweet = Rx.Observable.of(arrayOfTweetObjects);
tweet.filter((tweet) => tweet.user == "@angularfirebase").subscribe();
first / last
Permite modificar los valores emitidos según la función del callback
const names = Rx.Observable.of('Richard', 'Erlich', 'Dinesh', 'Gilfoyle')
names
.first()
.subscribe( n => console.log(n) )
// Richard
names
.last()
.subscribe(
mergeMap
Proyecta cada valor del observable fuente a un observable que es mergeado en la salida.
switchMap
High Order Observable. Convierte cada valor emitido en un Observable. Es el más usado. Unsubscribe el observable input antes de emitir el valor obtenido por el observable output . Es como el mergeMap
que no mezcla los valores que emite el observable de salida, ya que cuando llega el segundo valor en el observable de entrada, el observable que se creó para el primer valor se desuscribe para subscribirse al nuevo.
const clicks = Rx.Observable.fromEvent(document, "click");
clicks
.switchMap((click) => {
return Rx.Observable.interval(500);
})
.subscribe((i) => print(i));
takeUntil
Operador para completar streams. Completará el observable de salida cuando reciba una notificación del observable que se le pasa por parámetro. Muy útil para terminar ejecuciones.
const interval = Rx.Observable.interval(500);
const notifier = Rx.Observable.timer(2000);
interval
.takeUntil(notifier)
.finally(() => print("Complete!"))
.subscribe((i) => print(i));
Http
En Angular, las llamadas a http se suelen hacer con HttpClient
. El módulo HttpClientModule
configura un inyector para HttpClient
Como ejemplo de uso vamos a sustituir el código de nuestro servicio CandidateService
para que llame a un servicio de red real. Para ello hacemos uso de jsonserver
, una librería que nos permite hacer un mock rápido de respuestas para prototipar.
Descargamos el repositorio del servicio desde aquí:
git clone https://github.com/alexseik/jsonserver-candidates.git
cd jsonserver-candidates
npm install
npm start
> json-server-api@1.0.0 start
> node server.js
Run Auth API Server on 3000
Ahora tenemos un servicio desplegado en el 3000. Para probarlo podemos instalar una extensión en nuestro navegador o utilizar Postman.
Si atacamos con un GET
a http://localhost:3000/api/candidates
, veremos la antigua lista de candidates devuelta.
Antes de usar HttpClient
lo tenemos que añadir a la aplicación, por lo que modificamos el fichero app.module.ts
:
import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { CandidateListComponent } from "./components/candidate-list/candidate-list.component";
import { CandidateCardComponent } from "./components/candidate-card/candidate-card.component";
import { AppMaterialModule } from "./app-material.module";
import { APP_CONFIG, Config } from "./config/app.config";
import { CreateCandidateComponent } from "./components/create-candidate/create-candidate.component";
import { CandidateFormComponent } from "./components/candidate-form/candidate-form.component";
import { ReactiveFormsModule } from "@angular/forms";
import { EditCandidateComponent } from "./components/edit-candidate/edit-candidate.component";
import { ConfirmationModalComponent } from "./confirmation-modal/confirmation-modal.component";
import { HttpClientModule } from "@angular/common/http";
@NgModule({
declarations: [
AppComponent,
CandidateListComponent,
CandidateCardComponent,
CreateCandidateComponent,
CandidateFormComponent,
EditCandidateComponent,
ConfirmationModalComponent,
],
imports: [
BrowserModule,
AppRoutingModule,
BrowserAnimationsModule,
AppMaterialModule,
ReactiveFormsModule,
HttpClientModule, // <-- modulo de HttpClient
],
providers: [
{
provide: APP_CONFIG,
useValue: Config,
},
],
bootstrap: [AppComponent],
})
export class AppModule {}
Con eso ya podemos inyectar el servicio HttpClient
. Vamos a modificar nuestro servicio en candidates.service.ts
:
import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
import { Candidate } from "../models/candidate";
import { HttpClient } from "@angular/common/http";
@Injectable({
providedIn: "root",
})
export class CandidatesService {
private api = "http://localhost:3000/api";
constructor(private http: HttpClient) {}
getCandidates(): Observable<Candidate[]> {
return this.http.get<Candidate[]>(`${this.api}/candidates`);
}
getCandidate(id: number | string) {
return this.http.get<Candidate>(`${this.api}/candidates/${id}`);
}
save(candidate: Candidate): Observable<Candidate> {
return this.http.post<Candidate>(`${this.api}/candidates`, candidate, {
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
});
}
update(candidate: Candidate): Observable<Candidate> {
if (typeof candidate.id === "number" || typeof candidate.id === "string") {
return this.http.put<Candidate>(
`${this.api}/candidates/${candidate.id}`,
candidate
);
}
const cause = {
status: 404,
error: new Error("Recurso no encontrado"),
message: "Recurso no encontrado",
};
throw new Error("Recurso no encontrado", { cause });
}
remove(id: string | number) {
return this.http.delete(`${this.api}/candidates/${id}`);
}
}
Si navegamos a http://localhost:4200
, veremos la lista de candidatos. Pero si intentamos editar uno o guardar uno nuevo veremos que no funciona. Tenemos que actualizar el código de los componentes EditCandidateComponent
y CreateCandidateComponent
. En el fichero edit-candidate.component.ts
:
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { Location } from "@angular/common";
import { Observable, switchMap } from "rxjs";
import { Candidate } from "src/app/models/candidate";
import { CandidatesService } from "src/app/services/candidates.service";
@Component({
selector: "app-edit-candidate",
templateUrl: "./edit-candidate.component.html",
styleUrls: ["./edit-candidate.component.scss"],
})
export class EditCandidateComponent implements OnInit {
candidate$!: Observable<Candidate>;
constructor(
private activatedRoute: ActivatedRoute,
private candidatesService: CandidatesService,
private location: Location,
private router: Router
) {}
ngOnInit(): void {
this.candidate$ = this.activatedRoute.paramMap.pipe(
switchMap((params) => {
const selectedId = parseInt(params.get("id")!, 10);
return this.candidatesService.getCandidate(selectedId);
})
);
}
onSubmit(candidate: Candidate) {
this.candidatesService.update(candidate).subscribe();
this.router.navigate(["/"]);
}
back() {
this.location.back();
}
}
vemos que hemos tenido que añadir la llamada al método subscribe
. Tenemos que hacer lo mismo en create-candidate.component.ts
:
import { Component } from "@angular/core";
import { Location } from "@angular/common";
import { Candidate } from "src/app/models/candidate";
import { CandidatesService } from "src/app/services/candidates.service";
import { Router } from "@angular/router";
@Component({
selector: "app-create-candidate",
templateUrl: "./create-candidate.component.html",
styleUrls: ["./create-candidate.component.scss"],
})
export class CreateCandidateComponent {
constructor(
private location: Location,
private service: CandidatesService,
private router: Router
) {}
onSubmit(candidate: Candidate) {
this.service.save(candidate).subscribe();
this.router.navigate(["/"]);
}
back() {
this.location.back();
}
}
Testing de http
Angular además de HttpClient
también ofrece utilidades para falsear ese servicio que permite hacer test sobre las clases o servicios que lo utilizan. Podemos modificar nuestro test de candidates.service.spec.ts
:
import { TestBed } from "@angular/core/testing";
import {
HttpClientTestingModule,
HttpTestingController,
} from "@angular/common/http/testing";
import { CandidatesService } from "./candidates.service";
import { Candidate } from "../models/candidate";
import { Experience } from "../models/experience";
import { APP_CONFIG } from "../config/app.config";
fdescribe("CandidatesService", () => {
let service: CandidatesService;
let httpMock: HttpTestingController;
let testBed: TestBed;
const candidates: Candidate[] = [
{
id: 1,
name: "Nombre 1",
surname: "Apellido 1",
email: "email@email.com",
experience: Experience.Junior,
skills: [],
previousProjects: [],
age: 25,
},
];
const appConfig = { candidates };
beforeEach(() => {
testBed = TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
{
provide: APP_CONFIG,
useValue: appConfig,
},
],
});
service = TestBed.inject(CandidatesService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it("should be created", () => {
expect(service).toBeTruthy();
});
it("allows retrieve the candidates", () => {
service.getCandidates().subscribe((retrieved) => {
expect(retrieved).toEqual(candidates);
});
const req = httpMock.expectOne("http://localhost:3000/api/candidates");
req.flush(candidates);
});
it("create should call post", () => {
const newCandidate = {
name: "Nombre 1",
surname: "Apellido 1",
email: "email@email.com",
experience: Experience.Junior,
skills: [],
previousProjects: [],
age: 25,
};
service.save(newCandidate).subscribe();
const req = httpMock.expectOne("http://localhost:3000/api/candidates");
expect(req.request.method).toEqual("POST");
req.flush(candidates[0]);
});
it("update should call put", () => {
const newCandidate = {
id: "1",
name: "Nombre 1",
surname: "Apellido 1",
email: "email@email.com",
experience: Experience.Junior,
skills: [],
previousProjects: [],
age: 25,
};
service.update(newCandidate).subscribe();
const req = httpMock.expectOne("http://localhost:3000/api/candidates/1");
expect(req.request.method).toEqual("PUT");
req.flush(candidates[0]);
});
});
- Ejercicio: La URL del endpoint está codificada en duro directamente en el servicio. Hacer que sea configurable a través del
AppConfig
.
Autorización y autenticación
Angular permite restringir las vistas que se muestran en la aplicación añadiendo guardas a nuestras rutas. Para ello incluye el concepto de guardas.
Como ejemplo vamos a impedir que las rutas create
y edit
sean solo accesibles para usuarios autenticados.
Creamos una guarda mediante el Angular CLI:
ng g guard authentication
Eso nos genera el fichero authentication.guard.spec.ts
:
import { CanActivateFn } from "@angular/router";
export const authenticationGuard: CanActivateFn = (route, state) => {
return true;
};
Ahora tenemos que añadir la guarda a nuestras rutas. Modificamos el fichero app-routing.module.ts
:
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { CandidateListComponent } from "./components/candidate-list/candidate-list.component";
import { CreateCandidateComponent } from "./components/create-candidate/create-candidate.component";
import { EditCandidateComponent } from "./components/edit-candidate/edit-candidate.component";
import { authenticationGuard } from "./authentication.guard";
const routes: Routes = [
{
path: "",
component: CandidateListComponent,
},
{
path: "create",
canActivate: [authenticationGuard],
component: CreateCandidateComponent,
},
{
path: "edit/:id",
canActivate: [authenticationGuard],
component: EditCandidateComponent,
},
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
De momento siempre se puede acceder. La función de guarda debe devolver un boolean
, Promise<boolean>
u Observable<boolean>
.
A veces, hace falta falta modificar la respuesta o la petición http después o antes de que llegue al servidor. HttpClient
permite añadir interceptores para ello.
Pero antes debemos poder loguearnos en nuestra aplicación. Primero ajustamos el servidor jsonserver
que teníamos. Descomentamos las líneas 91 - 110, dejando el fichero server.js
así:
const fs = require("fs");
const bodyParser = require("body-parser");
const jsonServer = require("json-server");
const jwt = require("jsonwebtoken");
const server = jsonServer.create();
const router = jsonServer.router(require("./db.js")());
const userdb = JSON.parse(fs.readFileSync("./users.json", "UTF-8"));
server.use(bodyParser.urlencoded({ extended: true }));
server.use(bodyParser.json());
server.use(jsonServer.defaults());
const SECRET_KEY = "123456789";
const expiresIn = "1h";
// Create a token from a payload
function createToken(payload) {
return jwt.sign(payload, SECRET_KEY, { expiresIn });
}
// Verify the token
function verifyToken(token) {
return jwt.verify(token, SECRET_KEY, (err, decode) =>
decode !== undefined ? decode : err
);
}
// Check if the user exists in database and password is correct
function isAuthenticated({ email, password }) {
return (
userdb.findIndex(
(user) => user.email === email && user.password === password
) !== -1
);
}
function getUser({ email, password }) {
const index = userdb.findIndex(
(user) => user.email === email && user.password === password
);
if (index > -1) {
return userdb[index];
}
return null;
}
function userExist(email) {
return userdb.findIndex((user) => user.email === email) !== -1;
}
function paginate(payload, array) {
const clonedArray = Array.from(array);
const numberOfPages = Math.ceil(clonedArray.length / payload.pageSize);
const chunks = [];
for (let i = 0; i < numberOfPages; i++) {
chunks.push(clonedArray.slice(0, payload.pageSize));
clonedArray.splice(0, payload.pageSize);
}
return chunks[payload.page];
}
server.post("/auth/login", (req, res) => {
const { email, password } = req.body;
const user = getUser({ email, password });
if (!user) {
const status = 401;
const message = "Incorrect email or password";
res.status(status).json({ status, message });
return;
}
const access_token = createToken({ email, password });
res.status(200).json({ access_token, ...user });
});
server.post("/auth/recover", (req, res) => {
const { email } = req.body;
if (userExist(email)) {
res.status(200).json({ msg: "Solicitud enviada" });
return;
}
res.status(404).json({ msg: `No existe ningún usuario con ${email}` });
});
server.post("/auth/reset", (req, res) => {
const user = req.body.loginusuario;
if (userExist(user)) {
res.status(200).json();
return;
}
res.status(404).json({ msg: `No existe ningún usuario con ${user}` });
});
server.use(function (req, res, next) {
if (
(req.method === "POST" ||
req.method === "PUT" ||
req.method === "DELETE") &&
!req.url.includes("auth")
) {
if (req.headers.authorization === undefined) {
if (req.method === "GET") {
next();
return;
}
const status = 401;
const message = "Error in authorization format";
res.status(status).json({ status, message });
return;
}
try {
verifyToken(req.headers.authorization.split(" ")[1]);
next();
} catch (err) {
const status = 401;
const message = "Error access_token is revoked";
res.status(status).json({ status, message });
}
} else {
next();
}
});
server.use("/api", router);
server.listen(3000, () => {
console.log("Run Auth API Server on 3000");
});
y ajustamos el fichero db.js
para que quede así:
var candidates = require("./candidates.json");
var users = require("./users.json");
module.exports = function () {
return {
candidates,
users,
};
};
Con ésto hemos habilitado el endpoint http://localhost:3000/auth/login
. Ahora en nuestra aplicación Angular creamos la lógica para realizar el login. Normalmente debe ser un servicio el encargado de ésto, así que creamos el servicio UserService
:
ng g service services/user
En el fichero user.service.ts
incluimos el siguiente código:
import { Injectable } from "@angular/core";
import { RequestAuth } from "../models/request-auth";
import { HttpClient } from "@angular/common/http";
import { Observable, switchMap, take, tap } from "rxjs";
import { ResponseAuth } from "../models/response-auth";
import { User } from "../models/user";
const ENDPOINT = "http://localhost:3000";
@Injectable({
providedIn: "root",
})
export class UserService {
constructor(private http: HttpClient) {}
login(creds: RequestAuth): Observable<User> {
return this.http.post<ResponseAuth>(`${ENDPOINT}/auth/login`, creds).pipe(
tap((auth: ResponseAuth) => {
localStorage.setItem("token", `Bearer ${auth.access_token}`);
}),
switchMap((auth: ResponseAuth) => {
return this.http.get<User>(`${ENDPOINT}/api/users/${auth.id}`);
})
);
}
logout(): void {
localStorage.clear();
}
isLogged(): boolean {
return !!localStorage.getItem("token");
}
}
Para que el fichero no tenga errores de compilación hemos necesitado definir las siguientes interfaces:
// auth.ts
export interface Auth {
name: string;
token: string;
}
// request-auth.ts
export interface RequestAuth {
email: string;
password: string;
}
// response-auth.ts
export interface ResponseAuth {
access_token: string;
id: string;
}
//user.ts
export interface User {
id: string;
name: string;
email: string;
password?: string;
}
Con ésto, ya tenemos un método login
que nos devuelve un usuario, pero antes ha guardado el token en nuestro localstorage. Vemos que hemos utilizado el operador tap
que crea un efecto lateral para ese propósito.
Ahora vamos a utilizar dicho método en un componente login que nos permita enviar unas credenciales al servidor. Lanzamos el comando:
ng g component components/login --module app.module
Rellenamos los ficheros login.component.html
:
<mat-card>
<form [formGroup]="loginForm" (ngSubmit)="submit()">
<mat-form-field>
<input matInput type="text" placeholder="email" formControlName="email" />
</mat-form-field>
<mat-form-field>
<input
matInput
type="password"
placeholder="password"
formControlName="password"
/>
</mat-form-field>
<div>
<button color="primary" mat-button type="submit">Login</button>
</div>
</form>
</mat-card>
y login.component.ts
con
import { Component, OnInit } from "@angular/core";
import { FormBuilder, FormGroup, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { RequestAuth } from "src/app/models/request-auth";
import { User } from "src/app/models/user";
import { UserService } from "src/app/services/user.service";
@Component({
selector: "app-login",
templateUrl: "./login.component.html",
styleUrls: ["./login.component.scss"],
})
export class LoginComponent {
loginForm: FormGroup;
constructor(
private fb: FormBuilder,
private service: UserService,
private router: Router
) {
this.loginForm = this.fb.group({
email: ["", Validators.required],
password: ["", Validators.required],
});
}
submit() {
if (this.loginForm.valid) {
const creds = this.loginForm.value as RequestAuth;
this.service.login(creds).subscribe((user: User) => {
this.router.navigate(["/"]);
});
}
}
}
Además debemos modificar nuestras rutas para mostrar este componente, haciéndolo en app-routing.module.ts
:
para cerrar el círculo hacemos uso del token que guardamos en dos puntos:
-
En nuestra guarda
authentication.guard.ts
para permitir o no la navegaciónimport { inject } from "@angular/core"; import { CanActivateFn, Router } from "@angular/router"; export const authenticationGuard: CanActivateFn = (route, state) => { const router = inject(Router); if (!!localStorage.getItem("token")) { return true; } return router.parseUrl("/login"); };
Si el token existe devolvemos
true
. Si no existe devolvemos unUrlTree
que provoca que ser cancele la navegación y además se navegue a/login
. -
En un nuevo interceptor sobre
http
que crearemos con el CLI:ng g interceptor auth
Esto nos creará el fichero
auth.interceptor.ts
. Los interceptores son clases que utilizan el concepto de filtro. Es decir lógica que se incluye a modo de middleware en la ejecución de la request / response que realiza la librearíahttp
de Angular.Para incluir la lógica que definiremos en
auth.interceptor.ts
hay que proveer esta clase como interceptor dehttp
modificando el móduloapp.module.ts
:import { NgModule } from "@angular/core"; import { BrowserModule } from "@angular/platform-browser"; import { AppRoutingModule } from "./app-routing.module"; import { AppComponent } from "./app.component"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { CandidateListComponent } from "./components/candidate-list/candidate-list.component"; import { CandidateCardComponent } from "./components/candidate-card/candidate-card.component"; import { AppMaterialModule } from "./app-material.module"; import { APP_CONFIG, Config } from "./config/app.config"; import { CreateCandidateComponent } from "./components/create-candidate/create-candidate.component"; import { CandidateFormComponent } from "./components/candidate-form/candidate-form.component"; import { ReactiveFormsModule } from "@angular/forms"; import { EditCandidateComponent } from "./components/edit-candidate/edit-candidate.component"; import { ConfirmationModalComponent } from "./confirmation-modal/confirmation-modal.component"; import { HTTP_INTERCEPTORS, HttpClientModule } from "@angular/common/http"; import { LoginComponent } from "./components/login/login.component"; import { AuthInterceptor } from "./auth.interceptor"; @NgModule({ declarations: [ AppComponent, CandidateListComponent, CandidateCardComponent, CreateCandidateComponent, CandidateFormComponent, EditCandidateComponent, ConfirmationModalComponent, LoginComponent, ], imports: [ BrowserModule, AppRoutingModule, BrowserAnimationsModule, AppMaterialModule, ReactiveFormsModule, HttpClientModule, ], providers: [ { provide: APP_CONFIG, useValue: Config, }, // como podemos definir varios, se utiliza multi { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, ], bootstrap: [AppComponent], }) export class AppModule {}
Y por supuesto necesitamos hacer uso del
token
en nuestro interceptor, modificando el ficheroauth.interceptor.ts
:import { Injectable } from "@angular/core"; import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor, } from "@angular/common/http"; import { Observable, catchError } from "rxjs"; const ENDPOINT = "http://localhost:3000"; @Injectable() export class AuthInterceptor implements HttpInterceptor { constructor() {} intercept( request: HttpRequest<unknown>, next: HttpHandler ): Observable<HttpEvent<unknown>> { if (this.needAuth(request.url, request.method)) { request = request.clone({ setHeaders: { Authorization: this.token ? `${this.token}` : "", }, }); } return next.handle(request).pipe( catchError((err) => { if (err.status === 401 && err.error.data === "Token expired") { // hacer algo } throw err; }) ); } get token() { return localStorage.getItem("token"); } private needAuth(url: string, method: string) { const needUrl = url.includes(`${ENDPOINT}/api`); const needMethod = method !== "GET"; return needMethod && needUrl; } }
En el interceptor hemos implementado el método privado
needAuth
para comprobar que los métodos protegidos son losPOST
,PUT
yDELETE
y que están dentro dehttp://localhost:3000/api
.
Ejercicios finales
-
Hacer que la aplicación se pueda desloguear.
-
Hacer que si hay un error en una petición por culpa de un
404
se vaya al login. -
Mostrar errores de validación en el login.