diff --git a/docker-compose.yml b/docker-compose.yml index 7c2627c6a177b5b32b8a0a3867b7d28955e37d3f..4de6d88723015ce6d96923f73a7b4098c803faeb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,20 +34,6 @@ services: retries: 3 ports: - "5432:5432" - - flyway: - image: flyway/flyway:9-alpine - command: -connectRetries=60 -baselineVersion="0.0" baseline migrate info - volumes: - - ./flyway/sql:/flyway/sql:ro - environment: - - FLYWAY_URL=${JDBC_URL} - - FLYWAY_USER=${DATABASE_USERNAME} - - FLYWAY_PASSWORD=${DATABASE_PASSWORD} - depends_on: - - db - networks: - - db networks: db: diff --git a/pom.xml b/pom.xml index e105af5a7626687e88431591c776e283ca4d8e8f..49dae4a37672d5042cddc158db3b6f5fa551d7ba 100644 --- a/pom.xml +++ b/pom.xml @@ -23,6 +23,10 @@ <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-validation</artifactId> + </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-hateoas</artifactId> diff --git a/src/main/java/com/takima/entrainement/toDo/controllers/ToDoApi.java b/src/main/java/com/takima/entrainement/toDo/controllers/ToDoApi.java new file mode 100644 index 0000000000000000000000000000000000000000..a358df5e57a66764bbe98f4c30ff3fdfc5c7ae80 --- /dev/null +++ b/src/main/java/com/takima/entrainement/toDo/controllers/ToDoApi.java @@ -0,0 +1,99 @@ +package com.takima.entrainement.toDo.controllers; + + +import com.takima.entrainement.core.pagination.PageSearch; +import com.takima.entrainement.core.pagination.SearchSpecification; +import com.takima.entrainement.toDo.models.ToDo; +import com.takima.entrainement.toDo.services.ToDoService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.SortDefault; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.server.RepresentationModelAssembler; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.*; + +import java.util.NoSuchElementException; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; + +@RestController +@RequestMapping("/api/todos") +public class ToDoApi { + + private final ToDoService todoService; + private final ToDoResourceAssembler assembler; + + @Autowired + ToDoApi(ToDoService todoService, ToDoResourceAssembler assembler) { + this.todoService = todoService; + this.assembler = assembler; + } + + @GetMapping + public Page<ToDo> getAll(@RequestParam(required = false, defaultValue = "") String search, + @SortDefault Sort sort, + @RequestParam(required = false, defaultValue = "20") int limit, + @RequestParam(required = false, defaultValue = "0") int offset) { + + PageSearch<ToDo> pageSearch = PageSearch.<ToDo>builder() + .sort(sort) + .search(SearchSpecification.parse(search)) + .limit(limit) + .offset(offset) + .build(); + + return todoService.findAll(pageSearch); + } + + @GetMapping(value = "{todoId}") + public ToDo getOne(@PathVariable long todoId) { + ToDo todo = todoService.findById(todoId) + .orElseThrow(() -> new NoSuchElementException(String.format("no todo with id %d", todoId))); + return todo; + } + + @PostMapping + public ResponseEntity<EntityModel<ToDo>> create(@RequestBody ToDo todo) { + if (todo.getId() != null) { + throw new IllegalArgumentException("cannot create a todo and specify the ID"); + } + + todo = todoService.create(todo); + + return new ResponseEntity<>(assembler.toModel(todo), HttpStatus.CREATED); + } + + @PutMapping("/{todoId}") + public ResponseEntity<EntityModel<ToDo>> update(@RequestBody ToDo todo, @PathVariable long todoId) { + +// ToDo todoToUpdate = todoService.findById(todoId) +// .orElseThrow(() -> new NoSuchElementException(String.format("no todo with id %d", todoId))); + + todo.setId(todoId); + ToDo todoToUpdate=todoService.update(todo); + + return new ResponseEntity<>(assembler.toModel(todoToUpdate), HttpStatus.OK); + } + + @DeleteMapping(value = "{todoId}") + public void delete(@PathVariable long todoId) { + todoService.deleteById(todoId); + } + +} + +@Component +class ToDoResourceAssembler implements RepresentationModelAssembler<ToDo, EntityModel<ToDo>> { + + public EntityModel<ToDo> toModel(ToDo todo) { + + return EntityModel.of(todo, + linkTo(methodOn(ToDoApi.class).getOne(todo.getId())).withSelfRel()); + } + +} diff --git a/src/main/java/com/takima/entrainement/toDo/daos/DataJpaToDoDao.java b/src/main/java/com/takima/entrainement/toDo/daos/DataJpaToDoDao.java new file mode 100644 index 0000000000000000000000000000000000000000..81ec1f9d04d42ca683d6effe1b491f50d314ec4c --- /dev/null +++ b/src/main/java/com/takima/entrainement/toDo/daos/DataJpaToDoDao.java @@ -0,0 +1,19 @@ +package com.takima.entrainement.toDo.daos; + +import com.takima.entrainement.core.pagination.PageSearch; +import com.takima.entrainement.toDo.models.ToDo; +import org.springframework.data.domain.Page; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + + +public interface DataJpaToDoDao extends ToDoDao, JpaRepository<ToDo, Long>, JpaSpecificationExecutor<ToDo> { + + default Page<ToDo> findAll(PageSearch<ToDo> page) { + return findAll(page.getSearch(), page); + } + + default long count(PageSearch page) { + return count(page.getSearch()); + } +} diff --git a/src/main/java/com/takima/entrainement/toDo/daos/ToDoDao.java b/src/main/java/com/takima/entrainement/toDo/daos/ToDoDao.java new file mode 100644 index 0000000000000000000000000000000000000000..48d2dadc95377c7fc90d26cd1f3dbdcadbafc340 --- /dev/null +++ b/src/main/java/com/takima/entrainement/toDo/daos/ToDoDao.java @@ -0,0 +1,18 @@ +package com.takima.entrainement.toDo.daos; + + +import com.takima.entrainement.core.pagination.PageSearch; +import com.takima.entrainement.toDo.models.ToDo; +import org.springframework.data.domain.Page; + +import java.util.Optional; + +public interface ToDoDao { + Page<ToDo> findAll(PageSearch<ToDo> pageSearch); + + Optional<ToDo> findById(Long id); + + ToDo save(ToDo group); + + void deleteById(Long id); +} diff --git a/src/main/java/com/takima/entrainement/toDo/models/State.java b/src/main/java/com/takima/entrainement/toDo/models/State.java new file mode 100644 index 0000000000000000000000000000000000000000..5689d63a6d930e1815db72ebea40a24b148c05ef --- /dev/null +++ b/src/main/java/com/takima/entrainement/toDo/models/State.java @@ -0,0 +1,49 @@ +package com.takima.entrainement.toDo.models; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +public enum State { + TODO("todo"), + DONE("done"); + + public static final Map<String, State> lut = new HashMap<>(); + private final String code; + + State(String code) { + this.code = code; + } + + public static State fromCode(String code) { + return lut.computeIfAbsent(code, (s) -> Arrays.stream(values()) + .filter(c -> c.code.equals(code)) + .findAny() + .orElseThrow(() -> new IllegalArgumentException("no currency found for code " + code)) + + ); + } + + @Converter(autoApply = true) + public static class StateConverter + implements AttributeConverter<State, String> { + + public String convertToDatabaseColumn(State state) { + if (state == null) { + return null; + } + + return state.code; + } + + public State convertToEntityAttribute(String dbColumn) { + if (dbColumn == null) { + return null; + } + return State.fromCode(dbColumn); + } + } +} diff --git a/src/main/java/com/takima/entrainement/toDo/models/ToDo.java b/src/main/java/com/takima/entrainement/toDo/models/ToDo.java new file mode 100644 index 0000000000000000000000000000000000000000..0f1456938c456b755669fa3cc872a4f6fee36f5f --- /dev/null +++ b/src/main/java/com/takima/entrainement/toDo/models/ToDo.java @@ -0,0 +1,29 @@ +package com.takima.entrainement.toDo.models; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity +public class ToDo { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "to_do_id_seq") + private Long id; + + @Column + @NotBlank + private String name; + + @Column + private String description; + + @Column + @Convert(converter = State.StateConverter.class) + private State state = State.TODO; + +} diff --git a/src/main/java/com/takima/entrainement/toDo/services/ToDoService.java b/src/main/java/com/takima/entrainement/toDo/services/ToDoService.java new file mode 100644 index 0000000000000000000000000000000000000000..c6acb2e24fcadbf5f4440038abdb221eac914cac --- /dev/null +++ b/src/main/java/com/takima/entrainement/toDo/services/ToDoService.java @@ -0,0 +1,21 @@ +package com.takima.entrainement.toDo.services; + + + +import com.takima.entrainement.core.pagination.PageSearch; +import com.takima.entrainement.toDo.models.ToDo; +import org.springframework.data.domain.Page; + +import java.util.Optional; + +public interface ToDoService { + void deleteById(long toDoId); + + Page<ToDo> findAll(PageSearch<ToDo> pageSearch); + + Optional<ToDo> findById(long id); + + ToDo create(ToDo toDo); + + ToDo update(ToDo toDo); +} diff --git a/src/main/java/com/takima/entrainement/toDo/services/impl/ToDoServiceImpl.java b/src/main/java/com/takima/entrainement/toDo/services/impl/ToDoServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..c739e38fa6f6be95e4705553c94758cdbdf35f85 --- /dev/null +++ b/src/main/java/com/takima/entrainement/toDo/services/impl/ToDoServiceImpl.java @@ -0,0 +1,46 @@ +package com.takima.entrainement.toDo.services.impl; + + +import com.takima.entrainement.core.pagination.PageSearch; +import com.takima.entrainement.toDo.daos.ToDoDao; +import com.takima.entrainement.toDo.models.ToDo; +import com.takima.entrainement.toDo.services.ToDoService; +import lombok.AllArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@AllArgsConstructor +public class ToDoServiceImpl implements ToDoService { + + private final ToDoDao toDoDao; + + @Override + public void deleteById(long toDoId) { + toDoDao.deleteById(toDoId); + } + + @Override + public ToDo create(ToDo toDo) { + return toDoDao.save(toDo); + } + + @Override + public ToDo update(ToDo toDo) { + return toDoDao.save(toDo); + } + + @Override + public Page<ToDo> findAll(PageSearch<ToDo> pageSearch) { + return toDoDao.findAll(pageSearch); + } + + @Override + public Optional<ToDo> findById(long id) { + return toDoDao.findById(id); + } + +} diff --git a/src/main/resources/db/migration/V1_0__list_and_todo_tables.sql b/src/main/resources/db/migration/V1_0__list_and_todo_tables.sql new file mode 100644 index 0000000000000000000000000000000000000000..1e0984b63704acfa33ab850483ef2cacd774429d --- /dev/null +++ b/src/main/resources/db/migration/V1_0__list_and_todo_tables.sql @@ -0,0 +1,10 @@ +CREATE TABLE public.to_do +( + id BIGSERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + description VARCHAR, + state VARCHAR NOT NULL CHECK (state IN ('todo', 'done')) +); + + +ALTER SEQUENCE to_do_id_seq RESTART 100 INCREMENT BY 50; \ No newline at end of file diff --git a/src/main/resources/db/migration/V1_0__user_and_list_and_todo_tables.sql b/src/main/resources/db/migration/V1_0__user_and_list_and_todo_tables.sql deleted file mode 100644 index f8392af77c3a7b75ed43af93ca32037f4d7fcbc8..0000000000000000000000000000000000000000 --- a/src/main/resources/db/migration/V1_0__user_and_list_and_todo_tables.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE TABLE public.userb -( - id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL -); - -CREATE TABLE public.todo_list -( - id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, - user_id BIGINT NOT NULL REFERENCES userb (id) -); - -CREATE TABLE public.todo -( - id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, - description VARCHAR, - checked BOOLEAN NOT NULL DEFAULT FALSE, - seller_id BIGINT NOT NULL REFERENCES todo_list (id) -); diff --git a/src/test/java/com/takima/entrainement/toDo/controllers/ToDoApiIT.java b/src/test/java/com/takima/entrainement/toDo/controllers/ToDoApiIT.java new file mode 100644 index 0000000000000000000000000000000000000000..f14a517f2f445f714c3684108f8db9bb6ea2d828 --- /dev/null +++ b/src/test/java/com/takima/entrainement/toDo/controllers/ToDoApiIT.java @@ -0,0 +1,166 @@ +package com.takima.entrainement.toDo.controllers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.takima.entrainement.toDo.models.State; +import com.takima.entrainement.toDo.models.ToDo; +import com.takima.entrainement.toDo.services.ToDoService; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Optional; + +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ExtendWith(SpringExtension.class) +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class ToDoApiIT { + + static Flyway flyway; + final int TODO_ID = 1; + final String TODO_NAME = "Tache"; + final String URL = "/api/todos"; + @Autowired + ToDoService toDoService; + @Autowired + private MockMvc mvc; + + @Autowired + ToDoApiIT(Flyway flyway) { + ToDoApiIT.flyway = flyway; + } + + @Nested + @DisplayName("1-GET a todo/") + class GetDiscounts { + @AfterAll + static void clearDatabase() { + flyway.clean(); + flyway.migrate(); + } + + @Nested + @DisplayName("with a valid todo id") + class WithValidCustomerId { + final String testURL = URL + "/" + TODO_ID; + + @Test + @DisplayName("should give status 200 OK") + void shouldGive200() throws Exception { + mvc.perform(get(testURL) + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$['name']", is(TODO_NAME))); + + } + } + } + + + @Nested + @DisplayName("2-POST add todo") + class AddDiscounts { + + @AfterAll + static void clearDatabase() { + flyway.clean(); + flyway.migrate(); + } + + @Nested + @DisplayName("with a valid todo ") + class WithValidToDO { + @Test + @DisplayName("should give status 201 Created") + void shouldGive200() throws Exception { + + ToDo todo = ToDo.builder().name("Tache").state(State.TODO).build(); + + String jsonToDO = new ObjectMapper().writeValueAsString(todo); + mvc.perform(post(URL) + .content(jsonToDO) + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isCreated()); + + } + } + } + + @Nested + @DisplayName("3-PUT update todo") + class UpdateTodo { + + @AfterAll + static void clearDatabase() { + flyway.clean(); + flyway.migrate(); + } + + @Nested + @DisplayName("with a valid todo ") + class WithValidToDO { + @Test + @DisplayName("should update the todo") + void shouldGive200() throws Exception { + + String newName = "Tache2"; + ToDo todo = ToDo.builder().name(newName).state(State.TODO).build(); + + String jsonToDO = new ObjectMapper().writeValueAsString(todo); + + mvc.perform(put(URL+"/" + TODO_ID) + .content(jsonToDO) + .contentType(MediaType.APPLICATION_JSON_VALUE)); + + assertEquals(toDoService.findById(TODO_ID).get().getName(), newName); + } + } + } + + @Nested + @DisplayName("4-DELETE remove todo/") + class RemoveDiscount { + + @AfterAll + static void clearDatabase() { + flyway.clean(); + flyway.migrate(); + } + + + @Nested + @DisplayName("with an existing id") + class WithValidId { + + + @Test + @DisplayName("should give status 200 OK remove the todo") + void shouldGive200() throws Exception { + mvc.perform(delete(URL + "/" + TODO_ID) + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(status().isOk()); + Optional<ToDo> todo = toDoService.findById(TODO_ID); + assertEquals(todo, Optional.empty()); + } + + } + } +} + + diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000000000000000000000000000000000000..495591b2a139bb8195a3d2cdcd339b995045edec --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,12 @@ +spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver +spring.datasource.url=jdbc:tc:postgresql:15.2://localhost:5432/entrainement_db +spring.datasource.password=${DATABASE_PASSWORD:ilovethierion} +spring.datasource.username=${DATABASE_USERNAME:ilovethierion} +logging.level.root=info +logging.level.org.hibernate.SQL=DEBUG +logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE +spring.flyway.enabled=true +spring.flyway.baseline-version=0 +spring.flyway.locations=filesystem:src/main/resources/db/migration, filesystem:src/test/resources/db +spring.flyway.cleanDisabled=false + diff --git a/src/test/resources/db/V9999__insert_data.sql b/src/test/resources/db/V9999__insert_data.sql new file mode 100644 index 0000000000000000000000000000000000000000..206c03f0e2738fc5437a653a72765cd14deb4779 --- /dev/null +++ b/src/test/resources/db/V9999__insert_data.sql @@ -0,0 +1,3 @@ +INSERT INTO to_do (id, name, description, state) +VALUES (1, 'Tache', 'Description de la tâche 1', 'todo'); +