More reorg and middleware

main
Drew Bednar 6 days ago
parent f8b93af5c9
commit 95644eb432

7
.gitignore vendored

@ -21,4 +21,9 @@
# Go workspace file
go.work
tmp/
# Project
dist/
tmp/
*.db
*.db-shm
*.db-wal

@ -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

@ -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
}

@ -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
)

@ -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=

@ -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...),
}
}

@ -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)
})
}

@ -1,4 +1,4 @@
package internal
package logging
import (
"io"

@ -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)
}
}

@ -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;

@ -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
}
Loading…
Cancel
Save