More reorg and middleware
parent
f8b93af5c9
commit
95644eb432
@ -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
|
@ -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…
Reference in New Issue