From 7c007d351d935336b0b9f0d78fb2d9a647be734d Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Mon, 9 Jun 2025 12:29:24 -0600 Subject: [PATCH] Init --- .gitignore | 1 + Makefile | 25 +++++++ go.mod | 3 + main.go | 36 ++++++++++ src/handlers/todo_handler.go | 123 +++++++++++++++++++++++++++++++++++ src/models/todo.go | 26 ++++++++ src/router/router.go | 91 ++++++++++++++++++++++++++ src/storage/todo_storage.go | 110 +++++++++++++++++++++++++++++++ 8 files changed, 415 insertions(+) create mode 100644 Makefile create mode 100644 go.mod create mode 100644 main.go create mode 100644 src/handlers/todo_handler.go create mode 100644 src/models/todo.go create mode 100644 src/router/router.go create mode 100644 src/storage/todo_storage.go diff --git a/.gitignore b/.gitignore index 5b90e79..44f532d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ go.work.sum # env file .env +bin/ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..182048f --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +.PHONY: run build test clean help + +# Default target +help: + @echo "Available commands:" + @echo " run - Run the API server" + @echo " build - Build the API binary" + @echo " test - Run tests" + @echo " clean - Clean build artifacts" + +# Run the API server +run: + go run main.go + +# Build the API binary +build: + go build -o bin/api main.go + +# Run tests +test: + go test ./... + +# Clean build artifacts +clean: + rm -rf bin/ \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a26f99b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module goapi-template + +go 1.24 diff --git a/main.go b/main.go new file mode 100644 index 0000000..d7b8796 --- /dev/null +++ b/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + + "goapi-template/src/router" +) + +func main() { + // Get port from environment or use default + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + host := os.Getenv("HOST") + if host == "" { + host = "localhost" + } + + // Setup router + mux := router.SetupRoutes() + + // Start server + fmt.Printf("Todo API running at http://%s:%s/\n", host, port) + fmt.Println("Available endpoints:") + fmt.Println(" GET /todos - Get all todos") + fmt.Println(" POST /todos - Create todo") + fmt.Println(" PUT /todos/:id - Update todo") + fmt.Println(" DELETE /todos/:id - Delete todo") + + log.Fatal(http.ListenAndServe(":"+port, mux)) +} diff --git a/src/handlers/todo_handler.go b/src/handlers/todo_handler.go new file mode 100644 index 0000000..2e6090c --- /dev/null +++ b/src/handlers/todo_handler.go @@ -0,0 +1,123 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "goapi-template/src/models" + "goapi-template/src/storage" +) + +// TodoHandler handles todo-related HTTP requests +type TodoHandler struct { + storage *storage.TodoStorage +} + +// NewTodoHandler creates a new TodoHandler +func NewTodoHandler(storage *storage.TodoStorage) *TodoHandler { + return &TodoHandler{storage: storage} +} + +// GetTodos handles GET /todos +func (h *TodoHandler) GetTodos(w http.ResponseWriter, r *http.Request) { + todos := h.storage.GetAll() + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(todos); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + +// CreateTodo handles POST /todos +func (h *TodoHandler) CreateTodo(w http.ResponseWriter, r *http.Request) { + var req models.CreateTodoRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + if req.Title == "" { + http.Error(w, "Title is required", http.StatusBadRequest) + return + } + + todo := h.storage.Create(req) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(todo); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + +// UpdateTodo handles PUT /todos/:id +func (h *TodoHandler) UpdateTodo(w http.ResponseWriter, r *http.Request) { + id, err := h.extractIDFromPath(r.URL.Path) + if err != nil { + http.Error(w, "Invalid todo ID", http.StatusBadRequest) + return + } + + var req models.UpdateTodoRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + todo, err := h.storage.Update(id, req) + if err != nil { + if err == storage.ErrTodoNotFound { + http.Error(w, "Todo not found", http.StatusNotFound) + return + } + http.Error(w, "Failed to update todo", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(todo); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + +// DeleteTodo handles DELETE /todos/:id +func (h *TodoHandler) DeleteTodo(w http.ResponseWriter, r *http.Request) { + id, err := h.extractIDFromPath(r.URL.Path) + if err != nil { + http.Error(w, "Invalid todo ID", http.StatusBadRequest) + return + } + + err = h.storage.Delete(id) + if err != nil { + if err == storage.ErrTodoNotFound { + http.Error(w, "Todo not found", http.StatusNotFound) + return + } + http.Error(w, "Failed to delete todo", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// extractIDFromPath extracts the ID from a URL path like /todos/123 +func (h *TodoHandler) extractIDFromPath(path string) (int, error) { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) != 2 || parts[0] != "todos" { + return 0, fmt.Errorf("invalid path format") + } + + id, err := strconv.Atoi(parts[1]) + if err != nil { + return 0, fmt.Errorf("invalid ID format") + } + + return id, nil +} diff --git a/src/models/todo.go b/src/models/todo.go new file mode 100644 index 0000000..beafa8e --- /dev/null +++ b/src/models/todo.go @@ -0,0 +1,26 @@ +package models + +import "time" + +// Todo represents a todo item +type Todo struct { + ID int `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Completed bool `json:"completed"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateTodoRequest represents the request body for creating a todo +type CreateTodoRequest struct { + Title string `json:"title"` + Description string `json:"description,omitempty"` +} + +// UpdateTodoRequest represents the request body for updating a todo +type UpdateTodoRequest struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Completed *bool `json:"completed,omitempty"` +} \ No newline at end of file diff --git a/src/router/router.go b/src/router/router.go new file mode 100644 index 0000000..851ed43 --- /dev/null +++ b/src/router/router.go @@ -0,0 +1,91 @@ +package router + +import ( + "encoding/json" + "net/http" + "strings" + + "goapi-template/src/handlers" + "goapi-template/src/storage" +) + +// SetupRoutes configures and returns the HTTP router +func SetupRoutes() *http.ServeMux { + mux := http.NewServeMux() + + // Initialize storage and handlers + todoStorage := storage.NewTodoStorage() + todoHandler := handlers.NewTodoHandler(todoStorage) + + // Setup todo routes + mux.HandleFunc("/todos", func(w http.ResponseWriter, r *http.Request) { + // Enable CORS + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + switch r.Method { + case "GET": + todoHandler.GetTodos(w, r) + case "POST": + todoHandler.CreateTodo(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + mux.HandleFunc("/todos/", func(w http.ResponseWriter, r *http.Request) { + // Enable CORS + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + // Check if this is a specific todo request (has ID) + if strings.Count(strings.Trim(r.URL.Path, "/"), "/") != 1 { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + switch r.Method { + case "PUT": + todoHandler.UpdateTodo(w, r) + case "DELETE": + todoHandler.DeleteTodo(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + // Root endpoint showing available endpoints + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + response := map[string]interface{}{ + "message": "Todo API", + "endpoints": map[string]string{ + "GET /todos": "Get all todos", + "POST /todos": "Create todo", + "PUT /todos/:id": "Update todo", + "DELETE /todos/:id": "Delete todo", + }, + } + json.NewEncoder(w).Encode(response) + }) + + return mux +} diff --git a/src/storage/todo_storage.go b/src/storage/todo_storage.go new file mode 100644 index 0000000..44a31c8 --- /dev/null +++ b/src/storage/todo_storage.go @@ -0,0 +1,110 @@ +package storage + +import ( + "errors" + "sync" + "time" + + "goapi-template/src/models" +) + +var ( + ErrTodoNotFound = errors.New("todo not found") +) + +// TodoStorage provides in-memory storage for todos +type TodoStorage struct { + todos map[int]*models.Todo + nextID int + mutex sync.RWMutex +} + +// NewTodoStorage creates a new TodoStorage instance +func NewTodoStorage() *TodoStorage { + return &TodoStorage{ + todos: make(map[int]*models.Todo), + nextID: 1, + } +} + +// GetAll returns all todos +func (ts *TodoStorage) GetAll() []*models.Todo { + ts.mutex.RLock() + defer ts.mutex.RUnlock() + + todos := make([]*models.Todo, 0, len(ts.todos)) + for _, todo := range ts.todos { + todos = append(todos, todo) + } + return todos +} + +// GetByID returns a todo by ID +func (ts *TodoStorage) GetByID(id int) (*models.Todo, error) { + ts.mutex.RLock() + defer ts.mutex.RUnlock() + + todo, exists := ts.todos[id] + if !exists { + return nil, ErrTodoNotFound + } + return todo, nil +} + +// Create creates a new todo +func (ts *TodoStorage) Create(req models.CreateTodoRequest) *models.Todo { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + now := time.Now() + todo := &models.Todo{ + ID: ts.nextID, + Title: req.Title, + Description: req.Description, + Completed: false, + CreatedAt: now, + UpdatedAt: now, + } + + ts.todos[ts.nextID] = todo + ts.nextID++ + + return todo +} + +// Update updates an existing todo +func (ts *TodoStorage) Update(id int, req models.UpdateTodoRequest) (*models.Todo, error) { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + todo, exists := ts.todos[id] + if !exists { + return nil, ErrTodoNotFound + } + + if req.Title != nil { + todo.Title = *req.Title + } + if req.Description != nil { + todo.Description = *req.Description + } + if req.Completed != nil { + todo.Completed = *req.Completed + } + todo.UpdatedAt = time.Now() + + return todo, nil +} + +// Delete deletes a todo by ID +func (ts *TodoStorage) Delete(id int) error { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + if _, exists := ts.todos[id]; !exists { + return ErrTodoNotFound + } + + delete(ts.todos, id) + return nil +}