Add Go Todo App implementation

This commit adds a complete Go implementation of the Todo application. The
application uses Gin framework for the web server, GORM for database access,
and SQLite for storage.

Key features:
- Todo CRUD operations with the same API endpoints
- Health check endpoint
- Database migrations
- Tests for models, services, and API handlers
- Documentation for the API
- Configurable settings
This commit is contained in:
Automated Action 2025-05-17 22:20:05 +00:00
parent 163b82dab7
commit 887703b6a2
21 changed files with 1698 additions and 0 deletions

32
go-todo-app/.gitignore vendored Normal file
View File

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

36
go-todo-app/Makefile Normal file
View File

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

162
go-todo-app/README.md Normal file
View File

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

View File

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

52
go-todo-app/go.mod Normal file
View File

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

View File

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

View File

@ -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(`
<!DOCTYPE html>
<html>
<head>
<title>%s API Documentation</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; padding: 20px; max-width: 800px; margin: 0 auto; }
pre { background-color: #f5f5f5; padding: 10px; border-radius: 5px; overflow-x: auto; }
h1, h2, h3 { color: #333; }
.endpoint { margin-bottom: 30px; border-bottom: 1px solid #eee; padding-bottom: 20px; }
.method { font-weight: bold; display: inline-block; width: 80px; }
.url { font-family: monospace; }
.description { margin: 10px 0; }
</style>
</head>
<body>
<h1>%s API Documentation</h1>
<p>%s</p>
<p>Version: %s</p>
<h2>Endpoints</h2>
<div class="endpoint">
<h3>Health Check</h3>
<p><span class="method">GET</span> <span class="url">/health</span></p>
<p class="description">Check the health of the application and database.</p>
<h4>Response</h4>
<pre>{
"status": "healthy",
"db_status": "healthy"
}</pre>
</div>
<div class="endpoint">
<h3>List Todos</h3>
<p><span class="method">GET</span> <span class="url">/api/todos</span></p>
<p class="description">Retrieve a list of todos with optional filtering and pagination.</p>
<h4>Query Parameters</h4>
<ul>
<li><code>skip</code> - Number of items to skip (default: 0)</li>
<li><code>limit</code> - Maximum number of items to return (default: 100)</li>
<li><code>completed</code> - Filter by completion status (boolean, optional)</li>
</ul>
<h4>Response</h4>
<pre>[
{
"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"
}
]</pre>
</div>
<div class="endpoint">
<h3>Get Todo</h3>
<p><span class="method">GET</span> <span class="url">/api/todos/:id</span></p>
<p class="description">Retrieve a specific todo by ID.</p>
<h4>Path Parameters</h4>
<ul>
<li><code>id</code> - Todo ID</li>
</ul>
<h4>Response</h4>
<pre>{
"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"
}</pre>
</div>
<div class="endpoint">
<h3>Create Todo</h3>
<p><span class="method">POST</span> <span class="url">/api/todos</span></p>
<p class="description">Create a new todo item.</p>
<h4>Request Body</h4>
<pre>{
"title": "Learn Go",
"description": "Learn Go programming language",
"completed": false
}</pre>
<h4>Response</h4>
<pre>{
"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"
}</pre>
</div>
<div class="endpoint">
<h3>Update Todo</h3>
<p><span class="method">PUT</span> <span class="url">/api/todos/:id</span></p>
<p class="description">Update an existing todo.</p>
<h4>Path Parameters</h4>
<ul>
<li><code>id</code> - Todo ID</li>
</ul>
<h4>Request Body</h4>
<pre>{
"title": "Updated title", // Optional
"description": "Updated description", // Optional
"completed": true // Optional
}</pre>
<h4>Response</h4>
<pre>{
"id": 1,
"title": "Updated title",
"description": "Updated description",
"completed": true,
"created_at": "2023-09-10T12:00:00Z",
"updated_at": "2023-09-10T12:05:00Z"
}</pre>
</div>
<div class="endpoint">
<h3>Delete Todo</h3>
<p><span class="method">DELETE</span> <span class="url">/api/todos/:id</span></p>
<p class="description">Delete a todo.</p>
<h4>Path Parameters</h4>
<ul>
<li><code>id</code> - Todo ID</li>
</ul>
<h4>Response</h4>
<p>No content (HTTP 204)</p>
</div>
</body>
</html>
`, cfg.AppName, cfg.AppName, cfg.AppDescription, cfg.AppVersion)
c.Header("Content-Type", "text/html")
c.String(http.StatusOK, html)
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File