From 7ed9a278b984058ff37da897286e7959acf573b1 Mon Sep 17 00:00:00 2001 From: juangarcia15525 Date: Mon, 27 Apr 2026 08:06:04 +0200 Subject: [PATCH] Add files via upload --- README.md | 207 +++++++++++------- pom.xml | 54 +++++ .../org/example/ProductApiApplication.java | 12 + .../org/example/config/ApiKeyInterceptor.java | 28 +++ .../config/InvalidApiKeyException.java | 8 + .../config/MissingApiKeyException.java | 8 + .../java/org/example/config/WebConfig.java | 20 ++ .../controller/CustomerController.java | 55 +++++ .../example/controller/ProductController.java | 66 ++++++ .../exception/CustomerNotFoundException.java | 8 + .../exception/GlobalExceptionHandler.java | 85 +++++++ .../exception/InvalidPriceRangeException.java | 8 + .../exception/ProductNotFoundException.java | 8 + src/main/java/org/example/model/Customer.java | 63 ++++++ src/main/java/org/example/model/Product.java | 64 ++++++ .../org/example/service/CustomerService.java | 44 ++++ .../org/example/service/ProductService.java | 61 ++++++ src/main/resources/application.properties | 1 + .../controller/CustomerControllerTest.java | 86 ++++++++ .../controller/ProductControllerTest.java | 89 ++++++++ 20 files changed, 897 insertions(+), 78 deletions(-) create mode 100644 pom.xml create mode 100644 src/main/java/org/example/ProductApiApplication.java create mode 100644 src/main/java/org/example/config/ApiKeyInterceptor.java create mode 100644 src/main/java/org/example/config/InvalidApiKeyException.java create mode 100644 src/main/java/org/example/config/MissingApiKeyException.java create mode 100644 src/main/java/org/example/config/WebConfig.java create mode 100644 src/main/java/org/example/controller/CustomerController.java create mode 100644 src/main/java/org/example/controller/ProductController.java create mode 100644 src/main/java/org/example/exception/CustomerNotFoundException.java create mode 100644 src/main/java/org/example/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/org/example/exception/InvalidPriceRangeException.java create mode 100644 src/main/java/org/example/exception/ProductNotFoundException.java create mode 100644 src/main/java/org/example/model/Customer.java create mode 100644 src/main/java/org/example/model/Product.java create mode 100644 src/main/java/org/example/service/CustomerService.java create mode 100644 src/main/java/org/example/service/ProductService.java create mode 100644 src/main/resources/application.properties create mode 100644 src/test/java/org/example/controller/CustomerControllerTest.java create mode 100644 src/test/java/org/example/controller/ProductControllerTest.java diff --git a/README.md b/README.md index 3e441c9..087c608 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,129 @@ -![logo_ironhack_blue 7](https://user-images.githubusercontent.com/23629340/40541063-a07a0a8a-601a-11e8-91b5-2f13e4e6b441.png) - -# LAB | SpringBoot REST API - -### Instructions - -1. Fork this repo. -2. Clone your fork to your local machine. -3. Solve the challenges. - - -## Deliverables - -- Upon completion, add your solution to git. -- Then commit to git and push to your repo on GitHub. -- Make a pull request and paste the pull request link in the submission field in the Student Portal. - -## Tasks - -1. Create a Spring Boot application using Spring Initializr with the following dependencies: - - Spring Web - - Spring Boot DevTools - - Spring Boot Starter Validation - -2. Create a `Product` class with the following validated properties: - - name (not blank, min length 3) - - price (positive number) - - category (not blank) - - quantity (positive number) - -3. Create a `ProductService` class that manages a List of Products and has methods to: - - Add a new product - - Get all products - - Get product by name - - Update product - - Delete product - - Get products by category - - Get products by price range - -4. Create a `ProductController` class that: - - Uses constructor injection for the ProductService - - Requires an "API-Key" header for all requests (value: "123456") - - Has the following endpoints: - * POST `/products` - Create new product - * GET `/products` - Get all products - * GET `/products/{name}` - Get product by name - * PUT `/products/{name}` - Update product - * DELETE `/products/{name}` - Delete product - * GET `/products/category/{category}` - Get products by category - * GET `/products/price?min={min}&max={max}` - Get products by price range - -5. Create a global exception handler that handles: - - Validation errors (return proper error messages) - - Missing API-Key header - - Product not found - - Invalid price range - -6. Create a `Customer` class with the following validated properties: - - name (not blank) - - email (valid email format) - - age (minimum 18) - - address (not blank) - -7. Create a `CustomerController` with endpoints to: - - Create new customer (with validation) - - Get all customers - - Get customer by email - - Update customer - - Delete customer - -**Remember**: -- Use proper package structure -- Use constructor injection instead of @Autowired -- Test all endpoints using Postman -- Include appropriate error handling -- Use meaningful variable and method names -- Return appropriate HTTP status codes -- Include validation messages in responses +# Spring Boot REST API Lab 05 Final + +Aplicacion REST desarrollada con Spring Boot para gestionar productos y clientes en memoria. + +## Tecnologias + +- Java 17 +- Spring Boot 3.3.5 +- Spring Web +- Spring Boot DevTools +- Spring Boot Starter Validation +- Maven + +## Requisitos + +- API-Key obligatoria en todas las peticiones: `123456` +- Datos validados en productos y clientes +- Manejo global de errores con respuestas JSON + +## Estructura + +- `model`: clases `Product` y `Customer` +- `service`: logica de negocio en memoria +- `controller`: endpoints REST +- `config`: interceptor de API key +- `exception`: excepciones personalizadas y handler global + +## Endpoints + +### Products + +- `POST /products` +- `GET /products` +- `GET /products/{name}` +- `PUT /products/{name}` +- `DELETE /products/{name}` +- `GET /products/category/{category}` +- `GET /products/price?min={min}&max={max}` + +### Customers + +- `POST /customers` +- `GET /customers` +- `GET /customers/{email}` +- `PUT /customers/{email}` +- `DELETE /customers/{email}` + +## Ejemplo de headers + +```http +API-Key: 123456 +Content-Type: application/json +``` + +## Ejemplo de payloads + +### Product + +```json +{ + "name": "Laptop", + "price": 1200, + "category": "Tech", + "quantity": 5 +} +``` + +### Customer + +```json +{ + "name": "Ana", + "email": "ana@example.com", + "age": 30, + "address": "Madrid" +} +``` + +## Ejecutar el proyecto + +```bash +mvn spring-boot:run +``` + +La API queda disponible en: + +```text +http://localhost:8080 +``` + +## Ejecutar tests + +```bash +mvn test +``` + +## Validaciones implementadas + +### Product + +- `name`: obligatorio, minimo 3 caracteres +- `price`: numero positivo +- `category`: obligatoria +- `quantity`: numero positivo + +### Customer + +- `name`: obligatorio +- `email`: formato valido +- `age`: minimo 18 +- `address`: obligatoria + +## Manejo de errores + +La API devuelve respuestas con codigo HTTP adecuado para: + +- errores de validacion +- API-Key ausente +- API-Key invalida +- producto no encontrado +- cliente no encontrado +- rango de precios invalido +- parametros faltantes o con tipo invalido + +## Verificacion realizada + +- La aplicacion compila correctamente +- Los tests pasan correctamente +- Se verificaron endpoints clave en `localhost:8080` diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..16a3da6 --- /dev/null +++ b/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + + org.example + springboot-rest-api-lab05final + 0.0.1-SNAPSHOT + springboot-rest-api-lab05final + Spring Boot REST API lab solution + + + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/src/main/java/org/example/ProductApiApplication.java b/src/main/java/org/example/ProductApiApplication.java new file mode 100644 index 0000000..394f553 --- /dev/null +++ b/src/main/java/org/example/ProductApiApplication.java @@ -0,0 +1,12 @@ +package org.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ProductApiApplication { + + public static void main(String[] args) { + SpringApplication.run(ProductApiApplication.class, args); + } +} diff --git a/src/main/java/org/example/config/ApiKeyInterceptor.java b/src/main/java/org/example/config/ApiKeyInterceptor.java new file mode 100644 index 0000000..ef05d71 --- /dev/null +++ b/src/main/java/org/example/config/ApiKeyInterceptor.java @@ -0,0 +1,28 @@ +package org.example.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class ApiKeyInterceptor implements HandlerInterceptor { + + private static final String API_KEY_HEADER = "API-Key"; + private static final String EXPECTED_API_KEY = "123456"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String apiKey = request.getHeader(API_KEY_HEADER); + + if (apiKey == null || apiKey.isBlank()) { + throw new MissingApiKeyException("Falta el header API-Key"); + } + + if (!EXPECTED_API_KEY.equals(apiKey)) { + throw new InvalidApiKeyException("API-Key invalida"); + } + + return true; + } +} diff --git a/src/main/java/org/example/config/InvalidApiKeyException.java b/src/main/java/org/example/config/InvalidApiKeyException.java new file mode 100644 index 0000000..a6885d0 --- /dev/null +++ b/src/main/java/org/example/config/InvalidApiKeyException.java @@ -0,0 +1,8 @@ +package org.example.config; + +public class InvalidApiKeyException extends RuntimeException { + + public InvalidApiKeyException(String message) { + super(message); + } +} diff --git a/src/main/java/org/example/config/MissingApiKeyException.java b/src/main/java/org/example/config/MissingApiKeyException.java new file mode 100644 index 0000000..2e92a17 --- /dev/null +++ b/src/main/java/org/example/config/MissingApiKeyException.java @@ -0,0 +1,8 @@ +package org.example.config; + +public class MissingApiKeyException extends RuntimeException { + + public MissingApiKeyException(String message) { + super(message); + } +} diff --git a/src/main/java/org/example/config/WebConfig.java b/src/main/java/org/example/config/WebConfig.java new file mode 100644 index 0000000..4897e6d --- /dev/null +++ b/src/main/java/org/example/config/WebConfig.java @@ -0,0 +1,20 @@ +package org.example.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final ApiKeyInterceptor apiKeyInterceptor; + + public WebConfig(ApiKeyInterceptor apiKeyInterceptor) { + this.apiKeyInterceptor = apiKeyInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(apiKeyInterceptor); + } +} diff --git a/src/main/java/org/example/controller/CustomerController.java b/src/main/java/org/example/controller/CustomerController.java new file mode 100644 index 0000000..0ede271 --- /dev/null +++ b/src/main/java/org/example/controller/CustomerController.java @@ -0,0 +1,55 @@ +package org.example.controller; + +import jakarta.validation.Valid; +import org.example.model.Customer; +import org.example.service.CustomerService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/customers") +public class CustomerController { + + private final CustomerService customerService; + + public CustomerController(CustomerService customerService) { + this.customerService = customerService; + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public Customer createCustomer(@Valid @RequestBody Customer customer) { + return customerService.createCustomer(customer); + } + + @GetMapping + public List getAllCustomers() { + return customerService.getAllCustomers(); + } + + @GetMapping("/{email}") + public Customer getCustomerByEmail(@PathVariable String email) { + return customerService.getCustomerByEmail(email); + } + + @PutMapping("/{email}") + public Customer updateCustomer(@PathVariable String email, @Valid @RequestBody Customer customer) { + return customerService.updateCustomer(email, customer); + } + + @DeleteMapping("/{email}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteCustomer(@PathVariable String email) { + customerService.deleteCustomer(email); + } +} diff --git a/src/main/java/org/example/controller/ProductController.java b/src/main/java/org/example/controller/ProductController.java new file mode 100644 index 0000000..15aad3a --- /dev/null +++ b/src/main/java/org/example/controller/ProductController.java @@ -0,0 +1,66 @@ +package org.example.controller; + +import jakarta.validation.Valid; +import org.example.model.Product; +import org.example.service.ProductService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/products") +public class ProductController { + + private final ProductService productService; + + public ProductController(ProductService productService) { + this.productService = productService; + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public Product createProduct(@Valid @RequestBody Product product) { + return productService.addProduct(product); + } + + @GetMapping + public List getAllProducts() { + return productService.getAllProducts(); + } + + @GetMapping("/{name}") + public Product getProductByName(@PathVariable String name) { + return productService.getProductByName(name); + } + + @PutMapping("/{name}") + public Product updateProduct(@PathVariable String name, @Valid @RequestBody Product product) { + return productService.updateProduct(name, product); + } + + @DeleteMapping("/{name}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteProduct(@PathVariable String name) { + productService.deleteProduct(name); + } + + @GetMapping("/category/{category}") + public List getProductsByCategory(@PathVariable String category) { + return productService.getProductsByCategory(category); + } + + @GetMapping("/price") + public List getProductsByPriceRange(@RequestParam double min, @RequestParam double max) { + return productService.getProductsByPriceRange(min, max); + } +} diff --git a/src/main/java/org/example/exception/CustomerNotFoundException.java b/src/main/java/org/example/exception/CustomerNotFoundException.java new file mode 100644 index 0000000..c3eb0ab --- /dev/null +++ b/src/main/java/org/example/exception/CustomerNotFoundException.java @@ -0,0 +1,8 @@ +package org.example.exception; + +public class CustomerNotFoundException extends RuntimeException { + + public CustomerNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/org/example/exception/GlobalExceptionHandler.java b/src/main/java/org/example/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..a2f695b --- /dev/null +++ b/src/main/java/org/example/exception/GlobalExceptionHandler.java @@ -0,0 +1,85 @@ +package org.example.exception; + +import org.example.config.InvalidApiKeyException; +import org.example.config.MissingApiKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationErrors(MethodArgumentNotValidException exception) { + Map errors = new LinkedHashMap<>(); + + for (FieldError fieldError : exception.getBindingResult().getFieldErrors()) { + errors.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + + return buildResponse(HttpStatus.BAD_REQUEST, "Error de validacion", errors); + } + + @ExceptionHandler(MissingApiKeyException.class) + public ResponseEntity> handleMissingApiKey(MissingApiKeyException exception) { + return buildResponse(HttpStatus.UNAUTHORIZED, exception.getMessage(), null); + } + + @ExceptionHandler(InvalidApiKeyException.class) + public ResponseEntity> handleInvalidApiKey(InvalidApiKeyException exception) { + return buildResponse(HttpStatus.UNAUTHORIZED, exception.getMessage(), null); + } + + @ExceptionHandler(ProductNotFoundException.class) + public ResponseEntity> handleProductNotFound(ProductNotFoundException exception) { + return buildResponse(HttpStatus.NOT_FOUND, exception.getMessage(), null); + } + + @ExceptionHandler(CustomerNotFoundException.class) + public ResponseEntity> handleCustomerNotFound(CustomerNotFoundException exception) { + return buildResponse(HttpStatus.NOT_FOUND, exception.getMessage(), null); + } + + @ExceptionHandler(InvalidPriceRangeException.class) + public ResponseEntity> handleInvalidPriceRange(InvalidPriceRangeException exception) { + return buildResponse(HttpStatus.BAD_REQUEST, exception.getMessage(), null); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingRequestParameter(MissingServletRequestParameterException exception) { + return buildResponse(HttpStatus.BAD_REQUEST, "Falta el parametro requerido: " + exception.getParameterName(), null); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleTypeMismatch(MethodArgumentTypeMismatchException exception) { + return buildResponse(HttpStatus.BAD_REQUEST, "Valor invalido para el parametro: " + exception.getName(), null); + } + + @ExceptionHandler(MissingRequestHeaderException.class) + public ResponseEntity> handleMissingHeader(MissingRequestHeaderException exception) { + return buildResponse(HttpStatus.BAD_REQUEST, "Falta el header requerido: " + exception.getHeaderName(), null); + } + + private ResponseEntity> buildResponse(HttpStatus status, String message, Object errors) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", status.value()); + body.put("message", message); + + if (errors != null) { + body.put("errors", errors); + } + + return ResponseEntity.status(status).body(body); + } +} diff --git a/src/main/java/org/example/exception/InvalidPriceRangeException.java b/src/main/java/org/example/exception/InvalidPriceRangeException.java new file mode 100644 index 0000000..0422399 --- /dev/null +++ b/src/main/java/org/example/exception/InvalidPriceRangeException.java @@ -0,0 +1,8 @@ +package org.example.exception; + +public class InvalidPriceRangeException extends RuntimeException { + + public InvalidPriceRangeException(String message) { + super(message); + } +} diff --git a/src/main/java/org/example/exception/ProductNotFoundException.java b/src/main/java/org/example/exception/ProductNotFoundException.java new file mode 100644 index 0000000..e00cb7c --- /dev/null +++ b/src/main/java/org/example/exception/ProductNotFoundException.java @@ -0,0 +1,8 @@ +package org.example.exception; + +public class ProductNotFoundException extends RuntimeException { + + public ProductNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/org/example/model/Customer.java b/src/main/java/org/example/model/Customer.java new file mode 100644 index 0000000..0aee88d --- /dev/null +++ b/src/main/java/org/example/model/Customer.java @@ -0,0 +1,63 @@ +package org.example.model; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; + +public class Customer { + + @NotBlank(message = "El nombre es obligatorio") + private String name; + + @NotBlank(message = "El email es obligatorio") + @Email(message = "El email debe tener un formato valido") + private String email; + + @Min(value = 18, message = "La edad minima permitida es 18") + private int age; + + @NotBlank(message = "La direccion es obligatoria") + private String address; + + public Customer() { + } + + public Customer(String name, String email, int age, String address) { + this.name = name; + this.email = email; + this.age = age; + this.address = address; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public int getAge() { + return age; + } + + public void setAge(int age) { + this.age = age; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } +} diff --git a/src/main/java/org/example/model/Product.java b/src/main/java/org/example/model/Product.java new file mode 100644 index 0000000..5de937e --- /dev/null +++ b/src/main/java/org/example/model/Product.java @@ -0,0 +1,64 @@ +package org.example.model; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public class Product { + + @NotBlank(message = "El nombre es obligatorio") + @Size(min = 3, message = "El nombre debe tener al menos 3 caracteres") + private String name; + + @DecimalMin(value = "0.01", inclusive = true, message = "El precio debe ser un numero positivo") + private double price; + + @NotBlank(message = "La categoria es obligatoria") + private String category; + + @Min(value = 1, message = "La cantidad debe ser un numero positivo") + private int quantity; + + public Product() { + } + + public Product(String name, double price, String category, int quantity) { + this.name = name; + this.price = price; + this.category = category; + this.quantity = quantity; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public double getPrice() { + return price; + } + + public void setPrice(double price) { + this.price = price; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } + + public int getQuantity() { + return quantity; + } + + public void setQuantity(int quantity) { + this.quantity = quantity; + } +} diff --git a/src/main/java/org/example/service/CustomerService.java b/src/main/java/org/example/service/CustomerService.java new file mode 100644 index 0000000..2e00652 --- /dev/null +++ b/src/main/java/org/example/service/CustomerService.java @@ -0,0 +1,44 @@ +package org.example.service; + +import org.example.exception.CustomerNotFoundException; +import org.example.model.Customer; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class CustomerService { + + private final List customers = new ArrayList<>(); + + public Customer createCustomer(Customer customer) { + customers.add(customer); + return customer; + } + + public List getAllCustomers() { + return new ArrayList<>(customers); + } + + public Customer getCustomerByEmail(String email) { + return customers.stream() + .filter(customer -> customer.getEmail().equalsIgnoreCase(email)) + .findFirst() + .orElseThrow(() -> new CustomerNotFoundException("Cliente no encontrado: " + email)); + } + + public Customer updateCustomer(String email, Customer updatedCustomer) { + Customer existingCustomer = getCustomerByEmail(email); + existingCustomer.setName(updatedCustomer.getName()); + existingCustomer.setEmail(updatedCustomer.getEmail()); + existingCustomer.setAge(updatedCustomer.getAge()); + existingCustomer.setAddress(updatedCustomer.getAddress()); + return existingCustomer; + } + + public void deleteCustomer(String email) { + Customer customer = getCustomerByEmail(email); + customers.remove(customer); + } +} diff --git a/src/main/java/org/example/service/ProductService.java b/src/main/java/org/example/service/ProductService.java new file mode 100644 index 0000000..0fcfa00 --- /dev/null +++ b/src/main/java/org/example/service/ProductService.java @@ -0,0 +1,61 @@ +package org.example.service; + +import org.example.exception.InvalidPriceRangeException; +import org.example.exception.ProductNotFoundException; +import org.example.model.Product; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class ProductService { + + private final List products = new ArrayList<>(); + + public Product addProduct(Product product) { + products.add(product); + return product; + } + + public List getAllProducts() { + return new ArrayList<>(products); + } + + public Product getProductByName(String name) { + return products.stream() + .filter(product -> product.getName().equalsIgnoreCase(name)) + .findFirst() + .orElseThrow(() -> new ProductNotFoundException("Producto no encontrado: " + name)); + } + + public Product updateProduct(String name, Product updatedProduct) { + Product existingProduct = getProductByName(name); + existingProduct.setName(updatedProduct.getName()); + existingProduct.setPrice(updatedProduct.getPrice()); + existingProduct.setCategory(updatedProduct.getCategory()); + existingProduct.setQuantity(updatedProduct.getQuantity()); + return existingProduct; + } + + public void deleteProduct(String name) { + Product product = getProductByName(name); + products.remove(product); + } + + public List getProductsByCategory(String category) { + return products.stream() + .filter(product -> product.getCategory().equalsIgnoreCase(category)) + .toList(); + } + + public List getProductsByPriceRange(double min, double max) { + if (min < 0 || max < 0 || min > max) { + throw new InvalidPriceRangeException("El rango de precios es invalido"); + } + + return products.stream() + .filter(product -> product.getPrice() >= min && product.getPrice() <= max) + .toList(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..779e018 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=springboot-rest-api-lab05final diff --git a/src/test/java/org/example/controller/CustomerControllerTest.java b/src/test/java/org/example/controller/CustomerControllerTest.java new file mode 100644 index 0000000..90a7109 --- /dev/null +++ b/src/test/java/org/example/controller/CustomerControllerTest.java @@ -0,0 +1,86 @@ +package org.example.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.example.config.ApiKeyInterceptor; +import org.example.config.WebConfig; +import org.example.exception.GlobalExceptionHandler; +import org.example.model.Customer; +import org.example.service.CustomerService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(CustomerController.class) +@Import({CustomerService.class, ApiKeyInterceptor.class, WebConfig.class, GlobalExceptionHandler.class}) +class CustomerControllerTest { + + private static final String API_KEY = "123456"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private CustomerService customerService; + + @BeforeEach + void setUp() { + customerService.getAllCustomers().forEach(customer -> customerService.deleteCustomer(customer.getEmail())); + } + + @Test + void shouldCreateCustomerWhenBodyIsValid() throws Exception { + Customer customer = new Customer("Ana", "ana@example.com", 30, "Madrid"); + + mockMvc.perform(post("/customers") + .header("API-Key", API_KEY) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(customer))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.email").value("ana@example.com")); + } + + @Test + void shouldReturnNotFoundForMissingCustomer() throws Exception { + mockMvc.perform(get("/customers/noone@example.com") + .header("API-Key", API_KEY)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").value("Cliente no encontrado: noone@example.com")); + } + + @Test + void shouldValidateCustomerPayload() throws Exception { + Customer customer = new Customer("", "correo-invalido", 16, ""); + + mockMvc.perform(post("/customers") + .header("API-Key", API_KEY) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(customer))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errors.name").exists()) + .andExpect(jsonPath("$.errors.email").exists()) + .andExpect(jsonPath("$.errors.age").exists()) + .andExpect(jsonPath("$.errors.address").exists()); + } + + @Test + void shouldDeleteCustomer() throws Exception { + customerService.createCustomer(new Customer("Luis", "luis@example.com", 28, "Sevilla")); + + mockMvc.perform(delete("/customers/luis@example.com") + .header("API-Key", API_KEY)) + .andExpect(status().isNoContent()); + } +} diff --git a/src/test/java/org/example/controller/ProductControllerTest.java b/src/test/java/org/example/controller/ProductControllerTest.java new file mode 100644 index 0000000..23b9a83 --- /dev/null +++ b/src/test/java/org/example/controller/ProductControllerTest.java @@ -0,0 +1,89 @@ +package org.example.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.example.config.ApiKeyInterceptor; +import org.example.config.WebConfig; +import org.example.exception.GlobalExceptionHandler; +import org.example.model.Product; +import org.example.service.ProductService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(ProductController.class) +@Import({ProductService.class, ApiKeyInterceptor.class, WebConfig.class, GlobalExceptionHandler.class}) +class ProductControllerTest { + + private static final String API_KEY = "123456"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private ProductService productService; + + @BeforeEach + void setUp() { + productService.getAllProducts().forEach(product -> productService.deleteProduct(product.getName())); + } + + @Test + void shouldCreateProductWhenApiKeyAndBodyAreValid() throws Exception { + Product product = new Product("Laptop", 1200.0, "Tech", 5); + + mockMvc.perform(post("/products") + .header("API-Key", API_KEY) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(product))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Laptop")) + .andExpect(jsonPath("$.price").value(1200.0)) + .andExpect(jsonPath("$.category").value("Tech")) + .andExpect(jsonPath("$.quantity").value(5)); + } + + @Test + void shouldRejectRequestWithoutApiKey() throws Exception { + mockMvc.perform(get("/products")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("Falta el header API-Key")); + } + + @Test + void shouldRejectInvalidProductBody() throws Exception { + Product product = new Product("", -10.0, "", 0); + + mockMvc.perform(post("/products") + .header("API-Key", API_KEY) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(product))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Error de validacion")) + .andExpect(jsonPath("$.errors.name").exists()) + .andExpect(jsonPath("$.errors.price").exists()) + .andExpect(jsonPath("$.errors.category").exists()) + .andExpect(jsonPath("$.errors.quantity").exists()); + } + + @Test + void shouldRejectInvalidPriceRange() throws Exception { + mockMvc.perform(get("/products/price") + .header("API-Key", API_KEY) + .param("min", "100") + .param("max", "50")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("El rango de precios es invalido")); + } +}