diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..58c77978 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Java/Maven +target/ +*.class +*.jar +*.war +*.ear +.classpath +.project +.settings/ + +# Build +.DS_Store +*.log + +# Local environment +.java-version +.env +.env.local + +# Test/validation scripts +VALIDAR_PRUEBA.sh diff --git a/REQUIREMENTS_VALIDATION.md b/REQUIREMENTS_VALIDATION.md new file mode 100644 index 00000000..f9b741a3 --- /dev/null +++ b/REQUIREMENTS_VALIDATION.md @@ -0,0 +1,120 @@ +# Validación de Requerimientos - Similar Products API + +## ✅ Requerimientos Cumplidos + +| Requerimiento | Cumplido | Detalles | +|---------------|----------|----------| +| **API REST Spring Boot** | ✅ | `GET /product/{productId}/similar` en puerto 5000 | +| **Integración APIs existentes** | ✅ | Consume `/similarids` y `/product/{id}` desde mocks | +| **Performance** | ✅ | Paralelización con ExecutorService (CompletableFuture) | +| **Resilience** | ✅ | Timeouts por producto + manejo de errores completo | +| **Code Clarity** | ✅ | Arquitectura MVC, documentación, constantes nombradas | + +--- + +## 🏗️ Arquitectura + +``` +GET /product/{productId}/similar (Puerto 5000) + ↓ +SimilarProductsController (validación) + ↓ +SimilarProductsService (orquestación) + ↓ [En Paralelo - ExecutorService] + ├→ getSimilarProductIds() → IDs + ├→ getProductDetail() × N (paralelo) + └→ getProductDetail() × N (paralelo) + ↓ +Retornar List +``` + +**Capas:** +- **Controller**: HTTP endpoint + validación +- **Service**: Orquestación paralela +- **ExternalApiService**: Integración con APIs externas +- **Model**: DTO ProductDetail + +--- + +## 🚀 Performance: Paralelización + +**Problema:** 5 productos × 10 seg/producto = 50 segundos + +**Solución - Paralelo con CompletableFuture:** +```java +List> futures = new ArrayList<>(); +for (String id : similarIds) { + futures.add(CompletableFuture.supplyAsync( + () -> externalApiService.getProductDetail(id), + executorService // Thread pool de 10 + )); +} +``` + +**Resultado:** 5 productos en ~10 segundos (máximo del grupo) +**Mejora:** **5x más rápido** + +--- + +## 🛡️ Resilience: Tolerancia a Fallos + +✅ **Timeout por producto:** 15 segundos max +✅ **Si uno falla, otros continúan:** Retorna resultados parciales +✅ **Timeout HTTP:** Connect 5s + Read 10s +✅ **Never crashes:** Nunca retorna 500 Error +✅ **Validación input:** productId no vacío +✅ **Logging:** Todos los errores se registran en logs para debugging + +**Ejemplo:** +- Productos: 5 +- Uno tarda 20 seg (timeout 15s) +- Retorna: 4 productos (no falla) +- Log: WARN/ERROR en logs con detalles para debugging + +--- + +## 📂 Estructura del Código + +``` +app/src/main/java/com/similarproducts/ +├── Application.java +├── controller/ → SimilarProductsController +├── service/ → SimilarProductsService, ExternalApiService +└── model/ → ProductDetail + +app/src/main/resources/ +└── application.yml (puerto 5000) +``` + +--- + +## 🧪 Ejemplo de Uso + +```bash +# Request +GET http://localhost:5000/product/1/similar + +# Response (200 OK) +[ + { + "id": "2", + "name": "Similar Product", + "price": 29.99, + "availability": true + } +] + +# Si no hay similares (200 OK - nunca falla) +[] +``` + +--- + +## 🔧 Configuración + +- **Puerto:** 5000 ✅ +- **Framework:** Spring Boot 3.3.8 +- **Java:** 21 LTS +- **Build:** Maven +- **Thread Pool:** 10 threads +- **Timeouts:** Configurables diff --git a/app/pom.xml b/app/pom.xml new file mode 100644 index 00000000..3e431ba0 --- /dev/null +++ b/app/pom.xml @@ -0,0 +1,68 @@ + + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.8 + + + + com.similarproducts + app + 1.0-SNAPSHOT + + app + + + UTF-8 + 21 + 21 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-logging + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/app/src/main/java/com/similarproducts/Application.java b/app/src/main/java/com/similarproducts/Application.java new file mode 100644 index 00000000..72aca813 --- /dev/null +++ b/app/src/main/java/com/similarproducts/Application.java @@ -0,0 +1,12 @@ +package com.similarproducts; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/app/src/main/java/com/similarproducts/controller/SimilarProductsController.java b/app/src/main/java/com/similarproducts/controller/SimilarProductsController.java new file mode 100644 index 00000000..22b02f2a --- /dev/null +++ b/app/src/main/java/com/similarproducts/controller/SimilarProductsController.java @@ -0,0 +1,44 @@ +package com.similarproducts.controller; + +import com.similarproducts.model.ProductDetail; +import com.similarproducts.service.SimilarProductsService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; + +@RestController +@RequestMapping("/product") +public class SimilarProductsController { + + private static final Logger logger = LoggerFactory.getLogger(SimilarProductsController.class); + + private final SimilarProductsService similarProductsService; + + public SimilarProductsController(SimilarProductsService similarProductsService) { + this.similarProductsService = similarProductsService; + } + + @GetMapping("/{productId}/similar") + public ResponseEntity> getSimilarProducts( + @PathVariable String productId) { + try { + if (productId == null || productId.trim().isEmpty()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ArrayList<>()); + } + + List similarProducts = similarProductsService.getSimilarProducts(productId); + + return ResponseEntity.ok(similarProducts); + + } catch (Exception e) { + logger.error("Unexpected error while fetching similar products for productId: {}. Error: {}", productId, + e.getMessage(), e); + return ResponseEntity.ok(new ArrayList<>()); + } + } +} diff --git a/app/src/main/java/com/similarproducts/model/ProductDetail.java b/app/src/main/java/com/similarproducts/model/ProductDetail.java new file mode 100644 index 00000000..fec71f25 --- /dev/null +++ b/app/src/main/java/com/similarproducts/model/ProductDetail.java @@ -0,0 +1,24 @@ +package com.similarproducts.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import com.fasterxml.jackson.annotation.JsonProperty; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ProductDetail { + + @JsonProperty("id") + private String id; + + @JsonProperty("name") + private String name; + + @JsonProperty("price") + private Double price; + + @JsonProperty("availability") + private Boolean availability; +} diff --git a/app/src/main/java/com/similarproducts/service/ExternalApiService.java b/app/src/main/java/com/similarproducts/service/ExternalApiService.java new file mode 100644 index 00000000..2dec7fbf --- /dev/null +++ b/app/src/main/java/com/similarproducts/service/ExternalApiService.java @@ -0,0 +1,58 @@ +package com.similarproducts.service; + +import com.similarproducts.model.ProductDetail; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.concurrent.TimeUnit; + +@Service +public class ExternalApiService { + + private static final Logger logger = LoggerFactory.getLogger(ExternalApiService.class); + + private final RestTemplate restTemplate; + private final String mockBaseUrl; + + private static final int CONNECT_TIMEOUT_SECONDS = 5; + private static final int READ_TIMEOUT_SECONDS = 10; + + public ExternalApiService( + @Value("${external.api.mock.url:http://localhost:3001}") String mockBaseUrl) { + this.mockBaseUrl = mockBaseUrl; + this.restTemplate = createRestTemplate(); + } + + public String[] getSimilarProductIds(String productId) { + String url = mockBaseUrl + "/product/" + productId + "/similarids"; + try { + String[] ids = restTemplate.getForObject(url, String[].class); + return ids != null ? ids : new String[0]; + } catch (Exception e) { + logger.warn("Failed to fetch similar product IDs for productId: {}. Error: {}", productId, e.getMessage()); + return new String[0]; + } + } + + public ProductDetail getProductDetail(String productId) { + String url = mockBaseUrl + "/product/" + productId; + try { + ProductDetail detail = restTemplate.getForObject(url, ProductDetail.class); + return detail; + } catch (Exception e) { + logger.warn("Failed to fetch product details for productId: {}. Error: {}", productId, e.getMessage()); + return null; + } + } + + private RestTemplate createRestTemplate() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout((int) TimeUnit.SECONDS.toMillis(CONNECT_TIMEOUT_SECONDS)); + factory.setReadTimeout((int) TimeUnit.SECONDS.toMillis(READ_TIMEOUT_SECONDS)); + return new RestTemplate(factory); + } +} diff --git a/app/src/main/java/com/similarproducts/service/SimilarProductsService.java b/app/src/main/java/com/similarproducts/service/SimilarProductsService.java new file mode 100644 index 00000000..dd3e8b7c --- /dev/null +++ b/app/src/main/java/com/similarproducts/service/SimilarProductsService.java @@ -0,0 +1,66 @@ +package com.similarproducts.service; + +import com.similarproducts.model.ProductDetail; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.*; +import java.util.stream.Collectors; + +@Service +public class SimilarProductsService { + + private static final Logger logger = LoggerFactory.getLogger(SimilarProductsService.class); + + private final ExternalApiService externalApiService; + private final ExecutorService executorService; + + private static final int THREAD_POOL_SIZE = 10; + private static final int PRODUCT_TIMEOUT_SECONDS = 15; + + public SimilarProductsService(ExternalApiService externalApiService) { + this.externalApiService = externalApiService; + this.executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE); + } + + public List getSimilarProducts(String productId) { + try { + String[] similarIds = externalApiService.getSimilarProductIds(productId); + + if (similarIds == null || similarIds.length == 0) { + return new ArrayList<>(); + } + + List> futures = new ArrayList<>(); + + for (String id : similarIds) { + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + return externalApiService.getProductDetail(id); + }, executorService); + futures.add(future); + } + + List similarProducts = futures.stream() + .map(f -> { + try { + return f.get(PRODUCT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (TimeoutException | InterruptedException | ExecutionException e) { + logger.warn("Failed to fetch product details within timeout ({}s). Error: {}", + PRODUCT_TIMEOUT_SECONDS, e.getMessage()); + return null; + } + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + return similarProducts; + + } catch (Exception e) { + logger.error("Unexpected error in getSimilarProducts for productId: {}. Error: {}", productId, + e.getMessage(), e); + return new ArrayList<>(); + } + } +} diff --git a/app/src/main/resources/application.yml b/app/src/main/resources/application.yml new file mode 100644 index 00000000..90a88bca --- /dev/null +++ b/app/src/main/resources/application.yml @@ -0,0 +1,11 @@ +server: + port: 5000 + +spring: + application: + name: similar-products-api + +external: + api: + mock: + url: http://localhost:3001 diff --git a/mock_server.py b/mock_server.py new file mode 100644 index 00000000..07641920 --- /dev/null +++ b/mock_server.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +Simula las dos APIs externas necesarias para la aplicación +Usa http.server (no requiere dependencias externas) +""" + +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse +import json +import time + +PRODUCTS = { + "1": {"id": "1", "name": "T-Shirt", "price": 9.99, "availability": True}, + "2": {"id": "2", "name": "Dress", "price": 19.99, "availability": True}, + "3": {"id": "3", "name": "Blazer", "price": 29.99, "availability": False}, + "4": {"id": "4", "name": "Jeans", "price": 39.99, "availability": True}, + "5": {"id": "5", "name": "Shirt", "price": 14.99, "availability": True}, + "6": {"id": "6", "name": "Sweater", "price": 24.99, "availability": True}, +} + +SIMILAR_IDS = { + "1": ["2", "3", "4"], + "2": ["1", "3", "5"], + "3": ["2", "4", "6"], + "4": ["1", "2", "5"], + "5": ["1", "2", "3"], +} + +DELAYS = { + "2": 0.1, + "3": 5.0, +} + +ERRORS = { + "5": 500, +} + + +class MockServerHandler(BaseHTTPRequestHandler): + + def do_GET(self): + parsed_path = urlparse(self.path) + path = parsed_path.path + + if path == '/health': + self.send_json({"status": "ok"}, 200) + elif '/similarids' in path: + self.handle_similar_ids(path) + elif path.startswith('/product/'): + self.handle_product_detail(path) + else: + self.send_json({"error": "Not found"}, 404) + + def handle_similar_ids(self, path): + try: + parts = path.split('/') + product_id = parts[2] + + if product_id in DELAYS: + delay = DELAYS[product_id] + print(f" [Mock] Simulando delay de {delay}s para producto {product_id}") + time.sleep(delay) + + if product_id in ERRORS: + error_code = ERRORS[product_id] + print(f" [Mock] Retornando error {error_code} para {product_id}/similarids") + self.send_json({"error": f"Error {error_code}"}, error_code) + return + + if product_id in SIMILAR_IDS: + similar_ids = SIMILAR_IDS[product_id] + print(f"📍 GET /product/{product_id}/similarids → [{', '.join(similar_ids)}]") + self.send_json(similar_ids, 200) + else: + print(f"📍 GET /product/{product_id}/similarids → 404 NOT FOUND") + self.send_json({"error": "Product not found"}, 404) + except: + self.send_json({"error": "Error"}, 500) + + def handle_product_detail(self, path): + try: + parts = path.split('/') + product_id = parts[2] + + if product_id in DELAYS: + delay = DELAYS[product_id] + print(f" [Mock] Simulando delay de {delay}s para detalle de {product_id}") + time.sleep(delay) + + if product_id in ERRORS: + error_code = ERRORS[product_id] + print(f" [Mock] Retornando error {error_code} para detalle de {product_id}") + self.send_json({"error": f"Error {error_code}"}, error_code) + return + + if product_id in PRODUCTS: + product = PRODUCTS[product_id] + print(f" → {product['name']} (${product['price']})") + self.send_json(product, 200) + else: + print(f" → 404 - Producto {product_id} no encontrado") + self.send_json({"error": "Product not found"}, 404) + except: + self.send_json({"error": "Error"}, 500) + + def send_json(self, data, status_code): + self.send_response(status_code) + self.send_header('Content-Type', 'application/json') + self.send_header('Access-Control-Allow-Origin', '*') + self.end_headers() + self.wfile.write(json.dumps(data).encode()) + + def log_message(self, format, *args): + pass + + +def run_server(): + server_address = ('localhost', 3001) + httpd = HTTPServer(server_address, MockServerHandler) + print("🚀 MOCK SERVER - Similar Products API") + print("=" * 60) + print("Escuchando en http://localhost:3001\n") + print("Endpoints disponibles:") + print(" - GET /product/{id}/similarids") + print(" - GET /product/{id}") + print(" - GET /health\n") + print("Casos de prueba:") + print(" - Producto 1: normal") + print(" - Producto 2: delay 100ms") + print(" - Producto 3: delay 5000ms") + print(" - Producto 4: similar no encontrado") + print(" - Producto 5: error 500") + print("=" * 60) + print() + + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nServidor detenido") + + +if __name__ == '__main__': + run_server() diff --git a/run_services.sh b/run_services.sh new file mode 100644 index 00000000..e00129d4 --- /dev/null +++ b/run_services.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# Script para ejecutar los servicios necesarios + +echo "🚀 Iniciando servicios..." +echo "" + +killall java 2>/dev/null +killall python3 2>/dev/null +sleep 2 + +echo "1️⃣ Iniciando Mock Server (puerto 3001)..." +python3 /Users/javikuka/Documents/GitHub/backendDevTest/mock_server.py > /tmp/mock.log 2>&1 & +MOCK_PID=$! +sleep 3 + +if curl -s http://localhost:3001/health > /dev/null 2>&1; then + echo " ✅ Mock Server corriendo (PID: $MOCK_PID)" +else + echo " ❌ Error iniciando Mock Server" + cat /tmp/mock.log + exit 1 +fi + +# Iniciar Spring Boot +echo "" +echo "2️⃣ Iniciando Spring Boot (puerto 8080)..." +cd /Users/javikuka/Documents/GitHub/backendDevTest/app +java -jar target/app-1.0-SNAPSHOT.jar > /tmp/spring.log 2>&1 & +SPRING_PID=$! +sleep 8 + +# Verificar que Spring Boot esté corriendo +if curl -s http://localhost:8080/product/1/similar > /dev/null 2>&1; then + echo " ✅ Spring Boot corriendo (PID: $SPRING_PID)" +else + echo " ❌ Error iniciando Spring Boot" + echo " Primeros 50 líneas de logs:" + head -50 /tmp/spring.log + exit 1 +fi + +echo "" +echo "✨ Servicios iniciados correctamente!" +echo "" +echo "URLs disponibles:" +echo " - Mock Server: http://localhost:3001/health" +echo " - Spring Boot: http://localhost:8080/product/1/similar" +echo "" +echo "Para detener los servicios, ejecute: killall java python3"