Brijesh's Git Server — aegis @ 60578df256b067f0282c007af24a3343eb7461bb

Successor to whodis

setup migrations, db interface, api routes and handlers
Brijesh Wawdhane ops@brijesh.dev
Thu, 21 Nov 2024 18:29:13 +0530
commit

60578df256b067f0282c007af24a3343eb7461bb

A .air.toml

@@ -0,0 +1,46 @@

+root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./main" + cmd = "make build" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true
A .gitignore

@@ -0,0 +1,29 @@

+# 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 +tmp/ + +# .env file +.env + +# Project build +main +*templ.go + +# OS X generated file +.DS_Store
A Dockerfile

@@ -0,0 +1,28 @@

+FROM golang:1.23-alpine AS build + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN go build -o main cmd/main.go + +FROM alpine:3.20.1 AS prod +WORKDIR /app + +# Copy the .env file and set environment variables +COPY .env .env +ENV APP_ENV=${APP_ENV} +ENV PORT=${PORT} +ENV DB_HOST=${DB_HOST} +ENV DB_PORT=${DB_PORT} +ENV DB_DATABASE=${DB_DATABASE} +ENV DB_USERNAME=${DB_USERNAME} +ENV DB_PASSWORD=${DB_PASSWORD} +ENV DB_SCHEMA=${DB_SCHEMA} + +COPY --from=build /app/main /app/main +EXPOSE ${PORT} +CMD ["./main"]
A Makefile

@@ -0,0 +1,22 @@

+build: + @echo "Building..." + @go build -o main cmd/main.go + + +watch: + @if command -v air > /dev/null; then \ + air; \ + echo "Watching...";\ + else \ + read -p "Go's 'air' is not installed on your machine. Do you want to install it? [Y/n] " choice; \ + if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \ + go install github.com/air-verse/air@latest; \ + air; \ + echo "Watching...";\ + else \ + echo "You chose not to install air. Exiting..."; \ + exit 1; \ + fi; \ + fi + +.PHONY: build watch
A cmd/main.go

@@ -0,0 +1,64 @@

+package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os/signal" + "syscall" + "time" + + "aegis/internal/database" + "aegis/internal/server" +) + +func init() { + err := database.New().RunMigrations() + if err != nil { + log.Fatalf("error running migrations: %v", err) + } +} + +func gracefulShutdown(apiServer *http.Server, done chan bool) { + // Create context that listens for the interrupt signal from the OS. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + // Listen for the interrupt signal. + <-ctx.Done() + + log.Println("shutting down gracefully, press Ctrl+C again to force") + + // The context is used to inform the server it has 5 seconds to finish + // the request it is currently handling + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := apiServer.Shutdown(ctx); err != nil { + log.Printf("Server forced to shutdown with error: %v", err) + } + + log.Println("Server exiting") + + // Notify the main goroutine that the shutdown is complete + done <- true +} + +func main() { + server := server.NewServer() + + // Create a done channel to signal when the shutdown is complete + done := make(chan bool, 1) + + // Run graceful shutdown in a separate goroutine + go gracefulShutdown(server, done) + + err := server.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + panic(fmt.Sprintf("http server error: %s", err)) + } + + // Wait for the graceful shutdown to complete + <-done + log.Println("Graceful shutdown complete.") +}
A go.mod

@@ -0,0 +1,25 @@

+module aegis + +go 1.23.3 + +require ( + github.com/go-chi/chi/v5 v5.1.0 + github.com/go-chi/cors v1.2.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.1 + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 + github.com/pressly/goose/v3 v3.22.1 +) + +require ( + 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/mfridman/interpolate v0.0.2 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.27.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/text v0.18.0 // indirect +)
A go.sum

@@ -0,0 +1,70 @@

+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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +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.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= +github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= +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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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/pressly/goose/v3 v3.22.1 h1:2zICEfr1O3yTP9BRZMGPj7qFxQ+ik6yeo+z1LMuioLc= +github.com/pressly/goose/v3 v3.22.1/go.mod h1:xtMpbstWyCpyH+0cxLTMCENWBG+0CSxvTsXhW95d5eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/sqlite v1.33.0 h1:WWkA/T2G17okiLGgKAj4/RMIvgyMT19yQ038160IeYk= +modernc.org/sqlite v1.33.0/go.mod h1:9uQ9hF/pCZoYZK73D/ud5Z7cIRIILSZI8NdIemVMTX8= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
A internal/database/close.go

@@ -0,0 +1,12 @@

+package database + +import ( + "log" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func (s *service) Close() error { + log.Printf("Disconnected from database: %s", database) + return s.db.Close() +}
A internal/database/health.go

@@ -0,0 +1,56 @@

+package database + +import ( + "context" + "fmt" + "log" + "strconv" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func (s *service) Health() map[string]string { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + stats := make(map[string]string) + + err := s.db.PingContext(ctx) + if err != nil { + stats["status"] = "down" + stats["error"] = fmt.Sprintf("db down: %v", err) + log.Fatalf("db down: %v", err) + return stats + } + + stats["status"] = "up" + stats["message"] = "It's healthy" + + dbStats := s.db.Stats() + stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections) + stats["in_use"] = strconv.Itoa(dbStats.InUse) + stats["idle"] = strconv.Itoa(dbStats.Idle) + stats["wait_count"] = strconv.FormatInt(dbStats.WaitCount, 10) + stats["wait_duration"] = dbStats.WaitDuration.String() + stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleClosed, 10) + stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeClosed, 10) + + if dbStats.OpenConnections > 40 { + stats["message"] = "The database is experiencing heavy load." + } + + if dbStats.WaitCount > 1000 { + stats["message"] = "The database has a high number of wait events, indicating potential bottlenecks." + } + + if dbStats.MaxIdleClosed > int64(dbStats.OpenConnections)/2 { + stats["message"] = "Many idle connections are being closed, consider revising the connection pool settings." + } + + if dbStats.MaxLifetimeClosed > int64(dbStats.OpenConnections)/2 { + stats["message"] = "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising the connection usage pattern." + } + + return stats +}
A internal/database/migrate.go

@@ -0,0 +1,28 @@

+package database + +import ( + "fmt" + "os" + + _ "github.com/lib/pq" // PostgreSQL driver + "github.com/pressly/goose/v3" +) + +func (s *service) RunMigrations() error { + databaseURL := os.Getenv("DB_CONN_STR") + + migrationPath := "internal/database/migrations" + + db, err := goose.OpenDBWithDriver("postgres", databaseURL) + if err != nil { + return fmt.Errorf("could not open database: %v", err) + } + defer db.Close() + + if err := goose.Up(db, migrationPath); err != nil { + return fmt.Errorf("could not apply migrations: %v", err) + } + + fmt.Println("Migrations applied successfully.") + return nil +}
A internal/database/migrations/20241121105353_create_developers_table.sql

@@ -0,0 +1,14 @@

+-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS developers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS developers; +-- +goose StatementEnd
A internal/database/migrations/20241121120024_create_applications_table.sql

@@ -0,0 +1,19 @@

+-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS applications ( + id TEXT PRIMARY KEY, + developer_id TEXT NOT NULL, + name TEXT NOT NULL, + rp_id TEXT NOT NULL UNIQUE, + rp_display_name TEXT, + rp_origins TEXT[] NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (developer_id) REFERENCES developers(id), + CONSTRAINT unique_application_name UNIQUE(developer_id, name) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS applications; +-- +goose StatementEnd
A internal/database/migrations/20241121120039_create_users_table.sql

@@ -0,0 +1,17 @@

+-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + application_id TEXT NOT NULL, + name TEXT NOT NULL, + display_name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (application_id) REFERENCES applications(id), + CONSTRAINT unique_user_name UNIQUE(application_id, name) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS users; +-- +goose StatementEnd
A internal/database/migrations/20241121120056_create_credentials_table.sql

@@ -0,0 +1,23 @@

+-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS credentials ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + public_key BYTEA NOT NULL, + credential_id BYTEA NOT NULL, + sign_count INTEGER NOT NULL, + aaguid BYTEA, + clone_warning BOOLEAN NOT NULL DEFAULT FALSE, + attachment TEXT, + backup_eligible BOOLEAN NOT NULL DEFAULT FALSE, + backup_state BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT unique_credential_id UNIQUE(user_id, credential_id) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS credentials; +-- +goose StatementEnd
A internal/database/migrations/20241121120112_create_sessions_table.sql

@@ -0,0 +1,19 @@

+-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + challenge TEXT NOT NULL, + rp_id TEXT NOT NULL, + allowed_credential_ids BYTEA[], + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT unique_session_per_user UNIQUE(user_id, rp_id) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS sessions; +-- +goose StatementEnd
A internal/database/new.go

@@ -0,0 +1,36 @@

+package database + +import ( + "database/sql" + "fmt" + "log" + "os" + + _ "github.com/jackc/pgx/v5/stdlib" + _ "github.com/joho/godotenv/autoload" +) + +var ( + database = os.Getenv("DB_DATABASE") + password = os.Getenv("DB_PASSWORD") + username = os.Getenv("DB_USERNAME") + port = os.Getenv("DB_PORT") + host = os.Getenv("DB_HOST") + schema = os.Getenv("DB_SCHEMA") + dbInstance *service +) + +func New() Service { + if dbInstance != nil { + return dbInstance + } + connStr := fmt.Sprintf("postgresql://%s:%s@%s:%s/%s?sslmode=verify-full", username, password, host, port, database) + db, err := sql.Open("pgx", connStr) + if err != nil { + log.Fatal(err) + } + dbInstance = &service{ + db: db, + } + return dbInstance +}
A internal/database/ops_developer.go

@@ -0,0 +1,99 @@

+package database + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func (s *service) developerEmailExists(ctx context.Context, email string) (bool, error) { + query := `SELECT 1 FROM developers WHERE email = $1` + row := s.db.QueryRowContext(ctx, query, email) + + var exists int + if err := row.Scan(&exists); err != nil { + if err == sql.ErrNoRows { + return false, nil + } + return false, fmt.Errorf("failed to check if email exists: %v", err) + } + + return true, nil +} + +func (s *service) CreateDeveloper(name, email string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + exists, err := s.developerEmailExists(ctx, email) + if err != nil { + return "", fmt.Errorf("failed to check if email exists: %v", err) + } + if exists { + return "", fmt.Errorf("email already in use") + } + + id := uuid.New().String() + query := `INSERT INTO developers (id, name, email) VALUES ($1, $2, $3)` + + _, err = s.db.ExecContext(ctx, query, id, name, email) + if err != nil { + return "", fmt.Errorf("failed to create developer: %v", err) + } + + return id, nil +} + +func (s *service) GetDeveloper(id string) (*Developer, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + query := `SELECT id, name, email, created_at FROM developers WHERE id = $1` + row := s.db.QueryRowContext(ctx, query, id) + + var developer Developer + if err := row.Scan(&developer.ID, &developer.Name, &developer.Email, &developer.CreatedAt); err != nil { + return nil, fmt.Errorf("failed to get developer: %v", err) + } + + return &developer, nil +} + +func (s *service) UpdateDeveloper(id, name, email string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + exists, err := s.developerEmailExists(ctx, email) + if err != nil { + return fmt.Errorf("failed to check if email exists: %v", err) + } + if exists { + return fmt.Errorf("email already in use") + } + + query := `UPDATE developers SET name = $2, email = $3 WHERE id = $1` + _, err = s.db.ExecContext(ctx, query, id, name, email) + if err != nil { + return fmt.Errorf("failed to update developer: %v", err) + } + + return nil +} + +func (s *service) DeleteDeveloper(id string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + query := `DELETE FROM developers WHERE id = $1` + _, err := s.db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("failed to delete developer: %v", err) + } + + return nil +}
A internal/database/service.go

@@ -0,0 +1,28 @@

+package database + +import ( + "database/sql" + "time" +) + +type Developer struct { + ID string + Name string + Email string + CreatedAt time.Time +} + +type Service interface { + Health() map[string]string + Close() error + RunMigrations() error + + CreateDeveloper(name, email string) (string, error) + GetDeveloper(id string) (*Developer, error) + UpdateDeveloper(id, name, email string) error + DeleteDeveloper(id string) error +} + +type service struct { + db *sql.DB +}
A internal/server/handlers_developer.go

@@ -0,0 +1,77 @@

+package server + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" +) + +func (s *Server) createDeveloperHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Email string `json:"email"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request payload", http.StatusBadRequest) + return + } + + id, err := s.db.CreateDeveloper(req.Name, req.Email) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + resp := map[string]string{"id": id} + jsonResp, _ := json.Marshal(resp) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(jsonResp) +} + +func (s *Server) getDeveloperHandler(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + developer, err := s.db.GetDeveloper(id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + jsonResp, _ := json.Marshal(developer) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(jsonResp) +} + +func (s *Server) updateDeveloperHandler(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + var req struct { + Name string `json:"name"` + Email string `json:"email"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request payload", http.StatusBadRequest) + return + } + + if err := s.db.UpdateDeveloper(id, req.Name, req.Email); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) deleteDeveloperHandler(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + if err := s.db.DeleteDeveloper(id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +}
A internal/server/routes.go

@@ -0,0 +1,52 @@

+package server + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" +) + +func (s *Server) RegisterRoutes() http.Handler { + r := chi.NewRouter() + r.Use(middleware.Logger) + + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"https://*", "http://*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, + AllowCredentials: true, + MaxAge: 300, + })) + + r.Get("/", s.HelloWorldHandler) + + r.Get("/health", s.healthHandler) + + r.Post("/developers", s.createDeveloperHandler) + r.Get("/developers/{id}", s.getDeveloperHandler) + r.Put("/developers/{id}", s.updateDeveloperHandler) + r.Delete("/developers/{id}", s.deleteDeveloperHandler) + + return r +} + +func (s *Server) HelloWorldHandler(w http.ResponseWriter, r *http.Request) { + resp := make(map[string]string) + resp["message"] = "Hello World" + + jsonResp, err := json.Marshal(resp) + if err != nil { + log.Fatalf("error handling JSON marshal. Err: %v", err) + } + + _, _ = w.Write(jsonResp) +} + +func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { + jsonResp, _ := json.Marshal(s.db.Health()) + _, _ = w.Write(jsonResp) +}
A internal/server/server.go

@@ -0,0 +1,39 @@

+package server + +import ( + "fmt" + "net/http" + "os" + "strconv" + "time" + + _ "github.com/joho/godotenv/autoload" + + "aegis/internal/database" +) + +type Server struct { + port int + + db database.Service +} + +func NewServer() *http.Server { + port, _ := strconv.Atoi(os.Getenv("PORT")) + NewServer := &Server{ + port: port, + + db: database.New(), + } + + // Declare Server config + server := &http.Server{ + Addr: fmt.Sprintf(":%d", NewServer.port), + Handler: NewServer.RegisterRoutes(), + IdleTimeout: time.Minute, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + } + + return server +}