← Proyecto backend completo

Sesión 50 · Semana 25

Conectar Angular al backend del proyecto

Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado ensayar la recuperación y publicar el incremento. En Servidor continúas la implementación del mismo producto.

Se explica

25 minutos · explicación y demostración

La ampliación del backend ya tiene reglas y permisos. Hoy la conectas al cliente Angular del proyecto. Angular se trabaja con los conocimientos del módulo de cliente; aquí observaremos el contrato HTTP, el envío de identidad y las respuestas de error del servidor.

El salto de Bruno al Navegador: La barrera de CORS

Durante todo el curso has probado tus endpoints con herramientas de escritorio como Bruno o curl. En ese entorno no existe ninguna restricción de origen cruzado.

Sin embargo, cuando el usuario abre la aplicación en Chrome o Firefox:

  • El código frontend de Angular se sirve desde el origen http://localhost:4200.
  • La API de Spring Boot escucha en el origen http://localhost:8080.
  • Como los puertos difieren, el navegador activa la Política del Mismo Origen (Same-Origin Policy).

Antes de enviar una petición destructiva (POST, PUT, DELETE) con cabeceras personalizadas (Authorization: Bearer), el navegador envía automáticamente una petición de sondeo previo (Preflight Request) con el método OPTIONS:

  • Le pregunta al servidor: «¿Aceptas peticiones desde http://localhost:4200 con la cabecera Authorization.
  • Si Spring Security no está configurado expresamente para autorizar peticiones OPTIONS, la rechaza y el navegador bloquea la llamada.

La regla de oro de la integración desacoplada

El backend no sabe ni le importa que el cliente sea Angular, React o una app móvil.

El backend solo responde a contratos HTTP estándar. Angular es solo un cliente más. La suite de pruebas de Bruno sigue siendo el certificador técnico oficial e independiente del backend.

Acordar tipos y errores entre Angular y Spring

El cliente Angular consume JSON, no entidades Java. Su interfaz TypeScript debe corresponder al DTO publicado: nombre de los campos, valores opcionales, fechas y estructura de errores. Que ambos proyectos compilen no demuestra que ese acuerdo se cumpla; la comprobación es la petición real y su respuesta.

La URL base pertenece a la configuración del entorno. Las credenciales se añaden de acuerdo con la estrategia implantada y el servidor sigue comprobando cada permiso. Ocultar un botón mejora la experiencia, pero el endpoint también debe rechazar la operación cuando se envía directamente desde un cliente HTTP sin autorización.

La práctica conecta el cliente de Desarrollo Web en Entorno Cliente al mismo backend desplegado. Se observa en DevTools qué ruta se pidió, qué credencial se envió y qué respondió la API. Se prueban un caso válido, un rechazo por permisos y una validación de formulario. La colección HTTP se mantiene para diagnosticar si el problema está en Angular o en el backend.

Se trabaja

140 minutos · implementación guiada sobre el proyecto propio

Paso 1 · Retomar el proyecto y preparar la comprobación

  1. Abre el cliente Angular existente y el backend y arráncalos siguiendo sus README. Si la integración del cliente aún no está preparada en el otro módulo, deja identificada esa dependencia y utiliza la colección para comprobar mientras tanto el contrato del servidor.
  2. Localiza el servicio HTTP de Angular, sus tipos de datos y el interceptor de autenticación. Compara sus campos con los DTO públicos actuales.
  3. Anota el origen del cliente y la dirección del backend que usa la configuración. Abre Red antes de ejecutar la primera acción.

Paso 2 · Configuración de CORS y sincronización de contratos

Para activar el interceptor funcional del ejemplo en un cliente Angular standalone, abre src/app/app.config.ts, importa los siguientes símbolos y añade el proveedor a su array providers existente, conservando router y los otros proveedores. Si ya existe provideHttpClient, modifica esa llamada en lugar de duplicarla.

import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './interceptors/auth.interceptor';
provideHttpClient(withInterceptors([authInterceptor]))

Reutiliza el AuthService del cliente Angular trabajado en Desarrollo Web en Entorno Cliente: obtenerToken() debe devolver el token actual o null. Si su método tiene otro nombre, adapta esa llamada. Intermodular reutiliza la integración resultante para publicarla. En Red comprueba que Authorization se añade únicamente a peticiones de tu API; si el cliente consume otras APIs, acota el interceptor al prefijo base de tu backend.

Conserva la SecurityFilterChain final de la UD9 y modifica solo orígenes y cabeceras necesarias para Angular. En el cliente, crea o actualiza los tipos en src/app/models y el interceptor en src/app/interceptors; registra el interceptor en provideHttpClient(withInterceptors([...])) de su configuración de arranque, o en el mecanismo equivalente si utiliza módulos. Sin ese registro el archivo no se ejecuta. Comprueba en Red que realmente se envía Authorization antes de diagnosticar permisos del servidor.

Qué se evalúa hoy y qué no

Esta sesión es de backend, aunque se vea TypeScript. Lo que se evalúa es que tu API se deje consumir desde un navegador con seguridad puesta: CORS acotado, cabeceras expuestas, errores legibles. El cliente Angular es el instrumento de medida, no el entregable.

La regla de la UD8 sigue vigente y es la que te salva si Angular no está listo: el backend debe poder comprobarse entero sin él. Si algo no funciona, la primera pregunta es siempre si la misma petición funciona desde tu cliente HTTP. Si desde ahí va y desde el navegador no, el problema es CORS. Si no va desde ninguno de los dos, el problema no tiene nada que ver con Angular.

Configuramos de forma granular los orígenes y cabeceras permitidas en SecurityConfig:

Sustituye solo el bean corsConfigurationSource de SecurityConfig por este método. Conserva la cadena JWT, sus reglas, respuestas 401/403 y demás beans de la sesión 40. Los imports de CorsConfiguration, CorsConfigurationSource, UrlBasedCorsConfigurationSource y List ya estaban en aquella clase.

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        // Origen del cliente Angular de desarrollo
        configuration.setAllowedOrigins(List.of("http://localhost:4200"));

        // Métodos HTTP permitidos
        configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));

        // Cabeceras permitidas en las peticiones entrantes
        configuration.setAllowedHeaders(List.of(
            "Authorization", "Content-Type", "X-Correlation-ID", "Accept"
        ));

        // Cabeceras expuestas legibles por el código JavaScript de Angular
        configuration.setExposedHeaders(List.of(
            "Location", "X-Correlation-ID", "Content-Disposition"
        ));

        // Permitir envío de credenciales/cookies si fuera necesario
        configuration.setAllowCredentials(true);

        // Tiempo de caché del resultado del preflight (1 hora)
        configuration.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", configuration);
        return source;
    }

Creamos las interfaces en Angular espejando los DTOs de Java:

// src/app/models/proyecto.model.ts
export interface ProyectoResponse {
  id: number;
  codigo: string;
  nombre: string;
  estado: 'PLANIFICADO' | 'EN_CURSO' | 'BLOQUEADO' | 'FINALIZADO' | 'CANCELADO';
  presupuestoTotal: number;
  responsableNombre: string;
}

export interface CrearProyectoRequest {
  codigo: string;
  nombre: string;
  descripcion?: string;
  presupuestoTotal: number;
  latitud: number;
  longitud: number;
  fechaInicio: string; // Formato ISO "YYYY-MM-DD"
  fechaFinEstimada: string;
}

// Estructura de error estándar RFC 7807 capturada en el frontend
export interface ProblemDetails {
  type?: string;
  title: string;
  status: number;
  detail: string;
  instance?: string;
  correlationId?: string;
}
// src/app/interceptors/auth.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { AuthService } from '../services/auth.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  const token = authService.obtenerToken();

  // Si tenemos token JWT en localStorage, lo clonamos en la cabecera Authorization
  let peticionClonada = req;
  if (token) {
    peticionClonada = req.clone({
      setHeaders: {
        Authorization: `Bearer ${token}`
      }
    });
  }

  return next(peticionClonada).pipe(
    catchError((error: HttpErrorResponse) => {
      // Capturamos el Problem Details RFC 7807 emitido por Spring Boot
      if (error.error && error.error.detail) {
        console.error(`[API Error ${error.status}] ${error.error.title}: ${error.error.detail}`);
        if (error.error.correlationId) {
          console.warn(`Código de soporte para reporte: ${error.error.correlationId}`);
        }
      }
      return throwError(() => error);
    })
  );
};
Por qué CORS se configura aquí y no en el @CrossOrigin de la UD8
Con Spring Security en medio, la petición se rechaza en la cadena de filtros antes de llegar a tu controlador, y una anotación en el controlador ya no llega a tiempo. El .cors(...) del SecurityFilterChain lo resuelve en el sitio correcto: en la frontera.
El OPTIONS tiene que ser público
El navegador envía el preflight sin la cabecera Authorization. Si tu cadena exige autenticación para todo, ese OPTIONS recibe un 401, el navegador cancela y en la consola verás un error de CORS que no es de CORS. Spring Security lo permite solo si .cors(...) está declarado antes de las reglas de autorización.
setExposedHeaders: la que se olvida siempre
Por defecto, JavaScript solo puede leer un puñado de cabeceras de la respuesta. Tu Location del 201 Created llega, pero el navegador se la esconde a Angular salvo que la declares aquí. El síntoma es desconcertante: la petición sale bien y aun así el cliente no encuentra la cabecera.
allowCredentials(true) y el comodín
Con credenciales activadas, el estándar prohíbe setAllowedOrigins(List.of("*")). Spring lanza una excepción al arrancar. Si se requieren varios orígenes, enuméralos o emplea setAllowedOriginPatterns. En producción, el comodín queda descartado en todo caso.

Paso 3 · Inspección de red en DevTools

  1. Arranca el backend (:8080) y el cliente Angular (:4200).

  2. Abre las herramientas de desarrollo de Chrome (F12) en la pestaña Network (Red).

  3. Inicia sesión y crea un proyecto desde el formulario web de Angular:

    • Observa la secuencia de dos peticiones en la lista de red:
      1. OPTIONS /api/v1/proyectos: Responde 200 OK con cabecera Access-Control-Allow-Origin: http://localhost:4200.
      2. POST /api/v1/proyectos: Responde 201 Created con el cuerpo JSON del proyecto y la cabecera Location.
  4. Prueba de captura de error RFC 7807 en pantalla:

    • Intenta introducir un código duplicado o un presupuesto de -500 €.
    • Comprueba que la pantalla de Angular muestra el mensaje amigable extraído directamente de error.error.detail y el identificador de correlación para soporte.
  5. Provoca el fallo de CORS a propósito, para saber reconocerlo: cambia setAllowedOrigins a http://localhost:9999, reinicia el backend y repite la operación desde Angular. Lee el mensaje exacto de la consola del navegador y anótalo. Comprueba a la vez que la misma petición sigue funcionando desde tu cliente HTTP: esa asimetría es la firma inconfundible de un problema de CORS y te ahorrará horas el día que aparezca de verdad. Devuelve el origen a :4200.

Paso 4 · Si algo no sale como dice el guion

Síntoma en la consola del navegador Causa real Qué mirar
No 'Access-Control-Allow-Origin' header is present El origen no está en la lista ¿http://localhost:4200 exacto? El puerto y el esquema forman parte del origen
Response to preflight request doesn't pass access control check: 401 El OPTIONS está siendo autenticado .cors(...) debe ir declarado en el SecurityFilterChain, antes de las reglas
Request header field authorization is not allowed Falta en setAllowedHeaders Añade Authorization a la lista
La petición va bien pero Angular no ve la cabecera Location Falta en setExposedHeaders Es lo que impide leerla desde JavaScript
Cannot use wildcard in Access-Control-Allow-Origin when credentials flag is true Comodín + credenciales Enumera los orígenes o usa setAllowedOriginPatterns
401 en todas las llamadas de Angular pero no en el cliente HTTP El interceptor no adjunta el token Comprueba en DevTools → Network → Headers que sale Authorization: Bearer …
Funciona todo salvo la subida de ficheros El interceptor fija Content-Type: application/json En un multipart, el navegador debe poner él el Content-Type con su boundary: no lo sobrescribas

Paso 5 · Cerrar el circuito completo desde el navegador

Conecta una operación del cliente cada vez: consulta de detalle, modificación, adjunto e integración externa. Para cada una compara su petición con la que ya funciona en la colección y corrige URL, campos o cabeceras antes de pasar a la siguiente. Un archivo multipart lo construye FormData: deja que el navegador añada Content-Type con su boundary. Tras una respuesta 204 no intentes analizar JSON. Conserva los mensajes de validación y permisos que devuelve el backend.

  1. Añade al detalle de proyecto un botón «Consultar meteorología en obra» que muestre la temperatura y la recomendación que devuelve tu API.
  2. Si el backend responde con el aviso de degradación de la UD10 («Servicio no disponible»), muestra una alerta amarilla sin romper la vista del proyecto. Es la demostración visible de que la degradación elegante servía para algo.
  3. Comprueba desde el navegador las tres respuestas de error que más cuesta ver bien: un 400 de validación (envía un presupuesto negativo), un 403 de permisos (entra como operario e intenta editar un proyecto ajeno) y un 404. Las tres deben mostrar el detail del RFC 7807 y ninguna debe dejar la pantalla en blanco.
  4. Verifica que la cabecera Location del 201 Created se lee desde Angular y la usas para navegar al recurso recién creado. Si no la ves, vuelve a setExposedHeaders.
  5. Repite el recorrido con los tres roles y comprueba que la interfaz oculta lo que el usuario no puede hacer y que el backend lo rechaza igualmente si lo fuerzas desde el cliente HTTP. Esa doble comprobación es la lección de la sesión 38 aplicada a tu propio proyecto.
  6. Anota en la memoria técnica los orígenes permitidos y por qué esos: es una decisión de seguridad y hay que defenderla en la sesión 52.
Cómo saber que lo has terminado
En DevTools ves el par OPTIONS 200 + POST 201; Angular lee la cabecera Location; los errores del backend llegan a la pantalla como texto legible y no como una pantalla en blanco; ninguna operación depende del cliente para poder comprobarse; y sabes reconocer un fallo de CORS por el hecho de que tu cliente HTTP sí funciona.

Paso 6 · Comprobar y registrar el resultado del proyecto

  1. Ejecuta desde Angular el recorrido de la ampliación y contrasta URL, método, cuerpo, identidad y respuesta con la colección.
  2. Prueba un dato inválido y un usuario sin permiso: la interfaz debe mostrar el error correspondiente y el backend debe conservar el estado correcto.

Ampliación si has completado el trabajo

Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.

Reto · Descarga de ficheros binarios Blob en Angular

La descarga de un archivo binario mediante un enlace <a> tradicional no permite inyectar cabeceras Authorization: Bearer <token>:

  1. Investiga cómo descargar el archivo mediante Angular HttpClient configurando { responseType: 'blob' }.
  2. Crea una URL de objeto en memoria con window.URL.createObjectURL(blob) y dispara la descarga programática asignando el nombre de fichero extraído de la cabecera Content-Disposition.

Formato de entrega

Incluye esta explicación en el registro de la sesión dentro del repositorio de GitHub, junto al código y las comprobaciones. La entrega es el enlace al repositorio y al commit de la sesión.

Objetivo mínimoConfiguración de CORS operativa permitiendo peticiones desde http://localhost:4200.
Si lo tienesInterceptor HTTP en Angular inyectando tokens Bearer y capturando errores RFC 7807.
RetoDescarga programática de binarios Blob con inyección de JWT y extracción de Content-Disposition.
Ver respuestas

1 · Porque las restricciones de CORS son implementadas exclusivamente por los navegadores web para proteger a los usuarios de peticiones no autorizadas entre sitios; los clientes de escritorio como Bruno no aplican la política Same-Origin.

2 · La cabecera Access-Control-Allow-Origin (por ejemplo: Access-Control-Allow-Origin: http://localhost:4200).

3 · Porque por defecto el navegador oculta a JavaScript casi todas las cabeceras de respuesta excepto las básicas; si necesitas leer Location, Content-Disposition o X-Correlation-ID en TypeScript debes exponerlas explícitamente.

4 · El correlationId generado por el servidor, que permite al usuario comunicar ese código al soporte técnico para que localicen el fallo exacto en los archivos de log del servidor.

Cierre

15 minutos · resultado comprobable y explicación individual

Al terminar la sesión:

El cliente Angular utiliza la misma API que sigue verificándose de manera independiente con la colección HTTP.

Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.