From 13a84188c90b63241d59f3cade332a33c30e3c60 Mon Sep 17 00:00:00 2001 From: Akash Kumar Date: Wed, 11 Mar 2026 18:59:00 +0530 Subject: [PATCH 1/2] feat: add sample app for http postgres Signed-off-by: Akash Kumar --- http-postgres/.gitignore | 1 + http-postgres/Dockerfile | 13 +++ http-postgres/db/db.go | 57 +++++++++++ http-postgres/docker-compose.yml | 33 +++++++ http-postgres/go.mod | 8 ++ http-postgres/go.sum | 4 + http-postgres/handler/handler.go | 110 +++++++++++++++++++++ http-postgres/handler/projects.go | 153 ++++++++++++++++++++++++++++++ http-postgres/main.go | 35 +++++++ http-postgres/test.sh | 71 ++++++++++++++ http-postgres/test_projects.sh | 55 +++++++++++ 11 files changed, 540 insertions(+) create mode 100644 http-postgres/.gitignore create mode 100644 http-postgres/Dockerfile create mode 100644 http-postgres/db/db.go create mode 100644 http-postgres/docker-compose.yml create mode 100644 http-postgres/go.mod create mode 100644 http-postgres/go.sum create mode 100644 http-postgres/handler/handler.go create mode 100644 http-postgres/handler/projects.go create mode 100644 http-postgres/main.go create mode 100755 http-postgres/test.sh create mode 100755 http-postgres/test_projects.sh diff --git a/http-postgres/.gitignore b/http-postgres/.gitignore new file mode 100644 index 00000000..bbfdc049 --- /dev/null +++ b/http-postgres/.gitignore @@ -0,0 +1 @@ +keploy \ No newline at end of file diff --git a/http-postgres/Dockerfile b/http-postgres/Dockerfile new file mode 100644 index 00000000..96aa4e63 --- /dev/null +++ b/http-postgres/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o server . + +FROM alpine:3.19 +WORKDIR /app +COPY --from=builder /app/server . +EXPOSE 8080 +CMD ["./server"] diff --git a/http-postgres/db/db.go b/http-postgres/db/db.go new file mode 100644 index 00000000..770e6070 --- /dev/null +++ b/http-postgres/db/db.go @@ -0,0 +1,57 @@ +package db + +import ( + "database/sql" + "fmt" + "os" + + _ "github.com/lib/pq" +) + +func Connect() (*sql.DB, error) { + host := getEnv("DB_HOST", "localhost") + port := getEnv("DB_PORT", "5432") + user := getEnv("DB_USER", "postgres") + password := getEnv("DB_PASSWORD", "postgres") + dbname := getEnv("DB_NAME", "pg_replicate") + + dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + host, port, user, password, dbname) + + db, err := sql.Open("postgres", dsn) + if err != nil { + return nil, err + } + + if err := db.Ping(); err != nil { + return nil, err + } + + return db, nil +} + +func Migrate(db *sql.DB) error { + query := ` + CREATE TABLE IF NOT EXISTS companies ( +id SERIAL PRIMARY KEY, +name TEXT NOT NULL UNIQUE, +created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + CREATE TABLE IF NOT EXISTS projects ( +id UUID PRIMARY KEY DEFAULT gen_random_uuid(), +name TEXT NOT NULL UNIQUE, +status TEXT NOT NULL DEFAULT 'active', +created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), +updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + );` + _, err := db.Exec(query) + return err +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/http-postgres/docker-compose.yml b/http-postgres/docker-compose.yml new file mode 100644 index 00000000..7e95445b --- /dev/null +++ b/http-postgres/docker-compose.yml @@ -0,0 +1,33 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: pg_replicate + ports: + - "5433:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 2s + timeout: 5s + retries: 5 + + api: + build: . + ports: + - "8080:8080" + environment: + DB_HOST: db + DB_PORT: "5432" + DB_USER: postgres + DB_PASSWORD: postgres + DB_NAME: pg_replicate + depends_on: + db: + condition: service_healthy + +volumes: + pgdata: diff --git a/http-postgres/go.mod b/http-postgres/go.mod new file mode 100644 index 00000000..ba0d64e7 --- /dev/null +++ b/http-postgres/go.mod @@ -0,0 +1,8 @@ +module pg-replicate + +go 1.24.4 + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/lib/pq v1.11.2 // indirect +) diff --git a/http-postgres/go.sum b/http-postgres/go.sum new file mode 100644 index 00000000..bc6a76de --- /dev/null +++ b/http-postgres/go.sum @@ -0,0 +1,4 @@ +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/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= +github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= diff --git a/http-postgres/handler/handler.go b/http-postgres/handler/handler.go new file mode 100644 index 00000000..6ff5998e --- /dev/null +++ b/http-postgres/handler/handler.go @@ -0,0 +1,110 @@ +package handler + +import ( + "database/sql" + "encoding/json" + "net/http" + "strings" +) + +type Handler struct { + db *sql.DB +} + +type Company struct { + ID int `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` +} + +type CreateCompanyRequest struct { + Name string `json:"name"` +} + +type ErrorResponse struct { + Error string `json:"error"` +} + +func New(db *sql.DB) *Handler { + return &Handler{db: db} +} + +func (h *Handler) CreateCompany(w http.ResponseWriter, r *http.Request) { + var req CreateCompanyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid request body"}) + return + } + + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "name is required"}) + return + } + + var company Company + err := h.db.QueryRow( + "INSERT INTO companies (name) VALUES ($1) RETURNING id, name, created_at", + req.Name, + ).Scan(&company.ID, &company.Name, &company.CreatedAt) + + if err != nil { + if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique constraint") { + writeJSON(w, http.StatusConflict, ErrorResponse{Error: "company already exists"}) + return + } + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to create company"}) + return + } + + writeJSON(w, http.StatusCreated, company) +} + +func (h *Handler) ListCompanies(w http.ResponseWriter, r *http.Request) { + rows, err := h.db.Query("SELECT id, name, created_at FROM companies ORDER BY id") + if err != nil { + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to list companies"}) + return + } + defer rows.Close() + + companies := []Company{} + for rows.Next() { + var c Company + if err := rows.Scan(&c.ID, &c.Name, &c.CreatedAt); err != nil { + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to scan company"}) + return + } + companies = append(companies, c) + } + + writeJSON(w, http.StatusOK, companies) +} + +func (h *Handler) GetCompany(w http.ResponseWriter, r *http.Request) { + name := strings.TrimPrefix(r.URL.Path, "/companies/") + if name == "" { + writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "company name is required"}) + return + } + + var c Company + err := h.db.QueryRow("SELECT id, name, created_at FROM companies WHERE name = $1", name). + Scan(&c.ID, &c.Name, &c.CreatedAt) + if err == sql.ErrNoRows { + writeJSON(w, http.StatusNotFound, ErrorResponse{Error: "company not found"}) + return + } + if err != nil { + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to get company"}) + return + } + + writeJSON(w, http.StatusOK, c) +} + +func writeJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} diff --git a/http-postgres/handler/projects.go b/http-postgres/handler/projects.go new file mode 100644 index 00000000..1df98699 --- /dev/null +++ b/http-postgres/handler/projects.go @@ -0,0 +1,153 @@ +package handler + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" +) + +// pgQuote escapes a string value for direct embedding in SQL. +// This mimics the simple query protocol used by Python/SQLAlchemy which +// embeds values as string literals rather than using $1 parameters. +func pgQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type CreateProjectRequest struct { + Name string `json:"name"` + Status string `json:"status"` +} + +type UpdateProjectRequest struct { + Name string `json:"name"` + Status string `json:"status"` +} + +func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) { + var req CreateProjectRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid request body"}) + return + } + + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "name is required"}) + return + } + if req.Status == "" { + req.Status = "active" + } + + projectID := uuid.New().String() + + var project Project + query := fmt.Sprintf( + "INSERT INTO projects (id, name, status) VALUES (%s::UUID, %s, %s) RETURNING id, name, status, created_at, updated_at", + pgQuote(projectID), pgQuote(req.Name), pgQuote(req.Status), + ) + err := h.db.QueryRow(query).Scan(&project.ID, &project.Name, &project.Status, &project.CreatedAt, &project.UpdatedAt) + + if err != nil { + if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique constraint") { + writeJSON(w, http.StatusConflict, ErrorResponse{Error: "project already exists"}) + return + } + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to create project"}) + return + } + + w.Header().Set("X-Project-Id", project.ID) + writeJSON(w, http.StatusCreated, project) +} + +func (h *Handler) GetProject(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/projects/") + if id == "" { + writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "project id is required"}) + return + } + + var p Project + query := fmt.Sprintf( + "SELECT id, name, status, created_at, updated_at FROM projects WHERE id = %s::UUID", + pgQuote(id), + ) + err := h.db.QueryRow(query).Scan(&p.ID, &p.Name, &p.Status, &p.CreatedAt, &p.UpdatedAt) + + if err == sql.ErrNoRows { + writeJSON(w, http.StatusNotFound, ErrorResponse{Error: "project not found"}) + return + } + if err != nil { + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to get project"}) + return + } + + writeJSON(w, http.StatusOK, p) +} + +func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/projects/") + if id == "" { + writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "project id is required"}) + return + } + + var req UpdateProjectRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid request body"}) + return + } + + var p Project + query := fmt.Sprintf( + "UPDATE projects SET name = %s, status = %s, updated_at = NOW() WHERE id = %s::UUID RETURNING id, name, status, created_at, updated_at", + pgQuote(req.Name), pgQuote(req.Status), pgQuote(id), + ) + err := h.db.QueryRow(query).Scan(&p.ID, &p.Name, &p.Status, &p.CreatedAt, &p.UpdatedAt) + + if err == sql.ErrNoRows { + writeJSON(w, http.StatusNotFound, ErrorResponse{Error: "project not found"}) + return + } + if err != nil { + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to update project"}) + return + } + + writeJSON(w, http.StatusOK, p) +} + +func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) { + rows, err := h.db.Query("SELECT id, name, status, created_at, updated_at FROM projects ORDER BY created_at") + if err != nil { + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to list projects"}) + return + } + defer rows.Close() + + projects := []Project{} + for rows.Next() { + var p Project + if err := rows.Scan(&p.ID, &p.Name, &p.Status, &p.CreatedAt, &p.UpdatedAt); err != nil { + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to scan project"}) + return + } + projects = append(projects, p) + } + + writeJSON(w, http.StatusOK, projects) +} diff --git a/http-postgres/main.go b/http-postgres/main.go new file mode 100644 index 00000000..b94aa3e1 --- /dev/null +++ b/http-postgres/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "log" + "net/http" + + "pg-replicate/db" + "pg-replicate/handler" +) + +func main() { + database, err := db.Connect() + if err != nil { + log.Fatalf("failed to connect to database: %v", err) + } + defer database.Close() + + if err := db.Migrate(database); err != nil { + log.Fatalf("failed to run migrations: %v", err) + } + + h := handler.New(database) + + http.HandleFunc("POST /companies", h.CreateCompany) + http.HandleFunc("GET /companies", h.ListCompanies) + http.HandleFunc("GET /companies/", h.GetCompany) + + http.HandleFunc("POST /projects", h.CreateProject) + http.HandleFunc("GET /projects", h.ListProjects) + http.HandleFunc("GET /projects/", h.GetProject) + http.HandleFunc("PUT /projects/", h.UpdateProject) + + log.Println("server listening on :8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/http-postgres/test.sh b/http-postgres/test.sh new file mode 100755 index 00000000..c0b77f0b --- /dev/null +++ b/http-postgres/test.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="http://localhost:8080" + +# Generate random company names +random_name() { + head -c 8 /dev/urandom | base64 | tr -dc 'a-zA-Z' | head -c 10 +} + +echo "=== Rapid-fire API test (25+ calls) ===" + +# 1-10: Create 10 unique companies in parallel +echo -e "\n--- Creating 10 unique companies (parallel) ---" +pids=() +for i in $(seq 1 10); do + name="Company_$(random_name)_$i" + curl -s -w " HTTP %{http_code}\n" -X POST "$BASE_URL/companies" \ + -H 'Content-Type: application/json' \ + -d "{\"name\": \"$name\"}" & + pids+=($!) +done +wait "${pids[@]}" + +# 11-15: Create 5 companies we'll reuse for duplicate tests +echo -e "\n--- Creating 5 known companies ---" +KNOWN=("DupeAlpha" "DupeBeta" "DupeGamma" "DupeDelta" "DupeEpsilon") +for name in "${KNOWN[@]}"; do + curl -s -w " HTTP %{http_code}\n" -X POST "$BASE_URL/companies" \ + -H 'Content-Type: application/json' \ + -d "{\"name\": \"$name\"}" & +done +wait + +# 16-20: Try to create the same 5 again (should all 409) +echo -e "\n--- Duplicate creates (expect 409) ---" +for name in "${KNOWN[@]}"; do + curl -s -w " HTTP %{http_code}\n" -X POST "$BASE_URL/companies" \ + -H 'Content-Type: application/json' \ + -d "{\"name\": \"$name\"}" & +done +wait + +# 21: List all companies +echo -e "\n--- List companies ---" +curl -s -w "\n HTTP %{http_code}\n" "$BASE_URL/companies" + +# 22-26: Get each known company by name +echo -e "\n--- Get companies by name ---" +for name in "${KNOWN[@]}"; do + curl -s -w " HTTP %{http_code}\n" "$BASE_URL/companies/$name" & +done +wait + +# 27: Get a company that doesn't exist (expect 404) +echo -e "\n--- Get non-existent company (expect 404) ---" +curl -s -w " HTTP %{http_code}\n" "$BASE_URL/companies/NoSuchCorp" + +# 28: Empty name (expect 400) +echo -e "\n--- Empty name (expect 400) ---" +curl -s -w " HTTP %{http_code}\n" -X POST "$BASE_URL/companies" \ + -H 'Content-Type: application/json' \ + -d '{"name": ""}' + +# 29: Invalid JSON (expect 400) +echo -e "\n--- Invalid JSON (expect 400) ---" +curl -s -w " HTTP %{http_code}\n" -X POST "$BASE_URL/companies" \ + -H 'Content-Type: application/json' \ + -d 'not-json' + +echo -e "\n=== Done ===" diff --git a/http-postgres/test_projects.sh b/http-postgres/test_projects.sh new file mode 100755 index 00000000..20103793 --- /dev/null +++ b/http-postgres/test_projects.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Test script for the UUID-based projects endpoint. +# During recording, the server generates UUIDs at runtime. +# During replay, the server generates DIFFERENT UUIDs. +# The value substitution feature should handle this transparently. +# +# Test flow: +# 1. Create a project (server generates UUID) +# 2. Get that project by UUID +# 3. Update that project by UUID +# 4. Get it again to verify update +# 5. List all projects +# 6. Create another project + +set -e + +BASE_URL="${BASE_URL:-http://localhost:8080}" + +echo "=== Test: Create project ===" +RESP=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/projects" \ + -H "Content-Type: application/json" \ + -d '{"name": "Alpha Project", "status": "active"}') +CODE=$(echo "$RESP" | tail -1) +BODY=$(echo "$RESP" | head -1) +echo "Status: $CODE" +echo "Body: $BODY" +PROJECT_ID=$(echo "$BODY" | grep -o '"id":"[^"]*"' | cut -d'"' -f4) +echo "Project ID: $PROJECT_ID" + +echo "" +echo "=== Test: Get project by UUID ===" +curl -s -X GET "$BASE_URL/projects/$PROJECT_ID" | python3 -m json.tool 2>/dev/null || true + +echo "" +echo "=== Test: Update project ===" +curl -s -X PUT "$BASE_URL/projects/$PROJECT_ID" \ + -H "Content-Type: application/json" \ + -d '{"name": "Alpha Project (Updated)", "status": "completed"}' | python3 -m json.tool 2>/dev/null || true + +echo "" +echo "=== Test: Get project after update ===" +curl -s -X GET "$BASE_URL/projects/$PROJECT_ID" | python3 -m json.tool 2>/dev/null || true + +echo "" +echo "=== Test: List all projects ===" +curl -s -X GET "$BASE_URL/projects" | python3 -m json.tool 2>/dev/null || true + +echo "" +echo "=== Test: Create another project ===" +curl -s -X POST "$BASE_URL/projects" \ + -H "Content-Type: application/json" \ + -d '{"name": "Beta Project", "status": "pending"}' | python3 -m json.tool 2>/dev/null || true + +echo "" +echo "Done." From d6fc88ca1a424215ff62402facc86336a4b22405 Mon Sep 17 00:00:00 2001 From: Akash Kumar Date: Thu, 12 Mar 2026 18:17:26 +0530 Subject: [PATCH 2/2] chore: add readme.md Signed-off-by: Akash Kumar --- http-postgres/README.md | 103 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 http-postgres/README.md diff --git a/http-postgres/README.md b/http-postgres/README.md new file mode 100644 index 00000000..a7ea3f90 --- /dev/null +++ b/http-postgres/README.md @@ -0,0 +1,103 @@ +# http-postgres + +`http-postgres` is a small Go HTTP API backed by PostgreSQL. It is designed as a Keploy sample application for recording and replaying HTTP traffic together with Postgres interactions. + +The app starts with `net/http`, connects to Postgres using `lib/pq`, runs startup migrations automatically, and exposes two resources: +- `companies` with a serial integer ID +- `projects` with a UUID ID + +## Endpoints + +### Companies +- `POST /companies` +- `GET /companies` +- `GET /companies/{name}` + +### Projects +- `POST /projects` +- `GET /projects` +- `GET /projects/{id}` +- `PUT /projects/{id}` + +## Prerequisites + +- Go `1.24+` +- Docker and Docker Compose +- Keploy CLI + +Install Keploy: + +```bash +curl --silent -O -L https://keploy.io/install.sh +source install.sh +``` + +## Setup + +```bash +git clone https://github.com/keploy/samples-go.git +cd samples-go/http-postgres +go mod download +``` + +## Run with Docker + +Start the application and Postgres: + +```bash +docker compose up --build +``` + +The API will be available at `http://localhost:8080`. + +## Record Testcases with Keploy + +Start recording: + +```bash +keploy record -c "docker compose up" --container-name api --delay 10 +``` + +In another terminal, generate traffic using the bundled scripts: + +```bash +./test.sh +./test_projects.sh +``` + +This records API testcases and Postgres mocks into the `keploy/` directory. + +## Replay Recorded Tests + +Run the recorded testcases: + +```bash +keploy test -c "docker compose up" --container-name api --delay 20 +``` + +Keploy will replay the captured HTTP calls and mock the Postgres interactions during the test run. + +## Run Natively + +If you want to run the app without Docker for the API process, start only Postgres through Compose: + +```bash +docker compose up -d db +``` + +Then run the server with local environment variables: + +```bash +DB_HOST=localhost \ +DB_PORT=5433 \ +DB_USER=postgres \ +DB_PASSWORD=postgres \ +DB_NAME=pg_replicate \ +go run . +``` + +## Notes + +- Database tables are created automatically on startup. +- `test.sh` focuses on company creation, duplicates, validation, and listing flows. +- `test_projects.sh` exercises UUID-based project create, get, update, and list flows.