From 96fc87ea5eb3a9120bd7348a73afc00327fdc762 Mon Sep 17 00:00:00 2001 From: phoenix Date: Mon, 3 Nov 2025 22:27:34 +0000 Subject: [PATCH] tsk-5: Add contact (#7) Closes #5 Reviewed-on: https://git.kundeng.us/phoenix/textsender-api/pulls/7 Co-authored-by: phoenix Co-committed-by: phoenix --- .gitea/workflows/workflow.yaml | 126 +++++++++++++++++++++----- cmd/api/main.go | 92 +++++++++++++++++-- cmd/api/main_test.go | 82 +++++++++++++++++ go.mod | 23 +++++ go.sum | 47 ++++++++++ internal/config/config.go | 120 +++++++++++++++++++++++- internal/db/connection.go | 118 ++++++++++++++++++++++++ internal/handler/contact.go | 60 +++++------- internal/handler/contact_test.go | 53 +++++++++++ internal/handler/endpoint/endpoint.go | 4 + internal/handler/endpoints.go | 4 - internal/handler/helper.go | 2 - internal/handler/mock_store_test.go | 117 ++++++++++++++++++++++++ internal/store/contact_store.go | 115 +++++++++++++++++++++++ internal/version/version.go | 16 +++- migrations/schema.sql | 10 ++ 16 files changed, 911 insertions(+), 78 deletions(-) create mode 100644 cmd/api/main_test.go create mode 100644 internal/db/connection.go create mode 100644 internal/handler/contact_test.go create mode 100644 internal/handler/endpoint/endpoint.go delete mode 100644 internal/handler/endpoints.go create mode 100644 internal/handler/mock_store_test.go create mode 100644 internal/store/contact_store.go create mode 100644 migrations/schema.sql diff --git a/.gitea/workflows/workflow.yaml b/.gitea/workflows/workflow.yaml index 879494a..a63c29b 100644 --- a/.gitea/workflows/workflow.yaml +++ b/.gitea/workflows/workflow.yaml @@ -11,32 +11,116 @@ jobs: runs-on: ubuntu-24.04 # You can change this to macos-latest or windows-latest if needed steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Set up Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v4 with: - go-version: '1.24.5' # You can specify a specific version or 'stable' + go-version: '1.25.3' # You can specify a specific version or 'stable' - name: Build - run: go build -v ./... - - - name: Test - run: go test -v ./... - - - name: Run gofmt (optional) - # Uncomment to check code formatting with gofmt run: | - if [ -n "$(gofmt -l.)" ]; then - echo "Go code is not formatted. Please run 'gofmt -w.' to fix it." - exit 1 - fi + echo "Initializing config" + mkdir -p ~/.ssh + echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/textsender_models_deploy_key + chmod 600 ~/.ssh/textsender_models_deploy_key + ssh-keyscan ${{ vars.MY_HOST }} >> ~/.ssh/known_hosts + + eval $(ssh-agent -s) + ssh-add -v ~/.ssh/textsender_models_deploy_key - - name: Run golint (optional) - # Uncomment to check code style with golint - run: | - if [ -n "$(golint./...)" ]; then - echo "Go code has style issues. Please run 'golint./...' to see them." - exit 1 - fi + go env -w GOPRIVATE='${{ secrets.GIT_HOST_ROOT }}' + echo "Creating local .gitconfig" + touch ~/.gitconfig + cat > ~/.gitconfig << "EOF" + [url "ssh://git@${{ secrets.GIT_HOST_ROOT }}"] + insteadOf = https://${{ secrets.GIT_HOST_ROOT }} + EOF + + echo "Building binary" + make build + + echo "Binary built" + file textsender-api + + test: + name: Test + runs-on: ubuntu-24.04 + services: + postgres: + image: postgres:18.0 + env: + POSTGRES_USER: ${{ secrets.DB_TEST_USER }} + POSTGRES_PASSWORD: ${{ secrets.DB_TEST_PASSWORD }} + POSTGRES_DB: ${{ secrets.DB_TEST_NAME }} + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: '1.25.3' + + - name: Install PostgreSQL client + run: sudo apt update && sudo apt-get install -y postgresql-client + + - name: Wait for PostgreSQL to be ready + run: | + for i in {1..30}; do + if pg_isready -h postgres -p 5432; then + echo "PostgreSQL is ready" + exit 0 + fi + echo "Waiting for PostgreSQL... Attempt $i" + sleep 2 + done + echo "PostgreSQL did not start in time" + exit 1 + + - name: Run tests + env: + DB_NAME: ${{ secrets.DB_TEST_NAME }} + DB_USER: ${{ secrets.DB_TEST_USER }} + DB_PASSWORD: ${{ secrets.DB_TEST_PASSWORD }} + DB_HOST: postgres + DB_PORT: 5432 + DB_SSLMODE: disable + run: | + echo "Parent directory" + echo `pwd` + + echo "DB_NAME=$DB_NAME" > .env + echo "DB_USER=$DB_USER" >> .env + echo "DB_PASSWORD=$DB_PASSWORD" >> .env + echo "DB_HOST=$DB_HOST" >> .env + echo "DB_PORT=$DB_PORT" >> .env + echo "DB_SSLMODE=$DB_SSLMODE" >> .env + + echo "Initializing config" + mkdir -p ~/.ssh + echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/textsender_models_deploy_key + chmod 600 ~/.ssh/textsender_models_deploy_key + ssh-keyscan ${{ vars.MY_HOST }} >> ~/.ssh/known_hosts + + eval $(ssh-agent -s) + ssh-add -v ~/.ssh/textsender_models_deploy_key + + go env -w GOPRIVATE='${{ secrets.GIT_HOST_ROOT }}' + + echo "Creating local .gitconfig" + touch ~/.gitconfig + cat > ~/.gitconfig << "EOF" + [url "ssh://git@${{ secrets.GIT_HOST_ROOT }}"] + insteadOf = https://${{ secrets.GIT_HOST_ROOT }} + EOF + + go test -v ./... diff --git a/cmd/api/main.go b/cmd/api/main.go index 988c43e..280a931 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -1,17 +1,91 @@ package main -import "fmt" -import "log" -import "net/http" +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" -import "git.kundeng.us/phoenix/textsender-api/internal/config" -import "git.kundeng.us/phoenix/textsender-api/internal/handler" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "git.kundeng.us/phoenix/textsender-api/internal/config" + "git.kundeng.us/phoenix/textsender-api/internal/db" + "git.kundeng.us/phoenix/textsender-api/internal/handler" + "git.kundeng.us/phoenix/textsender-api/internal/handler/endpoint" + mdlware "git.kundeng.us/phoenix/textsender-api/internal/middleware" + "git.kundeng.us/phoenix/textsender-api/internal/store" +) func main() { - fmt.Println(config.APP_NAME) + cfg := config.Load() + if cfg == nil { + fmt.Errorf("Error initializing config") + os.Exit(-1) + } - http.HandleFunc(handler.MESSAGE_DRAFT_ENDPOINT, handler.DraftMessageHandler) + database, err := db.NewDatabase(cfg.GetDBConnString()) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer database.Close() - log.Println("Starting server", config.PORT) - log.Fatal(http.ListenAndServe(config.PortString(), nil)) + ctx := context.Background() + + if cfg.ResetDB { + log.Println("Resetting database") + if err := database.ResetDatabase(ctx); err != nil { + log.Fatalf("Failed to reset database: %v", err) + } + log.Println("Database reset completed. Exiting.") + return + } + + contactStore := store.NewContactStore(database.Pool) + contactHandler := handler.NewContactHandler(contactStore) + + router := chi.NewRouter() + + router.Use(middleware.Logger) + router.Use(middleware.Recoverer) + router.Use(middleware.Timeout(60 * time.Second)) + router.Use(mdlware.JSONContentType) + + router.Post(endpoint.ADD_CONTACT_ENDPOINT, contactHandler.AddContact) + + // Start server + server := &http.Server{ + Addr: ":" + cfg.ServerPort, + Handler: router, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Graceful shutdown + go func() { + log.Printf("Server starting on port %s", cfg.ServerPort) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server failed to start: %v", err) + } + }() + + // Wait for interrupt signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("Shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + log.Fatalf("Server forced to shutdown: %v", err) + } + + log.Println("Server exited") } diff --git a/cmd/api/main_test.go b/cmd/api/main_test.go new file mode 100644 index 0000000..944ec5d --- /dev/null +++ b/cmd/api/main_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "path" + "testing" + + "github.com/gorilla/mux" + "github.com/joho/godotenv" + + "git.kundeng.us/phoenix/textsender-api/internal/config" + "git.kundeng.us/phoenix/textsender-api/internal/db" + "git.kundeng.us/phoenix/textsender-api/internal/handler" + "git.kundeng.us/phoenix/textsender-api/internal/handler/endpoint" + "git.kundeng.us/phoenix/textsender-api/internal/store" +) + +var testRouter *mux.Router + +func TestMain(m *testing.M) { + cfg := load() + + db, err := db.NewDatabase(cfg.GetDBConnString()) + if err != nil { + fmt.Println(err.Error()) + panic("Failed to initialize database") + } + defer db.Close() + + ctx := context.Background() + err = db.ResetDatabase(ctx) + if err != nil { + fmt.Println(err.Error()) + panic("Failed to initialize database") + } + + contactStore := store.NewContactStore(db.Pool) + contactHandler := handler.NewContactHandler(contactStore) + + testRouter = mux.NewRouter() + testRouter.HandleFunc(endpoint.ADD_CONTACT_ENDPOINT, contactHandler.AddContact).Methods("POST") + + code := m.Run() + os.Exit(code) +} + +func load() *config.Config { + resetDb := flag.Bool("reset-db", false, "Reset the database schema and exit") + port := flag.String("port", config.PORT, "Server port") + flag.Parse() + + cwd, _ := os.Getwd() + envPath := path.Join(cwd, ".env") + + err := godotenv.Load(envPath) + if err != nil { + envPath = path.Join(cwd, "../..", ".env") + if err := godotenv.Load(envPath); err != nil { + panic("Error loading .env file: " + err.Error()) + } + } + + unpackedConnString := config.UnpackDBConnString() + dbConnString := unpackedConnString.Parse() + + return &config.Config{ + DBConnString: dbConnString, + ServerPort: *port, + ResetDB: *resetDb, + } +} + +func resetTestDB(t *testing.T) { + t.Helper() + _, err := db.Pool.Exec(context.Background(), "DELETE FROM contacts") + if err != nil { + t.Fatalf("Failed to reset test database: %v", err) + } +} diff --git a/go.mod b/go.mod index ca404bc..c0f507e 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,26 @@ module git.kundeng.us/phoenix/textsender-api go 1.25.3 require github.com/google/uuid v1.6.0 + +require ( + git.kundeng.us/phoenix/textsender-models v0.0.4 + github.com/go-chi/chi/v5 v5.2.3 + github.com/gorilla/mux v1.8.1 + github.com/jackc/pgx/v5 v5.7.6 + github.com/joho/godotenv v1.5.1 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + golang.org/x/crypto v0.42.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index 7790d7c..035b138 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,49 @@ +git.kundeng.us/phoenix/textsender-models v0.0.4-1-0e41936506-556 h1:htIAVchoU7LzTlDuAOwwUYmJH8U9UXCwjt3lO00juEc= +git.kundeng.us/phoenix/textsender-models v0.0.4-1-0e41936506-556/go.mod h1:lx5MCnOgGgsdpwzrfi9uph5xmkeb6H8AuexUNGss2no= +git.kundeng.us/phoenix/textsender-models v0.0.4 h1:r3kqo/Xe5hEcRceknPZ/gw1y4VHSrhG+UYYgrgGTsVg= +git.kundeng.us/phoenix/textsender-models v0.0.4/go.mod h1:lx5MCnOgGgsdpwzrfi9uph5xmkeb6H8AuexUNGss2no= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index 8d73faa..d7a496a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,13 +1,127 @@ package config -import "fmt" +import ( + "flag" + "fmt" + "log" + "os" + "strconv" -const PORT = 8080 + "github.com/joho/godotenv" + "git.kundeng.us/phoenix/textsender-api/internal/version" +) + +type Config struct { + DBConnString string + ServerPort string + ResetDB bool +} + +type ConnectionInfo struct { + Username string + Password string + Database string + Host string + Port int + SslMode string +} + +const PORT = "8080" const APP_NAME = "textsender-api" func PortString() string { - portMessage := fmt.Sprintf(":%d", PORT) + portMessage := fmt.Sprintf(":%s", PORT) return portMessage } + +func (ci ConnectionInfo) Parse() string { + return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s", ci.Username, ci.Password, ci.Host, ci.Port, ci.Database, ci.SslMode) +} + +func PrintName() { + fmt.Println(APP_NAME) + fmt.Println(version.String()) +} + +func Load() *Config { + versionFlag := flag.Bool("version", false, "Print version information") + resetDb := flag.Bool("reset-db", false, "Reset the database schema and exit") + port := flag.String("port", PORT, "Server port") + flag.Parse() + + if *versionFlag { + fmt.Println(version.String()) + os.Exit(-1) + } + + err := godotenv.Load() + if err != nil { + log.Fatal("Error loading .env file") + } + + unpackedConnString := UnpackDBConnString() + dbConnString := unpackedConnString.Parse() + + return &Config{ + DBConnString: dbConnString, + ServerPort: *port, + ResetDB: *resetDb, + } +} + +func UnpackDBConnString() (connInfo ConnectionInfo) { + username := os.Getenv("DB_USER") + password := os.Getenv("DB_PASSWORD") + host := os.Getenv("DB_HOST") + port := os.Getenv("DB_PORT") + database := os.Getenv("DB_NAME") + sslMode := os.Getenv("DB_SSLMODE") + + if username != "" { + connInfo.Username = username + } else { + connInfo.Username = "user" + } + + if password != "" { + connInfo.Password = password + } else { + connInfo.Password = "password" + } + + if host != "" { + connInfo.Host = host + } else { + connInfo.Host = "localhost" + } + + if port != "" { + num, err := strconv.Atoi(port) + if err != nil { + return + } + connInfo.Port = num + } else { + connInfo.Port = 5432 + } + + if database != "" { + connInfo.Database = database + } else { + connInfo.Database = "textsender_db" + } + + if sslMode != "" { + connInfo.SslMode = sslMode + } else { + connInfo.SslMode = "disable" + } + + return +} + +func (c *Config) GetDBConnString() string { + return c.DBConnString +} diff --git a/internal/db/connection.go b/internal/db/connection.go new file mode 100644 index 0000000..beccf11 --- /dev/null +++ b/internal/db/connection.go @@ -0,0 +1,118 @@ +// db/connection.go +package db + +import ( + "context" + "fmt" + "log" + "os" + "path" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var Pool *pgxpool.Pool + +type Database struct { + Pool *pgxpool.Pool +} + +func NewDatabase(connString string) (*Database, error) { + ctx := context.Background() + // Parse the connection string and create pool configuration + config, err := pgxpool.ParseConfig(connString) + if err != nil { + return nil, fmt.Errorf("unable to parse DSN: %v", err) + } + + // Configure connection pool settings + config.MaxConns = 25 + config.MinConns = 5 + config.MaxConnLifetime = time.Hour + config.MaxConnIdleTime = 30 * time.Minute + config.HealthCheckPeriod = time.Minute + + // Configure connection timeouts + config.ConnConfig.ConnectTimeout = 10 * time.Second + config.ConnConfig.RuntimeParams["timezone"] = "UTC" + + // Create the connection pool + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, fmt.Errorf("unable to create connection pool: %v", err) + } + + // Test the connection with a short timeout + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("unable to ping database: %v", err) + } + + log.Printf("Successfully connected to database") + return &Database{Pool: pool}, nil +} + +func (db *Database) Close() { + if db.Pool != nil { + db.Pool.Close() + log.Println("Database connection pool closed") + } +} + +// HealthCheck verifies the database connection is still alive +func (db *Database) HealthCheck(ctx context.Context) error { + return db.Pool.Ping(ctx) +} + +// GetPoolStats returns connection pool statistics +func (db *Database) GetPoolStats() string { + stats := db.Pool.Stat() + return fmt.Sprintf("TotalConns: %d, IdleConns: %d, AcquiredConns: %d", + stats.TotalConns(), stats.IdleConns(), stats.AcquiredConns()) +} + +func (db *Database) ResetDatabase(ctx context.Context) error { + tx, err := db.Pool.Begin(ctx) + if err != nil { + return fmt.Errorf("Transaction unable to begin: %v", err) + } + defer tx.Rollback(ctx) + + schemaContent, err := os.ReadFile("migrations/schema.sql") + if err != nil { + log.Println("Default migrations not found. Checking different directory") + cwd, _ := os.Getwd() + migrationsPath := path.Join(cwd, "../..", "migrations/schema.sql") + schemaContent, err = os.ReadFile(migrationsPath) + if err != nil { + return fmt.Errorf("error reading schema file: %v", err) + } + } + + for _, stmt := range strings.Split(string(schemaContent), ";") { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + + if strings.HasPrefix(stmt, "--") { + continue + } + + _, err := tx.Exec(ctx, stmt) + if err != nil { + return fmt.Errorf("error executing SQL statement: %v\nStatement: %s", err, stmt) + } + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("unable to commit transaction: %v", err) + } + + log.Println("Database schema applied successfully") + + return nil +} + diff --git a/internal/handler/contact.go b/internal/handler/contact.go index c638804..7a9322f 100644 --- a/internal/handler/contact.go +++ b/internal/handler/contact.go @@ -1,32 +1,34 @@ package handler import ( - // "fmt" + "fmt" "net/http" "github.com/google/uuid" + + "git.kundeng.us/phoenix/textsender-api/internal/store" + "git.kundeng.us/phoenix/textsender-models/pkg/contact" ) type RequestAddContact struct { - PhoneNumber string `json:"phone_number"` - UserId uuid.UUID`json:"user_id"` + PhoneNumber string `json:"phone_number"` + UserId uuid.UUID `json:"user_id"` } -type LoginResponse struct { - Message string `json:"message"` - // Data []model.Data`json:"data"` +type AddContactResponse struct { + Message string `json:"message"` + Data []contact.Contact `json:"data"` } type ContactHandler struct { + ContactStore store.ContactStore } -/* -func NewContactHandler(store model.Store) *ContactHandler { - return &ContactHandler{Store: store} +func NewContactHandler(str store.ContactStore) *ContactHandler { + return &ContactHandler{ContactStore: str} } -*/ -func (l *ContactHandler) AddContact(w http.ResponseWriter, r *http.Request) { +func (c *ContactHandler) AddContact(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return @@ -38,47 +40,33 @@ func (l *ContactHandler) AddContact(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() + newContact := contact.Contact{PhoneNumber: req.PhoneNumber, UserId: req.UserId} + var statusCode int - var resp LoginResponse - /* + var resp AddContactResponse ctx := r.Context() - if exists, err := l.UserStore.UserExists(ctx, req.Username); err != nil { + if exists, err := c.ContactStore.ContactExists(ctx, newContact.PhoneNumber, newContact.UserId); err != nil { fmt.Printf("Error: %v", err) statusCode = http.StatusInternalServerError resp.Message = err.Error() } else { - if !exists { + if exists { statusCode = http.StatusBadRequest - resp.Message = "Failure in user check" + resp.Message = "Cannot create contact" } else { - if user, err := l.UserStore.GetUserByUsername(ctx, req.Username); err != nil { + if err := c.ContactStore.CreateContact(ctx, &newContact); err != nil { + fmt.Printf("Error: %v", err) statusCode = http.StatusInternalServerError resp.Message = err.Error() } else { - hashing := utility.HashMash{Password: req.Password} - if hashing.CheckPasswordHash(req.Password, user.Password) { - var tokGen utility.TokenGenerator - secretKey := config.GetSecretKey() - tokGen.SetSecretKey(secretKey) - if token, err := tokGen.GenerateToken(*user); err != nil { - fmt.Println(err.Error()) - statusCode = http.StatusInternalServerError - resp.Message = "Error generating token" - } else { - statusCode = http.StatusOK - resp.Data = append(resp.Data, *token) - resp.Message = "Successful" - } - } else { - statusCode = http.StatusNotFound - resp.Message = "User not found" - } + statusCode = http.StatusCreated + resp.Message = "Contact created" + resp.Data = append(resp.Data, newContact) } } } - */ RespondWithJSON(w, statusCode, &resp) } diff --git a/internal/handler/contact_test.go b/internal/handler/contact_test.go new file mode 100644 index 0000000..276d70d --- /dev/null +++ b/internal/handler/contact_test.go @@ -0,0 +1,53 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "git.kundeng.us/phoenix/textsender-api/internal/db" + "git.kundeng.us/phoenix/textsender-api/internal/handler/endpoint" +) + +type Request struct { + PhoneNumber string + UserId uuid.UUID +} + +func TestCreateContactWithMock(t *testing.T) { + mockstore := NewMockContactStore() + handler := NewContactHandler(mockstore) + + testUserId := uuid.New() + testBody := Request{PhoneNumber: "+12335403383", UserId: testUserId} + jsonValue, _ := json.Marshal(testBody) + + req, _ := http.NewRequest("POST", endpoint.ADD_CONTACT_ENDPOINT, strings.NewReader(string(jsonValue))) + rr := httptest.NewRecorder() + + handler.AddContact(rr, req) + + assert.Equal(t, http.StatusCreated, rr.Code) + + var response AddContactResponse + err := json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err, "Error Creating contact %v", err) + + assert.NotEmpty(t, response.Data, "No Contact created") + + assert.NotNil(t, response.Data[0].Id, "Id should not be nil") +} + +func resetTestDB(t *testing.T) { + t.Helper() + _, err := db.Pool.Exec(context.Background(), "DELETE FROM contacts") + if err != nil { + t.Fatalf("Failed to reset test database: %v", err) + } +} diff --git a/internal/handler/endpoint/endpoint.go b/internal/handler/endpoint/endpoint.go new file mode 100644 index 0000000..3b42140 --- /dev/null +++ b/internal/handler/endpoint/endpoint.go @@ -0,0 +1,4 @@ +package endpoint + +const MESSAGE_DRAFT_ENDPOINT = "/api/v1/message/draft" +const ADD_CONTACT_ENDPOINT = "/api/v1/contact" diff --git a/internal/handler/endpoints.go b/internal/handler/endpoints.go deleted file mode 100644 index 8382353..0000000 --- a/internal/handler/endpoints.go +++ /dev/null @@ -1,4 +0,0 @@ -package handler - -const MESSAGE_DRAFT_ENDPOINT = "/api/v1/message/draft" -const ADD_CONTACT_ENDPOINT = "/api/v1/contact"; diff --git a/internal/handler/helper.go b/internal/handler/helper.go index 2f4bc47..075f09e 100644 --- a/internal/handler/helper.go +++ b/internal/handler/helper.go @@ -4,7 +4,6 @@ import "encoding/json" import "log" import "net/http" - func ExtractFromRequest(r *http.Request, reqItem interface{}) error { err := json.NewDecoder(r.Body).Decode(&reqItem) if err != nil { @@ -14,7 +13,6 @@ func ExtractFromRequest(r *http.Request, reqItem interface{}) error { } } - // Helper function to send JSON responses func RespondWithJSON(w http.ResponseWriter, statusCode int, data interface{}) { w.Header().Set("Content-Type", "application/json") diff --git a/internal/handler/mock_store_test.go b/internal/handler/mock_store_test.go new file mode 100644 index 0000000..2f81755 --- /dev/null +++ b/internal/handler/mock_store_test.go @@ -0,0 +1,117 @@ +package handler + +import ( + "context" + "errors" + "sync" + + "github.com/google/uuid" + + "git.kundeng.us/phoenix/textsender-models/pkg/contact" +) + +type Key struct { + PhoneNumber string + UserId uuid.UUID +} + +type MockContactStore struct { + Contacts map[uuid.UUID]*contact.Contact + ContactsByKey map[Key]*contact.Contact + mu sync.RWMutex + Error error // Optional: simulate errors +} + +func NewMockContactStore() *MockContactStore { + return &MockContactStore{ + Contacts: make(map[uuid.UUID]*contact.Contact), + ContactsByKey: make(map[Key]*contact.Contact), + } +} + +func (m *MockContactStore) CreateContact(ctx context.Context, con *contact.Contact) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.Error != nil { + return m.Error + } + + if con.Id == uuid.Nil { + con.Id = uuid.New() + } + + key := Key{PhoneNumber: con.PhoneNumber, UserId: con.UserId} + if _, exists := m.ContactsByKey[key]; exists { + return errors.New("Contact with phone number already exists") + } + + m.Contacts[con.Id] = con + m.ContactsByKey[key] = con + return nil +} + +func (m *MockContactStore) GetContactByID(ctx context.Context, id uuid.UUID) (*contact.Contact, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.Error != nil { + return nil, m.Error + } + + if m.Error != nil { + return nil, m.Error + } + + con, exists := m.Contacts[id] + if !exists { + return nil, errors.New("Contact not found") + } + + return con, nil +} + +func (m *MockContactStore) GetContactByPhone(ctx context.Context, phoneNumber string, userId uuid.UUID) (*contact.Contact, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.Error != nil { + return nil, m.Error + } + + con, exists := m.ContactsByKey[Key{PhoneNumber: phoneNumber, UserId: userId}] + if !exists { + return nil, errors.New("Contact not found") + } + + return con, nil +} + +func (m *MockContactStore) GetAllContacts(ctx context.Context) ([]*contact.Contact, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.Error != nil { + return nil, m.Error + } + + cons := make([]*contact.Contact, 0, len(m.Contacts)) + for _, con := range m.Contacts { + cons = append(cons, con) + } + + return cons, nil +} + +func (m *MockContactStore) ContactExists(ctx context.Context, phoneNumber string, userId uuid.UUID) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.Error != nil { + return false, m.Error + } + + _, exists := m.ContactsByKey[Key{PhoneNumber: phoneNumber, UserId: userId}] + + return exists, nil +} diff --git a/internal/store/contact_store.go b/internal/store/contact_store.go new file mode 100644 index 0000000..9eb050a --- /dev/null +++ b/internal/store/contact_store.go @@ -0,0 +1,115 @@ +package store + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.kundeng.us/phoenix/textsender-models/pkg/contact" +) + +type ContactStore interface { + CreateContact(ctx context.Context, con *contact.Contact) error + GetContactByID(ctx context.Context, id uuid.UUID) (*contact.Contact, error) + GetContactByPhone(ctx context.Context, phone string, userId uuid.UUID) (*contact.Contact, error) + GetAllContacts(ctx context.Context) ([]*contact.Contact, error) + ContactExists(ctx context.Context, phone string, userId uuid.UUID) (bool, error) +} + +type PGContactStore struct { + db *pgxpool.Pool +} + +func NewContactStore(db *pgxpool.Pool) *PGContactStore { + return &PGContactStore{db: db} +} + +func (s *PGContactStore) CreateContact(ctx context.Context, con *contact.Contact) error { + query := ` + INSERT INTO contacts (phone_number, user_id) + VALUES ($1, $2) + RETURNING id, phone_number, user_id + ` + + return s.db.QueryRow(ctx, query, con.PhoneNumber, con.UserId).Scan( + &con.Id, &con.PhoneNumber, &con.UserId, + ) +} + +func (s *PGContactStore) GetContactByID(ctx context.Context, id uuid.UUID) (*contact.Contact, error) { + query := `SELECT id, phone_number, user_id FROM contacts WHERE id = $1` + + var con contact.Contact + err := s.db.QueryRow(ctx, query, id).Scan( + &con.Id, &con.PhoneNumber, &con.UserId, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("getting contact by ID: %w", err) + } + + return &con, nil +} + +func (s *PGContactStore) GetContactByPhone(ctx context.Context, phone string, userId uuid.UUID) (*contact.Contact, error) { + query := `SELECT id, phone_number, user_id FROM contacts WHERE phone_number = $1 AND user_id = $2` + + var con contact.Contact + err := s.db.QueryRow(ctx, query, phone, userId).Scan( + &con.Id, &con.PhoneNumber, &con.UserId, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("getting contact by phone and User ID: %w", err) + } + + return &con, nil +} + +func (s *PGContactStore) GetAllContacts(ctx context.Context) ([]*contact.Contact, error) { + query := `SELECT id, phone_number, user_id FROM contacts` + + rows, err := s.db.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("querying all contacts: %w", err) + } + defer rows.Close() + + var cons []*contact.Contact + for rows.Next() { + var con contact.Contact + if err := rows.Scan( + &con.Id, &con.PhoneNumber, &con.UserId, + ); err != nil { + return nil, fmt.Errorf("scanning contact row: %w", err) + } + cons = append(cons, &con) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating contact rows: %w", err) + } + + return cons, nil +} + +func (s *PGContactStore) ContactExists(ctx context.Context, phone string, userId uuid.UUID) (bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM contacts WHERE phone_number = $1 AND user_id = $2)` + + var exists bool + err := s.db.QueryRow(ctx, query, phone, userId).Scan(&exists) + if err != nil { + return false, fmt.Errorf("checking if contact exists: %w", err) + } + + return exists, nil +} diff --git a/internal/version/version.go b/internal/version/version.go index 155cee9..bcac161 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,7 +1,17 @@ package version +import "fmt" + var ( - Version = "dev" - Commit = "none" - Date = "unknown" + Version = "dev" + BuildTime = "none" + Commit = "unknown" + GoVersion = "unknown" ) + +func String() string { + return fmt.Sprintf( + "Version: %s\nBuild Date: %s\nCommit: %s\nGo Version: %s", + Version, BuildTime, Commit, GoVersion, + ) +} diff --git a/migrations/schema.sql b/migrations/schema.sql new file mode 100644 index 0000000..2b987e1 --- /dev/null +++ b/migrations/schema.sql @@ -0,0 +1,10 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +DROP TABLE IF EXISTS contacts CASCADE; + +CREATE TABLE contacts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + phone_number TEXT NOT NULL, + user_id UUID NOT NULL +); +