This commit is contained in:
2025-06-09 12:29:24 -06:00
parent 50aafc932c
commit 7c007d351d
8 changed files with 415 additions and 0 deletions

1
.gitignore vendored
View File

@ -25,3 +25,4 @@ go.work.sum
# env file
.env
bin/

25
Makefile Normal file
View File

@ -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/

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module goapi-template
go 1.24

36
main.go Normal file
View File

@ -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))
}

View File

@ -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
}

26
src/models/todo.go Normal file
View File

@ -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"`
}

91
src/router/router.go Normal file
View File

@ -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
}

110
src/storage/todo_storage.go Normal file
View File

@ -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
}