From 5455f50e491f41fedae7bf0bd7d6ab19e1a47c39 Mon Sep 17 00:00:00 2001 From: Laura Castillo Date: Sun, 23 Aug 2026 15:44:18 -0500 Subject: [PATCH 1/3] feat: completar Fase A - configuracion de persistencia JPA y PostgreSQL con Docker --- docker-compose.yml | 18 ++++++ pom.xml | 13 +++++ .../blueprints/filters/IdentityFilter.java | 2 + .../eci/arsw/blueprints/model/Blueprint.java | 35 +++++++++++- .../arsw/blueprints/model/BlueprintId.java | 30 ++++++++++ .../edu/eci/arsw/blueprints/model/Point.java | 44 ++++++++++++++- .../InMemoryBlueprintPersistence.java | 2 + .../PostgresBlueprintPersistence.java | 56 +++++++++++++++++++ .../SpringDataBlueprintRepository.java | 17 ++++++ src/main/resources/application.yml | 22 ++++++++ .../arsw/blueprints/BlueprintsSmokeTest.java | 2 + src/test/resources/application-test.yml | 10 ++++ 12 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 docker-compose.yml create mode 100644 src/main/java/edu/eci/arsw/blueprints/model/BlueprintId.java create mode 100644 src/main/java/edu/eci/arsw/blueprints/persistence/PostgresBlueprintPersistence.java create mode 100644 src/main/java/edu/eci/arsw/blueprints/persistence/SpringDataBlueprintRepository.java create mode 100644 src/main/resources/application.yml create mode 100644 src/test/resources/application-test.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..56cc6cd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: "3.9" + +services: + postgres: + image: postgres:15 + container_name: blueprints-postgres + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: blueprintsdb + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: diff --git a/pom.xml b/pom.xml index 0a831ff..4d2bc82 100644 --- a/pom.xml +++ b/pom.xml @@ -36,11 +36,24 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.postgresql + postgresql + org.springframework.boot spring-boot-starter-test test + + com.h2database + h2 + test + diff --git a/src/main/java/edu/eci/arsw/blueprints/filters/IdentityFilter.java b/src/main/java/edu/eci/arsw/blueprints/filters/IdentityFilter.java index 0dc713e..7e3ff35 100644 --- a/src/main/java/edu/eci/arsw/blueprints/filters/IdentityFilter.java +++ b/src/main/java/edu/eci/arsw/blueprints/filters/IdentityFilter.java @@ -1,6 +1,7 @@ package edu.eci.arsw.blueprints.filters; import edu.eci.arsw.blueprints.model.Blueprint; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; /** @@ -8,6 +9,7 @@ * This matches the baseline behavior of the reference lab before students implement custom filters. */ @Component +@Profile({"identity", "default", "test"}) public class IdentityFilter implements BlueprintsFilter { @Override public Blueprint apply(Blueprint bp) { return bp; } diff --git a/src/main/java/edu/eci/arsw/blueprints/model/Blueprint.java b/src/main/java/edu/eci/arsw/blueprints/model/Blueprint.java index 4ea6a7e..f65d29a 100644 --- a/src/main/java/edu/eci/arsw/blueprints/model/Blueprint.java +++ b/src/main/java/edu/eci/arsw/blueprints/model/Blueprint.java @@ -1,15 +1,48 @@ package edu.eci.arsw.blueprints.model; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OrderColumn; +import jakarta.persistence.Table; + import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; +@Entity +@Table(name = "blueprints") +@IdClass(BlueprintId.class) public class Blueprint { + @Id + @Column(name = "author", nullable = false) private String author; + + @Id + @Column(name = "name", nullable = false) private String name; - private final List points = new ArrayList<>(); + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "blueprint_points", + joinColumns = { + @JoinColumn(name = "blueprint_author", referencedColumnName = "author"), + @JoinColumn(name = "blueprint_name", referencedColumnName = "name") + } + ) + @OrderColumn(name = "point_order") + private List points = new ArrayList<>(); + + protected Blueprint() { + // Required by JPA. + } public Blueprint(String author, String name, List pts) { this.author = author; diff --git a/src/main/java/edu/eci/arsw/blueprints/model/BlueprintId.java b/src/main/java/edu/eci/arsw/blueprints/model/BlueprintId.java new file mode 100644 index 0000000..1e590f5 --- /dev/null +++ b/src/main/java/edu/eci/arsw/blueprints/model/BlueprintId.java @@ -0,0 +1,30 @@ +package edu.eci.arsw.blueprints.model; + +import java.io.Serializable; +import java.util.Objects; + +public class BlueprintId implements Serializable { + + private String author; + private String name; + + public BlueprintId() { + } + + public BlueprintId(String author, String name) { + this.author = author; + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof BlueprintId that)) return false; + return Objects.equals(author, that.author) && Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(author, name); + } +} diff --git a/src/main/java/edu/eci/arsw/blueprints/model/Point.java b/src/main/java/edu/eci/arsw/blueprints/model/Point.java index a835947..5652f15 100644 --- a/src/main/java/edu/eci/arsw/blueprints/model/Point.java +++ b/src/main/java/edu/eci/arsw/blueprints/model/Point.java @@ -1,3 +1,45 @@ package edu.eci.arsw.blueprints.model; -public record Point(int x, int y) { } +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.Objects; + +@Embeddable +public class Point { + + @Column(name = "x", nullable = false) + private int x; + + @Column(name = "y", nullable = false) + private int y; + + protected Point() { + // Required by JPA. + } + + public Point(int x, int y) { + this.x = x; + this.y = y; + } + + public int x() { + return x; + } + + public int y() { + return y; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Point point)) return false; + return x == point.x && y == point.y; + } + + @Override + public int hashCode() { + return Objects.hash(x, y); + } +} diff --git a/src/main/java/edu/eci/arsw/blueprints/persistence/InMemoryBlueprintPersistence.java b/src/main/java/edu/eci/arsw/blueprints/persistence/InMemoryBlueprintPersistence.java index e3de378..320e5b1 100644 --- a/src/main/java/edu/eci/arsw/blueprints/persistence/InMemoryBlueprintPersistence.java +++ b/src/main/java/edu/eci/arsw/blueprints/persistence/InMemoryBlueprintPersistence.java @@ -2,6 +2,7 @@ import edu.eci.arsw.blueprints.model.Blueprint; import edu.eci.arsw.blueprints.model.Point; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Repository; import java.util.*; @@ -9,6 +10,7 @@ import java.util.stream.Collectors; @Repository +@Profile("inmemory") public class InMemoryBlueprintPersistence implements BlueprintPersistence { private final Map blueprints = new ConcurrentHashMap<>(); diff --git a/src/main/java/edu/eci/arsw/blueprints/persistence/PostgresBlueprintPersistence.java b/src/main/java/edu/eci/arsw/blueprints/persistence/PostgresBlueprintPersistence.java new file mode 100644 index 0000000..86e8de1 --- /dev/null +++ b/src/main/java/edu/eci/arsw/blueprints/persistence/PostgresBlueprintPersistence.java @@ -0,0 +1,56 @@ +package edu.eci.arsw.blueprints.persistence; + +import edu.eci.arsw.blueprints.model.Blueprint; +import edu.eci.arsw.blueprints.model.Point; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Repository; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Repository +@Primary +public class PostgresBlueprintPersistence implements BlueprintPersistence { + + private final SpringDataBlueprintRepository repository; + + public PostgresBlueprintPersistence(SpringDataBlueprintRepository repository) { + this.repository = repository; + } + + @Override + public void saveBlueprint(Blueprint bp) throws BlueprintPersistenceException { + if (repository.existsByAuthorAndName(bp.getAuthor(), bp.getName())) { + throw new BlueprintPersistenceException("Blueprint already exists: " + bp.getAuthor() + ":" + bp.getName()); + } + repository.save(bp); + } + + @Override + public Blueprint getBlueprint(String author, String name) throws BlueprintNotFoundException { + return repository.findByAuthorAndName(author, name) + .orElseThrow(() -> new BlueprintNotFoundException("Blueprint not found: %s/%s".formatted(author, name))); + } + + @Override + public Set getBlueprintsByAuthor(String author) throws BlueprintNotFoundException { + List byAuthor = repository.findAllByAuthor(author); + if (byAuthor.isEmpty()) { + throw new BlueprintNotFoundException("No blueprints for author: " + author); + } + return new HashSet<>(byAuthor); + } + + @Override + public Set getAllBlueprints() { + return new HashSet<>(repository.findAll()); + } + + @Override + public void addPoint(String author, String name, int x, int y) throws BlueprintNotFoundException { + Blueprint bp = getBlueprint(author, name); + bp.addPoint(new Point(x, y)); + repository.save(bp); + } +} diff --git a/src/main/java/edu/eci/arsw/blueprints/persistence/SpringDataBlueprintRepository.java b/src/main/java/edu/eci/arsw/blueprints/persistence/SpringDataBlueprintRepository.java new file mode 100644 index 0000000..8e583d0 --- /dev/null +++ b/src/main/java/edu/eci/arsw/blueprints/persistence/SpringDataBlueprintRepository.java @@ -0,0 +1,17 @@ +package edu.eci.arsw.blueprints.persistence; + +import edu.eci.arsw.blueprints.model.Blueprint; +import edu.eci.arsw.blueprints.model.BlueprintId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface SpringDataBlueprintRepository extends JpaRepository { + + Optional findByAuthorAndName(String author, String name); + + List findAllByAuthor(String author); + + boolean existsByAuthorAndName(String author, String name); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..8b9b575 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,22 @@ +spring: + profiles: + active: redundancy + + datasource: + url: jdbc:postgresql://localhost:5433/blueprintsdb + username: postgres + password: postgres + driver-class-name: org.postgresql.Driver + + jpa: + database-platform: org.hibernate.dialect.PostgreSQLDialect + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + + mvc: + pathmatch: + matching-strategy: ant_path_matcher diff --git a/src/test/java/edu/eci/arsw/blueprints/BlueprintsSmokeTest.java b/src/test/java/edu/eci/arsw/blueprints/BlueprintsSmokeTest.java index 0909fbb..bafb53a 100644 --- a/src/test/java/edu/eci/arsw/blueprints/BlueprintsSmokeTest.java +++ b/src/test/java/edu/eci/arsw/blueprints/BlueprintsSmokeTest.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class BlueprintsSmokeTest { @Test void contextLoads() {} } diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..5ded082 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,10 @@ +spring: + datasource: + url: jdbc:h2:mem:blueprintsdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: create-drop \ No newline at end of file From feb3d0cfce12b48a6760a3bb2516ee50a091f69e Mon Sep 17 00:00:00 2001 From: Miguel Angel Sandoval Date: Wed, 26 Aug 2026 20:11:27 -0500 Subject: [PATCH 2/3] Fase 2: ApiResponse, manejo global de errores, Swagger, path /api/v1 --- .../controllers/BlueprintsAPIController.java | 97 +++++++------ .../controllers/GlobalExceptionHandler.java | 50 +++++++ .../eci/arsw/blueprints/dto/ApiResponse.java | 24 ++++ src/main/resources/application.yml | 2 +- .../BlueprintsAPIControllerTest.java | 129 ++++++++++++++++++ 5 files changed, 261 insertions(+), 41 deletions(-) create mode 100644 src/main/java/edu/eci/arsw/blueprints/controllers/GlobalExceptionHandler.java create mode 100644 src/main/java/edu/eci/arsw/blueprints/dto/ApiResponse.java create mode 100644 src/test/java/edu/eci/arsw/blueprints/controllers/BlueprintsAPIControllerTest.java diff --git a/src/main/java/edu/eci/arsw/blueprints/controllers/BlueprintsAPIController.java b/src/main/java/edu/eci/arsw/blueprints/controllers/BlueprintsAPIController.java index 7080f29..622c6c7 100644 --- a/src/main/java/edu/eci/arsw/blueprints/controllers/BlueprintsAPIController.java +++ b/src/main/java/edu/eci/arsw/blueprints/controllers/BlueprintsAPIController.java @@ -1,80 +1,97 @@ package edu.eci.arsw.blueprints.controllers; +import edu.eci.arsw.blueprints.dto.ApiResponse; import edu.eci.arsw.blueprints.model.Blueprint; import edu.eci.arsw.blueprints.model.Point; import edu.eci.arsw.blueprints.persistence.BlueprintNotFoundException; import edu.eci.arsw.blueprints.persistence.BlueprintPersistenceException; import edu.eci.arsw.blueprints.services.BlueprintsServices; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.Map; +import java.util.List; import java.util.Set; @RestController -@RequestMapping("/blueprints") +@RequestMapping("/api/v1/blueprints") public class BlueprintsAPIController { private final BlueprintsServices services; - public BlueprintsAPIController(BlueprintsServices services) { this.services = services; } + public BlueprintsAPIController(BlueprintsServices services) { + this.services = services; + } - // GET /blueprints + @Operation(summary = "Obtener todos los blueprints") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Consulta exitosa") + }) @GetMapping - public ResponseEntity> getAll() { - return ResponseEntity.ok(services.getAllBlueprints()); + public ResponseEntity>> getAll() { + return ResponseEntity.ok(ApiResponse.ok(services.getAllBlueprints())); } - // GET /blueprints/{author} + @Operation(summary = "Obtener blueprints por autor") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Consulta exitosa"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Autor sin blueprints registrados", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + }) @GetMapping("/{author}") - public ResponseEntity byAuthor(@PathVariable String author) { - try { - return ResponseEntity.ok(services.getBlueprintsByAuthor(author)); - } catch (BlueprintNotFoundException e) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", e.getMessage())); - } + public ResponseEntity>> byAuthor(@PathVariable String author) + throws BlueprintNotFoundException { + return ResponseEntity.ok(ApiResponse.ok(services.getBlueprintsByAuthor(author))); } - // GET /blueprints/{author}/{bpname} + @Operation(summary = "Obtener un blueprint por autor y nombre") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Consulta exitosa"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Blueprint no encontrado") + }) @GetMapping("/{author}/{bpname}") - public ResponseEntity byAuthorAndName(@PathVariable String author, @PathVariable String bpname) { - try { - return ResponseEntity.ok(services.getBlueprint(author, bpname)); - } catch (BlueprintNotFoundException e) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", e.getMessage())); - } + public ResponseEntity> byAuthorAndName( + @PathVariable String author, @PathVariable String bpname) + throws BlueprintNotFoundException { + return ResponseEntity.ok(ApiResponse.ok(services.getBlueprint(author, bpname))); } - // POST /blueprints + @Operation(summary = "Crear un nuevo blueprint") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "Blueprint creado"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Datos inválidos o blueprint duplicado") + }) @PostMapping - public ResponseEntity add(@Valid @RequestBody NewBlueprintRequest req) { - try { - Blueprint bp = new Blueprint(req.author(), req.name(), req.points()); - services.addNewBlueprint(bp); - return ResponseEntity.status(HttpStatus.CREATED).build(); - } catch (BlueprintPersistenceException e) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of("error", e.getMessage())); - } + public ResponseEntity> add(@Valid @RequestBody NewBlueprintRequest req) + throws BlueprintPersistenceException { + Blueprint bp = new Blueprint(req.author(), req.name(), req.points()); + services.addNewBlueprint(bp); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.created(bp)); } - // PUT /blueprints/{author}/{bpname}/points + @Operation(summary = "Agregar un punto a un blueprint existente") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "202", description = "Actualización aceptada"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Blueprint no encontrado") + }) @PutMapping("/{author}/{bpname}/points") - public ResponseEntity addPoint(@PathVariable String author, @PathVariable String bpname, - @RequestBody Point p) { - try { - services.addPoint(author, bpname, p.x(), p.y()); - return ResponseEntity.status(HttpStatus.ACCEPTED).build(); - } catch (BlueprintNotFoundException e) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", e.getMessage())); - } + public ResponseEntity> addPoint( + @PathVariable String author, @PathVariable String bpname, @RequestBody Point p) + throws BlueprintNotFoundException { + services.addPoint(author, bpname, p.x(), p.y()); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(ApiResponse.accepted(null)); } public record NewBlueprintRequest( @NotBlank String author, @NotBlank String name, - @Valid java.util.List points + @Valid List points ) { } -} +} \ No newline at end of file diff --git a/src/main/java/edu/eci/arsw/blueprints/controllers/GlobalExceptionHandler.java b/src/main/java/edu/eci/arsw/blueprints/controllers/GlobalExceptionHandler.java new file mode 100644 index 0000000..1ebe5e6 --- /dev/null +++ b/src/main/java/edu/eci/arsw/blueprints/controllers/GlobalExceptionHandler.java @@ -0,0 +1,50 @@ +package edu.eci.arsw.blueprints.controllers; + +import edu.eci.arsw.blueprints.dto.ApiResponse; +import edu.eci.arsw.blueprints.persistence.BlueprintNotFoundException; +import edu.eci.arsw.blueprints.persistence.BlueprintPersistenceException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.stream.Collectors; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BlueprintNotFoundException.class) + public ResponseEntity> handleNotFound(BlueprintNotFoundException ex) { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error(HttpStatus.NOT_FOUND.value(), ex.getMessage())); + } + + @ExceptionHandler(BlueprintPersistenceException.class) + public ResponseEntity> handlePersistence(BlueprintPersistenceException ex) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), ex.getMessage())); + } + + // Errores de validación de @Valid en el body (por ejemplo @NotBlank en NewBlueprintRequest) + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining("; ")); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), message)); + } + + // Cualquier otra excepción no controlada + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneric(Exception ex) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Unexpected error: " + ex.getMessage())); + } +} \ No newline at end of file diff --git a/src/main/java/edu/eci/arsw/blueprints/dto/ApiResponse.java b/src/main/java/edu/eci/arsw/blueprints/dto/ApiResponse.java new file mode 100644 index 0000000..1592404 --- /dev/null +++ b/src/main/java/edu/eci/arsw/blueprints/dto/ApiResponse.java @@ -0,0 +1,24 @@ +package edu.eci.arsw.blueprints.dto; + +public record ApiResponse(int code, String message, T data) { + + public static ApiResponse of(int code, String message, T data) { + return new ApiResponse<>(code, message, data); + } + + public static ApiResponse ok(T data) { + return new ApiResponse<>(200, "execute ok", data); + } + + public static ApiResponse created(T data) { + return new ApiResponse<>(201, "resource created", data); + } + + public static ApiResponse accepted(T data) { + return new ApiResponse<>(202, "update accepted", data); + } + + public static ApiResponse error(int code, String message) { + return new ApiResponse<>(code, message, null); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8b9b575..8c69675 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,7 +3,7 @@ spring: active: redundancy datasource: - url: jdbc:postgresql://localhost:5433/blueprintsdb + url: jdbc:postgresql://localhost:5432/blueprintsdb username: postgres password: postgres driver-class-name: org.postgresql.Driver diff --git a/src/test/java/edu/eci/arsw/blueprints/controllers/BlueprintsAPIControllerTest.java b/src/test/java/edu/eci/arsw/blueprints/controllers/BlueprintsAPIControllerTest.java new file mode 100644 index 0000000..099d1c7 --- /dev/null +++ b/src/test/java/edu/eci/arsw/blueprints/controllers/BlueprintsAPIControllerTest.java @@ -0,0 +1,129 @@ +package edu.eci.arsw.blueprints.controllers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import edu.eci.arsw.blueprints.model.Blueprint; +import edu.eci.arsw.blueprints.model.Point; +import edu.eci.arsw.blueprints.persistence.BlueprintNotFoundException; +import edu.eci.arsw.blueprints.persistence.BlueprintPersistenceException; +import edu.eci.arsw.blueprints.services.BlueprintsServices; +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.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(BlueprintsAPIController.class) +class BlueprintsAPIControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private BlueprintsServices services; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void getAll_returns200AndWrappedResponse() throws Exception { + Blueprint bp = new Blueprint("john", "house", List.of(new Point(1, 1))); + when(services.getAllBlueprints()).thenReturn(Set.of(bp)); + + mockMvc.perform(get("/api/v1/blueprints")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").isArray()); + } + + @Test + void byAuthor_notFound_returns404() throws Exception { + when(services.getBlueprintsByAuthor("ghost")) + .thenThrow(new BlueprintNotFoundException("author not found")); + + mockMvc.perform(get("/api/v1/blueprints/ghost")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(404)) + .andExpect(jsonPath("$.message").value("author not found")); + } + + @Test + void byAuthorAndName_found_returns200() throws Exception { + Blueprint bp = new Blueprint("john", "house", List.of(new Point(1, 1))); + when(services.getBlueprint("john", "house")).thenReturn(bp); + + mockMvc.perform(get("/api/v1/blueprints/john/house")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.author").value("john")) + .andExpect(jsonPath("$.data.name").value("house")); + } + + @Test + void add_valid_returns201() throws Exception { + var req = new BlueprintsAPIController.NewBlueprintRequest( + "john", "kitchen", List.of(new Point(1, 1), new Point(2, 2))); + + mockMvc.perform(post("/api/v1/blueprints") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.code").value(201)); + + verify(services).addNewBlueprint(any(Blueprint.class)); + } + + @Test + void add_invalidBody_returns400() throws Exception { + // author en blanco -> viola @NotBlank + var req = new BlueprintsAPIController.NewBlueprintRequest("", "kitchen", List.of()); + + mockMvc.perform(post("/api/v1/blueprints") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + @Test + void add_duplicateBlueprint_returns400() throws Exception { + var req = new BlueprintsAPIController.NewBlueprintRequest( + "john", "kitchen", List.of(new Point(1, 1))); + + doThrow(new BlueprintPersistenceException("blueprint already exists")) + .when(services).addNewBlueprint(any(Blueprint.class)); + + mockMvc.perform(post("/api/v1/blueprints") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("blueprint already exists")); + } + + @Test + void addPoint_notFound_returns404() throws Exception { + doThrow(new BlueprintNotFoundException("blueprint not found")) + .when(services).addPoint(eq("john"), eq("ghost"), anyInt(), anyInt()); + + mockMvc.perform(put("/api/v1/blueprints/john/ghost/points") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new Point(3, 3)))) + .andExpect(status().isNotFound()); + } + + @Test + void addPoint_valid_returns202() throws Exception { + mockMvc.perform(put("/api/v1/blueprints/john/kitchen/points") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new Point(3, 3)))) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.code").value(202)); + } +} \ No newline at end of file From dcde14f82acb410025889f980a3ff874fc38e0fb Mon Sep 17 00:00:00 2001 From: Miguel sandoval Date: Wed, 26 Aug 2026 20:24:37 -0500 Subject: [PATCH 3/3] Update README.md --- README.md | 226 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 225 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 63f0bdf..d38b0d9 100644 --- a/README.md +++ b/README.md @@ -121,4 +121,228 @@ src/main/java/edu/eci/arsw/blueprints **Bonus**: - Imagen de contenedor (`spring-boot:build-image`). -- Métricas con Actuator. \ No newline at end of file +- Métricas con Actuator. + +--- + +# Laboratorio #3 – REST API Blueprints +## Escuela Colombiana de Ingeniería – Arquitecturas de Software +**Java 21 / Spring Boot 3.3.9** + +--- + +## 1. Integrantes + + +- Laura castill: Base de datos, persistencia en PostgreSQL y filtros +- Miguel Sandoval: API REST, manejo de errores, Swagger/OpenAPI y documentación + +--- + +## 2. Arquitectura del Proyecto + +El proyecto sigue una arquitectura por capas lógicas, lo que permite cambiar la fuente de persistencia o el mecanismo de exposición de la API sin afectar el resto del sistema: + +``` +src/main/java/edu/eci/arsw/blueprints + ├── model/ # Entidades de dominio: Blueprint, Point, BlueprintId + ├── persistence/ # Interfaz BlueprintPersistence + implementaciones (InMemory, Postgres) + ├── services/ # Lógica de negocio y orquestación (BlueprintsServices) + ├── filters/ # Filtros de procesamiento (Identity, Redundancy, Undersampling) + ├── controllers/ # REST Controllers + manejo global de errores (advice) + ├── dto/ # Contratos de la API (ApiResponse) + └── config/ # Configuración de Swagger/OpenAPI +``` + +- Al desacoplar `persistence` de `services` mediante la interfaz `BlueprintPersistence`, fue posible migrar de una implementación en memoria a PostgreSQL sin modificar la lógica de negocio ni el controlador. De igual forma, el `dto.ApiResponse` separa el contrato expuesto al cliente de las entidades JPA del dominio. + +--- + +## 3. Requisitos y Ejecución + +### Requisitos +- Java 21 +- Maven 3.9+ +- Docker y Docker Desktop + +### Levantar la base de datos + +```bash +docker-compose up -d +``` + +Esto levanta un contenedor de PostgreSQL 15 (`blueprints-postgres`) en el puerto `5432`, con la base de datos `blueprintsdb`. + +### Ejecutar la aplicación + +```bash +mvn clean install +mvn spring-boot:run +``` + +La aplicación arranca en `http://localhost:8080`. + +### Cambiar el filtro activo + +El filtro de puntos se activa mediante perfiles de Spring, configurado en `application.yml`: + +```yaml +spring: + profiles: + active: redundancy # o "undersampling" +``` + +- `redundancy` → activa `RedundancyFilter` (elimina puntos duplicados consecutivos). +- `undersampling` → activa `UndersamplingFilter` (conserva 1 de cada 2 puntos). + +--- + +## 4. Diseño de la API + +### Versionamiento +Todos los endpoints están bajo el path base **`/api/v1/blueprints`**. + +### Respuesta uniforme + +Todas las respuestas de la API —exitosas o no— siguen la misma estructura, definida en `dto.ApiResponse`: + +```java +public record ApiResponse(int code, String message, T data) {} +``` + +Ejemplo de respuesta exitosa: + +```json +{ + "code": 200, + "message": "execute ok", + "data": { + "author": "john", + "name": "house", + "points": [{"x": 1, "y": 1}, {"x": 2, "y": 2}] + } +} +``` + +### Endpoints + +| Método | Path | Descripción | Código éxito | +|---|---|---|---| +| GET | `/api/v1/blueprints` | Obtiene todos los blueprints | 200 | +| GET | `/api/v1/blueprints/{author}` | Obtiene los blueprints de un autor | 200 | +| GET | `/api/v1/blueprints/{author}/{bpname}` | Obtiene un blueprint específico | 200 | +| POST | `/api/v1/blueprints` | Crea un nuevo blueprint | 201 | +| PUT | `/api/v1/blueprints/{author}/{bpname}/points` | Agrega un punto a un blueprint existente | 202 | + +--- + +## 5. Manejo de Errores + +Se implementó un manejador global de excepciones con `@RestControllerAdvice` (`GlobalExceptionHandler`), que centraliza la traducción de excepciones de negocio a respuestas HTTP consistentes con `ApiResponse`. + +| Excepción | Código HTTP | Cuándo ocurre | +|---|---|---| +| `BlueprintNotFoundException` | 404 Not Found | Se consulta un autor/blueprint que no existe | +| `BlueprintPersistenceException` | 400 Bad Request | Se intenta crear un blueprint duplicado | +| `MethodArgumentNotValidException` | 400 Bad Request | El body de la petición no cumple las validaciones (`@NotBlank`, etc.) | +| `Exception` (genérica) | 500 Internal Server Error | Cualquier error no controlado | + +Esto evita tener bloques `try/catch` repetidos en el controlador: los métodos declaran `throws` y Spring enruta automáticamente la excepción al manejador correspondiente. + +--- + +## 6. Persistencia en PostgreSQL (Fase 1) + +### Modelo de datos + +- **`blueprints`**: clave compuesta (`author`, `name`). +- **`blueprint_points`**: tabla de puntos asociada a cada blueprint mediante `blueprint_author` y `blueprint_name`, con orden preservado (`point_order`). + +### Configuración de conexión + +```yaml +spring: + datasource: + url: jdbc:postgresql://localhost:5432/blueprintsdb + username: postgres + password: postgres + driver-class-name: org.postgresql.Driver + jpa: + database-platform: org.hibernate.dialect.PostgreSQLDialect + hibernate: + ddl-auto: update + show-sql: true +``` + +> Las credenciales mostradas son las de desarrollo local definidas en `docker-compose.yml`; no se usan en un entorno productivo real. + +--- + +## 7. Filtros de Blueprints + +- **`RedundancyFilter`**: elimina puntos duplicados consecutivos de un blueprint antes de devolverlo. +- **`UndersamplingFilter`**: conserva 1 de cada 2 puntos, reduciendo la resolución del trazo. +- Ambos implementan la interfaz `BlueprintsFilter` y se activan mediante los perfiles de Spring `redundancy` y `undersampling` respectivamente, configurables en `application.yml`. + +--- + +## 8. Documentación OpenAPI / Swagger + +La documentación de la API se genera automáticamente con `springdoc-openapi`, disponible en: + +- Swagger UI: `http://localhost:8080/swagger-ui.html` +- OpenAPI JSON: `http://localhost:8080/v3/api-docs` + +Cada endpoint está anotado con `@Operation` y `@ApiResponses`, documentando explícitamente los códigos de éxito y error posibles. + +Captura de pantalla 2026-08-26 200923 + + +--- + +## 9. Evidencias de Pruebas + +Se implementaron pruebas de la capa web (`BlueprintsAPIControllerTest`) usando `@WebMvcTest` y `MockMvc`, mockeando `BlueprintsServices` para aislar la prueba del controlador de la capa de persistencia real. Se cubren los casos: + +- Listado exitoso (200) +- Autor no encontrado (404) +- Consulta de blueprint existente (200) +- Creación exitosa (201) +- Validación de body inválido (400) +- Creación de blueprint duplicado (400) +- Actualización de punto exitosa (202) y sobre blueprint inexistente (404) + +Resultado de la ejecución (`mvn test`): + +``` +Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +Captura de pantalla 2026-08-26 201059 + + +--- + +## 10. Evidencias Funcionales + + +image + +image + +image + + +--- + +## 11. Buenas Prácticas Aplicadas + +- **Versionamiento de API**: path base `/api/v1/blueprints`, permitiendo evolucionar la API sin romper clientes existentes. +- **Separación de contratos y dominio**: `ApiResponse` como DTO de respuesta, independiente de las entidades JPA. +- **Manejo centralizado de errores**: un único `@RestControllerAdvice` en lugar de `try/catch` repetido en cada endpoint. +- **Documentación automática**: OpenAPI/Swagger generado desde anotaciones, siempre sincronizado con el código. +- **Pruebas automatizadas**: cobertura de la capa web con `MockMvc`, desacoplada de la base de datos real. +- **Persistencia desacoplada**: la interfaz `BlueprintPersistence` permite alternar entre almacenamiento en memoria y PostgreSQL sin tocar la lógica de negocio. + +---