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:
+11
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+42
@@ -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());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
+25
@@ -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;
|
||||
|
||||
}
|
||||
+22
@@ -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() {}
|
||||
|
||||
}
|
||||
+36
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
+15
@@ -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;
|
||||
|
||||
}
|
||||
+83
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+14
@@ -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;
|
||||
|
||||
}
|
||||
+5
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+16
@@ -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);
|
||||
|
||||
}
|
||||
+28
@@ -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!"));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+103
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+53
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+9
@@ -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}
|
||||
Reference in New Issue
Block a user