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

  1. Introducción a rxjs (observable)
    1. Características de los observables
    2. Definición de un observable
    3. Operadores
  2. Http
    1. Testing de http
  3. Autenticación y Autorización
  4. Ejercicios finales
  5. Referencias

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:

Sistemas pull vs push

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

Valores únicos y múltiples

Así que a modo de resumen, un observable es:

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.

MergeMap

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));

SwitchMap

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]);
  });
});

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:

Ejercicios finales

Referencias