diff --git a/.gitignore b/.gitignore index 26ef082..9c1c114 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,9 @@ # Go workspace file go.work -tmp/ \ No newline at end of file +# Project +dist/ +tmp/ +*.db +*.db-shm +*.db-wal \ No newline at end of file diff --git a/air.toml b/air.toml new file mode 100644 index 0000000..0134dc9 --- /dev/null +++ b/air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = ["-logging=DEBUG"] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main cmd/appd/main.go" + 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", "go.tmpl"] + 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 + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/cmd/appd/main.go b/cmd/appd/main.go index 9c088ce..1a6c685 100644 --- a/cmd/appd/main.go +++ b/cmd/appd/main.go @@ -2,10 +2,14 @@ package main import ( "context" + "database/sql" "fmt" "io" "os" "os/signal" + + "git.runcible.io/androiddrew/cookiecutter-golang-server/internal/logging" + "git.runcible.io/androiddrew/cookiecutter-golang-server/internal/migrations" ) // run serves as the "entrypoint" of the application @@ -30,7 +34,30 @@ func run(ctx context.Context, w io.Writer, args []string) error { ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) defer cancel() - logger := InitLoggging() + // Config + // TODO add real config + var dbPath string = "./app.db" + + logger := logging.InitLogging("INFO", w) + logger.Info("I am alive!") + + full_database_path := "file:" + dbPath + "?cache=shared" + + logger.Debug(fmt.Sprintf("Using database path: %s", full_database_path)) + + db, err := sql.Open("sqlite3", full_database_path) + if err != nil { + return fmt.Errorf("failed to open: %s", full_database_path) + } + + err = migrations.Migrate(db, logger) + if err != nil { + return err + } + + // Initialize server + + // Add middleware return nil } diff --git a/go.mod b/go.mod index d151f66..eef5d21 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,11 @@ module git.runcible.io/androiddrew/cookiecutter-golang-server go 1.23.3 + +require ( + github.com/golang-migrate/migrate/v4 v4.18.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/mattn/go-sqlite3 v1.14.24 // indirect + go.uber.org/atomic v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..15f255f --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y= +github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= diff --git a/internal/apperror/errors.go b/internal/apperror/errors.go new file mode 100644 index 0000000..6560556 --- /dev/null +++ b/internal/apperror/errors.go @@ -0,0 +1,69 @@ +package apperror + +import ( + "errors" + "fmt" +) + +const INTERNAL_ERROR_MESSAGE = "internal error" + +// Application error codes. +// +// Note: these are generic codes but map well to HTTP status codes. +const ( + EINVALID = "invalid" + EINTERNAL = "internal" + ENOTFOUND = "not_found" +) + +// Error respresents an application specific-error. Application errors can be +// unwrapped by the caller to extract out the code and message +// +// Any non-application error (like disk error) should be reported as an EINTERNAL +// error and the human user should only see "Internal error" as the message. +// These low-level internal error details should only be logged and reported to +// the operation of the application, not the end user. +type Error struct { + // Machine-readable error code. + Code string + + // Human-readable error message. + Message string +} + +// Error implements the error interface. Not used by the application otherwise. +func (e Error) Error() string { + return fmt.Sprintf("ratchet error: code=%s message=%s", e.Code, e.Message) +} + +// ErrorCode unwraps an application error and returns its code. +// Non-application errors always return EINTERNAL. +func ErrorCode(err error) string { + var e *Error + if err == nil { + return "" + } else if errors.As(err, &e) { + return e.Code + } + return EINTERNAL +} + +// ErrorMessage unwraps an application error and returns it's message. +// Non-application errors always return "Internal error". +func ErrorMessage(err error) string { + var e *Error + if err == nil { + return "" + } else if errors.As(err, &e) { + return e.Message + } + return INTERNAL_ERROR_MESSAGE +} + +// Errorf is a helper function to return an Error with a given code and format +func Errorf(code string, format string, args ...interface{}) *Error { + return &Error{ + Code: code, + Message: fmt.Sprintf(format, args...), + } +} diff --git a/internal/apperror/errors_test.go b/internal/apperror/errors_test.go new file mode 100644 index 0000000..ea4b633 --- /dev/null +++ b/internal/apperror/errors_test.go @@ -0,0 +1,57 @@ +package apperror + +import ( + "errors" + "testing" +) + +func AssertErrorString(t *testing.T, got, want string) { + t.Helper() + if got != want { + t.Errorf("Incorrect error code/message got %q want %q", got, want) + } +} + +func TestErrorCode(t *testing.T) { + + t.Run("should return empty", func(t *testing.T) { + got := ErrorCode(nil) + + AssertErrorString(t, got, "") + }) + + t.Run("should return internal error", func(t *testing.T) { + want := EINTERNAL + got := ErrorCode(errors.New("Mock disk error")) + + AssertErrorString(t, got, want) + }) + + t.Run("should return my code", func(t *testing.T) { + e := &Error{Code: "my_code", Message: "my_message"} + got := ErrorCode(e) + + AssertErrorString(t, got, e.Code) + }) +} + +func TestErrorMessage(t *testing.T) { + t.Run("empty error should return empty string", func(t *testing.T) { + got := ErrorMessage(nil) + + AssertErrorString(t, got, "") + }) + + t.Run("should return internal error message", func(t *testing.T) { + got := ErrorMessage(errors.New("Mock disk error")) + + AssertErrorString(t, got, INTERNAL_ERROR_MESSAGE) + }) + + t.Run("should return application error message", func(t *testing.T) { + e := Errorf(ENOTFOUND, "Entity %s not found", "dirp") + got := ErrorMessage(e) + + AssertErrorString(t, got, e.Message) + }) +} diff --git a/internal/logging.go b/internal/logging/logging.go similarity index 98% rename from internal/logging.go rename to internal/logging/logging.go index c7fe98b..99e9618 100644 --- a/internal/logging.go +++ b/internal/logging/logging.go @@ -1,4 +1,4 @@ -package internal +package logging import ( "io" diff --git a/internal/middleware/accesslog.go b/internal/middleware/accesslog.go new file mode 100644 index 0000000..87cf859 --- /dev/null +++ b/internal/middleware/accesslog.go @@ -0,0 +1,66 @@ +// request_logger.go +package middleware + +import ( + "log/slog" + "net/http" + "runtime/debug" + "time" +) + +// responseWriter is a minimal wrapper for http.ResponseWriter that allows the +// written HTTP status code to be captured for logging. +type responseWriter struct { + http.ResponseWriter + status int + wroteHeader bool +} + +func wrapResponseWriter(w http.ResponseWriter) *responseWriter { + return &responseWriter{ResponseWriter: w} +} + +func (rw *responseWriter) Status() int { + return rw.status +} + +func (rw *responseWriter) WriteHeader(code int) { + if rw.wroteHeader { + return + } + + rw.status = code + rw.ResponseWriter.WriteHeader(code) + rw.wroteHeader = true + + return +} + +// LoggingMiddleware logs the incoming HTTP request & its duration. +func LoggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + w.WriteHeader(http.StatusInternalServerError) + logger.Error("Internal Server Error", + "err", err, + "trace", debug.Stack(), + ) + } + }() + + start := time.Now() + wrapped := wrapResponseWriter(w) + next.ServeHTTP(wrapped, r) + logger.Info("Request processed", + "status", wrapped.status, + "method", r.Method, + "path", r.URL.EscapedPath(), + "duration", time.Since(start), + ) + } + + return http.HandlerFunc(fn) + } +} diff --git a/internal/migrations/10_create_example.up.sql b/internal/migrations/10_create_example.up.sql new file mode 100644 index 0000000..f27f79b --- /dev/null +++ b/internal/migrations/10_create_example.up.sql @@ -0,0 +1,20 @@ +PRAGMA foreign_keys=1; +CREATE TABLE example ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + content TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL +); + +-- Add an index on the created column. +CREATE INDEX idx_example_created ON example(created_at); + +-- Add a trigger to keep timestamp updated. +CREATE TRIGGER snippet_example_timestamp +AFTER UPDATE ON example +FOR EACH ROW +BEGIN + UPDATE example SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id; +END; diff --git a/internal/migrations/10_examples.down.sql b/internal/migrations/10_examples.down.sql new file mode 100644 index 0000000..e69de29 diff --git a/internal/migrations/migrate.go b/internal/migrations/migrate.go new file mode 100644 index 0000000..bb11563 --- /dev/null +++ b/internal/migrations/migrate.go @@ -0,0 +1,51 @@ +package migrations + +import ( + "database/sql" + "embed" + "fmt" + "log/slog" + + "github.com/golang-migrate/migrate/v4" + // "github.com/golang-migrate/migrate/v4/database" + "github.com/golang-migrate/migrate/v4/database/sqlite3" + _ "github.com/golang-migrate/migrate/v4/source/file" + "github.com/golang-migrate/migrate/v4/source/iofs" +) + +//go:embed *.sql +var migrationFiles embed.FS + +func Migrate(db *sql.DB, logger *slog.Logger) error { + // Create a database driver for the specific database type + driver, err := sqlite3.WithInstance(db, &sqlite3.Config{}) + if err != nil { + return fmt.Errorf("failed to create database driver: %w", err) + } + + // Create an IFS source from the embedded files + source, err := iofs.New(migrationFiles, ".") + if err != nil { + return fmt.Errorf("failed to create migration source: %w", err) + } + + // Create a new migrate instance + m, err := migrate.NewWithInstance("iofs", source, "sqlite3", driver) + if err != nil { + return fmt.Errorf("failed to create migrate instance: %w", err) + } + + // Run migrations + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + return fmt.Errorf("failed to run migrations: %w", err) + } + + // set WAL mode + _, err = db.Exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;") + if err != nil { + return fmt.Errorf("failed to set wall mode: %w", err) + } + + logger.Info("Migrations completed successfully") + return nil +}