From 99e2f6155f55716519c4dfc48ff3e2ebf3f6664e Mon Sep 17 00:00:00 2001 From: AHMED-D007A Date: Wed, 12 Mar 2025 17:16:37 +0200 Subject: [PATCH] simple todo api for testing keploy idempotency new feature Signed-off-by: AHMED-D007A --- todo-mux/.gitignore | 3 + todo-mux/README.md | 128 +++++++++++++++++ todo-mux/docker-compose.yml | 16 +++ todo-mux/go.mod | 12 ++ todo-mux/go.sum | 8 ++ todo-mux/handler.go | 264 ++++++++++++++++++++++++++++++++++++ todo-mux/main.go | 120 ++++++++++++++++ todo-mux/middleware.go | 89 ++++++++++++ 8 files changed, 640 insertions(+) create mode 100644 todo-mux/.gitignore create mode 100644 todo-mux/README.md create mode 100644 todo-mux/docker-compose.yml create mode 100644 todo-mux/go.mod create mode 100644 todo-mux/go.sum create mode 100644 todo-mux/handler.go create mode 100644 todo-mux/main.go create mode 100644 todo-mux/middleware.go diff --git a/todo-mux/.gitignore b/todo-mux/.gitignore new file mode 100644 index 00000000..d54fdd5c --- /dev/null +++ b/todo-mux/.gitignore @@ -0,0 +1,3 @@ +.vscode +test.txt +todo-go \ No newline at end of file diff --git a/todo-mux/README.md b/todo-mux/README.md new file mode 100644 index 00000000..1a3b3e9b --- /dev/null +++ b/todo-mux/README.md @@ -0,0 +1,128 @@ +# Todo-Go Application + +This is a RESTful Todo application written in Go, using PostgreSQL as the database and Gorilla Mux for routing. The application includes JWT authentication, request logging, and idempotency handling. It is written for testing keploy idempotency feature. + +## Prerequisites + +- Go 1.16 or later +- Docker and Docker Compose +- PostgreSQL + +## Getting Started + +### 1. Start the PostgreSQL database + +Start the PostgreSQL database using Docker Compose: + +```sh +docker-compose up -d +``` + +To stop and remove the volume and its data, use: + +```sh +docker-compose down -v +``` + +### 2. Run the application + +Run the Go application: + +```sh +go build . +./todo-go +``` + +The server will start running on `http://localhost:3040`. + +## Authentication + +This application uses JWT for authentication. Before accessing protected endpoints, you need to obtain a token: + +```sh +curl -X POST -H "Content-Type: application/json" -d '{"username": "admin", "password": "password"}' http://localhost:3040/login +``` + +The response will contain a token that should be used in subsequent requests: + +```json +{"token":"your_jwt_token"} +``` + +## API Endpoints + +### Login (Public) + +```sh +curl -X POST -H "Content-Type: application/json" -d '{"username": "admin", "password": "password"}' http://localhost:3040/login +``` + +### Create a To-Do + +```sh +curl -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your_jwt_token" \ + -H "Idempotency-Key: unique_key" \ + -d '{"task": "Learn Go", "progress": "Todo"}' \ + http://localhost:3040/api/todos +``` + +Note: The Idempotency-Key header is required to prevent duplicate creation of todos. + +### Get All To-Dos + +```sh +curl -H "Authorization: Bearer your_jwt_token" http://localhost:3040/api/todos +``` + +### Get a Specific To-Do + +```sh +curl -H "Authorization: Bearer your_jwt_token" http://localhost:3040/api/todos/1 +``` + +### Update a To-Do + +```sh +curl -X PUT \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your_jwt_token" \ + -d '{"task": "Learn Keploy", "progress": "Done"}' \ + http://localhost:3040/api/todos/1 +``` + +### Delete a To-Do + +```sh +curl -X DELETE -H "Authorization: Bearer your_jwt_token" http://localhost:3040/api/todos/1 +``` + +## Features + +- **JWT Authentication**: Secure API endpoints with JWT tokens +- **Request Logging**: Each request is logged with a unique request ID +- **Idempotency Keys**: Prevent duplicate creation of resources +- **Error Handling**: Proper error responses with appropriate HTTP status codes +- **RESTful API Design**: Follow REST principles for API design + +## Response Format + +Most endpoints return responses in the following format: + +```json +{ + "request_id": "unique-request-id", + "timestamp": "2025-03-12T12:00:00Z", + "todo": { + "id": 1, + "task": "Learn Go", + "progress": "Todo", + "last_checked": "2025-03-12T12:00:00Z" + } +} +``` + +## License + +This project is licensed under the MIT License. diff --git a/todo-mux/docker-compose.yml b/todo-mux/docker-compose.yml new file mode 100644 index 00000000..13dd55e0 --- /dev/null +++ b/todo-mux/docker-compose.yml @@ -0,0 +1,16 @@ +version: "3.8" + +services: + db: + image: postgres:13 + environment: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: todo_db + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: \ No newline at end of file diff --git a/todo-mux/go.mod b/todo-mux/go.mod new file mode 100644 index 00000000..bfa62e8d --- /dev/null +++ b/todo-mux/go.mod @@ -0,0 +1,12 @@ +module todo-go + +go 1.23.4 + +require ( + github.com/gorilla/mux v1.8.1 + github.com/lib/pq v1.10.9 +) + +require github.com/google/uuid v1.6.0 + +require github.com/golang-jwt/jwt/v5 v5.2.1 diff --git a/todo-mux/go.sum b/todo-mux/go.sum new file mode 100644 index 00000000..14db6d0e --- /dev/null +++ b/todo-mux/go.sum @@ -0,0 +1,8 @@ +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= diff --git a/todo-mux/handler.go b/todo-mux/handler.go new file mode 100644 index 00000000..a293c8ae --- /dev/null +++ b/todo-mux/handler.go @@ -0,0 +1,264 @@ +package main + +import ( + "database/sql" + "encoding/json" + "log" + "net/http" + "strconv" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/gorilla/mux" +) + +func createTodo(w http.ResponseWriter, r *http.Request) { + // Retrieve the request ID from the context + requestID := r.Context().Value(requestIDKey).(string) + + // Check for Idempotency-Key header + idempotencyKey := r.Header.Get("Idempotency-Key") + if idempotencyKey == "" { + http.Error(w, "Idempotency-Key header is required", http.StatusBadRequest) + return + } + + // Check if the request has already been processed + var todoID int + err := db.QueryRow( + "SELECT todo_id FROM idempotency_keys WHERE key = $1", + idempotencyKey, + ).Scan(&todoID) + if err == nil { + var todo Todo + err = db.QueryRow( + "SELECT id, task, progress, last_checked FROM todos WHERE id = $1", + todoID, + ).Scan(&todo.ID, &todo.Task, &todo.Progress, &todo.LastChecked) + if err != nil { + log.Printf("Request ID: %s, Error: %v", requestID, err) + http.Error(w, "Failed to fetch cached todo", http.StatusInternalServerError) + return + } + + response := TodoResponse{ + RequestID: requestID, + Timestamp: time.Now(), + Todo: todo, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + return + } else if err != sql.ErrNoRows { + log.Printf("Request ID: %s, Error: %v", requestID, err) + http.Error(w, "Failed to check idempotency key", http.StatusInternalServerError) + return + } + + var todo Todo + if err := json.NewDecoder(r.Body).Decode(&todo); err != nil { + log.Printf("Request ID: %s, Error: %v", requestID, err) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if todo.Progress != "Todo" && todo.Progress != "InProgress" && todo.Progress != "Done" { + log.Printf("Request ID: %s, Error: Invalid progress value", requestID) + http.Error(w, "Invalid progress value. Must be 'Todo', 'InProgress', or 'Done'", http.StatusBadRequest) + return + } + + err = db.QueryRow( + "INSERT INTO todos (task, progress) VALUES ($1, $2) RETURNING id, last_checked", + todo.Task, todo.Progress, + ).Scan(&todo.ID, &todo.LastChecked) + if err != nil { + log.Printf("Request ID: %s, Error: %v", requestID, err) + http.Error(w, "Failed to create todo", http.StatusInternalServerError) + return + } + + _, err = db.Exec( + "INSERT INTO idempotency_keys (key, todo_id) VALUES ($1, $2)", + idempotencyKey, todo.ID, + ) + if err != nil { + log.Printf("Request ID: %s, Error: %v", requestID, err) + http.Error(w, "Failed to store idempotency key", http.StatusInternalServerError) + return + } + + response := TodoResponse{ + RequestID: requestID, + Timestamp: time.Now(), + Todo: todo, + } + + // Send the response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(response) +} + +func getTodos(w http.ResponseWriter, r *http.Request) { + requestID := r.Context().Value(requestIDKey).(string) + + rows, err := db.Query("SELECT id, task, progress, last_checked FROM todos") + if err != nil { + http.Error(w, "Failed to fetch todos", http.StatusInternalServerError) + return + } + defer rows.Close() + + var todos []Todo + for rows.Next() { + var todo Todo + if err := rows.Scan(&todo.ID, &todo.Task, &todo.Progress, &todo.LastChecked); err != nil { + http.Error(w, "Failed to scan todo", http.StatusInternalServerError) + return + } + todos = append(todos, todo) + } + + response := TodosResponse{ + RequestID: requestID, + Timestamp: time.Now(), + Todos: todos, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func getTodo(w http.ResponseWriter, r *http.Request) { + requestID := r.Context().Value(requestIDKey).(string) + + vars := mux.Vars(r) + id, err := strconv.Atoi(vars["id"]) + if err != nil { + http.Error(w, "Invalid ID", http.StatusBadRequest) + return + } + + _, err = db.Exec("UPDATE todos SET last_checked = CURRENT_TIMESTAMP WHERE id = $1", id) + if err != nil { + http.Error(w, "Failed to update last_checked", http.StatusInternalServerError) + return + } + + var todo Todo + err = db.QueryRow( + "SELECT id, task, progress, last_checked FROM todos WHERE id = $1", id, + ).Scan(&todo.ID, &todo.Task, &todo.Progress, &todo.LastChecked) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Todo not found", http.StatusNotFound) + } else { + http.Error(w, "Failed to fetch todo", http.StatusInternalServerError) + } + return + } + + response := TodoResponse{ + RequestID: requestID, + Timestamp: time.Now(), + Todo: todo, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func updateTodo(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id, err := strconv.Atoi(vars["id"]) + if err != nil { + http.Error(w, "Invalid ID", http.StatusBadRequest) + return + } + + var todo Todo + if err := json.NewDecoder(r.Body).Decode(&todo); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if todo.Progress != "Todo" && todo.Progress != "InProgress" && todo.Progress != "Done" { + http.Error(w, "Invalid progress value. Must be 'Todo', 'InProgress', or 'Done'", http.StatusBadRequest) + return + } + + _, err = db.Exec( + "UPDATE todos SET task = $1, progress = $2 WHERE id = $3", + todo.Task, todo.Progress, id, + ) + if err != nil { + http.Error(w, "Failed to update todo", http.StatusInternalServerError) + return + } + + err = db.QueryRow( + "SELECT id, task, progress, last_checked FROM todos WHERE id = $1", id, + ).Scan(&todo.ID, &todo.Task, &todo.Progress, &todo.LastChecked) + if err != nil { + http.Error(w, "Failed to fetch updated todo", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(todo) +} + +func deleteTodo(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id, err := strconv.Atoi(vars["id"]) + if err != nil { + http.Error(w, "Invalid ID", http.StatusBadRequest) + return + } + + _, err = db.Exec("DELETE FROM todos WHERE id = $1", id) + if err != nil { + http.Error(w, "Failed to delete todo", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func loginHandler(w http.ResponseWriter, r *http.Request) { + // Decode the request body + var loginReq LoginRequest + if err := json.NewDecoder(r.Body).Decode(&loginReq); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate credentials (replace with your authentication logic) + if loginReq.Username != "admin" || loginReq.Password != "password" { + http.Error(w, "Invalid credentials", http.StatusUnauthorized) + return + } + + // Create the JWT token + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "username": loginReq.Username, + "exp": time.Now().Add(time.Hour * 24).Unix(), // Token expires in 24 hours + }) + + // Sign the token with the secret key + tokenString, err := token.SignedString(jwtSecret) + if err != nil { + http.Error(w, "Failed to generate token", http.StatusInternalServerError) + return + } + + // Send the token in the response + response := LoginResponse{ + Token: tokenString, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/todo-mux/main.go b/todo-mux/main.go new file mode 100644 index 00000000..97499b65 --- /dev/null +++ b/todo-mux/main.go @@ -0,0 +1,120 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + "net/http" + "time" + + "github.com/gorilla/mux" + _ "github.com/lib/pq" +) + +var jwtSecret = []byte("this is my secret") + +type Todo struct { + ID int `json:"id"` + Task string `json:"task"` + Progress string `json:"progress"` // Enum: Todo, InProgress, Done + LastChecked time.Time `json:"last_checked"` +} + +type TodoResponse struct { + RequestID string `json:"request_id"` + Timestamp time.Time `json:"timestamp"` + Todo Todo `json:"todo"` +} + +type TodosResponse struct { + RequestID string `json:"request_id"` + Timestamp time.Time `json:"timestamp"` + Todos []Todo `json:"todos"` +} + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type LoginResponse struct { + Token string `json:"token"` +} + +// Custom response writer to capture the status code for logging. +type responseWriter struct { + http.ResponseWriter + status int +} + +var db *sql.DB + +type contextKey string + +var requestIDKey contextKey = "requestID" + +func main() { + var err error + connStr := "user=user dbname=todo_db password=password sslmode=disable host=localhost port=5432" + db, err = sql.Open("postgres", connStr) + if err != nil { + log.Fatalf("Unable to connect to database: %v\n", err) + } + defer db.Close() + + _, err = db.Exec(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'progress_status') THEN + CREATE TYPE progress_status AS ENUM ('Todo', 'InProgress', 'Done'); + END IF; + END $$; + `) + if err != nil { + log.Fatalf("Unable to create progress_status enum: %v\n", err) + } + + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS todos ( + id SERIAL PRIMARY KEY, + task TEXT NOT NULL, + progress progress_status DEFAULT 'Todo', + last_checked TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `) + if err != nil { + log.Fatalf("Unable to create table: %v\n", err) + } + + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS idempotency_keys ( + key TEXT PRIMARY KEY, + todo_id INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `) + if err != nil { + log.Fatalf("Unable to create idempotency_keys table: %v\n", err) + } + + r := mux.NewRouter() + + // Public routes + r.HandleFunc("/login", loginHandler).Methods("POST") + r.Use(requestIDMiddleware) + r.Use(loggerMiddleware) + + // Create an API subrouter with middleware + api := r.PathPrefix("/api").Subrouter() + api.Use(jwtMiddleware) + + // Register all todo endpoints on the API subrouter, not on the main router + api.HandleFunc("/todos", getTodos).Methods("GET") + api.HandleFunc("/todos", createTodo).Methods("POST") + api.HandleFunc("/todos/{id}", getTodo).Methods("GET") + api.HandleFunc("/todos/{id}", updateTodo).Methods("PUT") + api.HandleFunc("/todos/{id}", deleteTodo).Methods("DELETE") + + fmt.Println("Server is running on port 3040") + log.Fatal(http.ListenAndServe(":3040", r)) +} diff --git a/todo-mux/middleware.go b/todo-mux/middleware.go new file mode 100644 index 00000000..8540d7cc --- /dev/null +++ b/todo-mux/middleware.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +// Middleware to generate and add a request ID to each request +func requestIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestID := uuid.New().String() + + ctx := context.WithValue(r.Context(), requestIDKey, requestID) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// Middleware to log request details +func loggerMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Create a custom response writer to capture the status code + rw := &responseWriter{w, http.StatusOK} + + next.ServeHTTP(rw, r) + + duration := time.Since(start) + requestID := r.Context().Value(requestIDKey).(string) + log.Printf( + "Request ID: %s, Method: %s, Path: %s, Status: %d, Duration: %v", + requestID, r.Method, r.URL.Path, rw.status, duration, + ) + }) +} + +// Middleware to validate JWT tokens +func jwtMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get the token from the Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "Authorization header is required", http.StatusUnauthorized) + return + } + + // Check if the header starts with "Bearer " + if len(authHeader) < 7 || authHeader[:7] != "Bearer " { + http.Error(w, "Invalid token format, must be 'Bearer '", http.StatusUnauthorized) + return + } + + // Extract the token from the "Bearer " format + tokenString := authHeader[7:] + if tokenString == "" { + http.Error(w, "Token cannot be empty", http.StatusUnauthorized) + return + } + + // Parse and validate the token + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + // Validate the signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return jwtSecret, nil + }) + if err != nil { + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + // Check if the token is valid + if !token.Valid { + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + // Call the next handler + next.ServeHTTP(w, r) + }) +}