app.go

177 lines
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177
package application

import (
	"cmp"
	"context"
	"embed"
	"html/template"
	"io/fs"
	"log"
	"net/http"
	"os"
	"os/signal"
	"reflect"
	"syscall"
	"time"
)

// Middleware wraps an http.Handler (e.g. for request interception).
type Middleware func(http.Handler) http.Handler

// App is the main application container
type App struct {
	Emailer
	funcs       template.FuncMap
	viewsFS     fs.FS              // Store FS for on-demand loading
	base        *template.Template // Only layouts + partials
	controllers map[string]Controller
	middlewares []Middleware
	healthPath  string // Path for auto-registered health endpoint (default: "/health")

	// ScriptFunc generates the frontend <script> tags with a nonce.
	// Set by the frontend controller; called per-request in render().
	ScriptFunc func(nonce string) template.HTML
}

// Bouncer is a function that gates route access.
// Returns true to allow the request, false to block it.
type Bouncer func(app *App, w http.ResponseWriter, r *http.Request) bool

// Serve creates a View handler that renders a template
func (app *App) Serve(templateName string, bouncer Bouncer) *View {
	return &View{app, templateName, bouncer}
}

// Func registers a template function with the application.
// This must be called before templates are parsed (typically in options).
func (app *App) Func(name string, fn any) {
	app.funcs[name] = fn
}

// Controller registers a controller with the application.
// If the controller has a Setup method, it will be called.
func (app *App) Controller(name string, controller Controller) {
	app.controllers[name] = controller
	if setupper, ok := controller.(interface{ Setup(*App) }); ok {
		setupper.Setup(app)
	}
}

// Method creates a handler that calls a controller method with optional bouncer.
// Validates the method exists and has the correct signature at registration time.
func (app *App) Method(controller any, methodName string, bouncer Bouncer) http.Handler {
	method := reflect.ValueOf(controller).MethodByName(methodName)
	if !method.IsValid() {
		log.Fatalf("Method %q not found on %T", methodName, controller)
	}
	// Validate signature: func(http.ResponseWriter, *http.Request)
	mt := method.Type()
	if mt.NumIn() != 2 || mt.In(0) != reflect.TypeOf((*http.ResponseWriter)(nil)).Elem() || mt.In(1) != reflect.TypeOf((*http.Request)(nil)) {
		log.Fatalf("Method %q on %T must have signature (http.ResponseWriter, *http.Request), got %v", methodName, controller, mt)
	}

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if bouncer != nil && !bouncer(app, w, r) {
			return
		}
		method.Call([]reflect.Value{reflect.ValueOf(w), reflect.ValueOf(r)})
	})
}

// New creates an App and applies the given options.
// This triggers controller Setup functions, useful for build-time tasks.
func New(opts ...Option) *App {
	app := &App{
		funcs:       template.FuncMap{},
		controllers: map[string]Controller{},
	}
	for _, opt := range opts {
		opt(app)
	}
	return app
}

// Serve starts the HTTP server with the given views and options
func Serve(views embed.FS, opts ...Option) {
	app := New(opts...)

	// Store FS for on-demand loading, parse base templates (layouts + partials only)
	app.viewsFS = views
	app.base = app.parseBaseTemplates(views)

	// Register health endpoint (after all options so WithHealthPath can override)
	http.HandleFunc("GET "+cmp.Or(app.healthPath, "/health"), func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})

	// Serve static files
	if staticFS, err := fs.Sub(views, "views/static"); err == nil {
		http.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
	}

	port := cmp.Or(os.Getenv("PORT"), "5000")
	tlsCert := os.Getenv("TLS_CERT")
	tlsKey := os.Getenv("TLS_KEY")

	// Build handler chain: custom middlewares innermost, logging outermost
	var handler http.Handler = http.DefaultServeMux
	for _, mw := range app.middlewares {
		handler = mw(handler)
	}
	handler = LoggingMiddleware(handler)

	// Create HTTP server
	httpServer := &http.Server{
		Addr:    "0.0.0.0:" + port,
		Handler: handler,
	}

	// Start HTTP server
	go func() {
		log.Printf("HTTP server starting on http://0.0.0.0:%s", port)
		if err := httpServer.ListenAndServe(); err != http.ErrServerClosed {
			log.Fatalf("HTTP server error: %v", err)
		}
	}()

	// Start HTTPS server if TLS configured and certs exist
	var httpsServer *http.Server
	if tlsCert != "" && tlsKey != "" {
		if _, err := os.Stat(tlsCert); err == nil {
			httpsServer = &http.Server{
				Addr:    "0.0.0.0:443",
				Handler: handler,
			}
			go func() {
				log.Printf("HTTPS server starting on https://0.0.0.0:443")
				if err := httpsServer.ListenAndServeTLS(tlsCert, tlsKey); err != http.ErrServerClosed {
					log.Fatalf("HTTPS server error: %v", err)
				}
			}()
		} else {
			log.Printf("TLS cert not found at %s, skipping HTTPS", tlsCert)
		}
	}

	// Wait for shutdown signal
	stop := make(chan os.Signal, 1)
	signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
	<-stop

	log.Println("Shutting down gracefully...")

	// Give in-flight requests 30 seconds to complete
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	if err := httpServer.Shutdown(ctx); err != nil {
		log.Printf("HTTP shutdown error: %v", err)
	}
	if httpsServer != nil {
		if err := httpsServer.Shutdown(ctx); err != nil {
			log.Printf("HTTPS shutdown error: %v", err)
		}
	}

	log.Println("Server stopped")
}