BAEL-5630: Spring Boot 3 Sample (#12449)

* BAEL-5630: Spring Boot 3 Sample

* BAEL-5630: Reorganize project in Maven profiles

* BAEL-5630: Upgrade Maven PMD plugin version

* BAEL-5630: Rename tests to follow naming conventions
This commit is contained in:
Ralf Ueberfuhr
2022-07-28 21:04:26 +02:00
committed by GitHub
parent c0d5df745e
commit 33c18f2cd5
28 changed files with 1240 additions and 0 deletions
@@ -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);
}
}
@@ -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());
}
};
}
}
@@ -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:
*
* <pre>
* server:
* endpoints:
* api:
* v1: /api/v1
* </pre>
*/
@Configuration
@ConfigurationProperties(prefix = "cors.allow")
@Data
public class CorsConfigurationData {
private String[] origins = { "*" };
private boolean credentials = false;
}
@@ -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() {}
}
@@ -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;
};
}
}
@@ -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;
}
@@ -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;
}
@@ -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<TodoResponseDto> 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<TodoResponseDto> 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);
}
}
@@ -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;
}
@@ -0,0 +1,5 @@
package com.baeldung.sample.control;
public class NotFoundException extends RuntimeException {
}
@@ -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);
}
}
@@ -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);
}
@@ -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!"));
}
}
}
@@ -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<Todo> 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<Todo> 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();
}
}
}
@@ -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;
}
}
@@ -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<TodoEntity, Long> {
}
@@ -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}