Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions http-postgres/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
keploy
13 changes: 13 additions & 0 deletions http-postgres/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
103 changes: 103 additions & 0 deletions http-postgres/README.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 57 additions & 0 deletions http-postgres/db/db.go
Original file line number Diff line number Diff line change
@@ -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
}
33 changes: 33 additions & 0 deletions http-postgres/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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:
8 changes: 8 additions & 0 deletions http-postgres/go.mod
Original file line number Diff line number Diff line change
@@ -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
)
4 changes: 4 additions & 0 deletions http-postgres/go.sum
Original file line number Diff line number Diff line change
@@ -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=
110 changes: 110 additions & 0 deletions http-postgres/handler/handler.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading