├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── api.go ├── auth.go ├── auth_test.go ├── config.go ├── db.go ├── go.mod ├── go.sum ├── main.go ├── projects.go ├── projects_test.go ├── store.go ├── store_test.go ├── tasks.go ├── tasks_test.go ├── types.go ├── users.go ├── users_test.go └── utils.go /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | .envrc 3 | .env -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | # Build the application from source 4 | FROM golang:1.21.7 AS build-stage 5 | WORKDIR /app 6 | 7 | COPY go.mod go.sum ./ 8 | RUN go mod download 9 | 10 | COPY *.go ./ 11 | 12 | RUN CGO_ENABLED=0 GOOS=linux go build -o /api 13 | 14 | # Run the tests in the container 15 | FROM build-stage AS run-test-stage 16 | RUN go test -v ./... 17 | 18 | # Deploy the application binary into a lean image 19 | FROM scratch AS build-release-stage 20 | WORKDIR / 21 | 22 | COPY --from=build-stage /api /api 23 | 24 | EXPOSE 8080 25 | 26 | ENTRYPOINT ["/api"] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | run: build 2 | @./bin/api 3 | 4 | build: 5 | @go build -o bin/api 6 | 7 | test: 8 | @go test -v ./... 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go REST API 2 | 3 | Simple REST API with Go with minimal dependencies. Perfect starting point for a new project. 4 | 5 | ## Project 6 | 7 | The API is divided into smaller services that are responsible for a specific part of the application. 8 | Each service is the main package to not be opinionated about the project structure. 9 | 10 | Example: 11 | ``` 12 | . 13 | users.go // Service for users 14 | users_test.go // Tests for the users service 15 | ``` 16 | 17 | ## Run 18 | 19 | To run the project, you need to have Go installed. Then, you can run the following command: 20 | 21 | ```bash 22 | make run 23 | // or Docker 24 | ``` 25 | 26 | Also make sure to have the environment variables set from the `config.go` file. 27 | I recommend injecting the variables in runtime. I personally use [direnv](https://direnv.net/) for that. 28 | 29 | ## Test 30 | 31 | ```bash 32 | make test 33 | ``` 34 | 35 | ## Deploy 36 | 37 | Just Docker build and run into your favorite cloud provider. -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | ) 9 | 10 | type APIServer struct { 11 | addr string 12 | store Store 13 | } 14 | 15 | func NewAPIServer(addr string, store Store) *APIServer { 16 | return &APIServer{ 17 | addr: addr, 18 | store: store, 19 | } 20 | } 21 | 22 | func (s *APIServer) Serve() { 23 | router := mux.NewRouter() 24 | subrouter := router.PathPrefix("/api/v1").Subrouter() 25 | 26 | projectService := NewProjectService(s.store) 27 | projectService.RegisterRoutes(subrouter) 28 | 29 | userService := NewUserService(s.store) 30 | userService.RegisterRoutes(subrouter) 31 | 32 | 33 | tasksService := NewTasksService(s.store) 34 | tasksService.RegisterRoutes(subrouter) 35 | 36 | log.Println("Starting the API server at", s.addr) 37 | 38 | log.Fatal(http.ListenAndServe(s.addr, subrouter)) 39 | } 40 | -------------------------------------------------------------------------------- /auth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "os" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/golang-jwt/jwt" 12 | "golang.org/x/crypto/bcrypt" 13 | ) 14 | 15 | func WithJWTAuth(handlerFunc http.HandlerFunc, store Store) http.HandlerFunc { 16 | return func(w http.ResponseWriter, r *http.Request) { 17 | tokenString := GetTokenFromRequest(r) 18 | 19 | token, err := validateJWT(tokenString) 20 | if err != nil { 21 | log.Printf("failed to validate token: %v", err) 22 | permissionDenied(w) 23 | return 24 | } 25 | 26 | if !token.Valid { 27 | log.Println("invalid token") 28 | permissionDenied(w) 29 | return 30 | } 31 | 32 | claims := token.Claims.(jwt.MapClaims) 33 | userID := claims["userID"].(string) 34 | 35 | _, err = store.GetUserByID(userID) 36 | if err != nil { 37 | log.Printf("failed to get user by id: %v", err) 38 | permissionDenied(w) 39 | return 40 | } 41 | 42 | // Call the function if the token is valid 43 | handlerFunc(w, r) 44 | } 45 | } 46 | 47 | func CreateJWT(secret []byte, userID int64) (string, error) { 48 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 49 | "userID": strconv.Itoa(int(userID)), 50 | "expiresAt": time.Now().Add(time.Hour * 24 * 120).Unix(), 51 | }) 52 | 53 | tokenString, err := token.SignedString(secret) 54 | if err != nil { 55 | return "", err 56 | } 57 | 58 | return tokenString, err 59 | } 60 | 61 | func HashPassword(password string) (string, error) { 62 | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 63 | if err != nil { 64 | return "", err 65 | } 66 | 67 | return string(hash), nil 68 | } 69 | 70 | func validateJWT(tokenString string) (*jwt.Token, error) { 71 | secret := os.Getenv("JWT_SECRET") 72 | 73 | return jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { 74 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 75 | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 76 | } 77 | 78 | return []byte(secret), nil 79 | }) 80 | } 81 | 82 | func permissionDenied(w http.ResponseWriter) { 83 | WriteJSON(w, http.StatusUnauthorized, ErrorResponse{ 84 | Error: fmt.Errorf("permission denied").Error(), 85 | }) 86 | } 87 | -------------------------------------------------------------------------------- /auth_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCreateJWT(t *testing.T) { 8 | secret := []byte("secret") 9 | 10 | userID := int64(49) 11 | 12 | token, err := CreateJWT(secret, userID) 13 | if err != nil { 14 | t.Errorf("error creating JWT: %v", err) 15 | } 16 | 17 | if token == "" { 18 | t.Errorf("expected token to not be empty") 19 | } 20 | } 21 | 22 | func TestHashPassword(t *testing.T) { 23 | hash, err := HashPassword("password") 24 | if err != nil { 25 | t.Errorf("error hashing password: %v", err) 26 | } 27 | 28 | if len(hash) == 0 { 29 | t.Errorf("expected hash to not be empty") 30 | } 31 | 32 | if string(hash) == "password" { 33 | t.Errorf("expected hash to not be equal to password") 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | type Config struct { 9 | Port string 10 | DBUser string 11 | DBPassword string 12 | DBAddress string 13 | DBName string 14 | JWTSecret string 15 | } 16 | 17 | var Envs = initConfig() 18 | 19 | func initConfig() Config { 20 | return Config{ 21 | Port: getEnv("PORT", "8080"), 22 | DBUser: getEnv("DB_USER", "root"), 23 | DBPassword: getEnv("DB_PASSWORD", "password"), 24 | DBAddress: fmt.Sprintf("%s:%s", getEnv("DB_HOST", "127.0.0.1"), getEnv("DB_PORT", "3306")), 25 | DBName: getEnv("DB_NAME", "projectmanager"), 26 | JWTSecret: getEnv("JWT_SECRET", "randomjwtsecretkey"), 27 | } 28 | } 29 | 30 | func getEnv(key, fallback string) string { 31 | if value, ok := os.LookupEnv(key); ok { 32 | return value 33 | } 34 | 35 | return fallback 36 | } 37 | -------------------------------------------------------------------------------- /db.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/go-sql-driver/mysql" 9 | ) 10 | 11 | type MySQLStorage struct { 12 | db *sql.DB 13 | } 14 | 15 | func NewMySQLStorage(cfg mysql.Config) *MySQLStorage { 16 | db, err := sql.Open("mysql", cfg.FormatDSN()) 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | 21 | err = db.Ping() 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | 26 | fmt.Println("Connected to MySQL!") 27 | 28 | return &MySQLStorage{db: db} 29 | } 30 | 31 | func (s *MySQLStorage) Init() (*sql.DB, error) { 32 | // initialize the tables 33 | if err := s.createUsersTable(); err != nil { 34 | return nil, err 35 | } 36 | 37 | if err := s.createProjectsTable(); err != nil { 38 | return nil, err 39 | } 40 | 41 | if err := s.createTasksTable(); err != nil { 42 | return nil, err 43 | } 44 | 45 | return s.db, nil 46 | } 47 | 48 | func (s *MySQLStorage) createUsersTable() error { 49 | _, err := s.db.Exec(` 50 | CREATE TABLE IF NOT EXISTS users ( 51 | id INT UNSIGNED NOT NULL AUTO_INCREMENT, 52 | email VARCHAR(255) NOT NULL, 53 | firstName VARCHAR(255) NOT NULL, 54 | lastName VARCHAR(255) NOT NULL, 55 | password VARCHAR(255) NOT NULL, 56 | createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 57 | 58 | PRIMARY KEY (id), 59 | UNIQUE KEY (email) 60 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 61 | `) 62 | 63 | return err 64 | } 65 | 66 | func (s *MySQLStorage) createProjectsTable() error { 67 | _, err := s.db.Exec(` 68 | CREATE TABLE IF NOT EXISTS projects ( 69 | id INT UNSIGNED NOT NULL AUTO_INCREMENT, 70 | name VARCHAR(255) NOT NULL, 71 | createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 72 | 73 | PRIMARY KEY (id) 74 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 75 | `) 76 | 77 | return err 78 | } 79 | 80 | func (s *MySQLStorage) createTasksTable() error { 81 | _, err := s.db.Exec(` 82 | CREATE TABLE IF NOT EXISTS tasks ( 83 | id INT UNSIGNED NOT NULL AUTO_INCREMENT, 84 | name VARCHAR(255) NOT NULL, 85 | status ENUM('TODO', 'IN_PROGRESS', 'IN_TESTING', 'DONE') NOT NULL DEFAULT 'TODO', 86 | projectId INT UNSIGNED NOT NULL, 87 | AssignedToID INT UNSIGNED NOT NULL, 88 | createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 89 | 90 | PRIMARY KEY (id), 91 | FOREIGN KEY (AssignedToID) REFERENCES users(id), 92 | FOREIGN KEY (projectId) REFERENCES projects(id) 93 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 94 | `) 95 | 96 | return err 97 | } 98 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sikozonpc/rest-api 2 | 3 | go 1.21.7 4 | 5 | require ( 6 | github.com/go-sql-driver/mysql v1.7.1 7 | github.com/golang-jwt/jwt v3.2.2+incompatible 8 | github.com/gorilla/mux v1.8.1 9 | golang.org/x/crypto v0.19.0 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= 2 | github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= 3 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 4 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 5 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 6 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 7 | golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= 8 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 9 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/go-sql-driver/mysql" 7 | ) 8 | 9 | func main() { 10 | cfg := mysql.Config{ 11 | User: Envs.DBUser, 12 | Passwd: Envs.DBPassword, 13 | Addr: Envs.DBAddress, 14 | DBName: Envs.DBName, 15 | Net: "tcp", 16 | AllowNativePasswords: true, 17 | ParseTime: true, 18 | } 19 | 20 | sqlStorage := NewMySQLStorage(cfg) 21 | 22 | db, err := sqlStorage.Init() 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | 27 | store := NewStore(db) 28 | 29 | server := NewAPIServer(":8080", store) 30 | server.Serve() 31 | } 32 | -------------------------------------------------------------------------------- /projects.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/gorilla/mux" 9 | ) 10 | 11 | type ProjectService struct { 12 | store Store 13 | } 14 | 15 | func NewProjectService(s Store) *ProjectService { 16 | return &ProjectService{store: s} 17 | } 18 | 19 | func (s *ProjectService) RegisterRoutes(r *mux.Router) { 20 | r.HandleFunc("/projects", WithJWTAuth(s.handleCreateProject, s.store)).Methods("POST") 21 | r.HandleFunc("/projects/{id}", WithJWTAuth(s.handleGetProject, s.store)).Methods("GET") 22 | r.HandleFunc("/projects/{id}", WithJWTAuth(s.handleDeleteProject, s.store)).Methods("DELETE") 23 | } 24 | 25 | func (s *ProjectService) handleCreateProject(w http.ResponseWriter, r *http.Request) { 26 | body, err := io.ReadAll(r.Body) 27 | if err != nil { 28 | http.Error(w, "Error reading request body", http.StatusBadRequest) 29 | return 30 | } 31 | 32 | defer r.Body.Close() 33 | 34 | var project *Project 35 | err = json.Unmarshal(body, &project) 36 | if err != nil { 37 | WriteJSON(w, http.StatusBadRequest, ErrorResponse{Error: "Invalid request payload"}) 38 | return 39 | } 40 | 41 | if project.Name == "" { 42 | WriteJSON(w, http.StatusBadRequest, ErrorResponse{Error: "Name is required"}) 43 | return 44 | } 45 | 46 | err = s.store.CreateProject(project) 47 | if err != nil { 48 | WriteJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "Error creating project"}) 49 | return 50 | } 51 | 52 | WriteJSON(w, http.StatusCreated, project) 53 | } 54 | 55 | func (s *ProjectService) handleGetProject(w http.ResponseWriter, r *http.Request) { 56 | vars := mux.Vars(r) 57 | id := vars["id"] 58 | 59 | project, err := s.store.GetProject(id) 60 | if err != nil { 61 | WriteJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "Error getting project"}) 62 | return 63 | } 64 | 65 | WriteJSON(w, http.StatusOK, project) 66 | } 67 | 68 | func (s *ProjectService) handleDeleteProject(w http.ResponseWriter, r *http.Request) { 69 | vars := mux.Vars(r) 70 | id := vars["id"] 71 | 72 | err := s.store.DeleteProject(id) 73 | if err != nil { 74 | WriteJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "Error deleting project"}) 75 | return 76 | } 77 | 78 | WriteJSON(w, http.StatusNoContent, nil) 79 | } 80 | -------------------------------------------------------------------------------- /projects_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/gorilla/mux" 11 | ) 12 | 13 | func TestCreateProject(t *testing.T) { 14 | // Create a new project 15 | ms := &MockStore{} 16 | service := NewProjectService(ms) 17 | 18 | t.Run("should validate if the name is not empty", func(t *testing.T) { 19 | payload := &CreateProjectPayload{ 20 | Name: "", 21 | } 22 | 23 | b, err := json.Marshal(payload) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | req, err := http.NewRequest(http.MethodPost, "/projects", bytes.NewBuffer(b)) 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | 33 | rr := httptest.NewRecorder() 34 | router := mux.NewRouter() 35 | 36 | router.HandleFunc("/projects", service.handleCreateProject) 37 | 38 | router.ServeHTTP(rr, req) 39 | 40 | if rr.Code != http.StatusBadRequest { 41 | t.Errorf("expected status code %d, got %d", http.StatusBadRequest, rr.Code) 42 | } 43 | 44 | var response ErrorResponse 45 | err = json.NewDecoder(rr.Body).Decode(&response) 46 | if err != nil { 47 | t.Fatal(err) 48 | } 49 | 50 | if response.Error != "Name is required" { 51 | t.Errorf("expected error message %s, got %s", "Name is required", response.Error) 52 | } 53 | }) 54 | 55 | t.Run("should create a project", func(t *testing.T) { 56 | payload := &CreateProjectPayload{ 57 | Name: "Super cool project", 58 | } 59 | 60 | b, err := json.Marshal(payload) 61 | if err != nil { 62 | t.Fatal(err) 63 | } 64 | 65 | req, err := http.NewRequest(http.MethodPost, "/projects", bytes.NewBuffer(b)) 66 | if err != nil { 67 | t.Fatal(err) 68 | } 69 | 70 | rr := httptest.NewRecorder() 71 | router := mux.NewRouter() 72 | 73 | router.HandleFunc("/projects", service.handleCreateProject) 74 | 75 | router.ServeHTTP(rr, req) 76 | 77 | if rr.Code != http.StatusCreated { 78 | t.Errorf("expected status code %d, got %d", http.StatusCreated, rr.Code) 79 | } 80 | }) 81 | } 82 | 83 | func TestGetProject(t *testing.T) { 84 | // Create a new project 85 | ms := &MockStore{} 86 | service := NewProjectService(ms) 87 | 88 | t.Run("should return a project", func(t *testing.T) { 89 | req, err := http.NewRequest(http.MethodGet, "/projects", nil) 90 | if err != nil { 91 | t.Fatal(err) 92 | } 93 | 94 | rr := httptest.NewRecorder() 95 | router := mux.NewRouter() 96 | 97 | router.HandleFunc("/projects", service.handleGetProject) 98 | 99 | router.ServeHTTP(rr, req) 100 | 101 | if rr.Code != http.StatusOK { 102 | t.Errorf("expected status code %d, got %d", http.StatusOK, rr.Code) 103 | } 104 | 105 | var response Project 106 | err = json.NewDecoder(rr.Body).Decode(&response) 107 | if err != nil { 108 | t.Fatal(err) 109 | } 110 | 111 | if response.Name != "Super cool project" { 112 | t.Errorf("expected project name %s, got %s", "Super cool project", response.Name) 113 | } 114 | }) 115 | } 116 | 117 | func TestDeleteProject(t *testing.T) { 118 | // Create a new project 119 | ms := &MockStore{} 120 | service := NewProjectService(ms) 121 | 122 | t.Run("should delete the project", func(t *testing.T) { 123 | req, err := http.NewRequest(http.MethodDelete, "/projects", nil) 124 | if err != nil { 125 | t.Fatal(err) 126 | } 127 | 128 | rr := httptest.NewRecorder() 129 | router := mux.NewRouter() 130 | 131 | router.HandleFunc("/projects", service.handleDeleteProject) 132 | 133 | router.ServeHTTP(rr, req) 134 | 135 | if rr.Code != http.StatusNoContent { 136 | t.Errorf("expected status code %d, got %d", http.StatusNoContent, rr.Code) 137 | } 138 | }) 139 | } 140 | -------------------------------------------------------------------------------- /store.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "database/sql" 4 | 5 | type Storage struct { 6 | db *sql.DB 7 | } 8 | 9 | type Store interface { 10 | // Users 11 | CreateUser(u *User) (*User, error) 12 | GetUserByID(id string) (*User, error) 13 | // Projects 14 | CreateProject(p *Project) error 15 | GetProject(id string) (*Project, error) 16 | DeleteProject(id string) error 17 | // Tasks 18 | CreateTask(t *Task) (*Task, error) 19 | GetTask(id string) (*Task, error) 20 | } 21 | 22 | func NewStore(db *sql.DB) *Storage { 23 | return &Storage{ 24 | db: db, 25 | } 26 | } 27 | 28 | func (s *Storage) CreateProject(p *Project) error { 29 | _, err := s.db.Exec("INSERT INTO projects (name) VALUES (?)", p.Name) 30 | return err 31 | } 32 | 33 | func (s *Storage) GetProject(id string) (*Project, error) { 34 | var p Project 35 | err := s.db.QueryRow("SELECT id, name, createdAt FROM projects WHERE id = ?", id).Scan(&p.ID, &p.Name, &p.CreatedAt) 36 | return &p, err 37 | } 38 | 39 | func (s *Storage) DeleteProject(id string) error { 40 | _, err := s.db.Exec("DELETE FROM projects WHERE id = ?", id) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | return nil 46 | } 47 | 48 | func (s *Storage) CreateUser(u *User) (*User, error) { 49 | rows, err := s.db.Exec("INSERT INTO users (email, firstName, lastName, password) VALUES (?, ?, ?, ?)", u.Email, u.FirstName, u.LastName, u.Password) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | id, err := rows.LastInsertId() 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | u.ID = id 60 | return u, nil 61 | } 62 | 63 | func (s *Storage) GetUserByID(id string) (*User, error) { 64 | var u User 65 | err := s.db.QueryRow("SELECT id, email, firstName, lastName, createdAt FROM users WHERE id = ?", id).Scan(&u.ID, &u.Email, &u.FirstName, &u.LastName, &u.CreatedAt) 66 | return &u, err 67 | } 68 | 69 | func (s *Storage) CreateTask(t *Task) (*Task, error) { 70 | rows, err := s.db.Exec("INSERT INTO tasks (name, status, project_id, assigned_to) VALUES (?, ?, ?, ?)", t.Name, t.Status, t.ProjectID, t.AssignedToID) 71 | 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | id, err := rows.LastInsertId() 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | t.ID = id 82 | return t, nil 83 | } 84 | 85 | func (s *Storage) GetTask(id string) (*Task, error) { 86 | var t Task 87 | err := s.db.QueryRow("SELECT id, name, status, project_id, assigned_to, createdAt FROM tasks WHERE id = ?", id).Scan(&t.ID, &t.Name, &t.Status, &t.ProjectID, &t.AssignedToID, &t.CreatedAt) 88 | return &t, err 89 | } 90 | -------------------------------------------------------------------------------- /store_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Mocks 4 | 5 | type MockStore struct{} 6 | 7 | func (s *MockStore) CreateProject(p *Project) error { 8 | return nil 9 | } 10 | 11 | func (s *MockStore) GetProject(id string) (*Project, error) { 12 | return &Project{Name: "Super cool project"}, nil 13 | } 14 | 15 | func (s *MockStore) DeleteProject(id string) error { 16 | return nil 17 | } 18 | 19 | func (s *MockStore) CreateUser(u *User) (*User, error) { 20 | return &User{}, nil 21 | } 22 | 23 | func (s *MockStore) GetUserByID(id string) (*User, error) { 24 | return &User{}, nil 25 | } 26 | 27 | func (s *MockStore) CreateTask(t *Task) (*Task, error) { 28 | return &Task{}, nil 29 | } 30 | 31 | func (s *MockStore) GetTask(id string) (*Task, error) { 32 | return &Task{}, nil 33 | } 34 | -------------------------------------------------------------------------------- /tasks.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/gorilla/mux" 10 | ) 11 | 12 | var errNameRequired = errors.New("name is required") 13 | var errProjectIDRequired = errors.New("project id is required") 14 | var errUserIDRequired = errors.New("user id is required") 15 | 16 | type TasksService struct { 17 | store Store 18 | } 19 | 20 | func NewTasksService(s Store) *TasksService { 21 | return &TasksService{store: s} 22 | } 23 | 24 | func (s *TasksService) RegisterRoutes(r *mux.Router) { 25 | r.HandleFunc("/tasks", WithJWTAuth(s.handleCreateTask, s.store)).Methods("POST") 26 | r.HandleFunc("/tasks/{id}", WithJWTAuth(s.handleGetTask, s.store)).Methods("GET") 27 | } 28 | 29 | func (s *TasksService) handleCreateTask(w http.ResponseWriter, r *http.Request) { 30 | body, err := io.ReadAll(r.Body) 31 | if err != nil { 32 | http.Error(w, "Error reading request body", http.StatusBadRequest) 33 | return 34 | } 35 | 36 | defer r.Body.Close() 37 | 38 | var task *Task 39 | err = json.Unmarshal(body, &task) 40 | if err != nil { 41 | WriteJSON(w, http.StatusBadRequest, ErrorResponse{Error: "Invalid request payload"}) 42 | return 43 | } 44 | 45 | if err := validateTaskPayload(task); err != nil { 46 | WriteJSON(w, http.StatusBadRequest, ErrorResponse{Error: err.Error()}) 47 | return 48 | } 49 | 50 | t, err := s.store.CreateTask(task) 51 | if err != nil { 52 | WriteJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "Error creating task"}) 53 | return 54 | } 55 | 56 | WriteJSON(w, http.StatusCreated, t) 57 | } 58 | 59 | func (s *TasksService) handleGetTask(w http.ResponseWriter, r *http.Request) { 60 | 61 | } 62 | 63 | func validateTaskPayload(task *Task) error { 64 | if task.Name == "" { 65 | return errNameRequired 66 | } 67 | 68 | if task.ProjectID == 0 { 69 | return errProjectIDRequired 70 | } 71 | 72 | if task.AssignedToID == 0 { 73 | return errUserIDRequired 74 | } 75 | 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /tasks_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/gorilla/mux" 11 | ) 12 | 13 | func TestCreateTask(t *testing.T) { 14 | ms := &MockStore{} 15 | service := NewTasksService(ms) 16 | 17 | t.Run("should return error if name is empty", func(t *testing.T) { 18 | payload := &CreateTaskPayload{ 19 | Name: "", 20 | } 21 | 22 | b, err := json.Marshal(payload) 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | req, err := http.NewRequest(http.MethodPost, "/tasks", bytes.NewBuffer(b)) 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | 32 | rr := httptest.NewRecorder() 33 | router := mux.NewRouter() 34 | 35 | router.HandleFunc("/tasks", service.handleCreateTask) 36 | 37 | router.ServeHTTP(rr, req) 38 | 39 | if rr.Code != http.StatusBadRequest { 40 | t.Errorf("expected status code %d, got %d", http.StatusBadRequest, rr.Code) 41 | } 42 | 43 | var response ErrorResponse 44 | err = json.NewDecoder(rr.Body).Decode(&response) 45 | if err != nil { 46 | t.Fatal(err) 47 | } 48 | 49 | if response.Error != errNameRequired.Error() { 50 | t.Errorf("expected error message %s, got %s", response.Error, errNameRequired.Error()) 51 | } 52 | }) 53 | 54 | t.Run("should create a task", func(t *testing.T) { 55 | payload := &CreateTaskPayload{ 56 | Name: "Creating a REST API in go", 57 | ProjectID: 1, 58 | AssignedToID: 42, 59 | } 60 | 61 | b, err := json.Marshal(payload) 62 | if err != nil { 63 | t.Fatal(err) 64 | } 65 | 66 | req, err := http.NewRequest(http.MethodPost, "/tasks", bytes.NewBuffer(b)) 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | 71 | rr := httptest.NewRecorder() 72 | router := mux.NewRouter() 73 | 74 | router.HandleFunc("/tasks", service.handleCreateTask) 75 | 76 | router.ServeHTTP(rr, req) 77 | 78 | if rr.Code != http.StatusCreated { 79 | t.Errorf("expected status code %d, got %d", http.StatusCreated, rr.Code) 80 | } 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "time" 4 | 5 | type CreateProjectPayload struct { 6 | Name string `json:"name"` 7 | } 8 | 9 | type Project struct { 10 | ID int64 `json:"id"` 11 | Name string `json:"name"` 12 | CreatedAt time.Time `json:"createdAt"` 13 | } 14 | 15 | type RegisterPayload struct { 16 | Email string `json:"email"` 17 | FirstName string `json:"firstName"` 18 | LastName string `json:"lastName"` 19 | Password string `json:"password"` 20 | } 21 | 22 | type User struct { 23 | ID int64 `json:"id"` 24 | Email string `json:"email"` 25 | FirstName string `json:"firstName"` 26 | LastName string `json:"lastName"` 27 | Password string `json:"password"` 28 | CreatedAt time.Time `json:"createdAt"` 29 | } 30 | 31 | type CreateTaskPayload struct { 32 | Name string `json:"name"` 33 | ProjectID int64 `json:"projectID"` 34 | AssignedToID int64 `json:"assignedTo"` 35 | } 36 | 37 | type Task struct { 38 | ID int64 `json:"id"` 39 | Name string `json:"name"` 40 | Status string `json:"status"` 41 | ProjectID int64 `json:"projectID"` 42 | AssignedToID int64 `json:"assignedTo"` 43 | CreatedAt time.Time `json:"createdAt"` 44 | } 45 | 46 | type LoginRequest struct { 47 | Email string `json:"email"` 48 | Password string `json:"password"` 49 | } 50 | -------------------------------------------------------------------------------- /users.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/gorilla/mux" 10 | ) 11 | 12 | var errEmailRequired = errors.New("email is required") 13 | var errFirstNameRequired = errors.New("first name is required") 14 | var errLastNameRequired = errors.New("last name is required") 15 | var errPasswordRequired = errors.New("password is required") 16 | 17 | type UserService struct { 18 | store Store 19 | } 20 | 21 | func NewUserService(s Store) *UserService { 22 | return &UserService{store: s} 23 | } 24 | 25 | func (s *UserService) RegisterRoutes(r *mux.Router) { 26 | r.HandleFunc("/users/register", s.handleUserRegister).Methods("POST") 27 | r.HandleFunc("/users/login", s.handleUserLogin).Methods("POST") 28 | } 29 | 30 | func (s *UserService) handleUserRegister(w http.ResponseWriter, r *http.Request) { 31 | body, err := io.ReadAll(r.Body) 32 | if err != nil { 33 | http.Error(w, "Error reading request body", http.StatusBadRequest) 34 | return 35 | } 36 | 37 | defer r.Body.Close() 38 | 39 | var payload *User 40 | err = json.Unmarshal(body, &payload) 41 | if err != nil { 42 | WriteJSON(w, http.StatusBadRequest, ErrorResponse{Error: "Invalid request payload"}) 43 | return 44 | } 45 | 46 | if err := validateUserPayload(payload); err != nil { 47 | WriteJSON(w, http.StatusBadRequest, ErrorResponse{Error: err.Error()}) 48 | return 49 | } 50 | 51 | hashedPassword, err := HashPassword(payload.Password) 52 | if err != nil { 53 | WriteJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "Error creating user"}) 54 | return 55 | } 56 | payload.Password = hashedPassword 57 | 58 | u, err := s.store.CreateUser(payload) 59 | if err != nil { 60 | WriteJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "Error creating user"}) 61 | return 62 | } 63 | 64 | token, err := createAndSetAuthCookie(u.ID, w) 65 | if err != nil { 66 | WriteJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "Error creating user"}) 67 | return 68 | } 69 | 70 | WriteJSON(w, http.StatusCreated, token) 71 | } 72 | 73 | func (s *UserService) handleUserLogin(w http.ResponseWriter, r *http.Request) { 74 | // 1. Find user in db by email 75 | // 2. Compare password with hashed password 76 | // 3. Create JWT and set it in a cookie 77 | // 4. Return JWT in response 78 | } 79 | 80 | func validateUserPayload(user *User) error { 81 | if user.Email == "" { 82 | return errEmailRequired 83 | } 84 | 85 | if user.FirstName == "" { 86 | return errFirstNameRequired 87 | } 88 | 89 | if user.LastName == "" { 90 | return errLastNameRequired 91 | } 92 | 93 | if user.Password == "" { 94 | return errPasswordRequired 95 | } 96 | 97 | return nil 98 | } 99 | 100 | func createAndSetAuthCookie(userID int64, w http.ResponseWriter) (string, error) { 101 | secret := []byte(Envs.JWTSecret) 102 | token, err := CreateJWT(secret, userID) 103 | if err != nil { 104 | return "", err 105 | } 106 | 107 | http.SetCookie(w, &http.Cookie{ 108 | Name: "Authorization", 109 | Value: token, 110 | }) 111 | 112 | return token, nil 113 | } 114 | -------------------------------------------------------------------------------- /users_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/gorilla/mux" 11 | ) 12 | 13 | func TestValidateUserPayload(t *testing.T) { 14 | type args struct { 15 | user *User 16 | } 17 | tests := []struct { 18 | name string 19 | args args 20 | want error 21 | }{ 22 | 23 | { 24 | name: "should return error if email is empty", 25 | args: args{ 26 | user: &User{ 27 | FirstName: "John", 28 | LastName: "Doe", 29 | }, 30 | }, 31 | want: errEmailRequired, 32 | }, 33 | { 34 | name: "should return error if first name is empty", 35 | args: args{ 36 | user: &User{ 37 | Email: "joe@mail.com", 38 | LastName: "Doe", 39 | }, 40 | }, 41 | want: errFirstNameRequired, 42 | }, 43 | { 44 | name: "should return error if last name is empty", 45 | args: args{ 46 | user: &User{ 47 | Email: "joe@mail.com", 48 | FirstName: "John", 49 | }, 50 | }, 51 | want: errLastNameRequired, 52 | }, 53 | { 54 | name: "should return error if the password is empty", 55 | args: args{ 56 | user: &User{ 57 | Email: "joe@mail.com", 58 | FirstName: "John", 59 | }, 60 | }, 61 | want: errLastNameRequired, 62 | }, 63 | { 64 | name: "should return nil if all fields are present", 65 | args: args{ 66 | user: &User{ 67 | Email: "joe@mail.com", 68 | FirstName: "John", 69 | LastName: "Doe", 70 | Password: "password", 71 | }, 72 | }, 73 | want: nil, 74 | }, 75 | } 76 | 77 | for _, tt := range tests { 78 | t.Run(tt.name, func(t *testing.T) { 79 | if got := validateUserPayload(tt.args.user); got != tt.want { 80 | t.Errorf("validateUserPayload() = %v, want %v", got, tt.want) 81 | } 82 | }) 83 | } 84 | } 85 | 86 | func TestCreateUser(t *testing.T) { 87 | // Create a new project 88 | ms := &MockStore{} 89 | service := NewUserService(ms) 90 | 91 | t.Run("should validate if the email is not empty", func(t *testing.T) { 92 | payload := &RegisterPayload{ 93 | Email: "", 94 | FirstName: "John", 95 | LastName: "Doe", 96 | Password: "password", 97 | } 98 | 99 | b, err := json.Marshal(payload) 100 | if err != nil { 101 | t.Fatal(err) 102 | } 103 | 104 | req, err := http.NewRequest(http.MethodPost, "/users/register", bytes.NewBuffer(b)) 105 | if err != nil { 106 | t.Fatal(err) 107 | } 108 | 109 | rr := httptest.NewRecorder() 110 | router := mux.NewRouter() 111 | 112 | router.HandleFunc("/users/register", service.handleUserRegister) 113 | 114 | router.ServeHTTP(rr, req) 115 | 116 | if rr.Code != http.StatusBadRequest { 117 | t.Errorf("expected status code %d, got %d", http.StatusBadRequest, rr.Code) 118 | } 119 | 120 | var response ErrorResponse 121 | err = json.NewDecoder(rr.Body).Decode(&response) 122 | if err != nil { 123 | t.Fatal(err) 124 | } 125 | 126 | if response.Error != errEmailRequired.Error() { 127 | t.Errorf("expected error message %s, got %s", response.Error, errEmailRequired.Error()) 128 | } 129 | }) 130 | 131 | t.Run("should create a user", func(t *testing.T) { 132 | payload := &RegisterPayload{ 133 | Email: "joe@mail.com", 134 | FirstName: "John", 135 | LastName: "Doe", 136 | Password: "password", 137 | } 138 | 139 | b, err := json.Marshal(payload) 140 | if err != nil { 141 | t.Fatal(err) 142 | } 143 | 144 | req, err := http.NewRequest(http.MethodPost, "/users/register", bytes.NewBuffer(b)) 145 | if err != nil { 146 | t.Fatal(err) 147 | } 148 | 149 | rr := httptest.NewRecorder() 150 | router := mux.NewRouter() 151 | 152 | router.HandleFunc("/users/register", service.handleUserRegister) 153 | 154 | router.ServeHTTP(rr, req) 155 | 156 | if rr.Code != http.StatusCreated { 157 | t.Errorf("expected status code %d, got %d", http.StatusCreated, rr.Code) 158 | } 159 | }) 160 | } 161 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | type ErrorResponse struct { 9 | Error string `json:"error"` 10 | } 11 | 12 | func WriteJSON(w http.ResponseWriter, status int, v any) { 13 | w.Header().Set("Content-Type", "application/json") 14 | w.WriteHeader(status) 15 | json.NewEncoder(w).Encode(v) 16 | } 17 | 18 | func GetTokenFromRequest(r *http.Request) string { 19 | tokenAuth := r.Header.Get("Authorization") 20 | tokenQuery := r.URL.Query().Get("token") 21 | 22 | if tokenAuth != "" { 23 | return tokenAuth 24 | } 25 | 26 | if tokenQuery != "" { 27 | return tokenQuery 28 | } 29 | 30 | return "" 31 | } 32 | --------------------------------------------------------------------------------