Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000
RATE_LIMIT_MAX_REQUESTS=60
RATE_LIMIT_WINDOW_SECONDS=60

# --- Autenticacion JWT (nivel 10) ---
# Usuario "demo" para probar /api/auth/login, y la clave con la que se
# firman los tokens. Cambia JWT_SECRET si vas a exponer esto mas alla de tu
# maquina local.
AUTH_DEMO_USERNAME=admin
AUTH_DEMO_PASSWORD=changeme123
JWT_SECRET=cambia-esta-clave-en-produccion-es-solo-para-desarrollo-local-1234
JWT_EXPIRATION_MINUTES=30

# --- Frontend (Docker Compose) ---
# URL donde el frontend estático espera encontrar la API.
API_BASE_URL=http://localhost:8080
32 changes: 32 additions & 0 deletions .github/workflows/nivel-10-autenticacion.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: "Nivel 10 - Autenticacion con JWT"

on:
push:
pull_request:

jobs:
test-nivel10:
runs-on: ubuntu-latest
steps:
- name: Checkout codigo
uses: actions/checkout@v4

- name: Configurar Java 17
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: "17"

- name: Cache Maven
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-

- name: Dar permisos de ejecucion a mvnw
run: chmod +x mvnw

- name: Ejecutar la suite completa (nivel 1-10)
run: ./mvnw -q test -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10
32 changes: 32 additions & 0 deletions .github/workflows/nivel-11-caching.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: "Nivel 11 - Caching"

on:
push:
pull_request:

jobs:
test-nivel11:
runs-on: ubuntu-latest
steps:
- name: Checkout codigo
uses: actions/checkout@v4

- name: Configurar Java 17
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: "17"

- name: Cache Maven
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-

- name: Dar permisos de ejecucion a mvnw
run: chmod +x mvnw

- name: Ejecutar la suite completa (nivel 1-11)
run: ./mvnw -q test -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10,nivel11
42 changes: 42 additions & 0 deletions .github/workflows/nivel-12-observabilidad.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: "Nivel 12 - Observabilidad"

on:
push:
pull_request:

jobs:
test-nivel12:
runs-on: ubuntu-latest
steps:
- name: Checkout codigo
uses: actions/checkout@v4

- name: Configurar Java 17
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: "17"

- name: Cache Maven
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-

- name: Dar permisos de ejecucion a mvnw
run: chmod +x mvnw

- name: Ejecutar la suite completa (nivel 1-12)
run: ./mvnw -q test -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10,nivel11,nivel12

- name: Verificar cobertura minima del proyecto (JaCoCo)
run: ./mvnw -q verify -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10,nivel11,nivel12 -Pcoverage-nivel12

- name: Publicar reporte de cobertura
if: always()
uses: actions/upload-artifact@v4
with:
name: jacoco-report-nivel12
path: target/site/jacoco/
8 changes: 5 additions & 3 deletions docs/curso/05-pruebas-unitarias/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,11 @@ void testFindBySeason() {
Completa
[`RecipeServiceExtraTest.java`](../../../src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceExtraTest.java).
Tiene 3 métodos con `fail("TODO ...")` — bórralo y escribe una prueba real
para cada uno, probando `countRecipes()` y `getRecipesPage(...)` (los que
implementaste en los niveles 2 y 7) **a nivel de `RecipeService`**, no de
`RecipeController`.
para cada uno, probando `countRecipes()` (el que implementaste en el nivel
2) y el caso "no encontrado" de `getRecipeById(...)` **a nivel de
`RecipeService`**, no de `RecipeController` — `RecipeServiceTest.java` ya
prueba el caso en que la receta sí existe, pero nadie probó todavía qué
pasa cuando no.

El archivo ya trae la configuración de `@Mock`/`@InjectMocks` lista, y
comentarios con pistas específicas en cada método.
Expand Down
10 changes: 5 additions & 5 deletions docs/curso/06-seguridad/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Nivel 6 — Seguridad básica

No vas a implementar autenticación/autorización completa en este curso (eso
da para todo un curso aparte, con su propio dolor de cabeza dedicado), pero
sí las prácticas de seguridad más básicas que **cualquier** API debería
tener desde el primer día — el equivalente a cerrar la puerta con llave
antes de preocuparte por instalar cámaras.
Todavía no vas a implementar autenticación/autorización (eso llega más
adelante, en el nivel 10 bonus, una vez tengas encima las prácticas más
básicas), pero sí las que **cualquier** API debería tener desde el primer
día — el equivalente a cerrar la puerta con llave antes de preocuparte por
instalar cámaras.

## Parte A — Validación de entrada (Bean Validation)

Expand Down
27 changes: 20 additions & 7 deletions docs/curso/09-proyecto-final/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Nivel 9 — Proyecto final: Comentarios de receta

Llegaste al último nivel. 🎉 Respira, esto ya es cuesta abajo — no porque
sea fácil, sino porque ya sabes exactamente cómo se ve el camino: lo
Llegaste al cierre del curso base. 🎉 Respira, esto ya es cuesta abajo — no
porque sea fácil, sino porque ya sabes exactamente cómo se ve el camino: lo
recorriste ocho veces con `Recipe`. Aquí no hay un método puntual con un
`TODO` esperando una línea: vas a construir un recurso **completo**, de
punta a punta, replicando por tu cuenta todo lo que aprendiste con `Recipe`
Expand Down Expand Up @@ -96,8 +96,21 @@ una cobertura mínima del 60% en todo el proyecto y 70% específicamente en
`CommentService` (perfil Maven `coverage-nivel9`).

Cuando este workflow (`.github/workflows/nivel-09-proyecto-final.yml`) esté
en verde, completaste el curso — ya construiste, de principio a fin, una
API REST siguiendo buenas prácticas de arquitectura, pruebas, seguridad,
paginación y control de tráfico. 🎉 En serio, guarda este repo: dentro de
un año, cuando alguien te pregunte "¿pero tú sí sabes hacer un backend de
verdad?", este es tu recibo.
en verde, completaste el curso base — ya construiste, de principio a fin,
una API REST siguiendo buenas prácticas de arquitectura, pruebas,
seguridad, paginación y control de tráfico. 🎉 En serio, guarda este repo:
dentro de un año, cuando alguien te pregunte "¿pero tú sí sabes hacer un
backend de verdad?", este es tu recibo.

## ¿Y ahora qué?

Si quieres seguir exprimiendo este proyecto, hay tres niveles bonus que
llevan la API un poco más allá de lo que la mayoría de cursos cubre:

- [Nivel 10 — Autenticación con JWT](../10-autenticacion/README.md)
- [Nivel 11 — Caching](../11-caching/README.md)
- [Nivel 12 — Observabilidad](../12-observabilidad/README.md)

No son obligatorios para decir que "terminaste el curso" — son el
equivalente a las misiones secundarias de un juego: opcionales, pero ahí es
donde vive buena parte de lo que se te va a pedir en un trabajo real.
117 changes: 117 additions & 0 deletions docs/curso/10-autenticacion/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Nivel 10 (bonus) — Autenticación con JWT

Este es el primero de tres niveles **bonus**: no son obligatorios para
"terminar el curso" (eso ya lo lograste en el nivel 9), pero sí son temas
que casi cualquier API real termina necesitando tarde o temprano. Aquí
vamos a cerrar la deuda pendiente del nivel 6: hasta ahora, cualquiera con
la URL puede crear, editar o borrar recetas y comentarios sin que nadie le
pregunte quién es.

## El problema

`POST /api/recipes`, `PUT /api/recipes/{name}`, `DELETE /api/recipes/{id}`
(y sus equivalentes en comentarios) están abiertos al mundo. Eso está bien
para aprender CRUD, pero ninguna API real se queda así: alguien tiene que
poder decir "esta petición viene de alguien que ya demostró quién es".

## JWT en dos frases

Un **JWT** (JSON Web Token) es un token firmado digitalmente que un
servidor emite después de verificar tus credenciales. El cliente lo guarda
y lo reenvía en cada petición futura (header `Authorization: Bearer
{token}`); el servidor solo tiene que verificar la firma para confiar en el
contenido, sin tener que ir a preguntarle a una base de datos "¿esta sesión
sigue viva?" en cada petición — por eso se dice que es "stateless": toda la
información que el servidor necesita ya viaja dentro del propio token.

Como el token está firmado (no cifrado), cualquiera puede leer su
contenido decodificando Base64 — pero solo quien conoce la clave secreta
pudo haberlo firmado. Por eso nunca metas datos sensibles dentro de un JWT,
y por eso la clave de firma (`jwt.secret`) nunca debe filtrarse.

## Cómo está armado este nivel

- [`LoginRequest.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginRequest.java) /
[`LoginResponse.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginResponse.java)
— los DTOs de entrada/salida de `POST /api/auth/login`.
- [`AuthController.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/AuthController.java)
— ya completo. Compara las credenciales recibidas contra un único usuario
"demo" configurado por variable de entorno (`AUTH_DEMO_USERNAME` /
`AUTH_DEMO_PASSWORD`, ver `application.properties`) y, si coinciden, pide
un token a `JwtService`. En una API real esto sería una tabla de usuarios
con contraseñas hasheadas — simplificamos a un solo usuario para
enfocarnos en el mecanismo de JWT en sí, que es idéntico en ambos casos.
- [`JwtAuthFilter.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtAuthFilter.java)
— ya completo. Lee el header `Authorization`, valida el token con
`JwtService` y, si es válido, marca la petición como autenticada.
- [`SecurityConfig.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/SecurityConfig.java)
— ya completo. Define qué rutas son públicas (login, todos los `GET` de
recetas/comentarios, Swagger, Actuator) y cuáles exigen un JWT válido
(todo lo demás: los `POST`/`PUT`/`DELETE`).

## Tu tarea: `JwtService.java`

Todo el mecanismo criptográfico vive, aislado, en
[`JwtService.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtService.java).
Dos métodos:

- **`generateToken(String username)`**: construye un JWT firmado (HS256)
con `username` como *subject*, fecha de emisión = ahora, y expiración =
ahora + `jwt.expiration-minutes`. La librería es
[jjwt](https://github.com/jwtk/jjwt) (`io.jsonwebtoken`), ya agregada al
`pom.xml`:

```java
Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expirationMillis))
.signWith(signingKey, SignatureAlgorithm.HS256)
.compact();
```

- **`validateAndGetUsername(String token)`**: parsea y valida la firma y la
expiración del token, devolviendo el username si es válido o `null` si
no (corrupto, mal firmado, o expirado — `Jwts.parserBuilder()` lanza
`JwtException` en cualquiera de esos casos; captúrala y devuelve `null`).

No necesitas tocar `AuthController`, `JwtAuthFilter` ni `SecurityConfig`:
una vez que `JwtService` funciona, todo el resto del mecanismo ya está
conectado.

## Probarlo a mano

```bash
# Sin token: las rutas GET siguen abiertas
curl http://localhost:8080/api/recipes

# Login con el usuario demo (ver .env / application.properties)
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"changeme123"}'
# -> { "token": "eyJhbGciOi..." }

# Sin token, una escritura debe rechazarse
curl -i -X DELETE http://localhost:8080/api/recipes/algun-id
# -> 401/403

# Con token, la misma escritura debe funcionar (si ya implementaste
# createRecipe/deleteRecipe en niveles anteriores)
curl -i -X DELETE http://localhost:8080/api/recipes/algun-id \
-H "Authorization: Bearer eyJhbGciOi..."
```

## Verificación de este nivel

```bash
./mvnw test -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10
```

El corrector es
[`JwtServiceTest.java`](../../../src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtServiceTest.java)
y prueba `JwtService` directamente (sin levantar un servidor HTTP ni Spring
Security completo, igual que `RateLimitFilterTest` en el nivel 8): genera
tokens, los valida, y verifica que un token corrupto, mal firmado, o
expirado devuelva `null`.

Sigue con el [nivel 11 (caching)](../11-caching/README.md).
74 changes: 74 additions & 0 deletions docs/curso/11-caching/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Nivel 11 (bonus) — Caching

Cada `GET /api/recipes/{id}` que llega hoy va directo a Mongo, aunque sea
la misma receta que veinte usuarios distintos pidieron en el último minuto.
Eso funciona, pero desperdicia trabajo: si los datos no cambiaron, ¿por qué
volver a preguntarle a la base de datos la misma cosa una y otra vez?

## Caching en una frase

Un **caché** guarda el resultado de una operación costosa (aquí, una
consulta a Mongo) para devolverlo directo la próxima vez que alguien pida
exactamente lo mismo, sin repetir el trabajo — como un mesero que ya se
aprendió tu pedido habitual y no necesita ir a preguntarle a cocina otra
vez. El truco (y el riesgo) está en **cuándo invalidar** esa memoria: si
actualizas o borras el dato original y el caché no se entera, empiezas a
servir información vieja.

## Spring Cache

Spring trae un mecanismo de caching declarativo: anotas un método con
`@Cacheable` y, antes de ejecutarlo de verdad, Spring revisa si ya tiene
guardado un resultado para esos mismos argumentos — si sí, lo devuelve sin
tocar tu código. `@CacheEvict` hace lo contrario: vacía el caché cuando los
datos ya no son válidos (típicamente, en cualquier operación de escritura).

[`CacheConfig.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/CacheConfig.java)
ya está completo: activa `@EnableCaching` y define dos cachés en memoria
(`recipeById`, `recipeCount`) usando `ConcurrentMapCacheManager` — la
opción más simple para aprender el mecanismo (en producción normalmente se
usa algo compartido entre instancias, como Redis, pero la forma de usar
`@Cacheable`/`@CacheEvict` en tu código es exactamente la misma).

## Tu tarea

Todo tu trabajo en este nivel es agregar anotaciones en
[`RecipeService.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeService.java)
— no necesitas cambiar ningún cuerpo de método, cada uno ya trae un
comentario `TODO (nivel 11)` con la anotación exacta que le corresponde
(y la [`Cacheable`](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/annotation/Cacheable.html)/[`CacheEvict`](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/annotation/CacheEvict.html)
que debes importar):

| Método | Anotación | Por qué |
|---|---|---|
| `countRecipes()` | `@Cacheable(CacheConfig.RECIPE_COUNT_CACHE)` | Lectura pura, se puede cachear sin más. |
| `getRecipeById(id)` | `@Cacheable(value = ..., key = "#id")` | Lectura por clave — el `key` distingue una receta de otra. |
| `saveRecipe(recipe)` | `@CacheEvict(..., allEntries = true)` | Una receta nueva puede cambiar el conteo y (si reemplaza un id) el resultado cacheado de esa receta. |
| `deleteRecipe(id)` | `@CacheEvict(..., allEntries = true)` | Mismo motivo que `saveRecipe`. |
| `updateRecipe(name, recipe)` | `@CacheEvict(value = RECIPE_BY_ID_CACHE, allEntries = true)` | El conteo no cambia con un update, solo hay que invalidar la caché de recetas por id. |

Sobre el `key = "#id"` de `getRecipeById`: sin él, Spring cachearía **un
solo** resultado para cualquier id (el primero que le pidas) — justo el bug
que no quieres. `"#id"` es [SpEL](https://docs.spring.io/spring-framework/reference/core/expressions.html)
(Spring Expression Language) y hace referencia al parámetro `id` del propio
método.

## Verificación de este nivel

```bash
./mvnw test -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10,nivel11
```

El corrector es
[`RecipeServiceCachingTest.java`](../../../src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceCachingTest.java).
A diferencia de los demás correctores de este curso, este sí levanta un
mini contexto de Spring (sin base de datos real, sin servidor HTTP) porque
el caching funciona mediante un proxy que envuelve tu bean — sin ese proxy
no hay forma de observar el efecto de las anotaciones. La estrategia es
siempre la misma: llamar dos veces al mismo método con los mismos
argumentos y verificar, con un repositorio mockeado, cuántas veces la
llamada llegó realmente hasta ahí. Si tu caché funciona, la segunda llamada
nunca debería tocar el repositorio — y si guardas/actualizas/borras algo,
la siguiente lectura sí debería volver a consultarlo.

Sigue con el [nivel 12 (observabilidad)](../12-observabilidad/README.md).
Loading
Loading