diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..304d6e5 --- /dev/null +++ b/.air.toml @@ -0,0 +1,46 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] +args_bin = ["serve"] +bin = "./tmp/main" +cmd = "go build -o ./tmp/main ." +delay = 1000 +exclude_dir = ["assets", "tmp", "vendor", "testdata"] +exclude_file = [] +exclude_regex = ["_test.go", "db/queries/.*\\.go"] +exclude_unchanged = false +follow_symlink = false +full_bin = "" +include_dir = [] +include_ext = ["go", "tpl", "tmpl", "html", "sql"] +include_file = [] +kill_delay = "500ms" +log = "build-errors.log" +poll = false +poll_interval = 0 +post_cmd = [] +pre_cmd = ["sqlc generate"] +rerun = false +rerun_delay = 500 +send_interrupt = true +stop_on_error = true + +[color] +app = "" +build = "yellow" +main = "magenta" +runner = "green" +watcher = "cyan" + +[log] +main_only = false +time = false + +[misc] +clean_on_exit = false + +[screen] +clear_on_rebuild = false +keep_scroll = true diff --git a/.gitignore b/.gitignore index 9d80053..1752702 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ *.db db/queries/**/*.go + +tmp diff --git a/api/subreddits/list_subreddits.go b/api/subreddits/list_subreddits.go new file mode 100644 index 0000000..4b26cea --- /dev/null +++ b/api/subreddits/list_subreddits.go @@ -0,0 +1 @@ +package subreddits diff --git a/api/subreddits/subreddits.go b/api/subreddits/subreddits.go new file mode 100644 index 0000000..97d8ded --- /dev/null +++ b/api/subreddits/subreddits.go @@ -0,0 +1,9 @@ +package subreddits + +import ( + subredditsDB "github.com/tigorlazuardi/redmage/db/queries/subreddits" +) + +type API struct { + SubredditDB *subredditsDB.Queries +} diff --git a/cli/serve.go b/cli/serve.go index 543b342..7bc0a0d 100644 --- a/cli/serve.go +++ b/cli/serve.go @@ -5,8 +5,12 @@ import ( "os/signal" "github.com/spf13/cobra" + "github.com/tigorlazuardi/redmage/db" + "github.com/tigorlazuardi/redmage/db/queries/subreddits" "github.com/tigorlazuardi/redmage/pkg/log" "github.com/tigorlazuardi/redmage/server" + "github.com/tigorlazuardi/redmage/server/routes/api" + "github.com/tigorlazuardi/redmage/server/routes/www" ) var serveCmd = &cobra.Command{ @@ -14,7 +18,22 @@ var serveCmd = &cobra.Command{ Short: "Starts the HTTP Server", SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { - server := server.New(cfg) + db, err := db.Open(cfg) + if err != nil { + log.Log(cmd.Context()).Err(err).Error("failed to connect database") + os.Exit(1) + } + + subreddits := subreddits.New(db) + + api := &api.API{ + Subreddits: subreddits, + } + + www := &www.WWW{ + Subreddits: subreddits, + } + server := server.New(cfg, api, www) exit := make(chan struct{}, 1) diff --git a/db/db.go b/db/db.go index 5760cbe..aafd882 100644 --- a/db/db.go +++ b/db/db.go @@ -7,25 +7,28 @@ import ( _ "github.com/mattn/go-sqlite3" "github.com/pressly/goose/v3" "github.com/tigorlazuardi/redmage/config" + "github.com/tigorlazuardi/redmage/pkg/errs" ) var Migrations fs.FS func Open(cfg *config.Config) (*sql.DB, error) { - db, err := sql.Open(cfg.String("db.driver"), cfg.String("db.string")) + driver := cfg.String("db.driver") + db, err := sql.Open(driver, cfg.String("db.string")) if err != nil { - return db, err + return db, errs.Wrapw(err, "failed to open database", "driver", driver) } if cfg.Bool("db.automigrate") { + goose.SetLogger(&gooseLogger{}) goose.SetBaseFS(Migrations) - if err := goose.SetDialect(cfg.String("db.driver")); err != nil { - return db, err + if err := goose.SetDialect(driver); err != nil { + return db, errs.Wrapw(err, "failed to set goose dialect", "dialect", driver) } if err := goose.Up(db, "db/migrations"); err != nil { - return db, err + return db, errs.Wrapw(err, "failed to migrate database", "dialect", driver) } } diff --git a/db/goose_logger.go b/db/goose_logger.go new file mode 100644 index 0000000..3b72939 --- /dev/null +++ b/db/goose_logger.go @@ -0,0 +1,21 @@ +package db + +import ( + "context" + "strings" + + "github.com/tigorlazuardi/redmage/pkg/caller" + "github.com/tigorlazuardi/redmage/pkg/log" +) + +type gooseLogger struct{} + +func (gl *gooseLogger) Fatalf(format string, v ...interface{}) { + format = strings.TrimSuffix(format, "\n") + log.Log(context.Background()).Caller(caller.New(3)).Errorf(format, v...) +} + +func (gl *gooseLogger) Printf(format string, v ...interface{}) { + format = strings.TrimSuffix(format, "\n") + log.Log(context.Background()).Caller(caller.New(3)).Infof(format, v...) +} diff --git a/db/queries/subreddits.sql b/db/queries/subreddits.sql index 903b788..d00d79a 100644 --- a/db/queries/subreddits.sql +++ b/db/queries/subreddits.sql @@ -1,3 +1,10 @@ -- name: ListSubreddits :many SELECT * FROM subreddits -ORDER BY name; +ORDER BY name +LIMIT ?; + +-- name: CreateSubreddit :one +INSERT INTO subreddits (name, subtype, schedule) +VALUES (?, ?, ?) +RETURNING *; + diff --git a/flake.nix b/flake.nix index 35f3585..d69bac4 100644 --- a/flake.nix +++ b/flake.nix @@ -11,7 +11,7 @@ pkgs = inputs.nixpkgs.legacyPackages.${system}; in { - devShell.${system} = pkgs.mkShell rec { + devShell.${system} = pkgs.mkShell { name = "redmage-shell"; buildInputs = with pkgs; [ templPkg @@ -20,6 +20,7 @@ nodejs_21 goose sqlc + air ]; }; }; diff --git a/main.go b/main.go index ae994f2..ea67d7e 100644 --- a/main.go +++ b/main.go @@ -9,12 +9,11 @@ import ( "github.com/tigorlazuardi/redmage/db" ) -// go:embed db/migrations/*.sql -var migrations embed.FS +//go:embed db/migrations/*.sql +var Migrations embed.FS func main() { - db.Migrations = migrations - + db.Migrations = Migrations if err := cli.RootCmd.ExecuteContext(context.Background()); err != nil { os.Exit(1) } diff --git a/server/routes/api/api.go b/server/routes/api/api.go index e32ca8d..3b58842 100644 --- a/server/routes/api/api.go +++ b/server/routes/api/api.go @@ -1,8 +1,22 @@ package api -import "github.com/go-chi/chi/v5" +import ( + "github.com/go-chi/chi/v5" + chimiddleware "github.com/go-chi/chi/v5/middleware" + "github.com/tigorlazuardi/redmage/db/queries/subreddits" + "github.com/tigorlazuardi/redmage/server/routes/middleware" +) -func Register(router chi.Router) { +type API struct { + Subreddits *subreddits.Queries +} + +func (api *API) Register(router chi.Router) { + router.Use(chimiddleware.RequestID) router.Get("/", HealthCheck) router.Get("/health", HealthCheck) + router.Route("/subreddits", func(r chi.Router) { + r.Use(chimiddleware.RequestLogger(middleware.ChiLogger{})) + r.Get("/", api.ListSubreddits) + }) } diff --git a/server/routes/api/subreddits_list.go b/server/routes/api/subreddits_list.go new file mode 100644 index 0000000..e9c1603 --- /dev/null +++ b/server/routes/api/subreddits_list.go @@ -0,0 +1,24 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/tigorlazuardi/redmage/pkg/log" +) + +func (api *API) ListSubreddits(rw http.ResponseWriter, r *http.Request) { + rw.Header().Add("Content-Type", "application/json") + subs, err := api.Subreddits.ListSubreddits(r.Context(), 10) + if err != nil { + rw.WriteHeader(http.StatusInternalServerError) + msg := fmt.Sprintf("failed to list subreddits: %s", err) + _ = json.NewEncoder(rw).Encode(map[string]string{"error": msg}) + return + } + + if err := json.NewEncoder(rw).Encode(subs); err != nil { + log.Log(r.Context()).Err(err).Error("failed to list subreddits") + } +} diff --git a/server/routes/htmx/htmx.go b/server/routes/htmx/htmx.go deleted file mode 100644 index 597fb1f..0000000 --- a/server/routes/htmx/htmx.go +++ /dev/null @@ -1,5 +0,0 @@ -package htmx - -import "github.com/go-chi/chi/v5" - -func Register(router chi.Router) {} diff --git a/server/routes/middleware/logger.go b/server/routes/middleware/logger.go new file mode 100644 index 0000000..f066007 --- /dev/null +++ b/server/routes/middleware/logger.go @@ -0,0 +1,59 @@ +package middleware + +import ( + "fmt" + "log/slog" + "net/http" + "time" + + chimiddleware "github.com/go-chi/chi/v5/middleware" + "github.com/tigorlazuardi/redmage/pkg/log" +) + +type ChiLogger struct{} + +func (ChiLogger) NewLogEntry(r *http.Request) chimiddleware.LogEntry { + return &ChiEntry{request: r} +} + +type ChiEntry struct { + request *http.Request +} + +func (ch *ChiEntry) Write(status int, bytes int, header http.Header, elapsed time.Duration, extra interface{}) { + message := fmt.Sprintf("%s %s %d %dms", ch.request.Method, ch.request.URL.Path, status, elapsed.Milliseconds()) + + requestLog := slog.Attr{Key: "request", Value: ch.extractRequestLog()} + responseLog := slog.Group("response", "status", status, "header", header, "bytes", bytes) + roundtripLog := slog.String("roundtrip", fmt.Sprintf("%dms", elapsed.Milliseconds())) + + if status >= 400 { + log.Log(ch.request.Context()).Error(message, requestLog, responseLog, roundtripLog) + return + } + + log.Log(ch.request.Context()).Info(message, requestLog, responseLog, roundtripLog) +} + +func (ch *ChiEntry) Panic(v interface{}, stack []byte) { + entry := log.Log(ch.request.Context()) + if err, ok := v.(error); ok { + entry.Err(err).Error("panic occurred", "stack", string(stack)) + } else { + entry.Error("panic occurred", "panic_data", v, "stack", string(stack)) + } +} + +func (ch *ChiEntry) extractRequestLog() slog.Value { + values := make([]slog.Attr, 0, 4) + values = append(values, + slog.String("method", ch.request.Method), + slog.String("path", ch.request.URL.Path), + ) + queries := ch.request.URL.Query() + if len(queries) > 0 { + values = append(values, slog.Any("query", queries)) + } + values = append(values, slog.Any("headers", ch.request.Header)) + return slog.GroupValue(values...) +} diff --git a/server/routes/middleware/middleware.go b/server/routes/middleware/middleware.go new file mode 100644 index 0000000..c870d7c --- /dev/null +++ b/server/routes/middleware/middleware.go @@ -0,0 +1 @@ +package middleware diff --git a/server/routes/www/www.go b/server/routes/www/www.go new file mode 100644 index 0000000..490c152 --- /dev/null +++ b/server/routes/www/www.go @@ -0,0 +1,12 @@ +package www + +import ( + "github.com/go-chi/chi/v5" + "github.com/tigorlazuardi/redmage/db/queries/subreddits" +) + +type WWW struct { + Subreddits *subreddits.Queries +} + +func (www *WWW) Register(router chi.Router) {} diff --git a/server/server.go b/server/server.go index bdf3d8e..9ab880f 100644 --- a/server/server.go +++ b/server/server.go @@ -11,7 +11,7 @@ import ( "github.com/tigorlazuardi/redmage/pkg/errs" "github.com/tigorlazuardi/redmage/pkg/log" "github.com/tigorlazuardi/redmage/server/routes/api" - "github.com/tigorlazuardi/redmage/server/routes/htmx" + "github.com/tigorlazuardi/redmage/server/routes/www" ) type Server struct { @@ -41,12 +41,11 @@ func (srv *Server) Start(exit <-chan struct{}) error { } } -func New(cfg *config.Config) *Server { +func New(cfg *config.Config, api *api.API, www *www.WWW) *Server { router := chi.NewRouter() router.Route("/api", api.Register) - - router.Route("/htmx", htmx.Register) + router.Route("/", www.Register) server := &http.Server{ Handler: router,