Fullstack REST API: Angular Frontend + Java (Spring) Backend

Introducción

Uno de los perfiles más demandados en el ámbito de la programación en la actualidad es el de Desarrollador Full Stack. Este tipo de programadores deben tener conocimientos amplios de desarrollo web y sus dos pilares fundamentales: Backend y Frontend.

El Backend está del lado del servidor y es el encargado de realizar las lógicas de negocio, recibir request, procesarlas y enviarlas al Frontend donde el usuario final obtiene la información requerida. Generalmente interactuamos con el Backend mediante una API, que en este caso será implementada como una API REST. A través de la API enviamos solicitudes al Backend y éste se encarga de realizar operaciones CRUD sobre una base de datos. En este sentido, debemos desarrollar una solución que permita hacer las operaciones Create, Read, Update y Delete. Para ello vamos a hacer uso de Spring, un popular framework de Java, basándonos en un proyecto MVC de Spring+Hibernate que ya detallamos en anteriores artículos; sería recomendable consultar dicho artículo y el código asociado para comprender los fundamentos básicos del framework Spring.

Por otro lado, el Frontend está del lado del cliente y es la parte con la que interactúa el usuario. Actualmente uno de los framework más utilizados es Angular. Se trata de una de las opciones más versátiles para aplicaciones web de una sola página (SPA) [1] y es una opción segura para implementar la solución que detallaremos en este artículo. Angular está desarrollado y mantenido por Google y utiliza TypeScript [2] -un superconjunto de JavaScript desarrollado por Microsoft para trabajar con tipos estáticos y objetos basados en clases- como lenguaje, por lo que es recomendable tener conocimientos básicos de dicho framework y de la sintaxis básica de TypeScript.


Backend

Introducción, Requisitos y Dependencias

Para la programación de nuestro Backend, utilizamos Spring Tool Suite 4, una versión del conocido Eclipse IDE diseñado para facilitar la creación de proyectos que utilicen Spring Framework. Comenzamos creando un Spring Starter Project de tipo Maven con las siguientes dependencias:

  • MySQL Driver: MySQL JDBC and R2DBC driver.
  • Spring Data JPA: Persist data in SQL stores with Java Persistence API using Spring Data and Hibernate.
  • Spring Web: Build web, including RESTful, applications using Spring MVC. Uses Apache Tomcat as the default embedded container.
  • Spring Boot DevTools: Provides fast application restarts, LiveReload, and configurations for enhanced development experience.

Obteniendo el siguiente árbol de dependencias y plugins en el archivo pom.xml:

<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<scope>runtime</scope>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

Capa Modelo

Tal y como hemos indicado en la introducción, nos vamos a basar en un proyecto Spring anterior que trabajaba con una clase User y realizaba operaciones CRUD sobre una DB utilizando una capa DAO, se puede consultar el código en mi repositorio. Comenzamos reutilizando la clase User y simplificándola para obtener la siguiente:

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;

@Entity
@Table(name="user")
public class User{
	
	//Parameters
	@Id
	@Column(name="id") 
	private int id;
	
	@Column(name="name") 
	private String name;
	
	@Column(name="email") 
	private String email;
	
	@Column(name="age") 
	private int age;
	
	@Column(name="language") 
	private String language;
		
	//Constructor
	public User() {}
	
	public User(String name, String email, int age, String language) {
		this.name = name;
		this.email = email;
		this.age = age;
		this.language = language;
	}
	
	//Methods
	public void copyDataFromUser(User source) {
		this.name = source.getName();
		this.email = source.getEmail();
		this.age = source.getAge();
		this.language = source.getLanguage();
	}
	...

	//Getters Setters
    ...
}

Las etiquetas @Column, @Id, @Entity, @Table pertenecen al paquete javax.persistence y permiten al framework interactuar con la base de datos asociando las propiedades de los objetos escritos en Java con las columnas de las respectivas tablas de la DB MySQL.


Capa de acceso a datos

Para conectar con la base de datos, en nuestro caso utilizaremos una DB en MySQL y la referenciaremos desde el archivo application.properties situado en la carpeta src/main/resources. En este archivo de propiedades incluiremos el nombre de la base de datos, el puerto donde tenemos nuestra conexión localhost de MySQL, el usuario y la contraseña.

Es importante remarcar que al tratarse de un ejemplo práctico incluimos archivos de configuración application.properties con username y password en el repositorio. En casos reales no se debe incluir información sensitiva en archivos de configuración en repositorios públicos.

Respecto a las propiedades de JPA, utilizaremos MySQL5InnoDBDialect:

spring.datasource.url=jdbc:mysql://localhost:3306/userdb?useSSL=false&serverTimezone=UTC
spring.datasource.username=USER
spring.datasource.password=PASS

spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.MySQL5InnoDBDialect

En el caso de que nos interese admitir la posibilidad de que se generen tablas automáticamente en nuestra DB para las clases del modelo, añadiríamos la directiva:

spring.jpa.hibernate.ddl-auto = update

No obstante, en nuestro caso estamos reutilizando una DB de un proyecto anterior con tablas ya creadas y omitimos ese parámetro.

Las operaciones de la capa DAO las delegaremos al propio framework, que se encargará de realizar las operaciones de búsqueda, modificación y borrado. Para ello, en el paquete de persistencia crearemos simplemente una interfaz Repository que derive de interface JpaRepository<T,ID>, donde T es el tipo de objeto y ID hace referencia al tipo de dato de la variable id, en nuestro caso Integer:

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import com.zalost.backend.model.User;

@Repository
public interface UserRepository extends JpaRepository<User, Integer>{ }

La etiqueta @Repository pertenece, en este caso, a org.springframework.stereotype, mientras que la interfaz JpaRepository pertenece a org.springframework.data.jpa, por lo que el propio framework se encargará de realizar las operaciones mencionadas. Si investigamos el contenido de JpaRepository obtenemos lo siguiente:

/**
 * JPA specific extension of {@link org.springframework.data.repository.Repository}.
 *
 * @author Oliver Gierke
 * @author Christoph Strobl
 * @author Mark Paluch
 */
@NoRepositoryBean
public interface JpaRepository<T, ID> extends PagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {

	/*
	 * (non-Javadoc)
	 * @see org.springframework.data.repository.CrudRepository#findAll()
	 */
	@Override
	List<T> findAll();

	/*
	 * (non-Javadoc)
	 * @see org.springframework.data.repository.PagingAndSortingRepository#findAll(org.springframework.data.domain.Sort)
	 */
	@Override
	List<T> findAll(Sort sort);

	/*
	 * (non-Javadoc)
	 * @see org.springframework.data.repository.CrudRepository#findAll(java.lang.Iterable)
	 */
	@Override
	List<T> findAllById(Iterable<ID> ids);

	/*
	 * (non-Javadoc)
	 * @see org.springframework.data.repository.CrudRepository#save(java.lang.Iterable)
	 */
	@Override
	<S extends T> List<S> saveAll(Iterable<S> entities);

	/**
	 * Flushes all pending changes to the database.
	 */
	void flush();

	/**
	 * Saves an entity and flushes changes instantly.
	 *
	 * @param entity
	 * @return the saved entity
	 */
	<S extends T> S saveAndFlush(S entity);

	/**
	 * Deletes the given entities in a batch which means it will create a single {@link Query}. Assume that we will clear
	 * the {@link javax.persistence.EntityManager} after the call.
	 *
	 * @param entities
	 */
	void deleteInBatch(Iterable<T> entities);

	/**
	 * Deletes all entities in a batch call.
	 */
	void deleteAllInBatch();

	/**
	 * Returns a reference to the entity with the given identifier. Depending on how the JPA persistence provider is
	 * implemented this is very likely to always return an instance and throw an
	 * {@link javax.persistence.EntityNotFoundException} on first access. Some of them will reject invalid identifiers
	 * immediately.
	 *
	 * @param id must not be {@literal null}.
	 * @return a reference to the entity with the given identifier.
	 * @see EntityManager#getReference(Class, Object) for details on when an exception is thrown.
	 */
	T getOne(ID id);

	/*
	 * (non-Javadoc)
	 * @see org.springframework.data.repository.query.QueryByExampleExecutor#findAll(org.springframework.data.domain.Example)
	 */
	@Override
	<S extends T> List<S> findAll(Example<S> example);

	/*
	 * (non-Javadoc)
	 * @see org.springframework.data.repository.query.QueryByExampleExecutor#findAll(org.springframework.data.domain.Example, org.springframework.data.domain.Sort)
	 */
	@Override
	<S extends T> List<S> findAll(Example<S> example, Sort sort);
}

Para nuestro ejemplo, se usarán los métodos (algunos presentes en esta interfaz y otros en las interfaces que extiende JpaRepository):

  • List<T> findAll()
  • Optional<T> findById(ID id);
  • <S extends T> S save(S entity);
  • void delete(T entity);

Controlador

El controlador, marcado con la etiqueta @RestController [3], implementa una API REST y se encarga de procesar las peticiones que llegan en los distintos métodos del protocolo HTML: GET, POST, PUT, DELETE. Para cada una de las operaciones CRUD asociaremos uno de estos métodos, de forma que cada tipo de request obtendrá la información requerida o realizará modificaciones de actualización y borrado sobre la DB.

@RestController 
@RequestMapping("/fullstack")
public class UserController {
	@Autowired
	private UserRepository userDAO;
	
	//REST: get all (GET)
	@GetMapping("/users")
	public List<User> findAllUsers(){
		return userDAO.findAll();
	}		
	
	//REST: get user by ID (GET)
	//@PathVariable: Annotation which indicates that a method parameter 
	//should be bound to a URI template variable.
	@GetMapping("/users/{id}")
	public ResponseEntity<User> getUserByID(@PathVariable int id) {
		//Es un Optional<T>
		Optional<User> u = userDAO.findById(id);
		//Si está presente lo devolvemos
		if(u.isPresent()){
		    return ResponseEntity.ok(u.get());
		}
		//Si no, lanzamos un error
		else{
		    throw new NotFoundException("Not found User by id: " + id);
		}
	}
	
	//REST: Create new user (POST Method)
	//@RequestBody: Annotation indicating a method parameter 
	//should be bound to the body of the web request.
	@PostMapping("/users")
	public User createUser(@RequestBody User u) {
		return userDAO.save(u);
	}	
	
	//REST: Update user (PUT)
	@PutMapping("/users/{id}")
	public ResponseEntity<User> updateUser(@PathVariable int id, @RequestBody User userUpdateData){
		//En primer lugar, buscamos el Usuario
		Optional<User> findUser = userDAO.findById(id);
		//Si está presente lo devolvemos
		if(findUser.isPresent()){
			//Usuario encontrado para realizar update sobre él.
			User userToUpdate = findUser.get();			
			//Copiamos los nuevos datos al usuario
			userToUpdate.copyDataFromUser(userUpdateData);			
			//Guadramos en la DB
			User userSaved = userDAO.save(userToUpdate);			
		    return ResponseEntity.ok(userSaved);
		}
		else {
			throw new NotFoundException("Not found User by id: " + id);
		}
	}
	
	//REST: Delete User (DELETE)
	@DeleteMapping("/users/{id}")
	public ResponseEntity<Boolean> deleteUser(@PathVariable int id) {
		//En primer lugar, buscamos el Usuario
		Optional<User> findUser = userDAO.findById(id);
		//Si está presente lo eliminamos
		if(findUser.isPresent()){	
			//Realizamos el Delete
			userDAO.delete(findUser.get());			
		    return ResponseEntity.ok(true);
		}
		else {
			throw new NotFoundException("Not found User by id: " + id);
		}
	}
}

Como vemos, cada uno de los métodos implementados tiene una etiqueta asociada: @GetMapping, @PostMapping, @PutMapping, @DeleteMapping. Con esta implementación sencilla del controlador, podemos pasar a implementar el Frontend de nuestra aplicación. Para ello guardaremos la dirección que sirve como endpoint de nuesto Backend -en nuestro caso, http://localhost:8080/fullstack/users– y la utilizaremos en nuestro desarrollo Angular para lanzar request para cada uno de los métodos descritos.


Frontend

Introducción, Requisitos y Dependencias

Para esta parte del proyecto es preciso tener conocimientos generales de TypeScript y Angular. Se requiere tener instalado: Node.js, npm, Bootstrap 4.6, Angular CLI. Como IDE usaremos Visual Studio Code (VSCode). Desde la terminal del IDE, utilizaremos el comando ng new [4] con el nombre que queramos para nuestro Frontend; cuando se nos solicite elegir Angular Routing indicaremos que . Al concluir el proceso de creación del proyecto se habrán generado una serie de archivos .json que contiene información general de dependencias y propiedades de nuestro proyecto. Por ejemplo el archivo package.json tendremos el nombre y versión del proyecto:

{
  "name": "angular-frontend",
  "version": "0.0.1",
  "scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",
    "test": "ng test",
    "lint": "ng lint",
    "e2e": "ng e2e"
  },
  "private": true,
  "dependencies": {
    "@angular/animations": "~11.2.3",
    "@angular/common": "~11.2.3",
    "@angular/compiler": "~11.2.3",
  ...
  },
 ...
}

Por otra parte Bootstrap es un Framework para hojas de estilo CSS desarrollado por Twitter. Este framework nos permitirá generar utilizar plantillas de diseño con tipografía, formularios, botones y tablas. Al instalarlo [5] podremos comprobar que en el fichero de dependencias aparece: «bootstrap»: «^4.6.0». Para poder utilizar Bootstrap nos dirigimos al archivo styles.css y añadimos:

/* You can add global styles to this file, and also import other style files */
@import "~bootstrap/dist/css/bootstrap.min.css";

Creación de Componentes en Angular

En VSCode, podemos crear los componentes necesarios desde la consola. Deberemos crear una clase User que haga referencia al objeto User de Java. Estando en la carpeta de nuestro proyecto desde la terminal de VSCode escribimos:

ng g class user

Si se ha creado correctamente, en la Terminal aparecerá:

CREATE src/app/user.spec.ts (146 bytes)
CREATE src/app/user.ts (22 bytes)

Por mantener el código ordenado, he creado una carpeta model (consultar el código en mi repositorio), de forma análoga a la del código Java, para almacenar la clase User en TypeScript

export class User {
    id: number;
    name: string;
    email: string;
    age: number;
    language: string;
}

A continuación, creamos el componente UserList, en esta ocasión usamos el comando:

ng g c user-list

En esta ocasión, comprobaremos que se han creado cuatro archivos:

CREATE src/app/user-list/user-list.component.html (24 bytes)
CREATE src/app/user-list/user-list.component.spec.ts (641 bytes)
CREATE src/app/user-list/user-list.component.ts (286 bytes)
CREATE src/app/user-list/user-list.component.css (0 bytes)

Podemos comenzar modificando el archivo user-list.component.html para mostrar una tabla:

<h2> User List</h2>
<table class = "table table-striped">
    <thead>
        <tr>
            <th>ID</th>
            <th>Name</th>
            <th>Email</th>
            <th>Age</th>
            <th>Language</th>
        </tr>
    </thead>
    <!-- El bucle recorre cada usuario de users-->
    <!-- Este archivo html está asociado a su respectivo .ts -->
    <!-- users es una propiedad definida en el .ts users: User[]-->
    <tbody>
        <tr *ngFor = "let u of users">
            <!-- {{}} Es interpolación para acceder a los campos -->
            <td>{{u.id}}</td>
            <td>{{u.name}}</td>
            <td>{{u.email}}</td>
            <td>{{u.age}}</td>
            <td>{{u.language}}</td>
        </tr>
    </tbody>
</table>

Para que la tabla pueda acceder a los datos en la sentencia *ngFor = «let u of users, debemos crear el campo users -un array de User- en la clase UserList:

export class UserListComponent implements OnInit {
  users: User[]
  constructor() { }
  ngOnInit(): void {
  }
}

A continuación, creamos un Servicio Angular, que es una forma de realizar Inyección de Dependencias en este framework. En este caso, utilizaremos un comando donde ng hace referencia a Angular, g a Generate y s a Service:

ng g s user

Al concluir el proceso se habrán generado dos ficheros que situaremos en la misma carpeta que user:

CREATE src/app/user.service.spec.ts (347 bytes)
CREATE src/app/user.service.ts (133 bytes)

Comprobamos que Angular ha creado una clase UserService con un Decorator @Injectable que permite la inyección de dependencias. La documentación del framework indica lo siguiente:

Decorator that marks a class as available to be provided and injected as a dependency. Marking a class with @Injectable ensures that the compiler will generate the necessary metadata to create the class’s dependencies when the class is injected.

Comenzaremos modificando el código de UserService importando HttpClient y Observable. Añadiremos como campo la dirección backendURL donde tenemos nuestro endpoint de la API REST construida con Spring.

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { User } from './user';

@Injectable({
  providedIn: 'root'
})
export class UserService {
  //Endpoint del Backend
  private backendURL: string = "http://localhost:8080/fullstack/users";
  
  constructor(
    //HttpClient para proporcionar métodos que reciben datos del backend
    private httpClient: HttpClient
    ) { }

  //Methods
  findAllUsers(): Observable<User[]>{
    return this.httpClient.get<User[]>(`${this.backendURL}`);
  }
  ...
...
}

El método findAllUsers() es un Observable que opera sobre la dirección proporcionada recogiendo callbacks. El tipo Observable viene proporcionado por la librería RxJS [6]. En su documentación podemos encontrar información general y casos de uso:

RxJS is a library for composing asynchronous and event-based programs by using observable sequences. It provides one core type, the Observable, satellite types (Observer, Schedulers, Subjects) and operators to allow handling asynchronous events as collections. ReactiveX combines the Observer pattern with the Iterator pattern and functional programming with collections to fill the need for an ideal way of managing sequences of events.

  • Observable: represents the idea of an invokable collection of future values or events.
  • Observer [7]: is a collection of callbacks that knows how to listen to values delivered by the Observable.

El método get de HttpClient genera un request GET que interpreta un objeto en formato JSON, devolviendo una respuesta del tipo determinado, en nuestro caso User; de ahí que utilizemos Observable<User[]> y get<User[]>. Una vez incluidas estas líneas de código, modificamos nuestra clase UserListComponent, inyectando este servicio:

...
import { UserService } from '../user/user.service';

export class UserListComponent implements OnInit {

  users: User[]

  constructor(
    //Inyectamos el UserService que hemos importado
    private userService : UserService
  ) { }

  //De la documentación: A lifecycle hook that is called after Angular 
  //has initialized all data-bound properties of a directive.
  ngOnInit(): void {
    this.getUsers();
  }

  private getUsers(){
    //Utilizamos el servicio inyectado para encontrar los usuarios
    this.userService.findAllUsers().subscribe(
      //Arrow function, funcion anónima similar a expersiones Lambda
      userData => {this.users = userData}
    );
  }

En primer lugar, inyectamos el servicio creado en el constructor. Después definimos el método getUsers() que es llamado en el ngOnInit(). El método getUsers() utiliza el servicio UserService que se ha inyectado en el constuctor para acceder a los datos de los usuarios recogidos desde el endpoint. En la definición del método utilizamos una Arrow Function [8], que es una forma similar a las expresiones Lambda de Java para crear métodos anónimos. De esta manera, nuestro Frontend Angular ya sería capaz de mostrar una tabla con los datos obtenidos desde la DB a la que accede nuestro Backend.


Creación de nuevos usuarios

Una parte esencial de cualquier CRUD es la creación de nuevos objetos que serán transferidos a nuestra DB. De forma similar (consultar código completo) al procedimiento seguido hasta ahora, pasaríamos a crear los componentes para crear nuevos usuarios, actualizar usuarios y finalmente borrar usuarios. Por limitar la extensión del artículo, sólo detallamos la el proceso de creación de usuarios, así como el formulario para dar de alta nuevos usuarios.

En primer lugar creamos un nuevo componente al que he llamado create-user. Modificamos el archivo .ts para añadir la funcionalidad de creación de usuarios:

export class CreateUserComponent implements OnInit {

  //Cramos un nuevo usuario vacío
  user: User = new User();

  constructor(
    private userSerice: UserService,
    private router: Router) { }

  ngOnInit(): void {
  }

  //Este método es llamado desde el formulario
  //Se encarga de disparar el método de guardado de usuarios
  onSubmitForm(){
    console.log(this.user);
    this.commitUser();
  }

  //Este método llama al createUser de userService.
  commitUser(){
    this.userSerice.createUser(this.user).subscribe( 
      userData =>{
        console.log(userData);
        //Llamamos al método de redirección para volver a la lista de usuarios
        this.redirectUserList();
      },
      error => console.log(error));
  }

  //Redirección a lista de usuarios
  redirectUserList(){
    this.router.navigate(['/userlist']);
  }
}

A continuación, tenemos que actualizar UserService para añadir el método que permite guardar usuarios en la DB utilizando el método POST de nuestro backend:

export class UserService {
  ...
  //Methods
  //GET
  findAllUsers(): Observable<User[]>{
    return this.httpClient.get<User[]>(`${this.backendURL}`);
  }

  //POST
  createUser(user: User): Observable<Object>{
    return this.httpClient.post(`${this.backendURL}`, user);
  }
  ...
}

En el archivo HTML asociado al componente de creación de usuarios creamos un formulario simple que utilize Two-Way Binding -coloquialmente llamado Banana in a box– [9] para transferir datos al archivo .ts:

<!--Usamos Binging de angular para llamar al método del .ts asociado-->
<!--La sintaxis [()] BananaInBox es un Two-Way Binding-->
    <form (ngSubmit) = "onSubmitForm()">
    
        <div class="form-group">
            <label> Name</label>
            <input type="text" class ="form-control" id = "name"
                [(ngModel)] = "user.name" name = "name">
        </div>
    
        <div class="form-group">
            <label> Email</label>
            <input type="text" class ="form-control" id = "email"
                [(ngModel)] = "user.email" name = "email">
        </div>
    
        <div class="form-group">
            <label> Age</label>
            <input type="text" class ="form-control" id = "age"
                [(ngModel)] = "user.age" name = "age">
        </div>

        <div class="form-group">
            <label> Language</label>
            <input type="text" class ="form-control" id = "language"
                [(ngModel)] = "user.language" name = "language">
        </div>
    
        <button class = "btn btn-success" type ="submit">Submit</button>
    
    </form>

Siguiendo la misma lógica, procederíamos para los métodos Update y Delete (consultar código completo en mi repositorio) hasta tener todos los métodos de UserService:

export class UserService {
  ...
  //Methods
  //Para cada uno de ellos usamos uno de los métodos request HTTP
  //GET
  findAllUsers(): Observable<User[]>{
    return this.httpClient.get<User[]>(`${this.backendURL}`);
  }

  getUserById(id: number): Observable<User>{
    return this.httpClient.get<User>(`${this.backendURL}/${id}`);
  }

  //POST
  createUser(user: User): Observable<Object>{
    return this.httpClient.post(`${this.backendURL}`, user);
  }

  //PUT
  updateUser(id: number, user: User): Observable<Object>{
    return this.httpClient.put(`${this.backendURL}/${id}`, user);
  }

  //DELETE
  deleteUser(id: number): Observable<Object>{
    return this.httpClient.delete(`${this.backendURL}/${id}`);
  }
  ...
...
}

Finalmente actualizaríamos el código HTML de la tabla user-list.component.html, añadiendo botones para las acciones Update y Delete para terminar el ciclo de operaciones CRUD. Para concluir, añadimos una página de información –About Us– y un formulario de búsqueda filtrada por ID, obteniendo el resultado definitivo:


Referencias

[1] SPA – Single-page application

[2] TypeScript

[3] Etiqueta @RestController

[4] Creación de estructura de proyecto Angular desde la consola

[5] Una de las muchas formas de instalar Bootstrap

[6] Librería RxJS

[7] Patrón Observer

[8] Arrow Functions

[9] Two-Way Binding (Banana In A Box)


Material útil para profundizar en el desarrollo Full Stack

Un comentario

Los comentarios están cerrados.