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
|
module git.runcible.io/androiddrew/cookiecutter-golang-server
|
||||||
|
|
||||||
go 1.23.3
|
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 (
|
import (
|
||||||
"io"
|
"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