diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..5bc4d3f91d8e8632faf7a42cf0f8c5d0f2ed043a --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# To-Do API + +This is a backend for managing a simple task management application. +The backend consists of three different parts: + +- Go application +- SQLite (default) +- PostgreSQL (optional) +- NGINX (optional) + +An overview of the system can be seen here (dotted boxes indicates optional parts): + + + +## Go application + +In order to run the application, you need one dependency: "github.com/lib/pq","github.com/mattn/go-sqlite3". +This can be retrieved by running `go get`. + +If you are using SQLite you may want to change the database file. +Update the following line in todo.go: +sql.Open("sqlite3", "./foo.db") + +If you are using PostgreSQL you may want to update the database information to match your system. +Update the following constants in todo.go: + +- databaseUser +- databaseHost +- databaseName + +## SQLite database + +If SQLite is used to store all the list and todo information. +In order to setup the database schema, run `sqlite3 foo.db < schema.sql`. + +## PostgreSQL database + +If PostgreSQL is used to store all the list and todo information. +In order to setup the database schema, run `psql -f schema.sql db_name`. + +## NGINX (Optional) + +There is a configuration ready for using NGINX as a reverse proxy (in `nginx.conf`). +However, the url to the application may need to be updated. + +## Run + +To build to application, simply use `go build` in the project directory. +You can also use `go run todo.go` to run the server directly. + +## Test + +Test the system to make sure everything works. +This is done by running `go test` in the application folder. diff --git a/architecture.png b/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..c77c5f8b3bb08e16b1174479156483cb34bfaa5c Binary files /dev/null and b/architecture.png differ diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000000000000000000000000000000000000..40a47a21a6b6e8abb14acfb36b4d1b82773418a0 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,27 @@ +user nginx; + +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events{} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + keepalive_timeout 65; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + server { + listen 80; + proxy_pass_header Server; + + location / { + # TODO: Update the url + proxy_pass http://app:8080; + } + } +} diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000000000000000000000000000000000000..aaab4e1b0b79f5dee813526b334f1a25b1aa107c --- /dev/null +++ b/schema.sql @@ -0,0 +1,15 @@ +-- A table that stores all the lists in the system. +drop table if exists list; +create table list( + id integer primary key autoincrement, + name text not null +); + +-- A table that stores all the tasks in the system. +drop table if exists task; +create table task( + id integer primary key autoincrement, + name text not null, + done boolean default false, + list int references list(id) +); diff --git a/todo.go b/todo.go new file mode 100644 index 0000000000000000000000000000000000000000..501f3ffba455a53dd8140e5038401bf78bc66fcd --- /dev/null +++ b/todo.go @@ -0,0 +1,214 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + _ "github.com/lib/pq" + _ "github.com/mattn/go-sqlite3" + "io/ioutil" + "log" + "net/http" + "strconv" + "strings" +) + +const ( + databaseUser = "postgres" + databaseHost = "db" + databaseName = "postgres" +) + +type Task struct { + Id int + Name string + Done bool + ListId int +} + +type CreateTaskRequest struct { + Name string `json:"name"` + ListId int `json:"list_id"` +} + +type List struct { + Id int + Name string +} + +type ListCreateRequest struct { + Name string +} + +type ListCreateResponse struct { + Id int +} + +func CheckFatal(err error, w http.ResponseWriter) { + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + } +} + +type Database struct { + Db *sql.DB +} + +// getURLParameter takes out the first URL parameter from the path +// path should be formated as /type/param. +// It returns a parameter representing a string. +func getURLParameter(path string) *string { + // TODO: Handle multiple parameters + param := strings.Split(path, "/") + if len(param) == 3 { + return ¶m[2] + } else { + return nil + } +} + +// getLists retrieves all the lists from the database. +// It returns a slice of List structs. +func getLists(db *sql.DB, w http.ResponseWriter) []List { + rows, err := db.Query("select * from list") + CheckFatal(err, w) + + // Retrieve all lists from the query + res := make([]List, 0) + for rows.Next() { + list := List{} + err := rows.Scan(&list.Id, &list.Name) + CheckFatal(err, w) + res = append(res, list) + } + + return res +} + +// getTass retrieves all the tasks from the database. +// It returns a slice of Task structs. +func getTasks(db *sql.DB, listId int, w http.ResponseWriter) []Task { + // Query the database for all tasks that references the specified list + rows, err := db.Query("select * from task where list=$1", listId) + CheckFatal(err, w) + + // Retrieve all tasks from the query + res := make([]Task, 0) + for rows.Next() { + var name string + var id, list int + var done bool + err := rows.Scan(&id, &name, &done, &list) + CheckFatal(err, w) + res = append(res, Task{Id: id, Name: name, Done: done, ListId: list}) + } + + return res +} + +// insertList adds a list to the database with listName as its name. +// It returns the Id of the list. +func insertList(db *sql.DB, listName string, w http.ResponseWriter) int { + var listId int + //err := db.QueryRow("insert into list (name) values ($1) returning id", listName).Scan(&listId) + _, err := db.Exec("insert into list (name) values ($1) ", listName) + db.QueryRow("select last_insert_rowid()").Scan(&listId) // SQLite specific + CheckFatal(err, w) + return 0 +} + +// insertTask adds a task to the database. +// taskName specifies the name of the task, and listId the list that it belongs to. +func insertTask(db *sql.DB, taskName string, listId int, w http.ResponseWriter) { + _, err := db.Exec("insert into task (name, list) values ($1, $2)", taskName, listId) + // Handle non-existing list id + CheckFatal(err, w) +} + +// listHandler manages requests with regards to the lists. +// A GET request to /list will retrieve all the lists. +// A GET request to /list/<id> will retrieve all the tasks of the list with id <id>. +func (db *Database) listHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + // Handle GET Request + param := getURLParameter(r.URL.Path) + + // If no parameter exists, retrieve all lists + if param == nil || *param == "" { + // Retrieve lists + list := getLists(db.Db, w) + json.NewEncoder(w).Encode(&list) + } else { + // Get the list id from the parameter + listId, err := strconv.Atoi(*param) + CheckFatal(err, w) + + // Retrieve tasks and send them back + tasks := getTasks(db.Db, listId, w) + json.NewEncoder(w).Encode(&tasks) + } + } else if r.Method == "POST" { + // Parse the request and create a new list + body, err := ioutil.ReadAll(r.Body) + CheckFatal(err, w) + listRequest := ListCreateRequest{} + err = json.Unmarshal(body, &listRequest) + CheckFatal(err, w) + listResponse := ListCreateResponse{} + listResponse.Id = insertList(db.Db, listRequest.Name, w) + + json.NewEncoder(w).Encode(&listResponse) + } +} + +// taskHandler manages requests with regards to the tasks. +// A POST request to /task will create a new task with the name and list provided +// in the Post Body. The Body should be in the format +// {"name": "taskName", "list_id": 123} +func (db *Database) taskHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + body, err := ioutil.ReadAll(r.Body) + CheckFatal(err, w) + taskRequest := CreateTaskRequest{} + err = json.Unmarshal(body, &taskRequest) + CheckFatal(err, w) + + insertTask(db.Db, taskRequest.Name, taskRequest.ListId, w) + + fmt.Fprintf(w, "OK") + } +} + +// ConnextDB connects to a postgres database. +// it returns a database handle. +func ConnectDb() *sql.DB { + // TODO: Refactor the database config + //for postgres database + //db, err := sql.Open("postgres", fmt.Sprintf("postgres://%s@%s/%s?sslmode=disable", databaseUser, databaseHost, databaseName)) + //for sqlite3 database + db, err := sql.Open("sqlite3", "./foo.db") + if err != nil { + log.Fatal(err) +} + + + return db +} + +// Handlers retrieves all handlers for the server. +func Handlers() *http.ServeMux { + db := Database{Db: ConnectDb()} + mux := http.NewServeMux() + mux.Handle("/list", http.HandlerFunc(db.listHandler)) + mux.Handle("/list/", http.HandlerFunc(db.listHandler)) + mux.Handle("/task", http.HandlerFunc(db.taskHandler)) + return mux +} + +func main() { + // Listen on port 5050 + err := http.ListenAndServe(":8080", Handlers()) + if err != nil { + log.Fatal(err) + } +} diff --git a/todo_test.go b/todo_test.go new file mode 100644 index 0000000000000000000000000000000000000000..69939917474064ba5faff4872f900fa8e97ee83a --- /dev/null +++ b/todo_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + "strings" + "strconv" + "encoding/json" + "bytes" +) + +// checkFail fails the tests if err is an error (not nil) +func checkFail(t *testing.T, err error) { + if err != nil { + t.Fatal(err) + } +} + +// setup recreates the database according to schema.sql. +// This is so that all tests occurs from a clean slate +func setup(t *testing.T) { + db := ConnectDb() + defer db.Close() + + sqlData, err := ioutil.ReadFile("schema.sql") + checkFail(t, err) + + sqlStmts := string(sqlData) + for _, stmt := range strings.Split(sqlStmts, ";") { + _, err := db.Exec(stmt) + checkFail(t, err) + } + +} + +// TestTask tests all functionality with regards to tasks +func TestTask(test *testing.T) { + // Setup the database and create a test server + setup(test) + testServer := httptest.NewServer(Handlers()) + defer testServer.Close() + // Create a new list for the different tasks + b, err := json.Marshal(ListCreateRequest{Name: "Work"}) + checkFail(test, err) + res, err := http.Post(testServer.URL + "/list", "application/json", bytes.NewReader(b)) + checkFail(test, err) + // Read the response and retrieve the id for the newly created list + listResp := ListCreateResponse{} + body, err := ioutil.ReadAll(res.Body) + defer res.Body.Close() + err = json.Unmarshal(body, &listResp) + + // Get all the tasks from the new list + res, err = http.Get(testServer.URL + "/list/" + strconv.Itoa(listResp.Id)) + checkFail(test, err) + if res.StatusCode != 200 { + test.Errorf("Expected 200 got %d", res.StatusCode) + } + + body, err = ioutil.ReadAll(res.Body) + defer res.Body.Close() + var tasks []Task + err = json.Unmarshal(body, &tasks) + checkFail(test, err) + + // There shouldn't be any tasks in the list yet + if len(tasks) != 0 { + test.Errorf("Expected len(tasks) == 0 got %d", len(tasks)) + } + + // Test the creation of a task in the list + b, err = json.Marshal(CreateTaskRequest{Name: "Work", ListId: listResp.Id}) + checkFail(test, err) + res, err = http.Post(testServer.URL + "/task", "application/json", bytes.NewReader(b)) + checkFail(test, err) + if res.StatusCode != 200 { + test.Errorf("Expected 200, got %d",res.StatusCode) + } + + // Test the if the task got stored in the list + res, err = http.Get(testServer.URL + "/list/" + strconv.Itoa(listResp.Id)) + checkFail(test, err) + if res.StatusCode != 200 { + test.Errorf("Expected 200 got %d", res.StatusCode) + } + + // Check so that the list contains 1 task + body, err = ioutil.ReadAll(res.Body) + defer res.Body.Close() + err = json.Unmarshal(body, &tasks) + checkFail(test, err) + if len(tasks) != 1 { + test.Errorf("Expected len(tasks) == 1 got %d", len(tasks)) + } +} + +// TestList manages the testing of lists +func TestList(test *testing.T) { + // Setup a clean database and a new test server + setup(test) + testServer := httptest.NewServer(Handlers()) + defer testServer.Close() + + // Retrieve the lists + res, err := http.Get(testServer.URL + "/list") + checkFail(test, err) + body, err := ioutil.ReadAll(res.Body) + res.Body.Close() + if res.StatusCode != 200 { + test.Errorf("Expected status code 200 got %d", res.StatusCode) + } + + // Check that it didn't contain any lists + var lists []List + err = json.Unmarshal(body, &lists) + checkFail(test, err) + + if len(lists) != 0 { + test.Errorf("Expected [] got %d", len(lists)) + } + + // Test creation of a list + b, err := json.Marshal(ListCreateRequest{Name: "Work"}) + checkFail(test, err) + res, err = http.Post(testServer.URL + "/list", "application/json", bytes.NewReader(b)) + checkFail(test, err) + + if res.StatusCode != 200 { + test.Errorf("Expected status code 200 got %d", res.StatusCode) + } + + // Make sure that the list is stored + res, err = http.Get(testServer.URL + "/list") + checkFail(test, err) + body, err = ioutil.ReadAll(res.Body) + res.Body.Close() + + if res.StatusCode != 200 { + test.Errorf("Expected status code 200 got %d", res.StatusCode) + } + + err = json.Unmarshal(body, &lists) + checkFail(test, err) + if len(lists) != 1 { + test.Errorf("Expected [] got len(%d)", len(lists)) + } +}