From 46f9dc93f40507fa75c1f8a3519f8f69791857c2 Mon Sep 17 00:00:00 2001 From: Eero Norri Date: Fri, 22 Nov 2024 16:45:06 +0200 Subject: [PATCH 1/6] add clean-code example with postgres --- clean-code/.gitignore | 1 + clean-code/Dockerfile-local | 8 ++ clean-code/README.md | 26 +++++ clean-code/app/config.go | 22 ++++ clean-code/app/config_test.go | 41 +++++++ clean-code/app/datasources/data_sources.go | 7 ++ clean-code/app/datasources/database/db.go | 35 ++++++ .../app/datasources/database/db_mock.go | 27 +++++ .../app/datasources/database/db_test.go | 34 ++++++ .../app/datasources/database/memory_db.go | 32 ++++++ .../datasources/database/memory_db_test.go | 44 ++++++++ .../app/datasources/database/postgres_db.go | 62 +++++++++++ .../datasources/database/postgres_db_test.go | 79 ++++++++++++++ clean-code/app/go.mod | 35 ++++++ clean-code/app/go.sum | 67 ++++++++++++ clean-code/app/main.go | 22 ++++ clean-code/app/server/domain/books.go | 9 ++ clean-code/app/server/domain/errors.go | 5 + clean-code/app/server/handlers/books.go | 47 ++++++++ clean-code/app/server/handlers/books_test.go | 102 ++++++++++++++++++ clean-code/app/server/server.go | 24 +++++ clean-code/app/server/server_test.go | 24 +++++ clean-code/app/server/services/books.go | 51 +++++++++ clean-code/app/server/services/books_mock.go | 26 +++++ clean-code/app/server/services/books_test.go | 49 +++++++++ clean-code/db/init_db.sql | 4 + clean-code/docker-compose.yml | 27 +++++ 27 files changed, 910 insertions(+) create mode 100644 clean-code/.gitignore create mode 100644 clean-code/Dockerfile-local create mode 100644 clean-code/README.md create mode 100644 clean-code/app/config.go create mode 100644 clean-code/app/config_test.go create mode 100644 clean-code/app/datasources/data_sources.go create mode 100644 clean-code/app/datasources/database/db.go create mode 100644 clean-code/app/datasources/database/db_mock.go create mode 100644 clean-code/app/datasources/database/db_test.go create mode 100644 clean-code/app/datasources/database/memory_db.go create mode 100644 clean-code/app/datasources/database/memory_db_test.go create mode 100644 clean-code/app/datasources/database/postgres_db.go create mode 100644 clean-code/app/datasources/database/postgres_db_test.go create mode 100644 clean-code/app/go.mod create mode 100644 clean-code/app/go.sum create mode 100644 clean-code/app/main.go create mode 100644 clean-code/app/server/domain/books.go create mode 100644 clean-code/app/server/domain/errors.go create mode 100644 clean-code/app/server/handlers/books.go create mode 100644 clean-code/app/server/handlers/books_test.go create mode 100644 clean-code/app/server/server.go create mode 100644 clean-code/app/server/server_test.go create mode 100644 clean-code/app/server/services/books.go create mode 100644 clean-code/app/server/services/books_mock.go create mode 100644 clean-code/app/server/services/books_test.go create mode 100644 clean-code/db/init_db.sql create mode 100644 clean-code/docker-compose.yml diff --git a/clean-code/.gitignore b/clean-code/.gitignore new file mode 100644 index 0000000000..17186c7705 --- /dev/null +++ b/clean-code/.gitignore @@ -0,0 +1 @@ +db_data diff --git a/clean-code/Dockerfile-local b/clean-code/Dockerfile-local new file mode 100644 index 0000000000..7030a3da0b --- /dev/null +++ b/clean-code/Dockerfile-local @@ -0,0 +1,8 @@ +FROM golang:1.23 +RUN apt update && apt upgrade -y && apt install -y git + +WORKDIR /go/src/app +COPY app ./ +RUN go mod tidy && go mod verify + +ENTRYPOINT [ "go", "run", "." ] diff --git a/clean-code/README.md b/clean-code/README.md new file mode 100644 index 0000000000..70febb8d6f --- /dev/null +++ b/clean-code/README.md @@ -0,0 +1,26 @@ +## Clean code example for Fiber and PostgreSQL + +This is an example of a RESTful API built using the Fiber framework (https://gofiber.io/) and PostgreSQL as the database. + +### Start + +1. Build and start the containers: + ```sh + docker compose up --build + ``` + +1. The application should now be running and accessible at `http://localhost:3000`. + +### Endpoints + +- `GET /api/v1/books`: Retrieves a list of all books. + ```sh + curl -X GET http://localhost:3000/api/v1/books + ``` + +- `POST /api/v1/books`: Adds a new book to the collection. + ```sh + curl -X POST http://localhost:3000/api/v1/books \ + -H "Content-Type: application/json" \ + -d '{"title":"Title"}' + ``` diff --git a/clean-code/app/config.go b/clean-code/app/config.go new file mode 100644 index 0000000000..0ab24db57d --- /dev/null +++ b/clean-code/app/config.go @@ -0,0 +1,22 @@ +package main + +import "os" + +type Configuration struct { + Port string + DatabaseURL string +} + +func NewConfiguration() *Configuration { + return &Configuration{ + Port: getEnvOrDefault("PORT", "3000"), + DatabaseURL: getEnvOrDefault("DATABASE_URL", ""), + } +} + +func getEnvOrDefault(key, defaultValue string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return defaultValue +} diff --git a/clean-code/app/config_test.go b/clean-code/app/config_test.go new file mode 100644 index 0000000000..4dc25bf483 --- /dev/null +++ b/clean-code/app/config_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewConfiguration(t *testing.T) { + os.Setenv("PORT", "8080") + os.Setenv("DATABASE_URL", "postgres://user:pass@localhost:5432/dbname") + defer os.Unsetenv("PORT") + defer os.Unsetenv("DATABASE_URL") + + conf := NewConfiguration() + + assert.Equal(t, "8080", conf.Port) + assert.Equal(t, "postgres://user:pass@localhost:5432/dbname", conf.DatabaseURL) +} + +func TestNewConfiguration_Defaults(t *testing.T) { + os.Unsetenv("PORT") + os.Unsetenv("DATABASE_URL") + + conf := NewConfiguration() + + assert.Equal(t, "3000", conf.Port) + assert.Equal(t, "", conf.DatabaseURL) +} + +func TestGetEnvOrDefault(t *testing.T) { + os.Setenv("TEST_ENV", "value") + defer os.Unsetenv("TEST_ENV") + + value := getEnvOrDefault("TEST_ENV", "default") + assert.Equal(t, "value", value) + + value = getEnvOrDefault("NON_EXISTENT_ENV", "default") + assert.Equal(t, "default", value) +} diff --git a/clean-code/app/datasources/data_sources.go b/clean-code/app/datasources/data_sources.go new file mode 100644 index 0000000000..f7aea8aa19 --- /dev/null +++ b/clean-code/app/datasources/data_sources.go @@ -0,0 +1,7 @@ +package datasources + +import "app/datasources/database" + +type DataSources struct { + DB database.Database +} diff --git a/clean-code/app/datasources/database/db.go b/clean-code/app/datasources/database/db.go new file mode 100644 index 0000000000..cba2cbd082 --- /dev/null +++ b/clean-code/app/datasources/database/db.go @@ -0,0 +1,35 @@ +package database + +import ( + "context" + "log" + "strings" +) + +type Book struct { + ID int + Title string +} + +type Database interface { + LoadAllBooks(ctx context.Context) ([]Book, error) + CreateBook(ctx context.Context, newBook Book) error + CloseConnections() +} + +func NewDatabase(ctx context.Context, databaseURL string) Database { + if databaseURL == "" { + log.Printf("Using in-memory database") + return newMemoryDB() + } else if strings.HasPrefix(databaseURL, "postgres://") { + db, err := newPostgresDB(ctx, databaseURL) + if err != nil { + log.Panicf("failed to create postgres database: %v", err) + } + log.Printf("Using Postgres database") + return db + } + log.Panicf("unsupported database: %s", databaseURL) + return nil + +} diff --git a/clean-code/app/datasources/database/db_mock.go b/clean-code/app/datasources/database/db_mock.go new file mode 100644 index 0000000000..cc24650252 --- /dev/null +++ b/clean-code/app/datasources/database/db_mock.go @@ -0,0 +1,27 @@ +package database + +import ( + "context" + + "github.com/stretchr/testify/mock" +) + +type DatabaseMock struct { + mock.Mock +} + +func (m *DatabaseMock) LoadAllBooks(ctx context.Context) ([]Book, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]Book), args.Error(1) +} + +func (m *DatabaseMock) CreateBook(ctx context.Context, newBook Book) error { + args := m.Called(ctx, newBook) + return args.Error(0) +} + +func (m *DatabaseMock) CloseConnections() { +} diff --git a/clean-code/app/datasources/database/db_test.go b/clean-code/app/datasources/database/db_test.go new file mode 100644 index 0000000000..e64cd8869a --- /dev/null +++ b/clean-code/app/datasources/database/db_test.go @@ -0,0 +1,34 @@ +package database + +import ( + "context" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewDatabase_MemoryDB(t *testing.T) { + ctx := context.Background() + db := NewDatabase(ctx, "") + assert.Equal(t, "*database.memoryDB", reflect.TypeOf(db).String()) +} + +func TestNewDatabase_PostgresDB(t *testing.T) { + ctx := context.Background() + db := NewDatabase(ctx, "postgres://localhost:5432") + assert.Equal(t, "*database.postgresDB", reflect.TypeOf(db).String()) +} + +func TestNewDatabase_InvalidDatabaseConfiguration(t *testing.T) { + ctx := context.Background() + defer func() { + assert.NotNil(t, recover()) + }() + _ = NewDatabase(ctx, "invalid") +} + +func assertBook(t *testing.T, book Book, expectedID int, expected Book) { + assert.Equal(t, expectedID, book.ID) + assert.Equal(t, expected.Title, book.Title) +} diff --git a/clean-code/app/datasources/database/memory_db.go b/clean-code/app/datasources/database/memory_db.go new file mode 100644 index 0000000000..1ca32834bf --- /dev/null +++ b/clean-code/app/datasources/database/memory_db.go @@ -0,0 +1,32 @@ +package database + +import "context" + +// This is just an example and not for production use +func newMemoryDB() Database { + return &memoryDB{ + records: make([]Book, 0, 10), + idCounter: 0, + } +} + +type memoryDB struct { + records []Book + idCounter int +} + +func (db *memoryDB) LoadAllBooks(_ context.Context) ([]Book, error) { + return db.records, nil +} + +func (db *memoryDB) CreateBook(_ context.Context, newBook Book) error { + db.records = append(db.records, Book{ + ID: db.idCounter, + Title: newBook.Title, + }) + db.idCounter++ + return nil +} + +func (db *memoryDB) CloseConnections() { +} diff --git a/clean-code/app/datasources/database/memory_db_test.go b/clean-code/app/datasources/database/memory_db_test.go new file mode 100644 index 0000000000..64927cf555 --- /dev/null +++ b/clean-code/app/datasources/database/memory_db_test.go @@ -0,0 +1,44 @@ +package database + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMemoryDB_LoadBooks(t *testing.T) { + db := newMemoryDB() + books, err := db.LoadAllBooks(context.Background()) + assert.Nil(t, err) + assert.Equal(t, 0, len(books)) +} + +func TestMemoryDB_SaveBook(t *testing.T) { + db := newMemoryDB() + newBook := Book{Title: "Title"} + err := db.CreateBook(context.Background(), newBook) + assert.Nil(t, err) + + books, err := db.LoadAllBooks(context.Background()) + assert.Nil(t, err) + assert.Equal(t, 1, len(books)) + assertBook(t, books[0], 0, newBook) +} + +func TestMemoryDB_SaveBookMultiple(t *testing.T) { + db := newMemoryDB() + newBook1 := Book{Title: "Title1"} + err := db.CreateBook(context.Background(), newBook1) + assert.Nil(t, err) + + newBook2 := Book{Title: "Title2"} + err = db.CreateBook(context.Background(), newBook2) + assert.Nil(t, err) + + books, err := db.LoadAllBooks(context.Background()) + assert.Nil(t, err) + assert.Equal(t, 2, len(books)) + assertBook(t, books[0], 0, newBook1) + assertBook(t, books[1], 1, newBook2) +} diff --git a/clean-code/app/datasources/database/postgres_db.go b/clean-code/app/datasources/database/postgres_db.go new file mode 100644 index 0000000000..282f543e9a --- /dev/null +++ b/clean-code/app/datasources/database/postgres_db.go @@ -0,0 +1,62 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +type PostgresPool interface { + Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) + Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) + Close() +} + +func newPostgresDB(ctx context.Context, databaseURL string) (Database, error) { + dbpool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("unable to create connection pool: %v", err) + } + + return &postgresDB{ + pool: dbpool, + }, nil +} + +type postgresDB struct { + pool PostgresPool +} + +func (db *postgresDB) LoadAllBooks(ctx context.Context) ([]Book, error) { + rows, err := db.pool.Query(ctx, "SELECT id, title FROM books") + if err != nil { + return nil, fmt.Errorf("failed to query books table: %w", err) + } + defer rows.Close() + + var books []Book + for rows.Next() { + var record Book + err := rows.Scan(&record.ID, &record.Title) + if err != nil { + return nil, fmt.Errorf("failed to scan rows: %w", err) + } + books = append(books, record) + } + return books, nil +} + +func (db *postgresDB) CreateBook(ctx context.Context, newBook Book) error { + _, err := db.pool.Exec(ctx, "INSERT INTO books (title) VALUES ($1)", newBook.Title) + if err != nil { + return fmt.Errorf("failed to insert book: %w", err) + } + return nil +} + +func (db *postgresDB) CloseConnections() { + db.pool.Close() +} diff --git a/clean-code/app/datasources/database/postgres_db_test.go b/clean-code/app/datasources/database/postgres_db_test.go new file mode 100644 index 0000000000..e6134b3a1c --- /dev/null +++ b/clean-code/app/datasources/database/postgres_db_test.go @@ -0,0 +1,79 @@ +package database + +import ( + "context" + "testing" + + "github.com/pashagolub/pgxmock/v4" + "github.com/stretchr/testify/assert" +) + +func TestPostgresDB_GetBooks(t *testing.T) { + mockPool, err := pgxmock.NewPool() + assert.Nil(t, err) + + mockPool.ExpectQuery("SELECT id, title FROM books"). + WillReturnRows(pgxmock.NewRows([]string{"id", "title"}). + AddRow(1, "book1")) + + db := postgresDB{ + pool: mockPool, + } + result, err := db.LoadAllBooks(context.Background()) + assert.Nil(t, err) + assert.Equal(t, 1, len(result)) + assertBook(t, result[0], 1, Book{Title: "book1"}) + + assert.Nil(t, mockPool.ExpectationsWereMet()) +} + +func TestPostgresDB_GetBooks_Fail(t *testing.T) { + mockPool, err := pgxmock.NewPool() + assert.Nil(t, err) + + mockPool.ExpectQuery("SELECT id, title FROM books"). + WillReturnError(assert.AnError) + + db := postgresDB{ + pool: mockPool, + } + result, err := db.LoadAllBooks(context.Background()) + assert.Nil(t, result) + assert.ErrorContains(t, err, "failed to query books table") + + assert.Nil(t, mockPool.ExpectationsWereMet()) +} + +func TestPostgresDB_CreateBook(t *testing.T) { + mockPool, err := pgxmock.NewPool() + assert.Nil(t, err) + + mockPool.ExpectExec("INSERT INTO books"). + WithArgs("book1"). + WillReturnResult(pgxmock.NewResult("INSERT", 1)) + + db := postgresDB{ + pool: mockPool, + } + err = db.CreateBook(context.Background(), Book{Title: "book1"}) + assert.Nil(t, err) + + assert.Nil(t, mockPool.ExpectationsWereMet()) +} + +func TestPostgresDB_CreateBook_Fail(t *testing.T) { + mockPool, err := pgxmock.NewPool() + assert.Nil(t, err) + + mockPool.ExpectExec("INSERT INTO books"). + WithArgs("book1"). + WillReturnError(assert.AnError) + + db := postgresDB{ + pool: mockPool, + } + err = db.CreateBook(context.Background(), Book{Title: "book1"}) + assert.ErrorContains(t, err, "failed to insert book") + + assert.Nil(t, mockPool.ExpectationsWereMet()) +} diff --git a/clean-code/app/go.mod b/clean-code/app/go.mod new file mode 100644 index 0000000000..a4a540a687 --- /dev/null +++ b/clean-code/app/go.mod @@ -0,0 +1,35 @@ +module app + +go 1.23.3 + +require ( + github.com/gofiber/fiber/v2 v2.52.5 + github.com/jackc/pgx/v5 v5.7.1 + github.com/pashagolub/pgxmock/v4 v4.3.0 + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/uuid v1.5.0 // 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/klauspost/compress v1.17.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/crypto v0.27.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/clean-code/app/go.sum b/clean-code/app/go.sum new file mode 100644 index 0000000000..1f21b05454 --- /dev/null +++ b/clean-code/app/go.sum @@ -0,0 +1,67 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +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/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= +github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +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/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +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/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/pashagolub/pgxmock/v4 v4.3.0 h1:DqT7fk0OCK6H0GvqtcMsLpv8cIwWqdxWgfZNLeHCb/s= +github.com/pashagolub/pgxmock/v4 v4.3.0/go.mod h1:9VoVHXwS3XR/yPtKGzwQvwZX1kzGB9sM8SviDcHDa3A= +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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +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= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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/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/clean-code/app/main.go b/clean-code/app/main.go new file mode 100644 index 0000000000..f2eca86272 --- /dev/null +++ b/clean-code/app/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "context" + "log" + + "app/datasources" + "app/datasources/database" + "app/server" +) + +func main() { + ctx := context.Background() + conf := NewConfiguration() + dataSources := &datasources.DataSources{ + DB: database.NewDatabase(ctx, conf.DatabaseURL), + } + defer dataSources.DB.CloseConnections() + + app := server.NewServer(ctx, dataSources) + log.Fatal(app.Listen(":" + conf.Port)) +} diff --git a/clean-code/app/server/domain/books.go b/clean-code/app/server/domain/books.go new file mode 100644 index 0000000000..82dff24e1e --- /dev/null +++ b/clean-code/app/server/domain/books.go @@ -0,0 +1,9 @@ +package domain + +type Book struct { + Title string `json:"title"` +} + +type BooksResponse struct { + Books []Book `json:"books"` +} diff --git a/clean-code/app/server/domain/errors.go b/clean-code/app/server/domain/errors.go new file mode 100644 index 0000000000..97111fc0d6 --- /dev/null +++ b/clean-code/app/server/domain/errors.go @@ -0,0 +1,5 @@ +package domain + +type ErrorResponse struct { + Error string `json:"error"` +} diff --git a/clean-code/app/server/handlers/books.go b/clean-code/app/server/handlers/books.go new file mode 100644 index 0000000000..e864eddb8b --- /dev/null +++ b/clean-code/app/server/handlers/books.go @@ -0,0 +1,47 @@ +package handlers + +import ( + "log" + + "app/server/domain" + "app/server/services" + + "github.com/gofiber/fiber/v2" +) + +func GetBooks(service services.BooksService) fiber.Handler { + return func(c *fiber.Ctx) error { + books, err := service.GetBooks(c.UserContext()) + if err != nil { + log.Printf("GetBooks failed: %v", err) + return sendError(c, fiber.StatusInternalServerError, "internal error") + } + + return c.JSON(domain.BooksResponse{ + Books: books, + }) + } +} + +func AddBook(service services.BooksService) fiber.Handler { + return func(c *fiber.Ctx) error { + var book domain.Book + if err := c.BodyParser(&book); err != nil { + log.Printf("AddBook request parsing failed: %v", err) + return sendError(c, fiber.StatusBadRequest, "invalid request") + } + + err := service.SaveBook(c.UserContext(), book) + if err != nil { + log.Printf("AddBook failed: %v", err) + return sendError(c, fiber.StatusInternalServerError, "internal error") + } + return c.SendStatus(fiber.StatusCreated) + } +} + +func sendError(c *fiber.Ctx, code int, message string) error { + return c.Status(code).JSON(domain.ErrorResponse{ + Error: message, + }) +} diff --git a/clean-code/app/server/handlers/books_test.go b/clean-code/app/server/handlers/books_test.go new file mode 100644 index 0000000000..7d2a9bb2b6 --- /dev/null +++ b/clean-code/app/server/handlers/books_test.go @@ -0,0 +1,102 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "app/server/domain" + "app/server/services" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +var booksRoute = "/api/v1/books" + +func TestGetBooks(t *testing.T) { + mockService := new(services.BooksServiceMock) + mockService.On("GetBooks", mock.Anything).Return([]domain.Book{{Title: "Title"}}, nil) + + app := fiber.New() + app.Get(booksRoute, GetBooks(mockService)) + + resp, err := app.Test(httptest.NewRequest("GET", booksRoute, nil)) + assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + + body := bodyFromResponse[domain.BooksResponse](t, resp) + assert.Len(t, body.Books, 1) +} + +func TestGetBooks_ServiceFails(t *testing.T) { + mockService := new(services.BooksServiceMock) + mockService.On("GetBooks", mock.Anything).Return(nil, assert.AnError) + + app := fiber.New() + app.Get(booksRoute, GetBooks(mockService)) + + resp, err := app.Test(httptest.NewRequest("GET", booksRoute, nil)) + assert.Nil(t, err) + assert.Equal(t, 500, resp.StatusCode) + + body := bodyFromResponse[domain.ErrorResponse](t, resp) + assert.Equal(t, "internal error", body.Error) +} + +func TestAddBook(t *testing.T) { + mockService := new(services.BooksServiceMock) + mockService.On("SaveBook", mock.Anything, domain.Book{Title: "Title"}).Return(nil) + + app := fiber.New() + app.Post(booksRoute, AddBook(mockService)) + + resp, err := app.Test(postRequest(booksRoute, `{"title":"Title"}`)) + assert.Nil(t, err) + assert.Equal(t, 201, resp.StatusCode) +} + +func TestAddBook_InvalidRequest(t *testing.T) { + mockService := new(services.BooksServiceMock) + + app := fiber.New() + app.Post(booksRoute, AddBook(mockService)) + + resp, err := app.Test(httptest.NewRequest("POST", booksRoute, nil)) + assert.Nil(t, err) + assert.Equal(t, 400, resp.StatusCode) + + body := bodyFromResponse[domain.ErrorResponse](t, resp) + assert.Equal(t, "invalid request", body.Error) +} + +func TestAddBook_ServiceFails(t *testing.T) { + mockService := new(services.BooksServiceMock) + mockService.On("SaveBook", mock.Anything, domain.Book{Title: "Title"}).Return(assert.AnError) + + app := fiber.New() + app.Post(booksRoute, AddBook(mockService)) + + resp, err := app.Test(postRequest(booksRoute, `{"title":"Title"}`)) + assert.Nil(t, err) + assert.Equal(t, 500, resp.StatusCode) + + body := bodyFromResponse[domain.ErrorResponse](t, resp) + assert.Equal(t, "internal error", body.Error) +} + +func postRequest(url string, body string) *http.Request { + req := httptest.NewRequest("POST", url, bytes.NewBufferString(body)) + req.Header.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON) + return req +} + +func bodyFromResponse[T any](t *testing.T, resp *http.Response) T { + var body T + err := json.NewDecoder(resp.Body).Decode(&body) + assert.Nil(t, err) + return body +} diff --git a/clean-code/app/server/server.go b/clean-code/app/server/server.go new file mode 100644 index 0000000000..5c27794028 --- /dev/null +++ b/clean-code/app/server/server.go @@ -0,0 +1,24 @@ +package server + +import ( + "context" + + "app/datasources" + "app/server/handlers" + "app/server/services" + + "github.com/gofiber/fiber/v2" +) + +func NewServer(ctx context.Context, dataSources *datasources.DataSources) *fiber.App { + app := fiber.New() + apiRoutes := app.Group("/api") + + apiRoutes.Get("/status", func(c *fiber.Ctx) error { + return c.SendString("ok") + }) + apiRoutes.Get("/v1/books", handlers.GetBooks(services.NewBooksService(dataSources.DB))) + apiRoutes.Post("/v1/books", handlers.AddBook(services.NewBooksService(dataSources.DB))) + + return app +} diff --git a/clean-code/app/server/server_test.go b/clean-code/app/server/server_test.go new file mode 100644 index 0000000000..ac37448af3 --- /dev/null +++ b/clean-code/app/server/server_test.go @@ -0,0 +1,24 @@ +package server + +import ( + "context" + "io" + "net/http/httptest" + "testing" + + "app/datasources" + + "github.com/stretchr/testify/assert" +) + +func TestGetStatus(t *testing.T) { + app := NewServer(context.Background(), &datasources.DataSources{}) + + resp, err := app.Test(httptest.NewRequest("GET", "/api/status", nil)) + assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + assert.Nil(t, err) + assert.Equal(t, "ok", string(body)) +} diff --git a/clean-code/app/server/services/books.go b/clean-code/app/server/services/books.go new file mode 100644 index 0000000000..46e6a2ad38 --- /dev/null +++ b/clean-code/app/server/services/books.go @@ -0,0 +1,51 @@ +package services + +import ( + "context" + "fmt" + + "app/datasources/database" + "app/server/domain" +) + +type BooksService interface { + GetBooks(ctx context.Context) ([]domain.Book, error) + SaveBook(ctx context.Context, newBook domain.Book) error +} + +type booksService struct { + db database.Database +} + +func NewBooksService(db database.Database) BooksService { + return &booksService{db: db} +} + +func (s *booksService) GetBooks(ctx context.Context) ([]domain.Book, error) { + dbRecords, err := s.db.LoadAllBooks(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load books: %w", err) + } + + books := make([]domain.Book, 0, len(dbRecords)) + for _, record := range dbRecords { + books = append(books, domain.Book{ + Title: record.Title, + }) + } + + return books, nil +} + +func (s *booksService) SaveBook(ctx context.Context, book domain.Book) error { + dbBook := database.Book{ + Title: book.Title, + } + + err := s.db.CreateBook(ctx, dbBook) + if err != nil { + return fmt.Errorf("failed to save book: %w", err) + } + + return nil +} diff --git a/clean-code/app/server/services/books_mock.go b/clean-code/app/server/services/books_mock.go new file mode 100644 index 0000000000..5940752d94 --- /dev/null +++ b/clean-code/app/server/services/books_mock.go @@ -0,0 +1,26 @@ +package services + +import ( + "context" + + "app/server/domain" + + "github.com/stretchr/testify/mock" +) + +type BooksServiceMock struct { + mock.Mock +} + +func (m *BooksServiceMock) GetBooks(ctx context.Context) ([]domain.Book, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Book), args.Error(1) +} + +func (m *BooksServiceMock) SaveBook(ctx context.Context, newBook domain.Book) error { + args := m.Called(ctx, newBook) + return args.Error(0) +} diff --git a/clean-code/app/server/services/books_test.go b/clean-code/app/server/services/books_test.go new file mode 100644 index 0000000000..9e017f3cb5 --- /dev/null +++ b/clean-code/app/server/services/books_test.go @@ -0,0 +1,49 @@ +package services + +import ( + "context" + "testing" + + "app/datasources/database" + "app/server/domain" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestGetBooks(t *testing.T) { + mockDB := new(database.DatabaseMock) + mockDB.On("LoadAllBooks", mock.Anything).Return([]database.Book{{Title: "Title"}}, nil) + + service := NewBooksService(mockDB) + books, err := service.GetBooks(context.Background()) + assert.Nil(t, err) + assert.Len(t, books, 1) +} + +func TestGetBooks_Fails(t *testing.T) { + mockDB := new(database.DatabaseMock) + mockDB.On("LoadAllBooks", mock.Anything).Return(nil, assert.AnError) + + service := NewBooksService(mockDB) + _, err := service.GetBooks(context.Background()) + assert.NotNil(t, err) +} + +func TestSaveBook(t *testing.T) { + mockDB := new(database.DatabaseMock) + mockDB.On("CreateBook", mock.Anything, database.Book{Title: "Title"}).Return(nil) + + service := NewBooksService(mockDB) + err := service.SaveBook(context.Background(), domain.Book{Title: "Title"}) + assert.Nil(t, err) +} + +func TestSaveBook_Fails(t *testing.T) { + mockDB := new(database.DatabaseMock) + mockDB.On("CreateBook", mock.Anything, database.Book{Title: "Title"}).Return(assert.AnError) + + service := NewBooksService(mockDB) + err := service.SaveBook(context.Background(), domain.Book{Title: "Title"}) + assert.NotNil(t, err) +} diff --git a/clean-code/db/init_db.sql b/clean-code/db/init_db.sql new file mode 100644 index 0000000000..6bc0f69fac --- /dev/null +++ b/clean-code/db/init_db.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS books ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL +); diff --git a/clean-code/docker-compose.yml b/clean-code/docker-compose.yml new file mode 100644 index 0000000000..ff4e7f3b6d --- /dev/null +++ b/clean-code/docker-compose.yml @@ -0,0 +1,27 @@ +services: + app: + container_name: app + build: + dockerfile: Dockerfile-local + environment: + - PORT=3000 + - DATABASE_URL=postgres://postgres:postgres@example_db:5432/example + ports: + - 3000:3000 + volumes: + - ./app:/go/src/app + depends_on: + - example_db + + example_db: + container_name: example_db + image: postgres:17.1-alpine + environment: + - POSTGRES_DB=example + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + ports: + - 5435:5432 + volumes: + - ./db/init_db.sql:/docker-entrypoint-initdb.d/init_db.sql + - ./db_data:/var/lib/postgresql/data From 6b8715e7008d63ae17e61ddb3c1cec2a36019437 Mon Sep 17 00:00:00 2001 From: Eero Norri Date: Sat, 23 Nov 2024 21:25:26 +0200 Subject: [PATCH 2/6] Improvements from review --- clean-code/README.md | 16 +++++++++++++ clean-code/app/config.go | 2 ++ clean-code/app/datasources/data_sources.go | 2 ++ clean-code/app/datasources/database/db.go | 23 +++++++++++++------ .../app/datasources/database/db_mock.go | 2 +- .../app/datasources/database/db_test.go | 14 +++++------ .../app/datasources/database/memory_db.go | 2 +- .../datasources/database/memory_db_test.go | 6 ++--- .../app/datasources/database/postgres_db.go | 20 ++++++++-------- .../datasources/database/postgres_db_test.go | 6 ++--- clean-code/app/main.go | 13 +++++++---- clean-code/app/server/domain/books.go | 2 ++ clean-code/app/server/domain/errors.go | 1 + clean-code/app/server/handlers/books.go | 2 ++ clean-code/app/server/handlers/books_test.go | 1 + clean-code/app/server/server.go | 1 + clean-code/app/server/services/books.go | 7 +++++- clean-code/app/server/services/books_test.go | 4 ++-- 18 files changed, 84 insertions(+), 40 deletions(-) diff --git a/clean-code/README.md b/clean-code/README.md index 70febb8d6f..61e83124ad 100644 --- a/clean-code/README.md +++ b/clean-code/README.md @@ -2,6 +2,22 @@ This is an example of a RESTful API built using the Fiber framework (https://gofiber.io/) and PostgreSQL as the database. +### Description of Clean Code + +Clean code is a philosophy and set of practices aimed at writing code that is easy to understand, maintain, and extend. Key principles of clean code include: + +- **Readability**: Code should be easy to read and understand. +- **Simplicity**: Avoid unnecessary complexity. +- **Consistency**: Follow consistent coding standards and conventions. +- **Modularity**: Break down code into small, reusable, and independent modules. +- **Testability**: Write code that is easy to test. + +This Fiber app is a good example of clean code because: + +- **Modular Structure**: The code is organized into distinct modules, making it easy to navigate and understand. +- **Clear Separation of Concerns**: Different parts of the application (e.g., routes, handlers, services) are clearly separated, making the codebase easier to maintain and extend. +- **Error Handling**: Proper error handling is implemented to ensure the application behaves predictably. + ### Start 1. Build and start the containers: diff --git a/clean-code/app/config.go b/clean-code/app/config.go index 0ab24db57d..7bfc8fe4b7 100644 --- a/clean-code/app/config.go +++ b/clean-code/app/config.go @@ -2,11 +2,13 @@ package main import "os" +// Configuration is used to store values from environment variables type Configuration struct { Port string DatabaseURL string } +// NewConfiguration reads environment variables and returns a new Configuration func NewConfiguration() *Configuration { return &Configuration{ Port: getEnvOrDefault("PORT", "3000"), diff --git a/clean-code/app/datasources/data_sources.go b/clean-code/app/datasources/data_sources.go index f7aea8aa19..94c8be8cfe 100644 --- a/clean-code/app/datasources/data_sources.go +++ b/clean-code/app/datasources/data_sources.go @@ -2,6 +2,8 @@ package datasources import "app/datasources/database" +// DataSources is a struct that contains all the data sources +// It is used to pass different data sources to the server and services type DataSources struct { DB database.Database } diff --git a/clean-code/app/datasources/database/db.go b/clean-code/app/datasources/database/db.go index cba2cbd082..468a4680ad 100644 --- a/clean-code/app/datasources/database/db.go +++ b/clean-code/app/datasources/database/db.go @@ -2,34 +2,43 @@ package database import ( "context" + "fmt" "log" "strings" ) +// Book represents a book in the database type Book struct { ID int Title string } +// NewBook represents a new book to be created to the database +type NewBook struct { + Title string +} + +// Database is an interface for interacting with the database +// With using this the implementation can be changed without affecting the rest of the code. type Database interface { LoadAllBooks(ctx context.Context) ([]Book, error) - CreateBook(ctx context.Context, newBook Book) error + CreateBook(ctx context.Context, newBook NewBook) error CloseConnections() } -func NewDatabase(ctx context.Context, databaseURL string) Database { +// NewDatabase creates a new Database instance +func NewDatabase(ctx context.Context, databaseURL string) (Database, error) { if databaseURL == "" { log.Printf("Using in-memory database") - return newMemoryDB() + return newMemoryDB(), nil } else if strings.HasPrefix(databaseURL, "postgres://") { db, err := newPostgresDB(ctx, databaseURL) if err != nil { - log.Panicf("failed to create postgres database: %v", err) + return nil, fmt.Errorf("failed to create postgres database: %w", err) } log.Printf("Using Postgres database") - return db + return db, nil } - log.Panicf("unsupported database: %s", databaseURL) - return nil + return nil, fmt.Errorf("unsupported database: %s", databaseURL) } diff --git a/clean-code/app/datasources/database/db_mock.go b/clean-code/app/datasources/database/db_mock.go index cc24650252..e61e3f7663 100644 --- a/clean-code/app/datasources/database/db_mock.go +++ b/clean-code/app/datasources/database/db_mock.go @@ -18,7 +18,7 @@ func (m *DatabaseMock) LoadAllBooks(ctx context.Context) ([]Book, error) { return args.Get(0).([]Book), args.Error(1) } -func (m *DatabaseMock) CreateBook(ctx context.Context, newBook Book) error { +func (m *DatabaseMock) CreateBook(ctx context.Context, newBook NewBook) error { args := m.Called(ctx, newBook) return args.Error(0) } diff --git a/clean-code/app/datasources/database/db_test.go b/clean-code/app/datasources/database/db_test.go index e64cd8869a..81929c8ada 100644 --- a/clean-code/app/datasources/database/db_test.go +++ b/clean-code/app/datasources/database/db_test.go @@ -10,25 +10,25 @@ import ( func TestNewDatabase_MemoryDB(t *testing.T) { ctx := context.Background() - db := NewDatabase(ctx, "") + db, err := NewDatabase(ctx, "") + assert.Nil(t, err) assert.Equal(t, "*database.memoryDB", reflect.TypeOf(db).String()) } func TestNewDatabase_PostgresDB(t *testing.T) { ctx := context.Background() - db := NewDatabase(ctx, "postgres://localhost:5432") + db, err := NewDatabase(ctx, "postgres://localhost:5432") + assert.Nil(t, err) assert.Equal(t, "*database.postgresDB", reflect.TypeOf(db).String()) } func TestNewDatabase_InvalidDatabaseConfiguration(t *testing.T) { ctx := context.Background() - defer func() { - assert.NotNil(t, recover()) - }() - _ = NewDatabase(ctx, "invalid") + _, err := NewDatabase(ctx, "invalid") + assert.ErrorContains(t, err, "unsupported database") } -func assertBook(t *testing.T, book Book, expectedID int, expected Book) { +func assertBook(t *testing.T, book Book, expectedID int, expected NewBook) { assert.Equal(t, expectedID, book.ID) assert.Equal(t, expected.Title, book.Title) } diff --git a/clean-code/app/datasources/database/memory_db.go b/clean-code/app/datasources/database/memory_db.go index 1ca32834bf..7115fb1923 100644 --- a/clean-code/app/datasources/database/memory_db.go +++ b/clean-code/app/datasources/database/memory_db.go @@ -19,7 +19,7 @@ func (db *memoryDB) LoadAllBooks(_ context.Context) ([]Book, error) { return db.records, nil } -func (db *memoryDB) CreateBook(_ context.Context, newBook Book) error { +func (db *memoryDB) CreateBook(_ context.Context, newBook NewBook) error { db.records = append(db.records, Book{ ID: db.idCounter, Title: newBook.Title, diff --git a/clean-code/app/datasources/database/memory_db_test.go b/clean-code/app/datasources/database/memory_db_test.go index 64927cf555..d6ae6e1e42 100644 --- a/clean-code/app/datasources/database/memory_db_test.go +++ b/clean-code/app/datasources/database/memory_db_test.go @@ -16,7 +16,7 @@ func TestMemoryDB_LoadBooks(t *testing.T) { func TestMemoryDB_SaveBook(t *testing.T) { db := newMemoryDB() - newBook := Book{Title: "Title"} + newBook := NewBook{Title: "Title"} err := db.CreateBook(context.Background(), newBook) assert.Nil(t, err) @@ -28,11 +28,11 @@ func TestMemoryDB_SaveBook(t *testing.T) { func TestMemoryDB_SaveBookMultiple(t *testing.T) { db := newMemoryDB() - newBook1 := Book{Title: "Title1"} + newBook1 := NewBook{Title: "Title1"} err := db.CreateBook(context.Background(), newBook1) assert.Nil(t, err) - newBook2 := Book{Title: "Title2"} + newBook2 := NewBook{Title: "Title2"} err = db.CreateBook(context.Background(), newBook2) assert.Nil(t, err) diff --git a/clean-code/app/datasources/database/postgres_db.go b/clean-code/app/datasources/database/postgres_db.go index 282f543e9a..0c3496cc3f 100644 --- a/clean-code/app/datasources/database/postgres_db.go +++ b/clean-code/app/datasources/database/postgres_db.go @@ -9,6 +9,8 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) +// PostgresPool is an interface for interacting with the database connection pool. +// Needed for mocking the database connection pool in tests. type PostgresPool interface { Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) @@ -16,11 +18,11 @@ type PostgresPool interface { } func newPostgresDB(ctx context.Context, databaseURL string) (Database, error) { + // For production use set connection pool settings and validate connection with ping dbpool, err := pgxpool.New(ctx, databaseURL) if err != nil { return nil, fmt.Errorf("unable to create connection pool: %v", err) } - return &postgresDB{ pool: dbpool, }, nil @@ -30,6 +32,7 @@ type postgresDB struct { pool PostgresPool } +// LoadAllBooks loads all books from the database func (db *postgresDB) LoadAllBooks(ctx context.Context) ([]Book, error) { rows, err := db.pool.Query(ctx, "SELECT id, title FROM books") if err != nil { @@ -37,19 +40,15 @@ func (db *postgresDB) LoadAllBooks(ctx context.Context) ([]Book, error) { } defer rows.Close() - var books []Book - for rows.Next() { - var record Book - err := rows.Scan(&record.ID, &record.Title) - if err != nil { - return nil, fmt.Errorf("failed to scan rows: %w", err) - } - books = append(books, record) + books, err := pgx.CollectRows(rows, pgx.RowToStructByName[Book]) + if err != nil { + return nil, fmt.Errorf("failed to collect rows: %w", err) } return books, nil } -func (db *postgresDB) CreateBook(ctx context.Context, newBook Book) error { +// CreateBook creates a new book in the database +func (db *postgresDB) CreateBook(ctx context.Context, newBook NewBook) error { _, err := db.pool.Exec(ctx, "INSERT INTO books (title) VALUES ($1)", newBook.Title) if err != nil { return fmt.Errorf("failed to insert book: %w", err) @@ -57,6 +56,7 @@ func (db *postgresDB) CreateBook(ctx context.Context, newBook Book) error { return nil } +// CloseConnections closes the database connection pool func (db *postgresDB) CloseConnections() { db.pool.Close() } diff --git a/clean-code/app/datasources/database/postgres_db_test.go b/clean-code/app/datasources/database/postgres_db_test.go index e6134b3a1c..3a8dd2e1b3 100644 --- a/clean-code/app/datasources/database/postgres_db_test.go +++ b/clean-code/app/datasources/database/postgres_db_test.go @@ -22,7 +22,7 @@ func TestPostgresDB_GetBooks(t *testing.T) { result, err := db.LoadAllBooks(context.Background()) assert.Nil(t, err) assert.Equal(t, 1, len(result)) - assertBook(t, result[0], 1, Book{Title: "book1"}) + assertBook(t, result[0], 1, NewBook{Title: "book1"}) assert.Nil(t, mockPool.ExpectationsWereMet()) } @@ -55,7 +55,7 @@ func TestPostgresDB_CreateBook(t *testing.T) { db := postgresDB{ pool: mockPool, } - err = db.CreateBook(context.Background(), Book{Title: "book1"}) + err = db.CreateBook(context.Background(), NewBook{Title: "book1"}) assert.Nil(t, err) assert.Nil(t, mockPool.ExpectationsWereMet()) @@ -72,7 +72,7 @@ func TestPostgresDB_CreateBook_Fail(t *testing.T) { db := postgresDB{ pool: mockPool, } - err = db.CreateBook(context.Background(), Book{Title: "book1"}) + err = db.CreateBook(context.Background(), NewBook{Title: "book1"}) assert.ErrorContains(t, err, "failed to insert book") assert.Nil(t, mockPool.ExpectationsWereMet()) diff --git a/clean-code/app/main.go b/clean-code/app/main.go index f2eca86272..5b842739b9 100644 --- a/clean-code/app/main.go +++ b/clean-code/app/main.go @@ -10,13 +10,16 @@ import ( ) func main() { - ctx := context.Background() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + conf := NewConfiguration() - dataSources := &datasources.DataSources{ - DB: database.NewDatabase(ctx, conf.DatabaseURL), + db, err := database.NewDatabase(ctx, conf.DatabaseURL) + if err != nil { + log.Fatalf("failed to create database: %v", err) } - defer dataSources.DB.CloseConnections() + defer db.CloseConnections() - app := server.NewServer(ctx, dataSources) + app := server.NewServer(ctx, &datasources.DataSources{DB: db}) log.Fatal(app.Listen(":" + conf.Port)) } diff --git a/clean-code/app/server/domain/books.go b/clean-code/app/server/domain/books.go index 82dff24e1e..f65bed69ba 100644 --- a/clean-code/app/server/domain/books.go +++ b/clean-code/app/server/domain/books.go @@ -1,9 +1,11 @@ package domain +// Book represents a book type Book struct { Title string `json:"title"` } +// BooksResponse represents a response containing a list of books type BooksResponse struct { Books []Book `json:"books"` } diff --git a/clean-code/app/server/domain/errors.go b/clean-code/app/server/domain/errors.go index 97111fc0d6..b99dff54a1 100644 --- a/clean-code/app/server/domain/errors.go +++ b/clean-code/app/server/domain/errors.go @@ -1,5 +1,6 @@ package domain +// ErrorResponse is a struct that represents an error response type ErrorResponse struct { Error string `json:"error"` } diff --git a/clean-code/app/server/handlers/books.go b/clean-code/app/server/handlers/books.go index e864eddb8b..269184279f 100644 --- a/clean-code/app/server/handlers/books.go +++ b/clean-code/app/server/handlers/books.go @@ -9,6 +9,7 @@ import ( "github.com/gofiber/fiber/v2" ) +// GetBooks returns a handler function that retrieves all books func GetBooks(service services.BooksService) fiber.Handler { return func(c *fiber.Ctx) error { books, err := service.GetBooks(c.UserContext()) @@ -23,6 +24,7 @@ func GetBooks(service services.BooksService) fiber.Handler { } } +// AddBook returns a handler function that adds a book func AddBook(service services.BooksService) fiber.Handler { return func(c *fiber.Ctx) error { var book domain.Book diff --git a/clean-code/app/server/handlers/books_test.go b/clean-code/app/server/handlers/books_test.go index 7d2a9bb2b6..cb1d46c259 100644 --- a/clean-code/app/server/handlers/books_test.go +++ b/clean-code/app/server/handlers/books_test.go @@ -95,6 +95,7 @@ func postRequest(url string, body string) *http.Request { } func bodyFromResponse[T any](t *testing.T, resp *http.Response) T { + defer resp.Body.Close() var body T err := json.NewDecoder(resp.Body).Decode(&body) assert.Nil(t, err) diff --git a/clean-code/app/server/server.go b/clean-code/app/server/server.go index 5c27794028..c382a4666a 100644 --- a/clean-code/app/server/server.go +++ b/clean-code/app/server/server.go @@ -10,6 +10,7 @@ import ( "github.com/gofiber/fiber/v2" ) +// NewServer creates a new Fiber app and sets up the routes func NewServer(ctx context.Context, dataSources *datasources.DataSources) *fiber.App { app := fiber.New() apiRoutes := app.Group("/api") diff --git a/clean-code/app/server/services/books.go b/clean-code/app/server/services/books.go index 46e6a2ad38..9f20b85a20 100644 --- a/clean-code/app/server/services/books.go +++ b/clean-code/app/server/services/books.go @@ -8,6 +8,8 @@ import ( "app/server/domain" ) +// BooksService is an interface that defines the methods for the books service. +// Interface is needed for mocking in tests. type BooksService interface { GetBooks(ctx context.Context) ([]domain.Book, error) SaveBook(ctx context.Context, newBook domain.Book) error @@ -17,10 +19,12 @@ type booksService struct { db database.Database } +// NewBooksService creates a new BooksService func NewBooksService(db database.Database) BooksService { return &booksService{db: db} } +// GetBooks retrieves all books from the database func (s *booksService) GetBooks(ctx context.Context) ([]domain.Book, error) { dbRecords, err := s.db.LoadAllBooks(ctx) if err != nil { @@ -37,8 +41,9 @@ func (s *booksService) GetBooks(ctx context.Context) ([]domain.Book, error) { return books, nil } +// SaveBook saves a book to the database func (s *booksService) SaveBook(ctx context.Context, book domain.Book) error { - dbBook := database.Book{ + dbBook := database.NewBook{ Title: book.Title, } diff --git a/clean-code/app/server/services/books_test.go b/clean-code/app/server/services/books_test.go index 9e017f3cb5..7c9df139ab 100644 --- a/clean-code/app/server/services/books_test.go +++ b/clean-code/app/server/services/books_test.go @@ -32,7 +32,7 @@ func TestGetBooks_Fails(t *testing.T) { func TestSaveBook(t *testing.T) { mockDB := new(database.DatabaseMock) - mockDB.On("CreateBook", mock.Anything, database.Book{Title: "Title"}).Return(nil) + mockDB.On("CreateBook", mock.Anything, database.NewBook{Title: "Title"}).Return(nil) service := NewBooksService(mockDB) err := service.SaveBook(context.Background(), domain.Book{Title: "Title"}) @@ -41,7 +41,7 @@ func TestSaveBook(t *testing.T) { func TestSaveBook_Fails(t *testing.T) { mockDB := new(database.DatabaseMock) - mockDB.On("CreateBook", mock.Anything, database.Book{Title: "Title"}).Return(assert.AnError) + mockDB.On("CreateBook", mock.Anything, database.NewBook{Title: "Title"}).Return(assert.AnError) service := NewBooksService(mockDB) err := service.SaveBook(context.Background(), domain.Book{Title: "Title"}) From 2d7ce388485bed404de24d4b72e904d4b25cde46 Mon Sep 17 00:00:00 2001 From: Eero Norri Date: Sat, 23 Nov 2024 21:26:01 +0200 Subject: [PATCH 3/6] Add link to clean-code to readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1d5fda0dd6..0eaee31b80 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Here you can find the most **delicious** recipes to cook delicious meals using o - [Amazon Web Services (AWS) Elastic Beanstalk](/aws-eb) - [AWS SAM](/aws-sam) - [Certificates from Let's Encrypt](/autocert) +- [Clean Code](/clean-code/) - [Clean Architecture](/clean-architecture) - [Cloud Run](/cloud-run) - [Colly Scraping using Fiber and PostgreSQL](/fiber-colly-gorm) From 8711c719af015591e7b8a912fbccdf71b691ac92 Mon Sep 17 00:00:00 2001 From: Eero Norri Date: Mon, 25 Nov 2024 21:26:09 +0200 Subject: [PATCH 4/6] refactoring - change log to slog - improve comments --- clean-code/app/config.go | 11 +++++++++-- clean-code/app/datasources/database/db.go | 15 ++++++++++----- clean-code/app/server/handlers/books.go | 9 +++++---- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/clean-code/app/config.go b/clean-code/app/config.go index 7bfc8fe4b7..6d07f2dcd7 100644 --- a/clean-code/app/config.go +++ b/clean-code/app/config.go @@ -1,6 +1,9 @@ package main -import "os" +import ( + "log/slog" + "os" +) // Configuration is used to store values from environment variables type Configuration struct { @@ -10,9 +13,13 @@ type Configuration struct { // NewConfiguration reads environment variables and returns a new Configuration func NewConfiguration() *Configuration { + dbURL := getEnvOrDefault("DATABASE_URL", "") + if dbURL == "" { + slog.Warn("DATABASE_URL is not set") + } return &Configuration{ Port: getEnvOrDefault("PORT", "3000"), - DatabaseURL: getEnvOrDefault("DATABASE_URL", ""), + DatabaseURL: dbURL, } } diff --git a/clean-code/app/datasources/database/db.go b/clean-code/app/datasources/database/db.go index 468a4680ad..d4a9d54013 100644 --- a/clean-code/app/datasources/database/db.go +++ b/clean-code/app/datasources/database/db.go @@ -3,7 +3,7 @@ package database import ( "context" "fmt" - "log" + "log/slog" "strings" ) @@ -18,25 +18,30 @@ type NewBook struct { Title string } -// Database is an interface for interacting with the database -// With using this the implementation can be changed without affecting the rest of the code. +// Database defines the interface for interacting with the book database. +// Using this interface allows changing the implementation without affecting the rest of the code. type Database interface { + // LoadAllBooks retrieves all books from the database. LoadAllBooks(ctx context.Context) ([]Book, error) + + // CreateBook adds a new book to the database. CreateBook(ctx context.Context, newBook NewBook) error + + // CloseConnections closes all open connections to the database. CloseConnections() } // NewDatabase creates a new Database instance func NewDatabase(ctx context.Context, databaseURL string) (Database, error) { if databaseURL == "" { - log.Printf("Using in-memory database") + slog.Info("Using in-memory database") return newMemoryDB(), nil } else if strings.HasPrefix(databaseURL, "postgres://") { db, err := newPostgresDB(ctx, databaseURL) if err != nil { return nil, fmt.Errorf("failed to create postgres database: %w", err) } - log.Printf("Using Postgres database") + slog.Info("Using Postgres database") return db, nil } return nil, fmt.Errorf("unsupported database: %s", databaseURL) diff --git a/clean-code/app/server/handlers/books.go b/clean-code/app/server/handlers/books.go index 269184279f..57c6591b95 100644 --- a/clean-code/app/server/handlers/books.go +++ b/clean-code/app/server/handlers/books.go @@ -1,7 +1,7 @@ package handlers import ( - "log" + "log/slog" "app/server/domain" "app/server/services" @@ -14,7 +14,7 @@ func GetBooks(service services.BooksService) fiber.Handler { return func(c *fiber.Ctx) error { books, err := service.GetBooks(c.UserContext()) if err != nil { - log.Printf("GetBooks failed: %v", err) + slog.Error("GetBooks failed", "error", err) return sendError(c, fiber.StatusInternalServerError, "internal error") } @@ -29,13 +29,14 @@ func AddBook(service services.BooksService) fiber.Handler { return func(c *fiber.Ctx) error { var book domain.Book if err := c.BodyParser(&book); err != nil { - log.Printf("AddBook request parsing failed: %v", err) + slog.Warn("AddBook request parsing failed", "error", err) return sendError(c, fiber.StatusBadRequest, "invalid request") } + // For production use add proper validation here err := service.SaveBook(c.UserContext(), book) if err != nil { - log.Printf("AddBook failed: %v", err) + slog.Error("AddBook failed", "error", err) return sendError(c, fiber.StatusInternalServerError, "internal error") } return c.SendStatus(fiber.StatusCreated) From f412683170a9ddc9c00e1f68065ebc0fb4ff44e9 Mon Sep 17 00:00:00 2001 From: Eero Norri Date: Mon, 25 Nov 2024 21:39:44 +0200 Subject: [PATCH 5/6] Update clean-code/app/datasources/database/db.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- clean-code/app/datasources/database/db.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/clean-code/app/datasources/database/db.go b/clean-code/app/datasources/database/db.go index d4a9d54013..8be925c0d0 100644 --- a/clean-code/app/datasources/database/db.go +++ b/clean-code/app/datasources/database/db.go @@ -34,16 +34,18 @@ type Database interface { // NewDatabase creates a new Database instance func NewDatabase(ctx context.Context, databaseURL string) (Database, error) { if databaseURL == "" { - slog.Info("Using in-memory database") + slog.Info("Using in-memory database implementation") return newMemoryDB(), nil - } else if strings.HasPrefix(databaseURL, "postgres://") { + } + + if strings.HasPrefix(databaseURL, "postgres://") { db, err := newPostgresDB(ctx, databaseURL) if err != nil { - return nil, fmt.Errorf("failed to create postgres database: %w", err) + return nil, fmt.Errorf("failed to initialize PostgreSQL database connection: %w", err) } - slog.Info("Using Postgres database") + slog.Info("Using PostgreSQL database implementation") return db, nil } - return nil, fmt.Errorf("unsupported database: %s", databaseURL) - + + return nil, fmt.Errorf("unsupported database URL scheme: %s", databaseURL) } From 9e69a11b9c21cf40282ef7d0e88a1c90efb547cc Mon Sep 17 00:00:00 2001 From: Eero Norri Date: Wed, 27 Nov 2024 22:21:15 +0200 Subject: [PATCH 6/6] update readme to match docusaurus format --- clean-code/README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/clean-code/README.md b/clean-code/README.md index 61e83124ad..7fd1627cfa 100644 --- a/clean-code/README.md +++ b/clean-code/README.md @@ -1,8 +1,16 @@ -## Clean code example for Fiber and PostgreSQL +--- +title: Clean Code +keywords: [clean, code, fiber, postgres, go] +description: Implementing clean code in Go. +--- + +# Clean Code Example + +[![Github](https://img.shields.io/static/v1?label=&message=Github&color=2ea44f&style=for-the-badge&logo=github)](https://github.com/gofiber/recipes/tree/master/clean-code) [![StackBlitz](https://img.shields.io/static/v1?label=&message=StackBlitz&color=2ea44f&style=for-the-badge&logo=StackBlitz)](https://stackblitz.com/github/gofiber/recipes/tree/master/clean-code) This is an example of a RESTful API built using the Fiber framework (https://gofiber.io/) and PostgreSQL as the database. -### Description of Clean Code +## Description of Clean Code Clean code is a philosophy and set of practices aimed at writing code that is easy to understand, maintain, and extend. Key principles of clean code include: @@ -18,7 +26,7 @@ This Fiber app is a good example of clean code because: - **Clear Separation of Concerns**: Different parts of the application (e.g., routes, handlers, services) are clearly separated, making the codebase easier to maintain and extend. - **Error Handling**: Proper error handling is implemented to ensure the application behaves predictably. -### Start +## Start 1. Build and start the containers: ```sh @@ -27,7 +35,7 @@ This Fiber app is a good example of clean code because: 1. The application should now be running and accessible at `http://localhost:3000`. -### Endpoints +## Endpoints - `GET /api/v1/books`: Retrieves a list of all books. ```sh