From 2644b079512fd71333913470c88ed4ccf98d5596 Mon Sep 17 00:00:00 2001 From: Dave Dietrick Date: Sat, 11 Jan 2025 09:29:50 -0500 Subject: [PATCH] Added go http and db pages --- docs/go/advanced/db.md | 555 +++++++++++++++++++++++++++++++++++++++ docs/go/advanced/http.md | 345 ++++++++++++++++++++++++ docs/go/sidebar.json | 3 +- 3 files changed, 902 insertions(+), 1 deletion(-) create mode 100644 docs/go/advanced/db.md diff --git a/docs/go/advanced/db.md b/docs/go/advanced/db.md new file mode 100644 index 0000000..67acdde --- /dev/null +++ b/docs/go/advanced/db.md @@ -0,0 +1,555 @@ +# Databases + +## Postgres + +```bash +go get github.com/jackc/pgconn +go get github.com/jackc/pgx/v4 +go get github.com/jackc/pgx/v4/stdlib +``` + +```go +// models.go +package data + +import ( + "context" + "database/sql" + "errors" + "log" + "time" + + "golang.org/x/crypto/bcrypt" +) + +const dbTimeout = time.Second * 3 + +var db *sql.DB + +type PostgresRepository struct { + Conn *sql.DB +} + +func NewPostgresRepository(conn *sql.DB) *PostgresRepository { + db = conn + return &PostgresRepository{ + Conn: conn, + } +} + +type User struct { + ID int `json:"id"` + Email string `json:"email"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + Password string `json:"-"` + Active int `json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GetAll returns a slice of all users, sorted by last name +func (u *PostgresRepository) GetAll() ([]*User, error) { + ctx, cancel := context.WithTimeout(context.Background(), dbTimeout) + defer cancel() + + query := `select id, email, first_name, last_name, password, user_active, created_at, updated_at + from users order by last_name` + + rows, err := u.Conn.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []*User + + for rows.Next() { + var user User + err := rows.Scan( + &user.ID, + &user.Email, + &user.FirstName, + &user.LastName, + &user.Password, + &user.Active, + &user.CreatedAt, + &user.UpdatedAt, + ) + if err != nil { + log.Println("Error scanning", err) + return nil, err + } + + users = append(users, &user) + } + + return users, nil +} + +// GetByEmail returns one user by email +func (u *PostgresRepository) GetByEmail(email string) (*User, error) { + ctx, cancel := context.WithTimeout(context.Background(), dbTimeout) + defer cancel() + + query := `select id, email, first_name, last_name, password, user_active, created_at, updated_at from users where email = $1` + + var user User + row := db.QueryRowContext(ctx, query, email) + + err := row.Scan( + &user.ID, + &user.Email, + &user.FirstName, + &user.LastName, + &user.Password, + &user.Active, + &user.CreatedAt, + &user.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + return &user, nil +} + +// GetOne returns one user by id +func (u *PostgresRepository) GetOne(id int) (*User, error) { + ctx, cancel := context.WithTimeout(context.Background(), dbTimeout) + defer cancel() + + query := `select id, email, first_name, last_name, password, user_active, created_at, updated_at from users where id = $1` + + var user User + row := db.QueryRowContext(ctx, query, id) + + err := row.Scan( + &user.ID, + &user.Email, + &user.FirstName, + &user.LastName, + &user.Password, + &user.Active, + &user.CreatedAt, + &user.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + return &user, nil +} + +// Update updates one user in the database, using the information +// stored in the receiver u +func (u *PostgresRepository) Update(user User) error { + ctx, cancel := context.WithTimeout(context.Background(), dbTimeout) + defer cancel() + + stmt := `update users set + email = $1, + first_name = $2, + last_name = $3, + user_active = $4, + updated_at = $5 + where id = $6 + ` + + _, err := db.ExecContext(ctx, stmt, + user.Email, + user.FirstName, + user.LastName, + user.Active, + time.Now(), + user.ID, + ) + + if err != nil { + return err + } + + return nil +} + +// DeleteByID deletes one user from the database, by ID +func (u *PostgresRepository) DeleteByID(id int) error { + ctx, cancel := context.WithTimeout(context.Background(), dbTimeout) + defer cancel() + + stmt := `delete from users where id = $1` + + _, err := db.ExecContext(ctx, stmt, id) + if err != nil { + return err + } + + return nil +} + +// Insert inserts a new user into the database, and returns the ID of the newly inserted row +func (u *PostgresRepository) Insert(user User) (int, error) { + ctx, cancel := context.WithTimeout(context.Background(), dbTimeout) + defer cancel() + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), 12) + if err != nil { + return 0, err + } + + var newID int + stmt := `insert into users (email, first_name, last_name, password, user_active, created_at, updated_at) + values ($1, $2, $3, $4, $5, $6, $7) returning id` + + err = db.QueryRowContext(ctx, stmt, + user.Email, + user.FirstName, + user.LastName, + hashedPassword, + user.Active, + time.Now(), + time.Now(), + ).Scan(&newID) + + if err != nil { + return 0, err + } + + return newID, nil +} +``` + +```go +// main.go, instantiate connection and repo +package main + +import ( + "authentication/data" + + _ "github.com/jackc/pgconn" + _ "github.com/jackc/pgx/v4" + _ "github.com/jackc/pgx/v4/stdlib" +) +func connectToDB() *sql.DB { + dsn := os.Getenv("DSN") + + for { + conn, err := openDB(dsn) + if err != nil { + log.Println("DB not ready yet...") + counts++ + } else { + log.Println("Connected to DB!") + return conn + } + if counts > 10 { + log.Println(err) + return nil + } + + log.Println("Backing off for 2 seconds...") + time.Sleep(time.Second * 2) + continue + } +} + +func (app *Config) setupRepo(conn *sql.DB) { + db := data.NewPostgresRepository(conn) + app.Repo = db +} +``` + +### Testing + +It is best to use a repository pattern when dealing with databases, because it allows you to implement a mock repo that implements all the same functions as the real repo with mock return values. + +```go +// repository.go +package data + +type Repository interface { + GetAll() ([]*User, error) + GetByEmail(email string) (*User, error) + GetOne(id int) (*User, error) + Insert(user User) (int, error) + Update(user User) error + DeleteByID(id int) error + ResetPassword(password string, user User) error + PasswordMatches(password string, user User) (bool, error) +} +``` + +```go +package data + +import ( + "database/sql" + "time" +) + +type PostgresTestRepository struct { + Conn *sql.DB +} + +func NewPostgresTestRepository(conn *sql.DB) *PostgresTestRepository { + return &PostgresTestRepository{ + Conn: conn, + } +} + +// Implement all methods of Repository interface +func (repo *PostgresTestRepository) GetAll() ([]*User, error) { + users := []*User{} + return users, nil +} +``` + +```go +// setup_test.go +package main + +import ( + "authentication/data" + "os" + "testing" +) + +var testApp Config + +func TestMain(m *testing.M) { + repo := data.NewPostgresTestRepository(nil) + testApp.Repo = repo + + os.Exit(m.Run()) +} +``` + +## Mongo + +```bash +go get go.mongodb.org/mongo-driver/mongo +go get go.mongodb.org/mongo-driver/mongo/options +``` + +```go +// models.go +package data + +import ( + "context" + "log" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var client *mongo.Client + +func New(mongo *mongo.Client) Models { + client = mongo + + return Models{ + LogEntry: LogEntry{}, + } +} + +type Models struct { + LogEntry LogEntry +} + +type LogEntry struct { + ID string `bson:"_id,omitempty" json:"id,omitempty"` + Name string `bson:"name" json:"name"` + Data string `bson:"data" json:"data"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +func (l *LogEntry) Insert(entry LogEntry) error { + collection := client.Database("logs").Collection("logs") + + _, err := collection.InsertOne(context.TODO(), LogEntry{ + Name: entry.Name, + Data: entry.Data, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + if err != nil { + log.Println("Error insertint into logs: ", err) + return err + } + + return nil +} + +func (l *LogEntry) All() ([]*LogEntry, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + collection := client.Database("logs").Collection("logs") + + opts := options.Find() + opts.SetSort(bson.D{{"created_at", -1}}) + + cursor, err := collection.Find(context.TODO(), bson.D{}, opts) + if err != nil { + log.Println("Finding all docs error: ", err) + return nil, err + } + defer cursor.Close(ctx) + + var logs []*LogEntry + + for cursor.Next(ctx) { + var item LogEntry + + err := cursor.Decode(&item) + if err != nil { + log.Println("Error decoding log into slice: ", err) + return nil, err + } else { + logs = append(logs, &item) + } + } + + return logs, nil +} + +func (l *LogEntry) GetOne(id string) (*LogEntry, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + collection := client.Database("logs").Collection("logs") + + docId, err := primitive.ObjectIDFromHex(id) + if err != nil { + return nil, err + } + + var entry LogEntry + err = collection.FindOne(ctx, bson.M{"_id": docId}).Decode(&entry) + if err != nil { + return nil, err + } + + return &entry, nil +} + +func (l *LogEntry) DropCollections() error { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + collection := client.Database("logs").Collection("logs") + + if err := collection.Drop(ctx); err != nil { + return err + } + + return nil +} + +func (l *LogEntry) Update() (*mongo.UpdateResult, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + collection := client.Database("logs").Collection("logs") + docId, err := primitive.ObjectIDFromHex(l.ID) + if err != nil { + return nil, err + } + + result, err := collection.UpdateOne( + ctx, + bson.M{"_id": docId}, + bson.D{ + {"$set", bson.D{ + {"name", l.Name}, + {"data", l.Data}, + {"updated_at", time.Now()}, + }}, + }, + ) + if err != nil { + return nil, err + } + + return result, err + +} +``` + +```go +// main.go +package main + +import ( + "context" + "fmt" + "log" + "logger/data" + "net" + "net/http" + "net/rpc" + "time" + + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + mongoURL = "mongodb://mongo:27017" +) + +var client *mongo.Client + +type Config struct { + Models data.Models +} + +func main() { + // connect to mongo + mongoClient, err := connectToMongo() + if err != nil { + log.Panic(err) + } + + client = mongoClient + + // create a context to disconnect + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // close connection + defer func() { + if err = client.Disconnect(ctx); err != nil { + panic(err) + } + }() + + app := Config{ + Models: data.New(client), + } +} + +func connectToMongo() (*mongo.Client, error) { + // create connection options + clientOptions := options.Client().ApplyURI(mongoURL) + clientOptions.SetAuth(options.Credential{ + Username: "admin", + Password: "password", + }) + + c, err := mongo.Connect(context.TODO(), clientOptions) + if err != nil { + log.Println("Error connecting: %s", err) + return nil, err + } + + return c, nil +} +``` diff --git a/docs/go/advanced/http.md b/docs/go/advanced/http.md index e69de29..0d3b013 100644 --- a/docs/go/advanced/http.md +++ b/docs/go/advanced/http.md @@ -0,0 +1,345 @@ +# HTTP + +The below snippets are using the `chi` package for Go. + +### To Install + +```bash +go get github.com/go-chi/chi/v5 +go get github.com/go-chi/chi/v5/middleware +go get github.com/go-chi/cors +``` + +```go +// main.go +package main + +import ( + "fmt" + "log" + "net/http" + "os" + "time" + + _ "github.com/jackc/pgconn" + _ "github.com/jackc/pgx/v4" + _ "github.com/jackc/pgx/v4/stdlib" +) + +const webPort = "80" + +var counts int64 + +type Config struct { + Client *http.Client +} + +func main() { + app := Config{ + Client: &http.Client{}, + } + + srv := &http.Server{ + Addr: fmt.Sprintf(":%s", webPort), + Handler: app.routes(), + } + + err := srv.ListenAndServe() + if err != nil { + log.Panic(err) + } +} +``` + +```go +// routes.go +package main + +import ( + "net/http" + + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "github.com/go-chi/cors" +) + +func (app *Config) routes() http.Handler { + mux := chi.NewRouter() + + mux.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"https://*", "http://*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, + ExposedHeaders: []string{"Link"}, + AllowCredentials: true, + MaxAge: 300, + })) + + mux.Use(middleware.Heartbeat("/ping")) + + mux.Post("/authenticate", app.Authenticate) + + return mux +} +``` + +```go +// handlers.go +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" +) + +func (app *Config) Authenticate(w http.ResponseWriter, r *http.Request) { + var requestPayload struct { + Email string `json:"email"` + Password string `json:"password"` + } + + err := app.readJSON(w, r, &requestPayload) + if err != nil { + app.errorJSON(w, err, http.StatusBadRequest) + return + } + + // validate user against db + user, err := app.Repo.GetByEmail(requestPayload.Email) + if err != nil { + app.errorJSON(w, errors.New("invalid credentials"), http.StatusBadRequest) + return + } + + valid, err := app.Repo.PasswordMatches(requestPayload.Password, *user) + if err != nil || !valid { + app.errorJSON(w, errors.New("invalid credentials"), http.StatusBadRequest) + return + } + + // log authentication + err = app.logRequest("authentication", fmt.Sprintf("%s logged in", user.Email)) + if err != nil { + app.errorJSON(w, err) + return + } + + log.Println("Logged authentication request") + + payload := jsonResponse{ + Error: false, + Message: fmt.Sprintf("Logged in user %s", requestPayload.Email), + Data: user, + } + + app.writeJSON(w, http.StatusAccepted, payload) +} + +func (app *Config) logRequest(name, data string) error { + var entry struct { + Name string `json:"name"` + Data string `json:"data"` + } + + entry.Name = name + entry.Data = data + + jsonData, _ := json.MarshalIndent(entry, "", "\t") + + logURL := "http://logger/log" + + request, err := http.NewRequest("POST", logURL, bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + _, err = app.Client.Do(request) + if err != nil { + return err + } + + return nil +} + +``` + +```go +// helpers.go +package main + +import ( + "encoding/json" + "errors" + "io" + "net/http" +) + +type jsonResponse struct { + Error bool `json:"error"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +func (app *Config) readJSON(w http.ResponseWriter, r *http.Request, data any) error { + maxBytes := 1048576 // one MB + + r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes)) + + dec := json.NewDecoder(r.Body) + err := dec.Decode(data) + + if err != nil { + return err + } + + err = dec.Decode(&struct{}{}) + if err != io.EOF { + return errors.New("Body must have only a single json value") + } + + return nil +} + +func (app *Config) writeJSON(w http.ResponseWriter, status int, data any, headers ...http.Header) error { + out, err := json.Marshal(data) + if err != nil { + return err + } + + if len(headers) > 0 { + for key, val := range headers[0] { + w.Header()[key] = val + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, err = w.Write(out) + if err != nil { + return err + } + + return nil +} + +func (app *Config) errorJSON(w http.ResponseWriter, err error, status ...int) error { + statusCode := http.StatusBadRequest + + if len(status) > 0 { + statusCode = status[0] + } + + var payload jsonResponse + payload.Error = true + payload.Message = err.Error() + + return app.writeJSON(w, statusCode, payload) +} +``` + +## Testing + +```go +// handlers_test.go +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +type RoundTripFunc func(req *http.Request) *http.Response + +func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req), nil +} + +func NewTestClient(fn RoundTripFunc) *http.Client { + return &http.Client{ + Transport: fn, + } +} + +func Test_Authenticate(t *testing.T) { + jsonToReturn := ` +{ + "error": false, + "message": "some message" +} +` + client := NewTestClient(func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(jsonToReturn)), + Header: make(http.Header), + } + }) + testApp.Client = client + + postBody := map[string]interface{}{ + "email": "me@here.com", + "password": "verysecret", + } + + body, _ := json.Marshal(postBody) + + req, _ := http.NewRequest("POST", "/authenticate", bytes.NewReader(body)) + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(testApp.Authenticate) + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusAccepted { + t.Errorf("handler returned wrong status code: got %v want %v", + rr.Code, http.StatusAccepted) + } +} +``` + +```go +// routes_test.go +package main + +import ( + "net/http" + "testing" + + "github.com/go-chi/chi" +) + +func Test_routes_exist(t *testing.T) { + testApp := Config{} + + testRoutes := testApp.routes() + chiRoutes := testRoutes.(chi.Router) + + routes := []string{"/authenticate"} + + for _, route := range routes { + routeExists(t, chiRoutes, route) + } +} + +func routeExists(t *testing.T, routes chi.Router, route string) { + found := false + _ = chi.Walk(routes, func(method string, foundRoute string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { + if route == foundRoute { + found = true + } + return nil + }) + + if !found { + t.Errorf("route %s not found", route) + } +} +``` diff --git a/docs/go/sidebar.json b/docs/go/sidebar.json index 33ac984..04fe7c6 100644 --- a/docs/go/sidebar.json +++ b/docs/go/sidebar.json @@ -19,7 +19,8 @@ { "text": "Testing", "link": "/go/advanced/testing" }, {"text": "HTTP", "link": "/go/advanced/http"}, {"text": "RPC", "link": "/go/advanced/rpc"}, - {"text": "gRPC", "link": "/go/advanced/grpc"} + {"text": "gRPC", "link": "/go/advanced/grpc"}, + {"text": "Databases", "link": "/go/advanced/db"} ] } ]