diff --git a/Postman_test_suite b/Postman_test_suite new file mode 100644 index 0000000..9b4f9b1 --- /dev/null +++ b/Postman_test_suite @@ -0,0 +1,449 @@ +{ + "id": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "name": "Redeye", + "description": "", + "order": [ + "28c8fdfc-2cef-b371-f52b-f23365432f02", + "6d5f6d6c-d935-3a08-3974-91fb4335fd2f", + "761ece2c-0fce-776e-048b-6dc50fdf7ad9", + "f1547a60-95af-f687-34c1-3a54923b12f3", + "40e6353e-da45-8e5e-95d2-b9790b7cada1", + "74e10085-91c2-f7fd-7c79-c11cc7297abc", + "3ae6234e-7b70-0fcd-6de5-e0c2bb1126da", + "b8964b96-10c0-9b84-8c40-c1768455ef98", + "e7797da5-2739-e224-4228-ebf033238ea9", + "b59e6244-f297-86dc-9e2f-ecfab73abb91", + "60d622bd-999b-7b51-4950-1e2f22d7bab0", + "3d389515-7454-c059-133a-f7b3252750e1", + "18b7439e-36c0-7897-a897-f7fad7e77a4c", + "319f5cf7-d147-1b00-0097-c47ca1f5ca09", + "2b18d8b5-484e-1737-4d81-da25488451f0", + "79e0105c-47a1-44fd-2e3c-cc4fea2966da", + "4d9857fa-6a04-2897-eab9-42e14baa2387", + "c63e1e0e-b9d3-10ea-aca7-3bed45a16e9c", + "35407456-75f7-6572-475d-8bbe8f95742d", + "1b61851b-eab8-084e-38bc-f83f5d7a1a65", + "a2d53da8-d963-74ee-85fd-b1527731daee" + ], + "folders": [], + "timestamp": 1468385917501, + "owner": 0, + "remoteLink": "", + "public": false, + "published": false, + "requests": [ + { + "id": "18b7439e-36c0-7897-a897-f7fad7e77a4c", + "headers": "Content-Type: application/json\n", + "url": "localhost:8080/account/register", + "preRequestScript": null, + "pathVariables": {}, + "method": "POST", + "data": [], + "dataMode": "raw", + "version": 2, + "tests": "tests[\"Status code is 200\"] = responseCode.code === 200;\nvar jsonData = JSON.parse(responseBody);\npostman.setGlobalVariable(\"authTokenRegister2\", jsonData.token);\n", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468545605620, + "name": "register second account", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "{\n\"username\": \"Bill\",\n\"password\": \"password\"\n}" + }, + { + "id": "1b61851b-eab8-084e-38bc-f83f5d7a1a65", + "headers": "Authorization: Bearer {{authTokenRegister}}\nContent-Type: application/json\n", + "url": "localhost:8080/todo/clear", + "preRequestScript": null, + "pathVariables": {}, + "method": "DELETE", + "data": [], + "dataMode": "raw", + "tests": "tests[\"Status code is 200\"] = responseCode.code === 200;", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468556010590, + "name": "Clear entire list", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "" + }, + { + "id": "28c8fdfc-2cef-b371-f52b-f23365432f02", + "headers": "Content-Type: application/json\n", + "url": "localhost:8080/account/register", + "preRequestScript": null, + "pathVariables": {}, + "method": "POST", + "data": [], + "dataMode": "raw", + "version": 2, + "tests": "tests[\"Status code is 200\"] = responseCode.code === 200;\nvar jsonData = JSON.parse(responseBody);\npostman.setGlobalVariable(\"authTokenRegister\", jsonData.token);\n", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468491701902, + "name": "register", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "{\n\"username\": \"Steve\",\n\"password\": \"password\"\n}" + }, + { + "id": "2b18d8b5-484e-1737-4d81-da25488451f0", + "headers": "Authorization: Bearer {{authTokenRegister2}}\nContent-Type: application/json\n", + "url": "localhost:8080/todo/{{itemId}}", + "preRequestScript": null, + "pathVariables": {}, + "method": "DELETE", + "data": [], + "dataMode": "raw", + "tests": "tests[\"Status code is 403\"] = responseCode.code === 403;", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468548161431, + "name": "Delete another user's item", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "" + }, + { + "id": "319f5cf7-d147-1b00-0097-c47ca1f5ca09", + "headers": "Authorization: Bearer {{authTokenRegister2}}\nContent-Type: application/json\n", + "url": "localhost:8080/todo/{{itemId}}", + "preRequestScript": null, + "pathVariables": {}, + "method": "POST", + "data": [], + "dataMode": "raw", + "tests": "tests[\"Status code is 403\"] = responseCode.code === 403;", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468547232076, + "name": "Edit another user's item", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "{\n \"done\": true,\n \"text\": \"Buy Bread\"\n}" + }, + { + "id": "35407456-75f7-6572-475d-8bbe8f95742d", + "headers": "Content-Type: application/json\nAuthorization: Bearer {{authTokenRegister}}\n", + "url": "localhost:8080/todo/", + "preRequestScript": null, + "pathVariables": {}, + "method": "GET", + "data": [], + "dataMode": "raw", + "version": 2, + "tests": "var jsonData = JSON.parse(responseBody);\ntests[\"Body contains one element\"] = jsonData.length === 1;\ntests[\"Correct done value\"] = jsonData[0].done === true;\ntests[\"Correct text value\"] = jsonData[0].text === \"Buy Bread\";", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468548365022, + "name": "Check todo list contains 1 item", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "{\n\"text\": \"Build a teleporter\",\n\"done\": \"false\"\n}" + }, + { + "id": "3ae6234e-7b70-0fcd-6de5-e0c2bb1126da", + "headers": "Content-Type: application/json\nAuthorization: Bearer {{authTokenRegister}}\n", + "url": "localhost:8080/todo/", + "preRequestScript": null, + "pathVariables": {}, + "method": "GET", + "data": [], + "dataMode": "raw", + "version": 2, + "tests": "var jsonData = JSON.parse(responseBody);\ntests[\"Body contains one element\"] = jsonData.length === 1;\npostman.setGlobalVariable(\"itemId\", jsonData[0].id);\ntests[\"Correct done value\"] = jsonData[0].done === false;\ntests[\"Correct text value\"] = jsonData[0].text === \"Buy bread\";", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468549277302, + "name": "Todo list contains item", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "{\n\"text\": \"Build a teleporter\",\n\"done\": \"false\"\n}" + }, + { + "id": "3d389515-7454-c059-133a-f7b3252750e1", + "headers": "Content-Type: application/json\nAuthorization: Bearer {{authTokenRegister}}\n", + "url": "localhost:8080/todo/inactive", + "preRequestScript": null, + "pathVariables": {}, + "method": "GET", + "data": [], + "dataMode": "raw", + "version": 2, + "tests": "tests[\"Status code is 200\"] = responseCode.code === 200;\nvar jsonData = JSON.parse(responseBody);\ntests[\"Body contains one element\"] = jsonData.length === 1;\nif (jsonData.length > 0) {\n tests[\"Correct done value\"] = jsonData[0].done === true;\n tests[\"Correct text value\"] = jsonData[0].text === \"Buy Bread\";\n}", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468555169686, + "name": "Test get inactive tasks", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "{\n\"text\": \"Build a teleporter\",\n\"done\": \"false\"\n}" + }, + { + "id": "40e6353e-da45-8e5e-95d2-b9790b7cada1", + "headers": "Content-Type: application/json\nAuthorization: Bearer {{authTokenRegister}}\n", + "url": "localhost:8080/todo/", + "preRequestScript": null, + "pathVariables": {}, + "method": "GET", + "data": [], + "dataMode": "raw", + "version": 2, + "tests": "tests[\"Body is empty array\"] = responseBody === \"[]\";", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468493239702, + "name": "Empty todo list", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "{\n\"text\": \"Build a teleporter\",\n\"done\": \"false\"\n}" + }, + { + "id": "4d9857fa-6a04-2897-eab9-42e14baa2387", + "headers": "Content-Type: application/json\nAuthorization: Bearer {{authTokenRegister}}\n", + "url": "localhost:8080/todo/", + "preRequestScript": null, + "pathVariables": {}, + "method": "GET", + "data": [], + "dataMode": "raw", + "version": 2, + "tests": "var jsonData = JSON.parse(responseBody);\ntests[\"Body contains one element\"] = jsonData.length === 2;\ntests[\"Correct done value\"] = jsonData[0].done === true;\ntests[\"Correct text value\"] = jsonData[0].text === \"Buy Bread\";\npostman.setGlobalVariable(\"itemId2\", jsonData[1].id);\ntests[\"Correct done value2\"] = jsonData[1].done === false;\ntests[\"Correct text value2\"] = jsonData[1].text === \"Buy milk\";", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468548312891, + "name": "Check todo list contains 2 items", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "{\n\"text\": \"Build a teleporter\",\n\"done\": \"false\"\n}" + }, + { + "id": "60d622bd-999b-7b51-4950-1e2f22d7bab0", + "headers": "Content-Type: application/json\nAuthorization: Bearer {{authTokenRegister}}\n", + "url": "localhost:8080/todo/active", + "preRequestScript": null, + "pathVariables": {}, + "method": "GET", + "data": [], + "dataMode": "raw", + "version": 2, + "tests": "tests[\"Status code is 200\"] = responseCode.code === 200;\nvar jsonData = JSON.parse(responseBody);\ntests[\"Body contains one element\"] = jsonData.length === 1;\nif (jsonData.length > 0) {\n tests[\"Correct done value\"] = jsonData[0].done === false;\n tests[\"Correct text value\"] = jsonData[0].text === \"Buy milk\";\n}", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468555112154, + "name": "Test get active tasks", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "{\n\"text\": \"Build a teleporter\",\n\"done\": \"false\"\n}" + }, + { + "id": "6d5f6d6c-d935-3a08-3974-91fb4335fd2f", + "headers": "Content-Type: application/json\n", + "url": "localhost:8080/account/register", + "preRequestScript": null, + "pathVariables": {}, + "method": "POST", + "data": [], + "dataMode": "raw", + "version": 2, + "tests": "tests[\"Status code is 400\"] = responseCode.code === 400;", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468489162232, + "name": "register username taken", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "{\n\"username\": \"Steve\",\n\"password\": \"password\"\n}" + }, + { + "id": "74e10085-91c2-f7fd-7c79-c11cc7297abc", + "headers": "Authorization: Bearer {{authTokenRegister}}\nContent-Type: application/json\n", + "url": "localhost:8080/todo/", + "preRequestScript": null, + "pathVariables": {}, + "method": "POST", + "data": [], + "dataMode": "raw", + "tests": "tests[\"Status code is 200\"] = responseCode.code === 200;", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468494040745, + "name": "Add to list", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "{\n \"done\": false,\n \"text\": \"Buy bread\"\n}" + }, + { + "id": "761ece2c-0fce-776e-048b-6dc50fdf7ad9", + "headers": "Content-Type: application/json\n", + "url": "localhost:8080/account/register", + "preRequestScript": null, + "pathVariables": {}, + "method": "POST", + "data": [], + "dataMode": "raw", + "version": 2, + "tests": "tests[\"Status code is 400\"] = responseCode.code === 400;", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468490114816, + "name": "register missing username", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "{\n \"password\": \"password\"\n}" + }, + { + "id": "79e0105c-47a1-44fd-2e3c-cc4fea2966da", + "headers": "Content-Type: application/json\n", + "url": "localhost:8080/account/login", + "preRequestScript": null, + "pathVariables": {}, + "method": "POST", + "data": [], + "dataMode": "raw", + "version": 2, + "tests": "tests[\"Status code is 200\"] = responseCode.code === 200;\nvar jsonData = JSON.parse(responseBody);\npostman.setGlobalVariable(\"authTokenRegister\", jsonData.token);\n", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468548172863, + "name": "login as first user", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "{\n\"username\": \"Steve\",\n\"password\": \"password\"\n}" + }, + { + "id": "a2d53da8-d963-74ee-85fd-b1527731daee", + "headers": "Content-Type: application/json\nAuthorization: Bearer {{authTokenRegister}}\n", + "url": "localhost:8080/todo/", + "preRequestScript": null, + "pathVariables": {}, + "method": "GET", + "data": [], + "dataMode": "raw", + "version": 2, + "tests": "tests[\"Status code is 200\"] = responseCode.code === 200;\ntests[\"Body is empty array\"] = responseBody === \"[]\";", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468556063063, + "name": "Check todo list is empty", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "{\n\"text\": \"Build a teleporter\",\n\"done\": \"false\"\n}" + }, + { + "id": "b59e6244-f297-86dc-9e2f-ecfab73abb91", + "headers": "Content-Type: application/json\nAuthorization: Bearer {{authTokenRegister}}\n", + "url": "localhost:8080/todo/", + "preRequestScript": null, + "pathVariables": {}, + "method": "GET", + "data": [], + "dataMode": "raw", + "version": 2, + "tests": "var jsonData = JSON.parse(responseBody);\npostman.setGlobalVariable(\"itemId\", jsonData[0].id);\ntests[\"Body contains two elements\"] = jsonData.length === 2;\ntests[\"Correct done value\"] = jsonData[0].done === true;\ntests[\"Correct text value\"] = jsonData[0].text === \"Buy Bread\";", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468551537121, + "name": "Check edit succeeded", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "{\n\"text\": \"Build a teleporter\",\n\"done\": \"false\"\n}" + }, + { + "id": "b8964b96-10c0-9b84-8c40-c1768455ef98", + "headers": "Authorization: Bearer {{authTokenRegister}}\nContent-Type: application/json\n", + "url": "localhost:8080/todo/", + "preRequestScript": null, + "pathVariables": {}, + "method": "POST", + "data": [], + "dataMode": "raw", + "tests": "tests[\"Status code is 200\"] = responseCode.code === 200;", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468547282599, + "name": "Add second item to list", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "{\n \"done\": false,\n \"text\": \"Buy milk\"\n}" + }, + { + "id": "c63e1e0e-b9d3-10ea-aca7-3bed45a16e9c", + "headers": "Authorization: Bearer {{authTokenRegister}}\nContent-Type: application/json\n", + "url": "localhost:8080/todo/{{itemId2}}", + "preRequestScript": null, + "pathVariables": {}, + "method": "DELETE", + "data": [], + "dataMode": "raw", + "tests": "tests[\"Status code is 200\"] = responseCode.code === 200;", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468548334514, + "name": "Delete item", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "" + }, + { + "id": "e7797da5-2739-e224-4228-ebf033238ea9", + "headers": "Authorization: Bearer {{authTokenRegister}}\nContent-Type: application/json\n", + "url": "localhost:8080/todo/{{itemId}}", + "preRequestScript": null, + "pathVariables": {}, + "method": "POST", + "data": [], + "dataMode": "raw", + "tests": "tests[\"Status code is 200\"] = responseCode.code === 200;", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468494185691, + "name": "Edit list item", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "{\n \"done\": true,\n \"text\": \"Buy Bread\"\n}" + }, + { + "id": "f1547a60-95af-f687-34c1-3a54923b12f3", + "headers": "Content-Type: application/json\n", + "url": "localhost:8080/account/register", + "preRequestScript": null, + "pathVariables": {}, + "method": "POST", + "data": [], + "dataMode": "raw", + "version": 2, + "tests": "tests[\"Status code is 400\"] = responseCode.code === 400;", + "currentHelper": "normal", + "helperAttributes": {}, + "time": 1468490118078, + "name": "register missing password", + "description": "", + "collectionId": "bb4070c0-5f32-bc49-8839-86571f6424b4", + "responses": [], + "rawModeData": "{\n \"username\": \"Steve\"\n}" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index c7f104d..5e38a2c 100644 --- a/README.md +++ b/README.md @@ -7,4 +7,12 @@ To run the project through maven: `mvn exec:java` ### Testing ### -By default, the application will be running on port 8080. \ No newline at end of file +By default, the application will be running on port 8080. + +This application does use a H2 SQL database using the persistance API. However, the application is still configured to +remake the database upon start up so user accounts and to do lists will not be persisted between runs. + +The included file `Postman_test_suite.json` can be imported into the Postman extension for Chrome to be run as an integration test suite. + +Note: Most of the intermittent commits will not compile due to a file rename not being committed by IntelliJ. Manually +renaming 'ToDoListService.java' to 'TodoListService.java' will fix this. \ No newline at end of file diff --git a/pom.xml b/pom.xml index c2854e1..214f655 100644 --- a/pom.xml +++ b/pom.xml @@ -21,17 +21,27 @@ co.redeye.spring.challenge.Application - - org.springframework.boot - spring-boot-starter-parent - 1.3.5.RELEASE - - - - org.springframework.boot - spring-boot-starter-web - - + + org.springframework.boot + spring-boot-starter-parent + 1.3.5.RELEASE + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + com.h2database + h2 + 1.4.192 + + @@ -70,6 +80,14 @@ ${start-class} + + org.apache.maven.plugins + maven-compiler-plugin + + 1.8 + 1.8 + + \ No newline at end of file diff --git a/src/main/java/co/redeye/spring/challenge/SampleController.java b/src/main/java/co/redeye/spring/challenge/SampleController.java deleted file mode 100644 index 95d15a6..0000000 --- a/src/main/java/co/redeye/spring/challenge/SampleController.java +++ /dev/null @@ -1,14 +0,0 @@ -package co.redeye.spring.challenge; - -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.ResponseBody; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class SampleController { - @RequestMapping("/") - @ResponseBody - public String home() { - return "Hello World!"; - } -} diff --git a/src/main/java/co/redeye/spring/challenge/Utils.java b/src/main/java/co/redeye/spring/challenge/Utils.java new file mode 100644 index 0000000..8e179d2 --- /dev/null +++ b/src/main/java/co/redeye/spring/challenge/Utils.java @@ -0,0 +1,16 @@ +package co.redeye.spring.challenge; + +/** + * Helper functions. + */ +public class Utils { + /** + * Checks that a string value is non-null and non-empty + * + * @param string The String to check. + * @return True if String is non-null and has a length >= 1 + */ + public static boolean stringPresent(String string) { + return string != null && !string.isEmpty(); + } +} diff --git a/src/main/java/co/redeye/spring/challenge/controllers/AuthenticationController.java b/src/main/java/co/redeye/spring/challenge/controllers/AuthenticationController.java new file mode 100644 index 0000000..299796c --- /dev/null +++ b/src/main/java/co/redeye/spring/challenge/controllers/AuthenticationController.java @@ -0,0 +1,34 @@ +package co.redeye.spring.challenge.controllers; + +import co.redeye.spring.challenge.Utils; +import co.redeye.spring.challenge.exceptions.AuthenticationException; +import co.redeye.spring.challenge.services.AuthenticatorService; +import co.redeye.spring.challenge.views.LoginRequest; +import co.redeye.spring.challenge.views.LoginResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/account") +public class AuthenticationController { + @Autowired + AuthenticatorService authenticator; + + @RequestMapping(value = "/register", method = RequestMethod.POST) + @ResponseBody + public LoginResponse register(@RequestBody LoginRequest registerRequest) throws AuthenticationException { + registerRequest.validate(); + + String token = authenticator.register(registerRequest.getUsername(), registerRequest.getPassword()); + return new LoginResponse(token); + } + + @RequestMapping(value = "/login", method = RequestMethod.POST) + @ResponseBody + public LoginResponse login(@RequestBody LoginRequest loginRequest) throws AuthenticationException { + loginRequest.validate(); + + String token = authenticator.login(loginRequest.getUsername(), loginRequest.getPassword()); + return new LoginResponse(token); + } +} diff --git a/src/main/java/co/redeye/spring/challenge/controllers/ExceptionHandlerController.java b/src/main/java/co/redeye/spring/challenge/controllers/ExceptionHandlerController.java new file mode 100644 index 0000000..aaa96aa --- /dev/null +++ b/src/main/java/co/redeye/spring/challenge/controllers/ExceptionHandlerController.java @@ -0,0 +1,39 @@ +package co.redeye.spring.challenge.controllers; + +import co.redeye.spring.challenge.exceptions.AuthenticationException; +import co.redeye.spring.challenge.exceptions.IllegalItemException; +import co.redeye.spring.challenge.exceptions.UserException; +import co.redeye.spring.challenge.views.ErrorResponse; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * Global Handler for uncaught Exceptions. + */ +@ControllerAdvice +public class ExceptionHandlerController { + @ExceptionHandler (value = IllegalItemException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + @ResponseBody + public ErrorResponse illegalAccessException(IllegalItemException e) { + return new ErrorResponse(e.getMessage()); + } + + @ExceptionHandler (value = UserException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ResponseBody + public ErrorResponse authenticationException(UserException e) { + return new ErrorResponse(e.getMessage()); + } + + @ExceptionHandler (value = Throwable.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ResponseBody + public ErrorResponse catchAll(Throwable e) { + e.printStackTrace(); + return new ErrorResponse("An unexpected error has occurred."); + } +} diff --git a/src/main/java/co/redeye/spring/challenge/controllers/TodoListController.java b/src/main/java/co/redeye/spring/challenge/controllers/TodoListController.java new file mode 100644 index 0000000..2abc9b8 --- /dev/null +++ b/src/main/java/co/redeye/spring/challenge/controllers/TodoListController.java @@ -0,0 +1,114 @@ +package co.redeye.spring.challenge.controllers; + +import co.redeye.spring.challenge.exceptions.AuthenticationException; +import co.redeye.spring.challenge.exceptions.UserException; +import co.redeye.spring.challenge.services.TodoListService; +import co.redeye.spring.challenge.views.TodoItem; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * Controller for handling all access of the user's to do list. + */ +@Controller +@RequestMapping("/todo") +public class TodoListController { + @Autowired + private TodoListService todoListService; + + /** + * Retrieves the user's entire to do list + * + * @param authToken The user's authentication token + * @throws AuthenticationException If the authentication token is missing or invalid. + */ + @RequestMapping(value = "/", method = RequestMethod.GET) + @ResponseBody + public List getTasks(@RequestHeader("Authorization") String authToken) throws UserException { + return todoListService.getItems(authToken); + } + + /** + * Adds a new item to the authenticated user's to do list. + * + * @param newItem The item being added. + * @param authToken The user's authentication token + * @throws AuthenticationException If the authentication token is missing or invalid. + */ + @RequestMapping(value = "/", method = RequestMethod.POST) + @ResponseStatus(HttpStatus.OK) + public void newTask(@RequestBody TodoItem newItem, @RequestHeader("Authorization") String authToken) throws UserException { + newItem.validate(); + todoListService.addItem(authToken, newItem.getText(), newItem.isDone()); + } + + /** + * Gets the user's incomplete items. + * + * @param authToken The user's authentication token. + * @return All of the user's items which are not done. + * @throws AuthenticationException If the user's token is invalid. + */ + @RequestMapping(value = "/active", method = RequestMethod.GET) + @ResponseBody + public List getActiveItems(@RequestHeader("Authorization") String authToken) throws AuthenticationException { + return todoListService.getIncompleteItems(authToken); + } + + /** + * Gets the user's complete items. + * + * @param authToken The user's authentication token. + * @return All of the user's items which are done. + * @throws AuthenticationException If the user's token is invalid. + */ + @RequestMapping(value = "/inactive", method = RequestMethod.GET) + @ResponseBody + public List getInactiveItems(@RequestHeader("Authorization") String authToken) throws AuthenticationException { + return todoListService.getCompleteItems(authToken); + } + + /** + * Modifies an existing task. + * + * @param item The new values for the item. + * @param authToken The user's authentication token. + * @param taskId The id of the task being modified. + * @throws UserException If there is any problem with the request. + */ + @RequestMapping(value = "/{id}", method = RequestMethod.POST) + @ResponseStatus(HttpStatus.OK) + public void editTask(@RequestBody TodoItem item, @RequestHeader("Authorization") String authToken, @PathVariable("id") long taskId) throws UserException { + item.validate(); + todoListService.editItem(authToken, taskId, item.getText(), item.isDone()); + } + + /** + * Completely removes an item from the user's to do list. + * + * @param authToken The user's authentication token. + * @param taskId The id of the task to be removed + * @throws UserException If there is an authentication issue or the task does not belong to the user. + */ + @RequestMapping(value = "/{id}", method = RequestMethod.DELETE) + @ResponseStatus(HttpStatus.OK) + public void deleteTask(@RequestHeader("Authorization") String authToken, @PathVariable("id") long taskId) throws UserException { + todoListService.deleteItem(authToken, taskId); + } + + /** + * Completely removes all items user's to do list. + * + * @param authToken The user's authentication token. + * @throws AuthenticationException If there is an authentication issue. + */ + @RequestMapping(value = "/clear", method = RequestMethod.DELETE) + @ResponseStatus(HttpStatus.OK) + public void deleteAllTasks(@RequestHeader("Authorization") String authToken) throws AuthenticationException { + todoListService.deleteAllItems(authToken); + } +} diff --git a/src/main/java/co/redeye/spring/challenge/db/Item.java b/src/main/java/co/redeye/spring/challenge/db/Item.java new file mode 100644 index 0000000..649ae9d --- /dev/null +++ b/src/main/java/co/redeye/spring/challenge/db/Item.java @@ -0,0 +1,56 @@ +package co.redeye.spring.challenge.db; + +import javax.persistence.*; + +/** + * Represents a single to do list item for a user. + */ +@Entity +@Table(name = "items") +public class Item { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private long id; + + @ManyToOne(targetEntity = User.class) + @JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false) + private User user; + + @Column(nullable = false) + private boolean done; + + @Column(nullable = false) + private String description; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public boolean isDone() { + return done; + } + + public void setDone(boolean done) { + this.done = done; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/src/main/java/co/redeye/spring/challenge/db/ItemRepository.java b/src/main/java/co/redeye/spring/challenge/db/ItemRepository.java new file mode 100644 index 0000000..a732219 --- /dev/null +++ b/src/main/java/co/redeye/spring/challenge/db/ItemRepository.java @@ -0,0 +1,21 @@ +package co.redeye.spring.challenge.db; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Provides custom database access methods we require. + */ +@Repository +public interface ItemRepository extends CrudRepository { + /** + * Query to retrieve all to do list items belonging to a specific user with a given status. + * + * @param user The user. + * @param done The item's status + * @return The user's to do list items with the given status. + */ + List findByUserAndDone(User user, boolean done); +} diff --git a/src/main/java/co/redeye/spring/challenge/db/User.java b/src/main/java/co/redeye/spring/challenge/db/User.java new file mode 100644 index 0000000..b945a6f --- /dev/null +++ b/src/main/java/co/redeye/spring/challenge/db/User.java @@ -0,0 +1,82 @@ +package co.redeye.spring.challenge.db; + +import javax.persistence.*; +import java.util.List; + +/** + * Persistence ORM for registered users. + *

+ * This is my first time using automatically generated data definition. + */ +@Entity +@Table(name = "users", + indexes = {@Index(name = "username_index", columnList = "username", unique = true), + @Index(name = "token_index", columnList = "token", unique = true)}) +public class User { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private long id; + + @Column(nullable = false, unique = true) + private String username; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String salt; + + @Column(nullable = false, unique = true) + private String token; + + @OneToMany(mappedBy = "user", targetEntity = Item.class, cascade = CascadeType.REMOVE) + private List items; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getSalt() { + return salt; + } + + public void setSalt(String salt) { + this.salt = salt; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public List getItems() { + return items; + } + + public void setItems(List items) { + this.items = items; + } +} diff --git a/src/main/java/co/redeye/spring/challenge/db/UserRepository.java b/src/main/java/co/redeye/spring/challenge/db/UserRepository.java new file mode 100644 index 0000000..8e194a5 --- /dev/null +++ b/src/main/java/co/redeye/spring/challenge/db/UserRepository.java @@ -0,0 +1,24 @@ +package co.redeye.spring.challenge.db; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +/** + * Provides custom database access methods we require. Methods declared below are defined by Spring Framework magic. + */ +@Repository +public interface UserRepository extends CrudRepository { + /** + * Retrieves the user with the given username. + * @param username The user to retrieve. + * @return The user or null if they do not exist. + */ + User findByUsername(String username); + + /** + * Retrieves the user currently using the given authentication token. + * @param token The user provided token. + * @return The user or null if no user is using this token. + */ + User findByToken(String token); +} diff --git a/src/main/java/co/redeye/spring/challenge/exceptions/AuthenticationException.java b/src/main/java/co/redeye/spring/challenge/exceptions/AuthenticationException.java new file mode 100644 index 0000000..f7c2109 --- /dev/null +++ b/src/main/java/co/redeye/spring/challenge/exceptions/AuthenticationException.java @@ -0,0 +1,11 @@ +package co.redeye.spring.challenge.exceptions; + +/** + * Exception to be used for problems with authentication. + * The message will be delivered to the user. + */ +public class AuthenticationException extends UserException { + public AuthenticationException(String message) { + super(message); + } +} diff --git a/src/main/java/co/redeye/spring/challenge/exceptions/IllegalItemException.java b/src/main/java/co/redeye/spring/challenge/exceptions/IllegalItemException.java new file mode 100644 index 0000000..38ecc94 --- /dev/null +++ b/src/main/java/co/redeye/spring/challenge/exceptions/IllegalItemException.java @@ -0,0 +1,11 @@ +package co.redeye.spring.challenge.exceptions; + +/** + * Exception class for users attempting to manipulate to do list items which they do not own. + * Results in a Forbidden Status. + */ +public class IllegalItemException extends UserException { + public IllegalItemException(String message) { + super(message); + } +} diff --git a/src/main/java/co/redeye/spring/challenge/exceptions/InvalidItemException.java b/src/main/java/co/redeye/spring/challenge/exceptions/InvalidItemException.java new file mode 100644 index 0000000..7bdf192 --- /dev/null +++ b/src/main/java/co/redeye/spring/challenge/exceptions/InvalidItemException.java @@ -0,0 +1,10 @@ +package co.redeye.spring.challenge.exceptions; + +/** + * For when a user submits an invalid to do list item. + */ +public class InvalidItemException extends UserException { + public InvalidItemException(String message) { + super(message); + } +} diff --git a/src/main/java/co/redeye/spring/challenge/exceptions/UserException.java b/src/main/java/co/redeye/spring/challenge/exceptions/UserException.java new file mode 100644 index 0000000..78da5a5 --- /dev/null +++ b/src/main/java/co/redeye/spring/challenge/exceptions/UserException.java @@ -0,0 +1,11 @@ +package co.redeye.spring.challenge.exceptions; + +/** + * Base checked exception class for all exceptions caused by bad user input. + * The message attached to this must be appropriate to send to the end user. + */ +public class UserException extends Exception { + public UserException(String message) { + super(message); + } +} diff --git a/src/main/java/co/redeye/spring/challenge/services/AuthenticatorService.java b/src/main/java/co/redeye/spring/challenge/services/AuthenticatorService.java new file mode 100644 index 0000000..e757b21 --- /dev/null +++ b/src/main/java/co/redeye/spring/challenge/services/AuthenticatorService.java @@ -0,0 +1,160 @@ +package co.redeye.spring.challenge.services; + +import co.redeye.spring.challenge.db.User; +import co.redeye.spring.challenge.db.UserRepository; +import co.redeye.spring.challenge.exceptions.AuthenticationException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import org.springframework.transaction.annotation.Transactional; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Random; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Handles all tasks related to user authentication. + */ +@Service +public class AuthenticatorService { + private static final String BAD_AUTH_MESSAGE = "Authentication error"; + private static final int SALT_LENGTH = 64; + private static final int TOKEN_LENGTH = 32; + private static final Pattern TOKEN_VALIDATOR = Pattern.compile("Bearer ([a-zA-Z0-9]{" + TOKEN_LENGTH +"})"); + + // Constants for random String generation + private static final int UNIQUE_CHARACTERS = 10 + 26 + 26; //Numerals, lowercase and uppercase letters + private static final int NUMERAL_CUTOFF = 10; + private static final int LOWERCASE_CUTOFF = NUMERAL_CUTOFF + 26; + + @Autowired + private UserRepository userRepository; + + private final Random random; + private final MessageDigest hashEncoder; + + public AuthenticatorService() { + random = new Random(); + try { + hashEncoder = MessageDigest.getInstance("MD5"); + } catch (NoSuchAlgorithmException ignored) { + //Impossible + throw new RuntimeException(ignored); + } + } + + + /** + * Registers a new user. + * + * @param username The user's desired username. + * @param password The user's desired password. + * @return The user's authentication token. + * @throws AuthenticationException When the specified username is taken. + */ + @Transactional + public String register(String username, String password) throws AuthenticationException { + User existingUser = userRepository.findByUsername(username); + if (existingUser != null) { + throw new AuthenticationException("This username has already been taken."); + } + + String salt = randomString(SALT_LENGTH); + String saltedPassword = generateSaltedPassword(salt, password); + String token = randomString(TOKEN_LENGTH); + + User newUser = new User(); + newUser.setUsername(username); + newUser.setPassword(saltedPassword); + newUser.setSalt(salt); + newUser.setToken(token); + + newUser = userRepository.save(newUser); + return token; + } + + /** + * Authenticates a user and returns a token. + * + * @param username The user's username. + * @param password The user's password. + * @return The user's authentication token. + * @throws AuthenticationException When either credential is invalid. + */ + @Transactional + public String login(String username, String password) throws AuthenticationException { + User user = userRepository.findByUsername(username); + if (user == null) { + throw new AuthenticationException(BAD_AUTH_MESSAGE); + } + + String saltedPassword = generateSaltedPassword(user.getSalt(), password); + if (!saltedPassword.equals(user.getPassword())) { + throw new AuthenticationException(BAD_AUTH_MESSAGE); + } + + String token = randomString(TOKEN_LENGTH); + user.setToken(token); + userRepository.save(user); + return token; + } + + /** + * Validates a user login token and returns the user. + * + * @param authToken The user's authentication toekn. + * @return The user's account object if the token is valid. + * @throws AuthenticationException If the token is invalid. + */ + @Transactional(readOnly = true) + public User fromToken(String authToken) throws AuthenticationException { + if (authToken == null) { + throw new AuthenticationException(BAD_AUTH_MESSAGE); + } + + Matcher matcher = TOKEN_VALIDATOR.matcher(authToken); + if (!matcher.matches()) { + throw new AuthenticationException(BAD_AUTH_MESSAGE); + } + + String cleanedToken = matcher.group(1); + User user = userRepository.findByToken(cleanedToken); + if (user == null) { + throw new AuthenticationException(BAD_AUTH_MESSAGE); + } + + return user; + } + + /** + * Generates the salted password hash. + * + * @param salt The salt to use on the password. + * @param password The user's password. + * @return The salted password hash. + */ + private String generateSaltedPassword(String salt, String password) { + return new String(hashEncoder.digest((salt + password).getBytes())); + } + + /** + * Generates a random string of alphanumberic characters + * + * @return The generated string. + */ + private String randomString(int length) { + StringBuilder salt = new StringBuilder(length); + for (int i = 0; i < length; ++i) { + int current = random.nextInt(UNIQUE_CHARACTERS); + if (current < NUMERAL_CUTOFF) { + salt.append((char) ('0' + current)); + } else if (current < LOWERCASE_CUTOFF) { + salt.append((char) ('a' + current - NUMERAL_CUTOFF)); + } else { + salt.append((char) ('A' + current - LOWERCASE_CUTOFF)); + } + } + return salt.toString(); + } +} diff --git a/src/main/java/co/redeye/spring/challenge/services/TodoListService.java b/src/main/java/co/redeye/spring/challenge/services/TodoListService.java new file mode 100644 index 0000000..e2f8dde --- /dev/null +++ b/src/main/java/co/redeye/spring/challenge/services/TodoListService.java @@ -0,0 +1,166 @@ +package co.redeye.spring.challenge.services; + +import co.redeye.spring.challenge.db.Item; +import co.redeye.spring.challenge.db.ItemRepository; +import co.redeye.spring.challenge.db.User; +import co.redeye.spring.challenge.exceptions.AuthenticationException; +import co.redeye.spring.challenge.exceptions.IllegalItemException; +import co.redeye.spring.challenge.exceptions.InvalidItemException; +import co.redeye.spring.challenge.exceptions.UserException; +import co.redeye.spring.challenge.views.TodoItem; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * All functionality related to the manipulation of to do lists. + */ +@Service +public class TodoListService { + @Autowired + private AuthenticatorService authenticatorService; + @Autowired + private ItemRepository itemRepository; + + /** + * Gets all the items a user has in their to do list, complete and incomplete. + * + * @param token The user's authentication token + * @return A list of this user's to do tasks. + * @throws AuthenticationException + */ + @Transactional(readOnly = true) + public List getItems(String token) throws AuthenticationException { + User user = authenticatorService.fromToken(token); + + return user.getItems().stream() + .map(TodoItem::new) + .collect(Collectors.toList()); + } + + /** + * Adds a new list item for the current user + * + * @param token The user's authentication token + * @param desc The new task's description + * @param complete Whether the user has completed this task. + * @ + */ + @Transactional + public void addItem(String token, String desc, boolean complete) throws AuthenticationException { + User user = authenticatorService.fromToken(token); + + Item newItem = new Item(); + newItem.setUser(user); + newItem.setDescription(desc); + newItem.setDone(complete); + + itemRepository.save(newItem); + } + + /** + * Edits an existing item on a user's to do list. + * + * @param token The user's authentication token. + * @param taskId The id of the task. + * @param text The new text for the list item. + * @param done The new status for the item. + * @throws AuthenticationException If the user's token is invalid. + * @throws IllegalItemException If the specified task does not belong to the user. + */ + @Transactional + public void editItem(String token, long taskId, String text, boolean done) throws UserException { + User user = authenticatorService.fromToken(token); + Item item = itemRepository.findOne(taskId); + + if (item == null) { + throw new InvalidItemException("The specified to do list item does not exist."); + } + + if (!user.equals(item.getUser())) { + throw new IllegalItemException("This item does not belong to you."); + } + + item.setDone(done); + item.setDescription(text); + itemRepository.save(item); + } + + /** + * Deletes a specified item from the current user's to do list. + * + * @param token The user's authentication token. + * @param taskId The id of the task to be removed. + * @throws UserException If there is an issue with authentication or the item. + */ + @Transactional + public void deleteItem(String token, long taskId) throws UserException { + User user = authenticatorService.fromToken(token); + Item item = itemRepository.findOne(taskId); + + if (item == null) { + throw new InvalidItemException("The specified to do list item does not exist."); + } + + if (!user.equals(item.getUser())) { + throw new IllegalItemException("This item does not belong to you."); + } + + itemRepository.delete(item); + } + + /** + * Gets all of the user's incomplete to do list items. + * + * @param token The user's authentication token. + * @return The user's incomplete items. + * @throws AuthenticationException If the user's token is invalid. + */ + public List getIncompleteItems(String token) throws AuthenticationException { + return getItemsWithDoneStatus(token, false); + } + + /** + * Gets all of the user's complete to do list items. + * + * @param token The user's authentication token. + * @return The user's complete items. + * @throws AuthenticationException If the user's token is invalid. + */ + public List getCompleteItems(String token) throws AuthenticationException { + return getItemsWithDoneStatus(token, true); + } + + /** + * Method for handling requests for a user's complete/incomplete items. + * + * @param token The user's authentication token. + * @param doneStatus The desired status of the TodoItems to return. + * @return The user's items with the desired status. + * @throws AuthenticationException If the user's token is invalid. + */ + @Transactional + private List getItemsWithDoneStatus(String token, boolean doneStatus) throws AuthenticationException { + User user = authenticatorService.fromToken(token); + + return itemRepository.findByUserAndDone(user, doneStatus).stream() + .filter(item -> item.isDone() == doneStatus) + .map(TodoItem::new) + .collect(Collectors.toList()); + } + + /** + * Deletes all of the user's to do list items. + * + * @param token The user's authentication token. + * @throws AuthenticationException If the user's token is invalid. + */ + @Transactional + public void deleteAllItems(String token) throws AuthenticationException { + User user = authenticatorService.fromToken(token); + itemRepository.delete(user.getItems()); + } +} diff --git a/src/main/java/co/redeye/spring/challenge/views/ErrorResponse.java b/src/main/java/co/redeye/spring/challenge/views/ErrorResponse.java new file mode 100644 index 0000000..3d30c02 --- /dev/null +++ b/src/main/java/co/redeye/spring/challenge/views/ErrorResponse.java @@ -0,0 +1,23 @@ +package co.redeye.spring.challenge.views; + +/** + * Standard response for when an error has occurred. + */ +public class ErrorResponse { + String message; + + public ErrorResponse() { + } + + public ErrorResponse(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/src/main/java/co/redeye/spring/challenge/views/LoginRequest.java b/src/main/java/co/redeye/spring/challenge/views/LoginRequest.java new file mode 100644 index 0000000..7554eb3 --- /dev/null +++ b/src/main/java/co/redeye/spring/challenge/views/LoginRequest.java @@ -0,0 +1,38 @@ +package co.redeye.spring.challenge.views; + +import co.redeye.spring.challenge.Utils; +import co.redeye.spring.challenge.exceptions.AuthenticationException; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents a request to register/login + */ +public class LoginRequest { + private String username; + private String password; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public void validate() throws AuthenticationException { + if (!Utils.stringPresent(username)) { + throw new AuthenticationException("Invalid username."); + } + if (!Utils.stringPresent(password)) { + throw new AuthenticationException("Invalid password."); + } + } +} diff --git a/src/main/java/co/redeye/spring/challenge/views/LoginResponse.java b/src/main/java/co/redeye/spring/challenge/views/LoginResponse.java new file mode 100644 index 0000000..5a857cf --- /dev/null +++ b/src/main/java/co/redeye/spring/challenge/views/LoginResponse.java @@ -0,0 +1,20 @@ +package co.redeye.spring.challenge.views; + +/** + * Standard response object for login/registering + */ +public class LoginResponse { + private String token; + + public LoginResponse(String token) { + this.token = token; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } +} diff --git a/src/main/java/co/redeye/spring/challenge/views/TodoItem.java b/src/main/java/co/redeye/spring/challenge/views/TodoItem.java new file mode 100644 index 0000000..19b1969 --- /dev/null +++ b/src/main/java/co/redeye/spring/challenge/views/TodoItem.java @@ -0,0 +1,56 @@ +package co.redeye.spring.challenge.views; + +import co.redeye.spring.challenge.Utils; +import co.redeye.spring.challenge.db.Item; +import co.redeye.spring.challenge.exceptions.InvalidItemException; + +/** + * Represents a single to do list item. + */ +public class TodoItem { + long id; + Boolean done; + String text; + + public TodoItem() { + } + + public TodoItem(Item item) { + this.id = item.getId(); + this.done = item.isDone(); + this.text = item.getDescription(); + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public boolean isDone() { + return done; + } + + public void setDone(boolean done) { + this.done = done; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public void validate() throws InvalidItemException { + if (!Utils.stringPresent(text)) { + throw new InvalidItemException("Missing field 'text'"); + } + if (done == null) { + throw new InvalidItemException("Missing field 'done' (true|false)"); + } + } +}