diff --git a/go-todo-app/.gitignore b/go-todo-app/.gitignore new file mode 100644 index 0000000..b9df8d7 --- /dev/null +++ b/go-todo-app/.gitignore @@ -0,0 +1,32 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo + +# SQLite database +*.db +*.sqlite +*.sqlite3 + +# Binary +/go-todo-app \ No newline at end of file diff --git a/go-todo-app/Makefile b/go-todo-app/Makefile new file mode 100644 index 0000000..21b1ae0 --- /dev/null +++ b/go-todo-app/Makefile @@ -0,0 +1,36 @@ +.PHONY: build run clean test + +# Build variables +BINARY_NAME=go-todo-app +MAIN_PATH=./cmd/api + +# Default target +all: build + +# Build the application +build: + go build -o $(BINARY_NAME) $(MAIN_PATH) + +# Run the application in development mode +run: + go run $(MAIN_PATH)/main.go + +# Clean build artifacts +clean: + go clean + rm -f $(BINARY_NAME) + +# Run tests +test: + go test -v ./... + +# Format code +fmt: + go fmt ./... + +# Check for issues +vet: + go vet ./... + +# All-in-one check +check: fmt vet test \ No newline at end of file diff --git a/go-todo-app/README.md b/go-todo-app/README.md new file mode 100644 index 0000000..465f5fc --- /dev/null +++ b/go-todo-app/README.md @@ -0,0 +1,162 @@ +# Go Todo App + +A simple Todo application API built with Go, Gin, and SQLite. This is a Go rewrite of the original FastAPI Python application. + +## Features + +- RESTful API for managing todo items +- SQLite database for persistent storage +- CORS support for cross-origin requests +- Health check endpoint +- Pagination and filtering support + +## Technology Stack + +- [Go](https://golang.org/) - Programming language +- [Gin](https://github.com/gin-gonic/gin) - Web framework +- [GORM](https://gorm.io/) - ORM library +- [SQLite](https://www.sqlite.org/) - Database +- [Viper](https://github.com/spf13/viper) - Configuration management + +## API Endpoints + +| Method | URL | Description | +|--------|--------------------|-----------------------------| +| GET | /health | Health check | +| GET | /api/todos | List all todos | +| POST | /api/todos | Create a new todo | +| GET | /api/todos/:id | Get a specific todo | +| PUT | /api/todos/:id | Update a todo | +| DELETE | /api/todos/:id | Delete a todo | + +## Query Parameters + +The `GET /api/todos` endpoint supports the following query parameters: + +- `skip` - Number of items to skip (default: 0) +- `limit` - Maximum number of items to return (default: 100) +- `completed` - Filter by completion status (boolean, optional) + +## Prerequisites + +- Go 1.18 or higher +- Git + +## Getting Started + +### Clone the repository + +```bash +git clone https://github.com/yourusername/go-todo-app.git +cd go-todo-app +``` + +### Build and run the application + +```bash +# Build the application +go build -o go-todo-app ./cmd/api + +# Run the application +./go-todo-app +``` + +The API will be available at http://localhost:8000 + +### Development mode + +```bash +go run cmd/api/main.go +``` + +## Configuration + +The application can be configured using environment variables or a config file. The following settings are available: + +| Environment Variable | Description | Default Value | +|----------------------|-------------------------------|---------------------------------------------| +| APP_NAME | Application name | Go Todo App | +| APP_DESCRIPTION | Application description | A simple Todo application API... | +| APP_VERSION | Application version | 0.1.0 | +| SERVER_PORT | HTTP server port | 8000 | +| DB_PATH | Database directory path | /app/storage/db | +| DB_NAME | Database file name | db.sqlite | + +## Project Structure + +``` +. +├── cmd/ +│ └── api/ +│ └── main.go # Application entry point +├── internal/ +│ ├── config/ # Application configuration +│ │ └── config.go +│ ├── api/ # API routes and handlers +│ │ ├── api.go # API initialization +│ │ ├── middleware.go # Middleware functions +│ │ ├── todo.go # Todo route handlers +│ │ └── health.go # Health check route handler +│ ├── database/ # Database connection and initialization +│ │ └── db.go +│ ├── model/ # Database models (GORM) +│ │ └── todo.go +│ ├── dto/ # Data transfer objects +│ │ └── todo.go +│ └── service/ # Business logic +│ └── todo.go +├── migrations/ # Database migration files +├── storage/ # Storage directory +│ └── db/ # Database files location +├── go.mod # Go module file +├── go.sum # Go dependencies checksums +└── README.md # Project documentation +``` + +## Example Usage + +### Create a todo + +```bash +curl -X POST http://localhost:8000/api/todos \ + -H "Content-Type: application/json" \ + -d '{"title": "Learn Go", "description": "Learn Go programming language", "completed": false}' +``` + +### Get all todos + +```bash +curl http://localhost:8000/api/todos +``` + +### Get a specific todo + +```bash +curl http://localhost:8000/api/todos/1 +``` + +### Update a todo + +```bash +curl -X PUT http://localhost:8000/api/todos/1 \ + -H "Content-Type: application/json" \ + -d '{"completed": true}' +``` + +### Delete a todo + +```bash +curl -X DELETE http://localhost:8000/api/todos/1 +``` + +### Get completed todos + +```bash +curl http://localhost:8000/api/todos?completed=true +``` + +### Health check + +```bash +curl http://localhost:8000/health +``` \ No newline at end of file diff --git a/go-todo-app/cmd/api/main.go b/go-todo-app/cmd/api/main.go new file mode 100644 index 0000000..cfbff86 --- /dev/null +++ b/go-todo-app/cmd/api/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "fmt" + "log" + "path/filepath" + + "github.com/simpletodoapp/go-todo-app/internal/api" + "github.com/simpletodoapp/go-todo-app/internal/config" + "github.com/simpletodoapp/go-todo-app/internal/database" +) + +func main() { + // Load configuration + cfg, err := config.LoadConfig() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + // Connect to database + db, err := database.NewDatabase(cfg) + if err != nil { + log.Fatalf("Failed to initialize database: %v", err) + } + + // Run migrations + migrationRunner := database.NewMigrationRunner(db, cfg) + migrationsPath := filepath.Join(".", "migrations") + if err := migrationRunner.RunMigrations(migrationsPath); err != nil { + log.Fatalf("Failed to run migrations: %v", err) + } + + // Setup router + router := api.SetupRouter(cfg, db) + + // Start server + serverAddr := fmt.Sprintf(":%s", cfg.ServerPort) + log.Printf("Starting server on %s", serverAddr) + if err := router.Run(serverAddr); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} \ No newline at end of file diff --git a/go-todo-app/go.mod b/go-todo-app/go.mod new file mode 100644 index 0000000..51bebdc --- /dev/null +++ b/go-todo-app/go.mod @@ -0,0 +1,52 @@ +module github.com/simpletodoapp/go-todo-app + +go 1.20 + +require ( + github.com/gin-contrib/cors v1.4.0 + github.com/gin-gonic/gin v1.9.1 + github.com/spf13/viper v1.16.0 + gorm.io/driver/sqlite v1.5.3 + gorm.io/gorm v1.25.4 +) + +require ( + github.com/bytedance/sonic v1.10.0 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect + github.com/chenzhuoyu/iasm v0.9.0 // indirect + github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.15.3 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-sqlite3 v1.14.17 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/spf13/afero v1.9.5 // indirect + github.com/spf13/cast v1.5.1 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + golang.org/x/arch v0.5.0 // indirect + golang.org/x/crypto v0.13.0 // indirect + golang.org/x/net v0.15.0 // indirect + golang.org/x/sys v0.12.0 // indirect + golang.org/x/text v0.13.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) \ No newline at end of file diff --git a/go-todo-app/internal/api/api.go b/go-todo-app/internal/api/api.go new file mode 100644 index 0000000..f40de67 --- /dev/null +++ b/go-todo-app/internal/api/api.go @@ -0,0 +1,32 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "github.com/simpletodoapp/go-todo-app/internal/config" + "github.com/simpletodoapp/go-todo-app/internal/database" + "github.com/simpletodoapp/go-todo-app/internal/service" +) + +// SetupRouter initializes the API router and routes +func SetupRouter(cfg *config.Config, db *database.Database) *gin.Engine { + router := gin.Default() + + // Setup middleware + SetupMiddleware(router) + + // Setup API documentation + SetupDocs(router, cfg) + + // Create services + todoService := service.NewTodoService(db.DB) + + // Register handlers + healthHandler := NewHealthHandler(db) + healthHandler.RegisterRoutes(router) + + todoHandler := NewTodoHandler(todoService) + api := router.Group("/api") + todoHandler.RegisterRoutes(api) + + return router +} \ No newline at end of file diff --git a/go-todo-app/internal/api/docs.go b/go-todo-app/internal/api/docs.go new file mode 100644 index 0000000..c829232 --- /dev/null +++ b/go-todo-app/internal/api/docs.go @@ -0,0 +1,154 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/simpletodoapp/go-todo-app/internal/config" +) + +// SetupDocs sets up the API documentation +func SetupDocs(router *gin.Engine, cfg *config.Config) { + // Simple documentation page + router.GET("/docs", func(c *gin.Context) { + html := fmt.Sprintf(` + + + + %s API Documentation + + + +

%s API Documentation

+

%s

+

Version: %s

+ +

Endpoints

+ +
+

Health Check

+

GET /health

+

Check the health of the application and database.

+

Response

+
{
+  "status": "healthy",
+  "db_status": "healthy"
+}
+
+ +
+

List Todos

+

GET /api/todos

+

Retrieve a list of todos with optional filtering and pagination.

+

Query Parameters

+ +

Response

+
[
+  {
+    "id": 1,
+    "title": "Learn Go",
+    "description": "Learn Go programming language",
+    "completed": false,
+    "created_at": "2023-09-10T12:00:00Z",
+    "updated_at": "2023-09-10T12:00:00Z"
+  }
+]
+
+ +
+

Get Todo

+

GET /api/todos/:id

+

Retrieve a specific todo by ID.

+

Path Parameters

+ +

Response

+
{
+  "id": 1,
+  "title": "Learn Go",
+  "description": "Learn Go programming language",
+  "completed": false,
+  "created_at": "2023-09-10T12:00:00Z",
+  "updated_at": "2023-09-10T12:00:00Z"
+}
+
+ +
+

Create Todo

+

POST /api/todos

+

Create a new todo item.

+

Request Body

+
{
+  "title": "Learn Go",
+  "description": "Learn Go programming language",
+  "completed": false
+}
+

Response

+
{
+  "id": 1,
+  "title": "Learn Go",
+  "description": "Learn Go programming language",
+  "completed": false,
+  "created_at": "2023-09-10T12:00:00Z",
+  "updated_at": "2023-09-10T12:00:00Z"
+}
+
+ +
+

Update Todo

+

PUT /api/todos/:id

+

Update an existing todo.

+

Path Parameters

+ +

Request Body

+
{
+  "title": "Updated title",  // Optional
+  "description": "Updated description",  // Optional
+  "completed": true  // Optional
+}
+

Response

+
{
+  "id": 1,
+  "title": "Updated title",
+  "description": "Updated description",
+  "completed": true,
+  "created_at": "2023-09-10T12:00:00Z",
+  "updated_at": "2023-09-10T12:05:00Z"
+}
+
+ +
+

Delete Todo

+

DELETE /api/todos/:id

+

Delete a todo.

+

Path Parameters

+ +

Response

+

No content (HTTP 204)

+
+ + + `, cfg.AppName, cfg.AppName, cfg.AppDescription, cfg.AppVersion) + + c.Header("Content-Type", "text/html") + c.String(http.StatusOK, html) + }) +} \ No newline at end of file diff --git a/go-todo-app/internal/api/health.go b/go-todo-app/internal/api/health.go new file mode 100644 index 0000000..103dc7b --- /dev/null +++ b/go-todo-app/internal/api/health.go @@ -0,0 +1,41 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/simpletodoapp/go-todo-app/internal/database" + "github.com/simpletodoapp/go-todo-app/internal/dto" +) + +// HealthHandler handles health check requests +type HealthHandler struct { + db *database.Database +} + +// NewHealthHandler creates a new health check handler +func NewHealthHandler(db *database.Database) *HealthHandler { + return &HealthHandler{db: db} +} + +// RegisterRoutes registers the health check routes +func (h *HealthHandler) RegisterRoutes(router *gin.Engine) { + router.GET("/health", h.HealthCheck) +} + +// HealthCheck handles GET /health +// Returns the health status of the application +func (h *HealthHandler) HealthCheck(c *gin.Context) { + // Check database health + dbStatus := "healthy" + if err := h.db.Health(); err != nil { + dbStatus = "unhealthy" + } + + response := dto.HealthResponse{ + Status: "healthy", + DBStatus: dbStatus, + } + + c.JSON(http.StatusOK, response) +} \ No newline at end of file diff --git a/go-todo-app/internal/api/middleware.go b/go-todo-app/internal/api/middleware.go new file mode 100644 index 0000000..49a637c --- /dev/null +++ b/go-todo-app/internal/api/middleware.go @@ -0,0 +1,27 @@ +package api + +import ( + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +// SetupMiddleware configures middleware for the router +func SetupMiddleware(router *gin.Engine) { + // CORS middleware + router.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + })) + + // Request logging middleware + router.Use(gin.Logger()) + + // Recovery middleware + router.Use(gin.Recovery()) +} \ No newline at end of file diff --git a/go-todo-app/internal/api/todo.go b/go-todo-app/internal/api/todo.go new file mode 100644 index 0000000..b45b98d --- /dev/null +++ b/go-todo-app/internal/api/todo.go @@ -0,0 +1,150 @@ +package api + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/simpletodoapp/go-todo-app/internal/dto" + "github.com/simpletodoapp/go-todo-app/internal/service" +) + +// TodoHandler handles todo-related API requests +type TodoHandler struct { + todoService *service.TodoService +} + +// NewTodoHandler creates a new todo handler +func NewTodoHandler(todoService *service.TodoService) *TodoHandler { + return &TodoHandler{todoService: todoService} +} + +// RegisterRoutes registers the todo routes +func (h *TodoHandler) RegisterRoutes(router *gin.RouterGroup) { + todos := router.Group("/todos") + { + todos.GET("", h.GetTodos) + todos.POST("", h.CreateTodo) + todos.GET("/:id", h.GetTodo) + todos.PUT("/:id", h.UpdateTodo) + todos.DELETE("/:id", h.DeleteTodo) + } +} + +// GetTodos handles GET /api/todos +// Returns a list of todos with optional filtering and pagination +func (h *TodoHandler) GetTodos(c *gin.Context) { + // Parse query parameters + skip, _ := strconv.Atoi(c.DefaultQuery("skip", "0")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100")) + + var completed *bool + if completedStr, exists := c.GetQuery("completed"); exists { + completedBool, err := strconv.ParseBool(completedStr) + if err == nil { + completed = &completedBool + } + } + + // Get todos from service + todos, err := h.todoService.GetTodos(skip, limit, completed) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, todos) +} + +// GetTodo handles GET /api/todos/:id +// Returns a specific todo by ID +func (h *TodoHandler) GetTodo(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID format"}) + return + } + + todo, err := h.todoService.GetTodoByID(uint(id)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if todo == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"}) + return + } + + c.JSON(http.StatusOK, todo) +} + +// CreateTodo handles POST /api/todos +// Creates a new todo +func (h *TodoHandler) CreateTodo(c *gin.Context) { + var todoCreate dto.TodoCreate + if err := c.ShouldBindJSON(&todoCreate); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + todo, err := h.todoService.CreateTodo(todoCreate) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, todo) +} + +// UpdateTodo handles PUT /api/todos/:id +// Updates an existing todo +func (h *TodoHandler) UpdateTodo(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID format"}) + return + } + + var todoUpdate dto.TodoUpdate + if err := c.ShouldBindJSON(&todoUpdate); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + todo, err := h.todoService.UpdateTodo(uint(id), todoUpdate) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if todo == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"}) + return + } + + c.JSON(http.StatusOK, todo) +} + +// DeleteTodo handles DELETE /api/todos/:id +// Deletes a todo +func (h *TodoHandler) DeleteTodo(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID format"}) + return + } + + deleted, err := h.todoService.DeleteTodo(uint(id)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if !deleted { + c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"}) + return + } + + c.Status(http.StatusNoContent) +} \ No newline at end of file diff --git a/go-todo-app/internal/api/todo_test.go b/go-todo-app/internal/api/todo_test.go new file mode 100644 index 0000000..1d1c1dd --- /dev/null +++ b/go-todo-app/internal/api/todo_test.go @@ -0,0 +1,222 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/gin-gonic/gin" + "github.com/simpletodoapp/go-todo-app/internal/dto" + "github.com/simpletodoapp/go-todo-app/internal/model" + "github.com/simpletodoapp/go-todo-app/internal/service" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// setupTestRouter sets up a test router and database +func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) { + gin.SetMode(gin.TestMode) + router := gin.Default() + + // Create an in-memory database + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("Failed to connect to in-memory database: %v", err) + } + + // Auto-migrate the schema + err = db.AutoMigrate(&model.Todo{}) + if err != nil { + t.Fatalf("Failed to migrate test database: %v", err) + } + + // Create services and handlers + todoService := service.NewTodoService(db) + todoHandler := NewTodoHandler(todoService) + + // Register routes + api := router.Group("/api") + todoHandler.RegisterRoutes(api) + + return router, db +} + +// populateTestTodos adds test todos to the database +func populateTestTodos(t *testing.T, db *gorm.DB) { + todos := []model.Todo{ + { + Title: "Test Todo 1", + Description: "Test Description 1", + Completed: false, + }, + { + Title: "Test Todo 2", + Description: "Test Description 2", + Completed: true, + }, + } + + for _, todo := range todos { + if err := db.Create(&todo).Error; err != nil { + t.Fatalf("Failed to create test todo: %v", err) + } + } +} + +func TestGetTodosHandler(t *testing.T) { + router, db := setupTestRouter(t) + populateTestTodos(t, db) + + // Test GET /api/todos + req, _ := http.NewRequest("GET", "/api/todos", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, resp.Code) + } + + var todos []model.Todo + if err := json.Unmarshal(resp.Body.Bytes(), &todos); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if len(todos) != 2 { + t.Errorf("Expected 2 todos, got %d", len(todos)) + } +} + +func TestGetTodoHandler(t *testing.T) { + router, db := setupTestRouter(t) + populateTestTodos(t, db) + + // Test GET /api/todos/:id + req, _ := http.NewRequest("GET", "/api/todos/1", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, resp.Code) + } + + var todo model.Todo + if err := json.Unmarshal(resp.Body.Bytes(), &todo); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if todo.ID != 1 { + t.Errorf("Expected todo ID to be 1, got %d", todo.ID) + } + + // Test non-existent todo + req, _ = http.NewRequest("GET", "/api/todos/999", nil) + resp = httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusNotFound { + t.Errorf("Expected status code %d, got %d", http.StatusNotFound, resp.Code) + } +} + +func TestCreateTodoHandler(t *testing.T) { + router, _ := setupTestRouter(t) + + // Create request body + todoCreate := dto.TodoCreate{ + TodoBase: dto.TodoBase{ + Title: "New Todo", + Description: "New Description", + Completed: false, + }, + } + body, _ := json.Marshal(todoCreate) + + // Test POST /api/todos + req, _ := http.NewRequest("POST", "/api/todos", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusCreated { + t.Errorf("Expected status code %d, got %d", http.StatusCreated, resp.Code) + } + + var todo model.Todo + if err := json.Unmarshal(resp.Body.Bytes(), &todo); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if todo.Title != "New Todo" { + t.Errorf("Expected title to be 'New Todo', got '%s'", todo.Title) + } +} + +func TestUpdateTodoHandler(t *testing.T) { + router, db := setupTestRouter(t) + populateTestTodos(t, db) + + // Create request body + title := "Updated Title" + completed := true + todoUpdate := dto.TodoUpdate{ + Title: &title, + Completed: &completed, + } + body, _ := json.Marshal(todoUpdate) + + // Test PUT /api/todos/:id + req, _ := http.NewRequest("PUT", "/api/todos/1", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, resp.Code) + } + + var todo model.Todo + if err := json.Unmarshal(resp.Body.Bytes(), &todo); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if todo.Title != "Updated Title" { + t.Errorf("Expected title to be 'Updated Title', got '%s'", todo.Title) + } + + if !todo.Completed { + t.Errorf("Expected completed to be true, got %t", todo.Completed) + } +} + +func TestDeleteTodoHandler(t *testing.T) { + router, db := setupTestRouter(t) + populateTestTodos(t, db) + + // Test DELETE /api/todos/:id + req, _ := http.NewRequest("DELETE", "/api/todos/1", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusNoContent { + t.Errorf("Expected status code %d, got %d", http.StatusNoContent, resp.Code) + } + + // Verify the todo was deleted + var count int64 + db.Model(&model.Todo{}).Where("id = ?", 1).Count(&count) + if count != 0 { + t.Errorf("Expected todo to be deleted, but it still exists") + } + + // Test deleting non-existent todo + req, _ = http.NewRequest("DELETE", "/api/todos/999", nil) + resp = httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusNotFound { + t.Errorf("Expected status code %d, got %d", http.StatusNotFound, resp.Code) + } +} \ No newline at end of file diff --git a/go-todo-app/internal/config/config.go b/go-todo-app/internal/config/config.go new file mode 100644 index 0000000..c49fff3 --- /dev/null +++ b/go-todo-app/internal/config/config.go @@ -0,0 +1,69 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/viper" +) + +// Config holds the application configuration +type Config struct { + AppName string `mapstructure:"APP_NAME"` + AppDescription string `mapstructure:"APP_DESCRIPTION"` + AppVersion string `mapstructure:"APP_VERSION"` + ServerPort string `mapstructure:"SERVER_PORT"` + DBPath string `mapstructure:"DB_PATH"` + DBName string `mapstructure:"DB_NAME"` +} + +// LoadConfig reads the configuration from environment variables or config file +func LoadConfig() (*Config, error) { + // Set default values + config := &Config{ + AppName: "Go Todo App", + AppDescription: "A simple Todo application API built with Go, Gin and SQLite", + AppVersion: "0.1.0", + ServerPort: "8000", + DBPath: "/app/storage/db", + DBName: "db.sqlite", + } + + // Set up viper + v := viper.New() + v.SetConfigName("config") + v.SetConfigType("yaml") + v.AddConfigPath(".") + v.AddConfigPath("./config") + + // Match environment variables with config fields + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + + // Try to read from config file (optional) + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, fmt.Errorf("error reading config file: %v", err) + } + } + + // Override config from environment variables + if err := v.Unmarshal(config); err != nil { + return nil, fmt.Errorf("error unmarshaling config: %v", err) + } + + // Ensure DB directory exists + dbDir := config.DBPath + if err := os.MkdirAll(dbDir, 0755); err != nil { + return nil, fmt.Errorf("error creating DB directory: %v", err) + } + + return config, nil +} + +// GetDatabaseURL returns the full SQLite database connection string +func (c *Config) GetDatabaseURL() string { + return fmt.Sprintf("file:%s", filepath.Join(c.DBPath, c.DBName)) +} \ No newline at end of file diff --git a/go-todo-app/internal/database/db.go b/go-todo-app/internal/database/db.go new file mode 100644 index 0000000..28fb9fc --- /dev/null +++ b/go-todo-app/internal/database/db.go @@ -0,0 +1,48 @@ +package database + +import ( + "fmt" + "log" + + "github.com/simpletodoapp/go-todo-app/internal/config" + "github.com/simpletodoapp/go-todo-app/internal/model" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// Database holds the database connection +type Database struct { + DB *gorm.DB +} + +// NewDatabase initializes a new database connection +func NewDatabase(cfg *config.Config) (*Database, error) { + // Configure GORM + gormConfig := &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + } + + // Connect to SQLite database + log.Printf("Connecting to database: %s", cfg.GetDatabaseURL()) + db, err := gorm.Open(sqlite.Open(cfg.GetDatabaseURL()), gormConfig) + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %v", err) + } + + // Auto-migrate the schema + if err := db.AutoMigrate(&model.Todo{}); err != nil { + return nil, fmt.Errorf("failed to migrate database: %v", err) + } + + return &Database{DB: db}, nil +} + +// Health checks the database connection +func (d *Database) Health() error { + sqlDB, err := d.DB.DB() + if err != nil { + return err + } + return sqlDB.Ping() +} \ No newline at end of file diff --git a/go-todo-app/internal/database/migrations.go b/go-todo-app/internal/database/migrations.go new file mode 100644 index 0000000..91c82b5 --- /dev/null +++ b/go-todo-app/internal/database/migrations.go @@ -0,0 +1,152 @@ +package database + +import ( + "fmt" + "io/ioutil" + "log" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/simpletodoapp/go-todo-app/internal/config" +) + +// MigrationRunner handles database migrations +type MigrationRunner struct { + db *Database + config *config.Config +} + +// NewMigrationRunner creates a new migration runner +func NewMigrationRunner(db *Database, cfg *config.Config) *MigrationRunner { + return &MigrationRunner{ + db: db, + config: cfg, + } +} + +// RunMigrations executes all pending migrations +func (m *MigrationRunner) RunMigrations(migrationsPath string) error { + log.Println("Running migrations...") + + // Get all migration files + files, err := getMigrationFiles(migrationsPath) + if err != nil { + return err + } + + if len(files) == 0 { + log.Println("No migration files found") + return nil + } + + // Create migrations table if it doesn't exist + err = m.createMigrationsTable() + if err != nil { + return err + } + + // Get applied migrations + appliedMigrations, err := m.getAppliedMigrations() + if err != nil { + return err + } + + // Apply pending migrations + for _, file := range files { + migrationName := filepath.Base(file) + if !contains(appliedMigrations, migrationName) { + log.Printf("Applying migration: %s", migrationName) + + // Read migration file + content, err := ioutil.ReadFile(file) + if err != nil { + return fmt.Errorf("failed to read migration file: %v", err) + } + + // Split into up and down migrations + parts := strings.Split(string(content), "-- Down migration") + upMigration := parts[0] + + // Execute migration + tx := m.db.DB.Begin() + if err := tx.Exec(upMigration).Error; err != nil { + tx.Rollback() + return fmt.Errorf("failed to apply migration: %v", err) + } + + // Record applied migration + if err := tx.Exec("INSERT INTO migrations (name, applied_at) VALUES (?, CURRENT_TIMESTAMP)", migrationName).Error; err != nil { + tx.Rollback() + return fmt.Errorf("failed to record migration: %v", err) + } + + tx.Commit() + log.Printf("Migration applied: %s", migrationName) + } else { + log.Printf("Migration already applied: %s", migrationName) + } + } + + log.Println("Migrations completed successfully") + return nil +} + +// createMigrationsTable creates the migrations table if it doesn't exist +func (m *MigrationRunner) createMigrationsTable() error { + query := ` + CREATE TABLE IF NOT EXISTS migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + applied_at DATETIME NOT NULL + ) + ` + + return m.db.DB.Exec(query).Error +} + +// getAppliedMigrations gets the list of already applied migrations +func (m *MigrationRunner) getAppliedMigrations() ([]string, error) { + var migrations []string + var records []struct { + Name string + } + + result := m.db.DB.Raw("SELECT name FROM migrations ORDER BY id ASC").Scan(&records) + if result.Error != nil { + return nil, fmt.Errorf("failed to get applied migrations: %v", result.Error) + } + + for _, record := range records { + migrations = append(migrations, record.Name) + } + + return migrations, nil +} + +// getMigrationFiles gets all migration files sorted by filename +func getMigrationFiles(migrationsPath string) ([]string, error) { + files, err := filepath.Glob(filepath.Join(migrationsPath, "*.sql")) + if err != nil { + return nil, fmt.Errorf("failed to read migration files: %v", err) + } + + // Check if directory exists + if _, err := os.Stat(migrationsPath); os.IsNotExist(err) { + return []string{}, nil + } + + sort.Strings(files) + return files, nil +} + +// contains checks if a string is in a slice +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} \ No newline at end of file diff --git a/go-todo-app/internal/dto/todo.go b/go-todo-app/internal/dto/todo.go new file mode 100644 index 0000000..63d322b --- /dev/null +++ b/go-todo-app/internal/dto/todo.go @@ -0,0 +1,40 @@ +package dto + +import ( + "time" +) + +// TodoBase contains the common fields for todo operations +type TodoBase struct { + Title string `json:"title" binding:"required"` + Description string `json:"description"` + Completed bool `json:"completed" default:"false"` +} + +// TodoCreate represents the data needed to create a new todo +type TodoCreate struct { + TodoBase +} + +// TodoUpdate represents the data that can be updated for a todo +type TodoUpdate struct { + Title *string `json:"title"` + Description *string `json:"description"` + Completed *bool `json:"completed"` +} + +// TodoResponse represents the todo data sent back to the client +type TodoResponse struct { + ID uint `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Completed bool `json:"completed"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// HealthResponse represents the health check response +type HealthResponse struct { + Status string `json:"status"` + DBStatus string `json:"db_status"` +} \ No newline at end of file diff --git a/go-todo-app/internal/model/todo.go b/go-todo-app/internal/model/todo.go new file mode 100644 index 0000000..0e60844 --- /dev/null +++ b/go-todo-app/internal/model/todo.go @@ -0,0 +1,20 @@ +package model + +import ( + "time" +) + +// Todo represents a todo item in the database +type Todo struct { + ID uint `gorm:"primaryKey" json:"id"` + Title string `gorm:"index" json:"title"` + Description string `gorm:"default:null" json:"description"` + Completed bool `gorm:"default:false" json:"completed"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +// TableName overrides the table name +func (Todo) TableName() string { + return "todos" +} \ No newline at end of file diff --git a/go-todo-app/internal/model/todo_test.go b/go-todo-app/internal/model/todo_test.go new file mode 100644 index 0000000..e8d8f38 --- /dev/null +++ b/go-todo-app/internal/model/todo_test.go @@ -0,0 +1,51 @@ +package model + +import ( + "testing" + "time" +) + +func TestTodoTableName(t *testing.T) { + todo := Todo{} + if todo.TableName() != "todos" { + t.Errorf("Expected table name to be 'todos', got '%s'", todo.TableName()) + } +} + +func TestTodoFields(t *testing.T) { + // Create a todo + now := time.Now() + todo := Todo{ + ID: 1, + Title: "Test Todo", + Description: "Test Description", + Completed: false, + CreatedAt: now, + UpdatedAt: now, + } + + // Check field values + if todo.ID != 1 { + t.Errorf("Expected ID to be 1, got %d", todo.ID) + } + + if todo.Title != "Test Todo" { + t.Errorf("Expected Title to be 'Test Todo', got '%s'", todo.Title) + } + + if todo.Description != "Test Description" { + t.Errorf("Expected Description to be 'Test Description', got '%s'", todo.Description) + } + + if todo.Completed != false { + t.Errorf("Expected Completed to be false, got %t", todo.Completed) + } + + if !todo.CreatedAt.Equal(now) { + t.Errorf("Expected CreatedAt to be %v, got %v", now, todo.CreatedAt) + } + + if !todo.UpdatedAt.Equal(now) { + t.Errorf("Expected UpdatedAt to be %v, got %v", now, todo.UpdatedAt) + } +} \ No newline at end of file diff --git a/go-todo-app/internal/service/todo.go b/go-todo-app/internal/service/todo.go new file mode 100644 index 0000000..c0c33ce --- /dev/null +++ b/go-todo-app/internal/service/todo.go @@ -0,0 +1,126 @@ +package service + +import ( + "errors" + "fmt" + + "github.com/simpletodoapp/go-todo-app/internal/dto" + "github.com/simpletodoapp/go-todo-app/internal/model" + "gorm.io/gorm" +) + +// TodoService handles todo operations +type TodoService struct { + db *gorm.DB +} + +// NewTodoService creates a new todo service +func NewTodoService(db *gorm.DB) *TodoService { + return &TodoService{db: db} +} + +// GetTodos retrieves todos with optional filters and pagination +func (s *TodoService) GetTodos(skip, limit int, completed *bool) ([]model.Todo, error) { + var todos []model.Todo + query := s.db + + // Apply completed filter if provided + if completed != nil { + query = query.Where("completed = ?", *completed) + } + + // Apply pagination + result := query.Offset(skip).Limit(limit).Find(&todos) + if result.Error != nil { + return nil, fmt.Errorf("error getting todos: %v", result.Error) + } + + return todos, nil +} + +// GetTodoByID retrieves a specific todo by ID +func (s *TodoService) GetTodoByID(id uint) (*model.Todo, error) { + var todo model.Todo + result := s.db.First(&todo, id) + + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, nil // Todo not found, but not an error + } + return nil, fmt.Errorf("error getting todo: %v", result.Error) + } + + return &todo, nil +} + +// CreateTodo creates a new todo +func (s *TodoService) CreateTodo(todoCreate dto.TodoCreate) (*model.Todo, error) { + todo := model.Todo{ + Title: todoCreate.Title, + Description: todoCreate.Description, + Completed: todoCreate.Completed, + } + + result := s.db.Create(&todo) + if result.Error != nil { + return nil, fmt.Errorf("error creating todo: %v", result.Error) + } + + return &todo, nil +} + +// UpdateTodo updates an existing todo +func (s *TodoService) UpdateTodo(id uint, todoUpdate dto.TodoUpdate) (*model.Todo, error) { + // Get existing todo + todo, err := s.GetTodoByID(id) + if err != nil { + return nil, err + } + if todo == nil { + return nil, nil // Not found + } + + // Update fields if provided + updates := map[string]interface{}{} + + if todoUpdate.Title != nil { + updates["title"] = *todoUpdate.Title + } + + if todoUpdate.Description != nil { + updates["description"] = *todoUpdate.Description + } + + if todoUpdate.Completed != nil { + updates["completed"] = *todoUpdate.Completed + } + + // Apply updates + result := s.db.Model(&todo).Updates(updates) + if result.Error != nil { + return nil, fmt.Errorf("error updating todo: %v", result.Error) + } + + // Refresh the todo from database + return s.GetTodoByID(id) +} + +// DeleteTodo deletes a todo by ID +func (s *TodoService) DeleteTodo(id uint) (bool, error) { + // Check if todo exists + todo, err := s.GetTodoByID(id) + if err != nil { + return false, err + } + if todo == nil { + return false, nil // Not found + } + + // Delete the todo + result := s.db.Delete(&model.Todo{}, id) + if result.Error != nil { + return false, fmt.Errorf("error deleting todo: %v", result.Error) + } + + return true, nil +} \ No newline at end of file diff --git a/go-todo-app/internal/service/todo_test.go b/go-todo-app/internal/service/todo_test.go new file mode 100644 index 0000000..5cf6714 --- /dev/null +++ b/go-todo-app/internal/service/todo_test.go @@ -0,0 +1,228 @@ +package service + +import ( + "testing" + + "github.com/simpletodoapp/go-todo-app/internal/dto" + "github.com/simpletodoapp/go-todo-app/internal/model" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// setupTestDB sets up an in-memory SQLite database for testing +func setupTestDB(t *testing.T) *gorm.DB { + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("Failed to connect to in-memory database: %v", err) + } + + // Auto-migrate the schema + err = db.AutoMigrate(&model.Todo{}) + if err != nil { + t.Fatalf("Failed to migrate test database: %v", err) + } + + return db +} + +// populateTestData adds test data to the database +func populateTestData(t *testing.T, db *gorm.DB) { + todos := []model.Todo{ + { + Title: "Test Todo 1", + Description: "Test Description 1", + Completed: false, + }, + { + Title: "Test Todo 2", + Description: "Test Description 2", + Completed: true, + }, + } + + for _, todo := range todos { + if err := db.Create(&todo).Error; err != nil { + t.Fatalf("Failed to create test data: %v", err) + } + } +} + +func TestGetTodos(t *testing.T) { + db := setupTestDB(t) + populateTestData(t, db) + + service := NewTodoService(db) + + // Test getting all todos + todos, err := service.GetTodos(0, 100, nil) + if err != nil { + t.Fatalf("Failed to get todos: %v", err) + } + + if len(todos) != 2 { + t.Errorf("Expected 2 todos, got %d", len(todos)) + } + + // Test filtering by completed + completed := true + todos, err = service.GetTodos(0, 100, &completed) + if err != nil { + t.Fatalf("Failed to get completed todos: %v", err) + } + + if len(todos) != 1 { + t.Errorf("Expected 1 completed todo, got %d", len(todos)) + } + + if !todos[0].Completed { + t.Errorf("Expected todo to be completed, got %t", todos[0].Completed) + } + + // Test pagination + todos, err = service.GetTodos(1, 1, nil) + if err != nil { + t.Fatalf("Failed to get paginated todos: %v", err) + } + + if len(todos) != 1 { + t.Errorf("Expected 1 todo with pagination, got %d", len(todos)) + } +} + +func TestCreateTodo(t *testing.T) { + db := setupTestDB(t) + service := NewTodoService(db) + + // Create a new todo + todoCreate := dto.TodoCreate{ + TodoBase: dto.TodoBase{ + Title: "New Todo", + Description: "New Description", + Completed: false, + }, + } + + todo, err := service.CreateTodo(todoCreate) + if err != nil { + t.Fatalf("Failed to create todo: %v", err) + } + + if todo.ID == 0 { + t.Errorf("Expected todo ID to be set, got %d", todo.ID) + } + + if todo.Title != "New Todo" { + t.Errorf("Expected title to be 'New Todo', got '%s'", todo.Title) + } + + if todo.Description != "New Description" { + t.Errorf("Expected description to be 'New Description', got '%s'", todo.Description) + } + + if todo.Completed != false { + t.Errorf("Expected completed to be false, got %t", todo.Completed) + } +} + +func TestGetTodoByID(t *testing.T) { + db := setupTestDB(t) + populateTestData(t, db) + service := NewTodoService(db) + + // Get a todo by ID + todo, err := service.GetTodoByID(1) + if err != nil { + t.Fatalf("Failed to get todo by ID: %v", err) + } + + if todo == nil { + t.Fatalf("Expected todo to be found, got nil") + } + + if todo.ID != 1 { + t.Errorf("Expected todo ID to be 1, got %d", todo.ID) + } + + // Test non-existent todo + todo, err = service.GetTodoByID(999) + if err != nil { + t.Fatalf("Expected no error for non-existent todo, got %v", err) + } + + if todo != nil { + t.Errorf("Expected nil for non-existent todo, got %+v", todo) + } +} + +func TestUpdateTodo(t *testing.T) { + db := setupTestDB(t) + populateTestData(t, db) + service := NewTodoService(db) + + // Update a todo + title := "Updated Title" + completed := true + todoUpdate := dto.TodoUpdate{ + Title: &title, + Completed: &completed, + } + + todo, err := service.UpdateTodo(1, todoUpdate) + if err != nil { + t.Fatalf("Failed to update todo: %v", err) + } + + if todo.Title != "Updated Title" { + t.Errorf("Expected title to be 'Updated Title', got '%s'", todo.Title) + } + + if !todo.Completed { + t.Errorf("Expected completed to be true, got %t", todo.Completed) + } + + // Test updating non-existent todo + todo, err = service.UpdateTodo(999, todoUpdate) + if err != nil { + t.Fatalf("Expected no error for non-existent todo, got %v", err) + } + + if todo != nil { + t.Errorf("Expected nil for non-existent todo, got %+v", todo) + } +} + +func TestDeleteTodo(t *testing.T) { + db := setupTestDB(t) + populateTestData(t, db) + service := NewTodoService(db) + + // Delete a todo + deleted, err := service.DeleteTodo(1) + if err != nil { + t.Fatalf("Failed to delete todo: %v", err) + } + + if !deleted { + t.Errorf("Expected todo to be deleted") + } + + // Try to get the deleted todo + todo, err := service.GetTodoByID(1) + if err != nil { + t.Fatalf("Unexpected error after deletion: %v", err) + } + + if todo != nil { + t.Errorf("Expected todo to be nil after deletion, got %+v", todo) + } + + // Test deleting non-existent todo + deleted, err = service.DeleteTodo(999) + if err != nil { + t.Fatalf("Expected no error for non-existent todo, got %v", err) + } + + if deleted { + t.Errorf("Expected false for deleting non-existent todo, got %t", deleted) + } +} \ No newline at end of file diff --git a/go-todo-app/migrations/00001_create_todos_table.sql b/go-todo-app/migrations/00001_create_todos_table.sql new file mode 100644 index 0000000..cf5ace2 --- /dev/null +++ b/go-todo-app/migrations/00001_create_todos_table.sql @@ -0,0 +1,14 @@ +-- Up migration +CREATE TABLE todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + completed BOOLEAN DEFAULT FALSE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_todos_title ON todos(title); + +-- Down migration +DROP TABLE IF EXISTS todos; \ No newline at end of file diff --git a/go-todo-app/storage/db/.gitkeep b/go-todo-app/storage/db/.gitkeep new file mode 100644 index 0000000..e69de29