diff --git a/3-0-spring-framework/3-0-0-hello-spring-framework/pom.xml b/3-0-spring-framework/3-0-0-hello-spring-framework/pom.xml index 0dd36ae..e168059 100644 --- a/3-0-spring-framework/3-0-0-hello-spring-framework/pom.xml +++ b/3-0-spring-framework/3-0-0-hello-spring-framework/pom.xml @@ -17,27 +17,11 @@ spring-context 5.2.12.RELEASE - - org.springframework - spring-test - 5.2.12.RELEASE - com.bobocode spring-framework-exercises-util 1.0-SNAPSHOT - - org.hamcrest - hamcrest-all - 1.3 - test - - - org.slf4j - slf4j-simple - 1.7.24 - com.bobocode spring-framework-exercises-model diff --git a/3-0-spring-framework/3-1-1-dispatcher-servlet-initializer/pom.xml b/3-0-spring-framework/3-1-1-dispatcher-servlet-initializer/pom.xml index 277065d..be4c329 100644 --- a/3-0-spring-framework/3-1-1-dispatcher-servlet-initializer/pom.xml +++ b/3-0-spring-framework/3-1-1-dispatcher-servlet-initializer/pom.xml @@ -13,11 +13,6 @@ war - - org.springframework - spring-webmvc - 5.2.12.RELEASE - javax.servlet javax.servlet-api diff --git a/3-0-spring-framework/3-2-1-account-rest-api/README.MD b/3-0-spring-framework/3-2-1-account-rest-api/README.MD new file mode 100644 index 0000000..1f7a720 --- /dev/null +++ b/3-0-spring-framework/3-2-1-account-rest-api/README.MD @@ -0,0 +1,21 @@ +# Account REST API exercise 💪 +Improve your *Spring MVC* configuration and rest mapping skills +### Task +This webapp provides a **simple REST API for `Account`**. The data is stored using in-memory fake DAO. Your job is to +**configure Spring MVC application** and **implement AccountRestController**. In order to complete the task, please +**follow the instructions in the *todo* section** + +To verify your configuration, run `AccountRestControllerTest.java` :white_check_mark: + + +### Pre-conditions ❗ +You're supposed to be familiar with *Spring MVC* + +### How to start ❓ +* Just clone the repository and start implementing the **todo** section, verify your changes by running tests +* If you don't have enough knowledge about this domain, check out the [links below](#related-materials-information_source) +* Don't worry if you got stuck, checkout the **exercise/completed** branch and see the final implementation + +### Related materials ℹ + * todo + diff --git a/3-0-spring-framework/3-2-1-account-rest-api/pom.xml b/3-0-spring-framework/3-2-1-account-rest-api/pom.xml new file mode 100644 index 0000000..7ec0e00 --- /dev/null +++ b/3-0-spring-framework/3-2-1-account-rest-api/pom.xml @@ -0,0 +1,57 @@ + + + + 3-0-spring-framework + com.bobocode + 1.0-SNAPSHOT + + 4.0.0 + + 3-2-1-account-rest-api + war + + + + javax.servlet + javax.servlet-api + 4.0.1 + provided + + + com.bobocode + spring-framework-exercises-util + 1.0-SNAPSHOT + + + com.jayway.jsonpath + json-path + 2.3.0 + test + + + com.jayway.jsonpath + json-path-assert + 2.3.0 + test + + + + + + + + org.apache.maven.plugins + maven-war-plugin + 2.6 + + false + + + + + + + + \ No newline at end of file diff --git a/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/config/AccountRestApiInitializer.java b/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/config/AccountRestApiInitializer.java new file mode 100644 index 0000000..38798ff --- /dev/null +++ b/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/config/AccountRestApiInitializer.java @@ -0,0 +1,20 @@ +package com.bobocode.config; + +import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer; + +public class AccountRestApiInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { + @Override + protected Class[] getRootConfigClasses() { + return new Class[]{RootConfig.class}; + } + + @Override + protected Class[] getServletConfigClasses() { + return new Class[]{WebConfig.class}; + } + + @Override + protected String[] getServletMappings() { + return new String[]{"/"}; + } +} diff --git a/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/config/RootConfig.java b/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/config/RootConfig.java new file mode 100644 index 0000000..c5d1a6d --- /dev/null +++ b/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/config/RootConfig.java @@ -0,0 +1,14 @@ +package com.bobocode.config; + +import org.springframework.stereotype.Controller; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +/** + * This class provides application root (non-web) configuration. + *

+ * todo: 1. Mark this class as config + * todo: 2. Enable component scanning for all packages in "com.bobocode" using annotation property "basePackages" + * todo: 3. Exclude web related config and beans (ignore @{@link Controller}, ignore {@link EnableWebMvc}) + */ +public class RootConfig { +} diff --git a/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/config/WebConfig.java b/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/config/WebConfig.java new file mode 100644 index 0000000..1197302 --- /dev/null +++ b/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/config/WebConfig.java @@ -0,0 +1,11 @@ +package com.bobocode.config; + +/** + * This class provides web (servlet) related configuration. + *

+ * todo: 1. Mark this class as Spring config class + * todo: 2. Enable web mvc using annotation + * todo: 3. Enable component scanning for package "web" using annotation value + */ +public class WebConfig { +} diff --git a/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/dao/AccountDao.java b/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/dao/AccountDao.java new file mode 100644 index 0000000..0ac5d36 --- /dev/null +++ b/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/dao/AccountDao.java @@ -0,0 +1,15 @@ +package com.bobocode.dao; + +import com.bobocode.model.Account; + +import java.util.List; + +public interface AccountDao { + List findAll(); + + Account findById(long id); + + Account save(Account account); + + void remove(Account account); +} diff --git a/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/dao/impl/InMemoryAccountDao.java b/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/dao/impl/InMemoryAccountDao.java new file mode 100644 index 0000000..5620d5b --- /dev/null +++ b/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/dao/impl/InMemoryAccountDao.java @@ -0,0 +1,53 @@ +package com.bobocode.dao.impl; + +import com.bobocode.dao.AccountDao; +import com.bobocode.exception.EntityNotFountException; +import com.bobocode.model.Account; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * {@link AccountDao} implementation that is based on {@link java.util.HashMap}. + *

+ * todo: 1. Configure a component with name "accountDao" + */ +public class InMemoryAccountDao implements AccountDao { + private Map accountMap = new HashMap<>(); + private long idSequence = 1L; + + @Override + public List findAll() { + return new ArrayList<>(accountMap.values()); + } + + @Override + public Account findById(long id) { + Account account = accountMap.get(id); + if (account == null) { + throw new EntityNotFountException(String.format("Cannot found account by id = %d", id)); + } + return account; + } + + @Override + public Account save(Account account) { + if (account.getId() == null) { + account.setId(idSequence++); + } + accountMap.put(account.getId(), account); + return account; + } + + @Override + public void remove(Account account) { + accountMap.remove(account.getId()); + } + + public void clear() { + accountMap.clear(); + idSequence = 1L; + } +} diff --git a/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/exception/EntityNotFountException.java b/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/exception/EntityNotFountException.java new file mode 100644 index 0000000..852eb13 --- /dev/null +++ b/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/exception/EntityNotFountException.java @@ -0,0 +1,7 @@ +package com.bobocode.exception; + +public class EntityNotFountException extends RuntimeException { + public EntityNotFountException(String message) { + super(message); + } +} diff --git a/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/web/controller/AccountRestController.java b/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/web/controller/AccountRestController.java new file mode 100644 index 0000000..84fb818 --- /dev/null +++ b/3-0-spring-framework/3-2-1-account-rest-api/src/main/java/com/bobocode/web/controller/AccountRestController.java @@ -0,0 +1,21 @@ +package com.bobocode.web.controller; + +import com.bobocode.dao.AccountDao; + +/** + *

+ * todo: 1. Configure rest controller that handles requests with url "/accounts" + * todo: 2. Inject {@link AccountDao} implementation + * todo: 3. Implement method that handles GET request and returns a list of accounts + * todo: 4. Implement method that handles GET request with id as path variable and returns account by id + * todo: 5. Implement method that handles POST request, receives account as request body, saves account and returns it + * todo: Configure HTTP response status code 201 - CREATED + * todo: 6. Implement method that handles PUT request with id as path variable and receives account as request body. + * todo: It check if account id and path variable are the same and throws {@link IllegalStateException} otherwise. + * todo: Then it saves received account. Configure HTTP response status code 204 - NO CONTENT + * todo: 7. Implement method that handles DELETE request with id as path variable removes an account by id + * todo: Configure HTTP response status code 204 - NO CONTENT + */ +public class AccountRestController { + +} diff --git a/3-0-spring-framework/3-2-1-account-rest-api/src/test/java/com/bobocode/AccountRestControllerTest.java b/3-0-spring-framework/3-2-1-account-rest-api/src/test/java/com/bobocode/AccountRestControllerTest.java new file mode 100644 index 0000000..78ee089 --- /dev/null +++ b/3-0-spring-framework/3-2-1-account-rest-api/src/test/java/com/bobocode/AccountRestControllerTest.java @@ -0,0 +1,164 @@ +package com.bobocode; + +import com.bobocode.config.RootConfig; +import com.bobocode.config.WebConfig; +import com.bobocode.dao.AccountDao; +import com.bobocode.dao.impl.InMemoryAccountDao; +import com.bobocode.model.Account; +import com.bobocode.web.controller.AccountRestController; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; + +import java.lang.reflect.Constructor; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.hamcrest.Matchers.hasItems; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@SpringJUnitWebConfig(classes = {RootConfig.class, WebConfig.class}) +class AccountRestControllerTest { + @Autowired + private WebApplicationContext applicationContext; + + @Autowired + private InMemoryAccountDao accountDao; + + private MockMvc mockMvc; + + @BeforeEach + void setup() { + mockMvc = MockMvcBuilders.webAppContextSetup(applicationContext).build(); + accountDao.clear(); + } + + @Test + @Order(1) + @DisplayName("AccountRestController is marked as @RestController") + void accountRestControllerAnnotation() { + RestController restController = AccountRestController.class.getAnnotation(RestController.class); + + assertNotNull(restController); + } + + @Test + @Order(2) + @DisplayName("AccountRestController is specified in @RequestMapping") + void accountRestControllerRequestMapping() { + RequestMapping requestMapping = AccountRestController.class.getAnnotation(RequestMapping.class); + + assertNotNull(requestMapping); + assertThat(requestMapping.value().length).isEqualTo(1); + assertThat(requestMapping.value()).contains("/accounts"); + } + + @Test + @Order(3) + @DisplayName("AccountDao is injected using constructor") + void accountDaoInjection() { + Constructor constructor = AccountRestController.class.getConstructors()[0]; + + assertThat(constructor.getParameterTypes()).contains(AccountDao.class); + } + + @Test + @Order(4) + @DisplayName("Getting all accounts is implemented") + void getAllAccounts() throws Exception { + Account account1 = create("Johnny", "Boy", "jboy@gmail.com"); + Account account2 = create("Okko", "Bay", "obay@gmail.com"); + accountDao.save(account1); + accountDao.save(account2); + + mockMvc.perform(get("/accounts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.[*].email").value(hasItems("jboy@gmail.com", "obay@gmail.com"))); + } + + @Test + @Order(5) + @DisplayName("Getting all accounts response status is OK") + void getAccountsResponseStatusCode() throws Exception { + mockMvc.perform(get("/accounts").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + @Order(6) + @DisplayName("Getting account by Id with path variable is implemented") + void getById() throws Exception { + Account account = create("Johnny", "Boy", "jboy@gmail.com"); + accountDao.save(account); + + mockMvc.perform(get(String.format("/accounts/%d", account.getId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(account.getId())) + .andExpect(jsonPath("$.email").value("jboy@gmail.com")) + .andExpect(jsonPath("$.firstName").value("Johnny")) + .andExpect(jsonPath("$.lastName").value("Boy")); + } + + @Test + @Order(7) + @DisplayName("Creating account returns corresponding HTTP status - 201") + void httpStatusCodeOnCreate() throws Exception { + mockMvc.perform( + post("/accounts") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"Johnny\", \"lastName\":\"Boy\", \"email\":\"jboy@gmail.com\"}")) + .andExpect(status().isCreated()); + } + + @Test + @Order(8) + @DisplayName("Creating account returns assigned Id") + void createAccountReturnsAssignedId() throws Exception { + mockMvc.perform( + post("/accounts") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"Johnny\", \"lastName\":\"Boy\", \"email\":\"jboy@gmail.com\"}")) + .andExpect(jsonPath("$.id").value(1L)); + } + + private Account create(String firstName, String lastName, String email) { + Account account = new Account(); + account.setFirstName(firstName); + account.setLastName(lastName); + account.setEmail(email); + return account; + } + + @Test + @Order(9) + @DisplayName("Updating account is implemented") + void updateAccount() throws Exception { + Account account = create("Johnny", "Boy", "jboy@gmail.com"); + accountDao.save(account); + + mockMvc.perform(put(String.format("/accounts/%d", account.getId())).contentType(MediaType.APPLICATION_JSON) + .content(String.format("{\"id\":\"%d\", \"firstName\":\"Johnny\", \"lastName\":\"Boy\", \"email\":\"johnny.boy@gmail.com\"}", account.getId()))) + .andExpect(status().isNoContent()); + } + + @Test + @Order(10) + @DisplayName("Removing account is implemented") + void removeAccount() throws Exception { + Account account = create("Johnny", "Boy", "jboy@gmail.com"); + accountDao.save(account); + + mockMvc.perform(delete(String.format("/accounts/%d", account.getId()))) + .andExpect(status().isNoContent()); + } +} + diff --git a/3-0-spring-framework/3-2-1-account-rest-api/src/test/java/com/bobocode/WebAppConfigurationTest.java b/3-0-spring-framework/3-2-1-account-rest-api/src/test/java/com/bobocode/WebAppConfigurationTest.java new file mode 100644 index 0000000..6b5640d --- /dev/null +++ b/3-0-spring-framework/3-2-1-account-rest-api/src/test/java/com/bobocode/WebAppConfigurationTest.java @@ -0,0 +1,76 @@ +package com.bobocode; + +import com.bobocode.config.RootConfig; +import com.bobocode.config.WebConfig; +import com.bobocode.dao.impl.InMemoryAccountDao; +import org.junit.jupiter.api.*; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Controller; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class WebAppConfigurationTest { + + @Test + @Order(1) + @DisplayName("RootConfig class is configured properly") + void rootConfigClassIsConfigured() { + Configuration configuration = RootConfig.class.getAnnotation(Configuration.class); + ComponentScan componentScan = RootConfig.class.getAnnotation(ComponentScan.class); + String[] packages = componentScan.basePackages(); + if (packages.length == 0) { + packages = componentScan.value(); + } + + assertNotNull(configuration); + assertNotNull(componentScan); + assertThat(packages).contains("com.bobocode"); + + Filter[] filters = componentScan.excludeFilters(); + List filteredClasses = getFilteredClasses(filters); + + assertThat(filters.length).isEqualTo(2); + assertThat(filters[0].type()).isEqualTo(FilterType.ANNOTATION); + assertThat(filters[1].type()).isEqualTo(FilterType.ANNOTATION); + assertThat(filteredClasses.toArray()).containsExactlyInAnyOrder(EnableWebMvc.class, Controller.class); + } + + @Test + @Order(2) + @DisplayName("WebConfig class is configured properly") + void webConfigClassIsConfiguredProperly() { + Configuration configuration = WebConfig.class.getAnnotation(Configuration.class); + ComponentScan componentScan = WebConfig.class.getAnnotation(ComponentScan.class); + EnableWebMvc enableWebMvc = WebConfig.class.getAnnotation(EnableWebMvc.class); + + assertNotNull(configuration); + assertNotNull(componentScan); + assertThat(componentScan.basePackages()).contains("com.bobocode.web"); + assertNotNull(enableWebMvc); + } + + private List getFilteredClasses(Filter[] filters) { + return Stream.of(filters).flatMap(filter -> Stream.of(filter.value())).collect(Collectors.toList()); + } + + @Test + @Order(3) + @DisplayName("InMemoryAccountDao class is configured properly") + void inMemoryAccountDaoClassIsConfiguredProperly() { + Component component = InMemoryAccountDao.class.getAnnotation(Component.class); + + assertNotNull(component); + assertThat(component.value()).contains("accountDao"); + } +} diff --git a/3-0-spring-framework/pom.xml b/3-0-spring-framework/pom.xml index f018246..c9a2d9c 100644 --- a/3-0-spring-framework/pom.xml +++ b/3-0-spring-framework/pom.xml @@ -16,6 +16,30 @@ 3-0-0-hello-spring-framework 3-0-1-hello-spring-mvc 3-1-1-dispatcher-servlet-initializer + 3-2-1-account-rest-api + + + org.springframework + spring-webmvc + 5.2.12.RELEASE + + + org.springframework + spring-test + 5.2.12.RELEASE + + + com.fasterxml.jackson.core + jackson-core + 2.12.2 + + + com.fasterxml.jackson.core + jackson-databind + 2.12.2 + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 02bdcc0..a71fb9d 100644 --- a/pom.xml +++ b/pom.xml @@ -35,7 +35,6 @@ slf4j-simple 1.7.24 - org.junit.jupiter junit-jupiter-engine @@ -59,6 +58,17 @@ reflections 0.9.12 + + org.hamcrest + hamcrest-all + 1.3 + test + + + org.reflections + reflections + 0.9.12 + org.mockito mockito-core