Initial commit
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
// Command guestbook is a tiny, self-contained web application used to
|
||||
// demonstrate deploying to Miabi: an Okapi (Go) API + embedded SPA, backed by
|
||||
// a Postgres (or SQLite) database via GORM.
|
||||
//
|
||||
// It is a classic "guestbook" — visitors leave a name and a message, and the
|
||||
// wall shows every signature. Everything a real Miabi deployment exercises is
|
||||
// here in miniature: env-based config (DATABASE_URL), an attached managed
|
||||
// database, a GORM AutoMigrate on startup, a first-run seeder, a /healthz
|
||||
// readiness probe, and a single static binary that ships the whole frontend
|
||||
// embedded — no Node build step.
|
||||
//
|
||||
// The HTTP server is driven by okapicli (a "server" subcommand with graceful
|
||||
// lifecycle hooks), and the embedded UI is served with app.WebFS.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"time"
|
||||
|
||||
"github.com/jkaninda/logger"
|
||||
"github.com/jkaninda/okapi"
|
||||
"github.com/jkaninda/okapi/okapicli"
|
||||
)
|
||||
|
||||
//go:embed all:web
|
||||
var webFS embed.FS
|
||||
|
||||
func main() {
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
logger.Fatal("invalid configuration", "error", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
store, err := NewStore(cfg.DBDriver, cfg.DatabaseDSN)
|
||||
if err != nil {
|
||||
logger.Fatal("failed to open database", "error", err)
|
||||
}
|
||||
|
||||
if err := migrateWithRetry(store, 30, 2*time.Second); err != nil {
|
||||
logger.Fatal("failed to run migrations", "error", err)
|
||||
}
|
||||
logger.Info("schema ready", "app", cfg.AppName)
|
||||
|
||||
// Seed sample data on first run (empty table only).
|
||||
if cfg.Seed {
|
||||
if err := Seed(ctx, store, cfg.DBDriver); err != nil {
|
||||
logger.Fatal("failed to seed database", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
broker := NewBroker()
|
||||
h := &Handler{store: store, broker: broker, appName: cfg.AppName, version: cfg.Version}
|
||||
|
||||
app := okapi.New(okapi.WithPort(cfg.Port))
|
||||
app.WithDebug()
|
||||
app.Use(okapi.LoggerMiddleware, okapi.RequestID())
|
||||
|
||||
app.Get("/healthz", h.Health)
|
||||
api := app.Group("/api")
|
||||
api.Get("/info", h.Info)
|
||||
api.Get("/entries", h.ListEntries)
|
||||
api.Post("/entries", h.CreateEntry)
|
||||
api.Delete("/entries/{id:int}", h.DeleteEntry)
|
||||
api.Get("/stream", h.Stream) // Server-Sent Events: live entries + presence
|
||||
|
||||
// Serve the embedded single-page UI. Okapi serves real files directly and
|
||||
// falls back to index.html for any other path so a client-side router can
|
||||
// take over.
|
||||
app.WebFS("/", webFS, okapi.WebConfig{Root: "web"})
|
||||
|
||||
cli := okapicli.New(app, "guestbook")
|
||||
cli.Command("server", "Start the Guestbook HTTP server", func(cmd *okapicli.Command) error {
|
||||
cmd.Okapi().WithPort(cmd.GetInt("port"))
|
||||
return cmd.CLI().RunServer(&okapicli.RunOptions{
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
OnStarted: func() {
|
||||
logger.Info("server started", "app", cfg.AppName, "version", cfg.Version, "port", cmd.GetInt("port"))
|
||||
},
|
||||
OnShutdown: func() {
|
||||
logger.Info("shutting down")
|
||||
_ = store.Close()
|
||||
},
|
||||
})
|
||||
}).Int("port", "p", cfg.Port, "HTTP server port")
|
||||
cli.DefaultCommand("server")
|
||||
|
||||
if err := cli.Execute(); err != nil {
|
||||
logger.Fatal("server error", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func migrateWithRetry(store *Store, attempts int, wait time.Duration) error {
|
||||
var err error
|
||||
for i := 0; i < attempts; i++ {
|
||||
if err = store.Migrate(); err == nil {
|
||||
return nil
|
||||
}
|
||||
logger.Warn("waiting for database", "attempt", i+1, "of", attempts, "error", err)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user