diff --git a/parent-boot-3/README.md b/parent-boot-3/README.md new file mode 100644 index 0000000000..738d97bdfd --- /dev/null +++ b/parent-boot-3/README.md @@ -0,0 +1,3 @@ +## Parent Boot 2 + +This is a parent module for all projects using Spring Boot 3. diff --git a/parent-boot-3/pom.xml b/parent-boot-3/pom.xml new file mode 100644 index 0000000000..711096fec8 --- /dev/null +++ b/parent-boot-3/pom.xml @@ -0,0 +1,97 @@ + + + 4.0.0 + parent-boot-3 + 0.0.1-SNAPSHOT + parent-boot-3 + pom + Parent for all Spring Boot 3 modules + + + com.baeldung + parent-modules + 1.0.0-SNAPSHOT + + + + + + org.junit + junit-bom + ${junit-jupiter.version} + pom + import + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + ${start-class} + + + + + + repackage + + + + + + + + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + + + 3.0.0-M3 + 5.8.2 + 3.0.0-M7 + 1.18.22 + 17 + 3.17.0 + + + diff --git a/pom.xml b/pom.xml index 6f727d0dd0..a4f6a744ea 100644 --- a/pom.xml +++ b/pom.xml @@ -1252,6 +1252,7 @@ quarkus-modules/quarkus-jandex spring-boot-modules/spring-boot-cassandre spring-boot-modules/spring-boot-camel + spring-boot-modules/spring-boot-3 testing-modules/testing-assertions persistence-modules/fauna lightrun diff --git a/spring-boot-modules/spring-boot-3/pom.xml b/spring-boot-modules/spring-boot-3/pom.xml new file mode 100644 index 0000000000..556168e205 --- /dev/null +++ b/spring-boot-modules/spring-boot-3/pom.xml @@ -0,0 +1,128 @@ + + + 4.0.0 + + com.baeldung + parent-boot-3 + 0.0.1-SNAPSHOT + ../../parent-boot-3 + + spring-boot-3-sample + 0.0.1-SNAPSHOT + spring-boot-3-sample + Demo project for Spring Boot + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-hateoas + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + + runtime + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.projectlombok + lombok + true + + + org.mapstruct + mapstruct + ${mapstruct.version} + true + + + org.springframework.boot + spring-boot-starter-test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + org.projectlombok + lombok + ${lombok.version} + + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + 1.5.2.Final + com.baeldung.sample.TodoApplication + + + diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/TodoApplication.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/TodoApplication.java new file mode 100644 index 0000000000..72c9c0e482 --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/TodoApplication.java @@ -0,0 +1,11 @@ +package com.baeldung.sample; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +@SpringBootApplication +public class TodoApplication { + public static void main(String[] args) { + SpringApplication.run(TodoApplication.class, args); + } + +} diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/CorsConfiguration.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/CorsConfiguration.java new file mode 100644 index 0000000000..e02e3d5442 --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/CorsConfiguration.java @@ -0,0 +1,42 @@ +package com.baeldung.sample.boundary; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import static java.util.Arrays.stream; +import static org.springframework.http.HttpHeaders.ACCEPT; +import static org.springframework.http.HttpHeaders.ACCEPT_LANGUAGE; +import static org.springframework.http.HttpHeaders.CONTENT_LANGUAGE; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; +import static org.springframework.http.HttpHeaders.IF_MATCH; +import static org.springframework.http.HttpHeaders.IF_NONE_MATCH; +import static org.springframework.http.HttpHeaders.LINK; +import static org.springframework.http.HttpHeaders.LOCATION; +import static org.springframework.http.HttpHeaders.ORIGIN; + +@Configuration +public class CorsConfiguration { + + @Bean + public WebMvcConfigurer corsConfigurer(final CorsConfigurationData allowed) { + return new WebMvcConfigurer() { + + @Override + public void addCorsMappings(final CorsRegistry registry) { + registry.addMapping("/**") + .exposedHeaders(LOCATION, LINK) + // allow all HTTP request methods + .allowedMethods(stream(RequestMethod.values()).map(Enum::name).toArray(String[]::new)) // + // allow the commonly used headers + .allowedHeaders(ORIGIN, CONTENT_TYPE, CONTENT_LANGUAGE, ACCEPT, ACCEPT_LANGUAGE, IF_MATCH, IF_NONE_MATCH) // + // this is stage specific + .allowedOrigins(allowed.getOrigins()) + .allowCredentials(allowed.isCredentials()); + } + }; + } + +} diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/CorsConfigurationData.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/CorsConfigurationData.java new file mode 100644 index 0000000000..1c4ef55e6c --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/CorsConfigurationData.java @@ -0,0 +1,25 @@ +package com.baeldung.sample.boundary; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * The properties from application.yml. You can specify them by the following snippet: + * + *
+ * server:
+ *   endpoints:
+ *     api:
+ *       v1: /api/v1
+ * 
+ */ +@Configuration +@ConfigurationProperties(prefix = "cors.allow") +@Data +public class CorsConfigurationData { + + private String[] origins = { "*" }; + private boolean credentials = false; + +} diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/GlobalExceptionHandler.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/GlobalExceptionHandler.java new file mode 100644 index 0000000000..eb5435656e --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/GlobalExceptionHandler.java @@ -0,0 +1,22 @@ +package com.baeldung.sample.boundary; + +import com.baeldung.sample.control.NotFoundException; +import jakarta.validation.ValidationException; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(NotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + protected void handleNotFoundException() {} + + @ExceptionHandler({ValidationException.class, MethodArgumentNotValidException.class}) + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) + protected void handleValidationException() {} + +} diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoDtoMapper.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoDtoMapper.java new file mode 100644 index 0000000000..a870eade7b --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoDtoMapper.java @@ -0,0 +1,36 @@ +package com.baeldung.sample.boundary; + +import com.baeldung.sample.control.Todo; +import org.mapstruct.Mapper; + +import java.util.Locale; + +/** + * Dieser Mapper kopiert die Informationen zwischen den Schichten. + */ +@Mapper(componentModel = "spring") +interface TodoDtoMapper { + + TodoResponseDto map(Todo todo); + + default String _mapStatus(Todo.Status status) { + return switch (status) { + case NEW -> "new"; + case PROGRESS -> "progress"; + case COMPLETED -> "completed"; + case ARCHIVED -> "archived"; + }; + } + + Todo map(TodoRequestDto todo, Long id); + + default Todo.Status _mapStatus(String status) { + return null == status ? Todo.Status.NEW : switch (status) { + case "progress" -> Todo.Status.PROGRESS; + case "completed" -> Todo.Status.COMPLETED; + case "archived" -> Todo.Status.ARCHIVED; + default -> Todo.Status.NEW; + }; + } + +} diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoRequestDto.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoRequestDto.java new file mode 100644 index 0000000000..fb7e646772 --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoRequestDto.java @@ -0,0 +1,17 @@ +package com.baeldung.sample.boundary; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +import java.time.LocalDate; + +@Data +public class TodoRequestDto { + @NotBlank + private String title; + private String description; + private LocalDate dueDate; + @Pattern(regexp = "new|progress|completed|archived") + private String status; +} diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoResponseDto.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoResponseDto.java new file mode 100644 index 0000000000..140db7b296 --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodoResponseDto.java @@ -0,0 +1,15 @@ +package com.baeldung.sample.boundary; + +import lombok.Data; + +import java.time.LocalDate; + +@Data +public class TodoResponseDto { + private Long id; + private String title; + private String description; + private LocalDate dueDate; + private String status; + +} diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodosController.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodosController.java new file mode 100644 index 0000000000..7efa7dfee3 --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/boundary/TodosController.java @@ -0,0 +1,83 @@ +package com.baeldung.sample.boundary; + +import com.baeldung.sample.control.NotFoundException; +import com.baeldung.sample.control.TodosService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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.net.URI; +import java.util.Collection; +import java.util.stream.Collectors; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; +import static org.springframework.http.HttpStatus.NO_CONTENT; + +@RestController +@RequestMapping("/api/v1/todos") +@RequiredArgsConstructor +public class TodosController { + + private static final String DEFAULT_MEDIA_TYPE = MediaType.APPLICATION_JSON_VALUE; + + private final TodosService service; + // Mapping zwischen den Schichten + private final TodoDtoMapper mapper; + + @GetMapping(produces = DEFAULT_MEDIA_TYPE) + public Collection findAll() { + return service.findAll().stream() + .map(mapper::map) + .collect(Collectors.toList()); + } + + @GetMapping(value = "/{id}", produces = DEFAULT_MEDIA_TYPE) + public TodoResponseDto findById( + @PathVariable("id") final Long id + ) { + // Action + return service.findById(id) // + .map(mapper::map) // map to dto + .orElseThrow(NotFoundException::new); + } + + @PostMapping(consumes = DEFAULT_MEDIA_TYPE) + public ResponseEntity create(final @Valid @RequestBody TodoRequestDto item) { + // Action + final var todo = mapper.map(item, null); + final var newTodo = service.create(todo); + final var result = mapper.map(newTodo); + // Response + final URI locationHeader = linkTo(methodOn(TodosController.class).findById(result.getId())).toUri(); // HATEOAS + return ResponseEntity.created(locationHeader).body(result); + } + + @PutMapping(value = "{id}", consumes = DEFAULT_MEDIA_TYPE) + @ResponseStatus(NO_CONTENT) + public void update( + @PathVariable("id") final Long id, + @Valid @RequestBody final TodoRequestDto item + ) { + service.update(mapper.map(item, id)); + } + + @DeleteMapping("/{id}") + @ResponseStatus(NO_CONTENT) + public void delete( + @PathVariable("id") final Long id + ) { + service.delete(id); + } + +} diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/DataInitializationConfigurationData.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/DataInitializationConfigurationData.java new file mode 100644 index 0000000000..5871325714 --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/DataInitializationConfigurationData.java @@ -0,0 +1,14 @@ +package com.baeldung.sample.control; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "application.data") +@Data +public class DataInitializationConfigurationData { + + private boolean initializeOnStartup = true; + +} diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/NotFoundException.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/NotFoundException.java new file mode 100644 index 0000000000..b06b54f547 --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/NotFoundException.java @@ -0,0 +1,5 @@ +package com.baeldung.sample.control; + +public class NotFoundException extends RuntimeException { + +} diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/Todo.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/Todo.java new file mode 100644 index 0000000000..a972a9a21a --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/Todo.java @@ -0,0 +1,24 @@ +package com.baeldung.sample.control; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDate; + +public record Todo( + Long id, + @NotNull @Size(min = 1) String title, + String description, + LocalDate dueDate, + @NotNull Status status +) { + + public enum Status { + NEW, PROGRESS, COMPLETED, ARCHIVED + } + + public Todo(Long id, String title) { + this(id, title, null, null, Status.NEW); + } + +} diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodoEntityMapper.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodoEntityMapper.java new file mode 100644 index 0000000000..37a74f7f85 --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodoEntityMapper.java @@ -0,0 +1,16 @@ +package com.baeldung.sample.control; + +import com.baeldung.sample.entity.TodoEntity; +import org.mapstruct.Mapper; + +/** + * Dieser Mapper kopiert die Informationen zwischen den Schichten. + */ +@Mapper(componentModel = "spring") +interface TodoEntityMapper { + + TodoEntity map(Todo todo); + + Todo map(TodoEntity todo); + +} diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodosInitializer.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodosInitializer.java new file mode 100644 index 0000000000..0c7b76d165 --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodosInitializer.java @@ -0,0 +1,28 @@ +package com.baeldung.sample.control; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class TodosInitializer { + + private final TodosService service; + /* + * we cannot use @Profile("default") because + * we are not able inject the bean during test + * depending from the profile activation + */ + private final DataInitializationConfigurationData config; + + @EventListener(ContextRefreshedEvent.class) + public void initializeTodos() { + if (this.config.isInitializeOnStartup() && this.service.count() < 1) { + this.service.create(new Todo(null, "Deploy and run the application.")); + this.service.create(new Todo(null, "Enter some TODO items!")); + } + } + +} diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodosService.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodosService.java new file mode 100644 index 0000000000..e10ef6440f --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/control/TodosService.java @@ -0,0 +1,103 @@ +package com.baeldung.sample.control; + +import com.baeldung.sample.entity.TodosRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.Optional; + +import static java.util.stream.Collectors.toList; + +/** + * Ein Service ist ein Singleton auf Control Layer, der in der Boundary von mehreren (REST) Controllern gemeinsam genutzt werden kann. + * Dieser hat keinen Bezug mehr zu HTTP. + */ +@Service +@RequiredArgsConstructor +public class TodosService { + + private final TodoEntityMapper mapper; + private final TodosRepository repo; + + /** + * Gibt die Anzahl an Datensätzen zurück. + * @return die Anzahl an Datensätzen + */ + long count() { + return repo.count(); + } + + /** + * Gibt alle Todos zurück. + * + * @return eine unveränderliche Collection + */ + public Collection findAll() { + return repo.findAll().stream() + .map(mapper::map) + .collect(toList()); + } + + /** + * Durchsucht die Todos nach einer ID. + * + * @param id die ID + * @return das Suchergebnis + */ + public Optional findById(long id) { + return repo.findById(id) + .map(mapper::map); + } + + /** + * Fügt ein Item in den Datenbestand hinzu. Dabei wird eine ID generiert. + * + * @param item das anzulegende Item (ohne ID) + * @return das gespeicherte Item (mit ID) + * @throws IllegalArgumentException wenn das Item null oder die ID bereits belegt ist + */ + public Todo create(Todo item) { + if (null == item || null != item.id()) { + throw new IllegalArgumentException("item must exist without any id"); + } + return mapper.map(repo.save(mapper.map(item))); + } + + /** + * Aktualisiert ein Item im Datenbestand. + * + * @param item das zu ändernde Item mit ID + * @throws IllegalArgumentException + * wenn das Item oder dessen ID nicht belegt ist + * @throws NotFoundException + * wenn das Element mit der ID nicht gefunden wird + */ + public void update(Todo item) { + if (null == item || null == item.id()) { + throw new IllegalArgumentException("item must exist with an id"); + } + // remove separat, um nicht neue Einträge hinzuzufügen (put allein würde auch ersetzen) + if (repo.existsById(item.id())) { + repo.save(mapper.map(item)); + } else { + throw new NotFoundException(); + } + } + + /** + * Entfernt ein Item aus dem Datenbestand. + * + * @param id die ID des zu löschenden Items + * @throws NotFoundException + * wenn das Element mit der ID nicht gefunden wird + */ + public void delete(long id) { + if (repo.existsById(id)) { + repo.deleteById(id); + } else { + throw new NotFoundException(); + } + } + +} diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/entity/TodoEntity.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/entity/TodoEntity.java new file mode 100644 index 0000000000..e551876584 --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/entity/TodoEntity.java @@ -0,0 +1,53 @@ +package com.baeldung.sample.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.time.LocalDate; + +/* + * Auch hier wird eine separate Klasse erstellt. + * Und auch hier geht es wieder um Unabhängigkeit der beiden Layer (Control und Persistence). + * So kann z.B. die Auflösung von Fremdschlüsseln (assignee, priority, topic) in der Persistence Layer erfolgen (JPA unterstützt das), oder aber auch erst in der Control Layer. + */ +@Entity(name = "todo") +@Table(name = "todos") +// we do not use @Data because hashCode() and equals() might influence JPA's behaviour +@NoArgsConstructor +@Getter +@Setter +@ToString +public class TodoEntity { + + public enum StatusEntity { + NEW, PROGRESS, COMPLETED, ARCHIVED + } + + @GeneratedValue(strategy = GenerationType.AUTO) + @Id + private Long id; + @NotNull + @Size(min = 1) + private String title; + private String description; + private LocalDate dueDate; + @Enumerated + @NotNull + private StatusEntity status = StatusEntity.NEW; + + public TodoEntity(Long id, String title) { + this.id = id; + this.title = title; + } + +} diff --git a/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/entity/TodosRepository.java b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/entity/TodosRepository.java new file mode 100644 index 0000000000..9f305a61c2 --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/main/java/com/baeldung/sample/entity/TodosRepository.java @@ -0,0 +1,9 @@ +package com.baeldung.sample.entity; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TodosRepository extends JpaRepository { + +} diff --git a/spring-boot-modules/spring-boot-3/src/main/resources/application.yml b/spring-boot-modules/spring-boot-3/src/main/resources/application.yml new file mode 100644 index 0000000000..9a966a5bbd --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/main/resources/application.yml @@ -0,0 +1,21 @@ +spring: + mvc: + throw-exception-if-no-handler-found: true + jackson: + deserialization: + FAIL_ON_UNKNOWN_PROPERTIES: true + property-naming-strategy: SNAKE_CASE + jpa: + open-in-view: false + generate-ddl: true + show-sql: true + hibernate: + ddl-auto: update + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect +# Custom Properties +cors: + allow: + origins: ${CORS_ALLOWED_ORIGINS:*} + credentials: ${CORS_ALLOW_CREDENTIALS:false} diff --git a/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/boundary/TodosBoundaryLayer.java b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/boundary/TodosBoundaryLayer.java new file mode 100644 index 0000000000..39797bf2fb --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/boundary/TodosBoundaryLayer.java @@ -0,0 +1,13 @@ +package com.baeldung.sample.boundary; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.ComponentScan; + +/** + * This class bundles all classes in the boundary layer. + * This includes also the generated mapper classes. + */ +@TestConfiguration +@ComponentScan(basePackageClasses = TodosBoundaryLayer.class) +public class TodosBoundaryLayer { +} diff --git a/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/boundary/TodosControllerApiIntegrationTest.java b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/boundary/TodosControllerApiIntegrationTest.java new file mode 100644 index 0000000000..680b6c85bb --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/boundary/TodosControllerApiIntegrationTest.java @@ -0,0 +1,218 @@ +package com.baeldung.sample.boundary; + +import com.baeldung.sample.control.NotFoundException; +import com.baeldung.sample.control.Todo; +import com.baeldung.sample.control.TodosService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +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.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +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.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Integrationstests der Todos REST API auf HTTP Layer. Die Anwendung wird als Black Box behandelt. + * Somit müssen alle Tests, die einen existierenden Datensatz benötigen, diesen im Test anlegen. + * Spring Boot startet den sog. "ApplicationContext" automatisch und bietet Möglichkeiten, die Anwendungsteile vom Test aus aufzurufen. + */ +@WebMvcTest +@ContextConfiguration(classes = TodosBoundaryLayer.class) +class TodosControllerApiIntegrationTest { + + private static final String BASEURL = "/api/v1/todos"; // URL to Resource + private static final String DEFAULT_MEDIA_TYPE = MediaType.APPLICATION_JSON_VALUE; + + @MockBean + TodosService service; + @Autowired + MockMvc mvc; // testing by sending HTTP requests and verifying HTTP responses + @Autowired + ObjectMapper mapper; // used to render or parse JSON + + /* + * Testfall: + * - GET auf alle Todos -> 200 OK mit JSON + */ + @DisplayName("GET auf alle Daten (200 OK)") + @Test + void testFindAllTodos() throws Exception { + when(service.findAll()).thenReturn(List.of()); + mvc + .perform(get(BASEURL).accept(DEFAULT_MEDIA_TYPE)) + .andExpect(status().isOk()) + .andExpect(content().contentType(DEFAULT_MEDIA_TYPE)); + } + + /* + * Testfall: + * - einzelnes Todos auslesen -> kein Fehler + */ + @Test + void testFindById() throws Exception { + when(service.findById(1L)) + .thenReturn(Optional.of(new Todo(1L, "test"))); + mvc + .perform(get(BASEURL + "/1").accept(DEFAULT_MEDIA_TYPE)) + .andExpect(status().isOk()) + .andExpect(content().contentType(DEFAULT_MEDIA_TYPE)) + .andExpect(jsonPath("$.title").value("test")); + } + + /* + * Testfall: + * - einzelnes Todos auslesen -> 404 + */ + @Test + void testFindByIdNotExisting() throws Exception { + when(service.findById(1L)) + .thenReturn(Optional.empty()); + mvc + .perform(get(BASEURL + "/1").accept(DEFAULT_MEDIA_TYPE)) + .andExpect(status().isNotFound()); + } + + /* + * Testfall: + * - Anlegen eines Todos per POST -> 201 mit Location Header + */ + @DisplayName("POST liefert 201 mit Location-Header") + @Test + void testCreateTodo() throws Exception { + // etwas umständlich, die ID zu besetzen, wenn auf dem Service create() aufgerufen wird + when(service.create(any())).thenReturn(new Todo(5L, "test-todo")); + final var json = "{\"title\":\"test-todo\"}"; + mvc + .perform(post(BASEURL).contentType(DEFAULT_MEDIA_TYPE).content(json)) + .andExpect(status().isCreated()) + .andExpect(header().exists(HttpHeaders.LOCATION)) + .andExpect(jsonPath("$.id").value(5L)); + } + + /* + * Testfall: + * - Anlegen eines Todos per POST ohne Titel -> 422 + */ + @DisplayName("POST erzeugt kein Todo, wenn kein Titel angegeben ist") + @Test + void testCreateTodoWithoutTitle() throws Exception { + final var json = "{}"; + mvc + .perform(post(BASEURL).contentType(DEFAULT_MEDIA_TYPE).content(json)) + .andExpect(status().isUnprocessableEntity()); + verifyNoInteractions(service); + } + + /* + * Testfall: + * - Anlegen eines Todos per POST mit ID -> 400 + */ + @DisplayName("POST erzeugt kein Todo, wenn die ID mitgegeben wird (undefinierte Property)") + @Test + void testCreateTodoWithID() throws Exception { + final var json = "{\"id\":1, \"title\":\"test\"}"; + mvc + .perform(post(BASEURL).contentType(DEFAULT_MEDIA_TYPE).content(json)) + .andExpect(status().isBadRequest()); + verifyNoInteractions(service); + } + + /* + * Testfall: + * - Anlegen eines Todos per POST mit leerem Titel -> 400 + */ + @DisplayName("POST erzeugt kein Todo, wenn Titel weniger als 1 Zeichen hat") + @Test + void testCreateTodoWithEmptyTitle() throws Exception { + final var newTodo = new TodoRequestDto(); + newTodo.setTitle(""); + final String json = mapper.writeValueAsString(newTodo); + this.mvc // + .perform(post(BASEURL).contentType(DEFAULT_MEDIA_TYPE).content(json)) // + .andExpect(status().isUnprocessableEntity()); + } + + /* + * Testfall: + * - Ändern -> 204 + */ + @Test + void testUpdateTodo() throws Exception { + final var json = "{\"title\":\"test-todo\"}"; + mvc + .perform(put(BASEURL + "/5").contentType(DEFAULT_MEDIA_TYPE).content(json)) + .andExpect(status().isNoContent()); + } + + /* + * Testfall: + * - Ändern -> 404 + */ + @Test + void testUpdateTodoNotExisting() throws Exception { + doThrow(NotFoundException.class).when(service).update(any()); + final var json = "{\"title\":\"test-todo\"}"; + mvc + .perform(put(BASEURL + "/5").contentType(DEFAULT_MEDIA_TYPE).content(json)) + .andExpect(status().isNotFound()); + } + + /* + * Testfall: + * - Ändern des Todos mit leerem Titel + */ + @Test + @DisplayName("PUT mit leerem Titel") + void testUpdateWithEmptyTitle() throws Exception { + // Act + final var json = "{}"; + mvc + .perform(put(BASEURL + "/5").contentType(DEFAULT_MEDIA_TYPE).content(json)) + .andExpect(status().isUnprocessableEntity()); + verifyNoInteractions(service); + } + + /* + * Testfall: + * - Löschen -> 204 + */ + @Test + void testDeleteTodo() throws Exception { + mvc + .perform(delete(BASEURL + "/5").contentType(DEFAULT_MEDIA_TYPE)) + .andExpect(status().isNoContent()); + } + + /* + * Testfall: + * - Löschen -> 404 + */ + @Test + void testDeleteTodoNotExisting() throws Exception { + doThrow(NotFoundException.class).when(service).delete(anyLong()); + mvc + .perform(delete(BASEURL + "/5").contentType(DEFAULT_MEDIA_TYPE)) + .andExpect(status().isNotFound()); + } + +} diff --git a/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosControlLayer.java b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosControlLayer.java new file mode 100644 index 0000000000..ac07355eb9 --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosControlLayer.java @@ -0,0 +1,13 @@ +package com.baeldung.sample.control; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.ComponentScan; + +/** + * This class bundles all classes in the control layer. + * This includes also the generated mapper classes. + */ +@TestConfiguration +@ComponentScan(basePackageClasses = TodosControlLayer.class) +public class TodosControlLayer { +} diff --git a/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosInitializerActivationIntegrationTest.java b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosInitializerActivationIntegrationTest.java new file mode 100644 index 0000000000..ff28756e8e --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosInitializerActivationIntegrationTest.java @@ -0,0 +1,39 @@ +package com.baeldung.sample.control; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@SpringBootTest +class TodosInitializerActivationIntegrationTest { + + @MockBean + TodosService service; + + @BeforeEach + void serviceHasEmptyData() { + when(service.count()).thenReturn(0L); + } + + @AfterEach + void serviceHasNoFurtherInteractions() { + verifyNoMoreInteractions(service); + } + + @Test + @DisplayName("data initialization is invoked on default profile") + void testIsInvoked() { + verify(service).count(); + verify(service, atLeastOnce()).create(any()); + } + +} diff --git a/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosServiceDatabaseIntegrationTest.java b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosServiceDatabaseIntegrationTest.java new file mode 100644 index 0000000000..4344e4ec3f --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosServiceDatabaseIntegrationTest.java @@ -0,0 +1,63 @@ +package com.baeldung.sample.control; + +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.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@AutoConfigureTestDatabase +@Transactional +@TestPropertySource( + properties = { + "application.data.initialize-on-startup=false" + } +) +class TodosServiceDatabaseIntegrationTest { + + @Autowired + TodosService service; + + @BeforeEach + void assertEmpty() { + assertThat(service.count()) + .isZero(); + } + + @Test + void testCreate() { + service.create(new Todo(null, "test")); + assertThat(service.count()) + .isEqualTo(1); + assertThat(service.findAll()) + .hasSize(1) + .element(0).extracting(Todo::title).isEqualTo("test"); + } + + @Test + void testFindById() { + final var todo = service.create(new Todo(null, "test")); + final var result = service.findById(todo.id()); + assertThat(result) + .isNotEmpty() + .get().usingRecursiveComparison().isEqualTo(todo); + } + + @Test + void testDelete() { + final var todo = service.create(new Todo(null, "test")); + final var id = todo.id(); + service.delete(id); + final var result = service.findById(id); + assertThat(result).isEmpty(); + assertThatThrownBy(() -> service.delete(id)) + .isInstanceOf(NotFoundException.class); + } + +} diff --git a/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosServiceIntegrationTest.java b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosServiceIntegrationTest.java new file mode 100644 index 0000000000..49bf34ac80 --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/control/TodosServiceIntegrationTest.java @@ -0,0 +1,126 @@ +package com.baeldung.sample.control; + +import com.baeldung.sample.entity.TodoEntity; +import com.baeldung.sample.entity.TodosRepository; +import org.junit.jupiter.api.Test; +import org.mockito.Answers; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.refEq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +/** + * Integrationstests des TodosServices. Die Anwendung wird als Black Box behandelt. + * Somit müssen alle Tests, die einen existierenden Datensatz benötigen, diesen im Test anlegen. + */ +@SpringBootTest(classes = TodosControlLayer.class) +class TodosServiceIntegrationTest { + + // dieses Objekt wird als Mock instruiert + // Answers.RETURNS_MOCKS ist notwendig, da Methoden in Service-Init-Methode bereits aufgerufen werden + @MockBean(answer = Answers.RETURNS_MOCKS) + TodosRepository repo; + + @Autowired + TodosService service; + + /* + * Testfall: + * - alle Todos auslesen -> kein Fehler + */ + @Test + void testFindAllTodos() { + when(repo.findAll()).thenReturn(List.of(new TodoEntity(1L, "test"))); + final var result = service.findAll(); + assertThat(result).hasSize(1) // + .element(0).extracting(Todo::id, Todo::title).containsExactly(1L, "test"); + } + + /* + * Testfall: + * - Anlegen eines Todos -> ID besetzt + * - Auslesen -> gefunden mit entsprechenden Werten + */ + @Test + @SuppressWarnings("ConstantConditions") + void testCreateTodo() { + final var newTodo = new Todo(null, "test-todo"); + when(repo.save(any())).thenReturn(new TodoEntity(5L, "test-todo")); + // create + final var result = this.service.create(newTodo); + // find out id + assertThat(result).extracting(Todo::id).isEqualTo(5L); + verify(repo).save(refEq(new TodoEntity(null, "test-todo"))); + } + + /* + * Testfall: + * - Ändern eines bestehenden Todos + * - Aufruf der Repo-Methode und Rückgabewert prüfen + */ + @Test + @SuppressWarnings("ConstantConditions") + void testUpdateExisting() { + final var todo = new Todo(5L, "test-todo"); + when(repo.existsById(todo.id())).thenReturn(true); + // Test + this.service.update(todo); + // Assert + verify(repo).save(refEq(new TodoEntity(5L, "test-todo"))); + } + + /* + * Testfall: + * - Ändern eines nicht existenten Todos + * - Aufruf der Repo-Methode und Rückgabewert prüfen + */ + @Test + void testUpdateNotExisting() { + final var todo = new Todo(5L, "test-todo"); + when(repo.existsById(todo.id())).thenReturn(false); + // Test+Assert + assertThatThrownBy(() -> this.service.update(todo)) + .isInstanceOf(NotFoundException.class); + verify(repo).existsById(todo.id()); + verifyNoMoreInteractions(repo); + } + + /* + * Testfall: + * - Löschen eines existenten Todos + * - Aufruf der Repo-Methode und Rückgabewert prüfen + */ + @Test + void testDeleteExisting() { + when(repo.existsById(5L)).thenReturn(true); + // Test + this.service.delete(5L); + // Assert + verify(repo).deleteById(5L); + } + + /* + * Testfall: + * - Löschen eines nicht existenten Todos + * - Aufruf der Repo-Methode und Rückgabewert prüfen + */ + @Test + void testDeleteNotExisting() { + when(repo.existsById(5L)).thenReturn(false); + // Test+Assert + assertThatThrownBy(() -> this.service.delete(5L)) + .isInstanceOf(NotFoundException.class); + verify(repo).existsById(5L); + verifyNoMoreInteractions(repo); + } + +} diff --git a/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/shared/EnableBeanValidation.java b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/shared/EnableBeanValidation.java new file mode 100644 index 0000000000..5780ac46fe --- /dev/null +++ b/spring-boot-modules/spring-boot-3/src/test/java/com/baeldung/sample/shared/EnableBeanValidation.java @@ -0,0 +1,15 @@ +package com.baeldung.sample.shared; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.validation.beanvalidation.MethodValidationPostProcessor; + +@TestConfiguration +public class EnableBeanValidation { + + @Bean + public MethodValidationPostProcessor validator() { + return new MethodValidationPostProcessor(); + } + +}