diff --git a/README.md b/README.md index 979fad2..e6497fb 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,37 @@ o.Get("/favicon.ico", func(c okapi.Context) error { o.Static("/static", "public/assets") ``` +## TLS Server + +```go + // Initialize TLS configuration for secure HTTPS connections + tls, err := okapi.LoadTLSConfig("path/to/cert.pem", "path/to/key.pem", "", false) + if err != nil { + panic(fmt.Sprintf("Failed to load TLS configuration: %v", err)) + } + // Create a new Okapi instance with default config + // Configured to listen on port 8080 for HTTP connections + o := okapi.Default(okapi.WithAddr(":8080")) + + // Configure a secondary HTTPS server listening on port 8443 + // This creates both HTTP (8080) and HTTPS (8443) endpoints + o.With(okapi.WithTLSServer(":8443", tls)) + + // Register application routes and handlers + o.Get("/", func(c okapi.Context) error { + return c.JSON(http.StatusOK, okapi.M{ + "message": "Welcome to Okapi!", + "status": "operational", + }) + }) + // Start the servers + // This will launch both HTTP and HTTPS listeners in separate goroutines + log.Println("Starting server on :8080 (HTTP) and :8443 (HTTPS)") + if err := o.Start(); err != nil { + panic(fmt.Sprintf("Server failed to start: %v", err)) + } +``` + --- ## Contributing @@ -320,7 +351,7 @@ Contributions are welcome! 4. Push to your fork 5. Open a Pull Request ---- + --- ## Give a Star! ⭐ diff --git a/examples/group/main.go b/examples/group/main.go index cbac63d..acef830 100644 --- a/examples/group/main.go +++ b/examples/group/main.go @@ -63,17 +63,11 @@ func main() { return c.JSON(http.StatusOK, users) }) // Get user - v1.Get("/users/:id", func(c okapi.Context) error { - return show(c) - }) + v1.Get("/users/:id", show) // Update user - v1.Put("/users/:id", func(c okapi.Context) error { - return update(c) - }) + v1.Put("/users/:id", update) // Create user - v1.Post("/users", func(c okapi.Context) error { - return store(c) - }) + v1.Post("/users", store) // Create a new group with a base path v2 v2 := api.Group("/v2") diff --git a/examples/middleware/main.go b/examples/middleware/main.go index f094c6c..eb269b1 100644 --- a/examples/middleware/main.go +++ b/examples/middleware/main.go @@ -25,6 +25,7 @@ package main import ( + "errors" "fmt" "github.com/jkaninda/okapi" "log/slog" @@ -109,8 +110,7 @@ func adminStore(c okapi.Context) error { func adminUpdate(c okapi.Context) error { var newBook Book if ok, err := c.ShouldBind(&newBook); !ok { - errMessage := fmt.Sprintf("Failed to bind book: %v", err) - return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid input " + errMessage}) + return c.AbortBadRequest(err) } for _, book := range books { if book.ID == newBook.ID { @@ -121,7 +121,7 @@ func adminUpdate(c okapi.Context) error { return c.JSON(http.StatusOK, book) } } - return c.JSON(http.StatusNotFound, okapi.M{"error": "Book not found"}) + return c.AbortNotFound(errors.New("book not found")) } func index(c okapi.Context) error { return c.JSON(http.StatusOK, books) diff --git a/examples/tls/main.go b/examples/tls/main.go new file mode 100644 index 0000000..a4a1832 --- /dev/null +++ b/examples/tls/main.go @@ -0,0 +1,80 @@ +/* + * MIT License + * + * Copyright (c) 2025 Jonas Kaninda + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package main + +import ( + "fmt" + "github.com/jkaninda/okapi" + "log" + "net/http" + "time" +) + +func main() { + // Initialize TLS configuration for secure HTTPS connections + tls, err := okapi.LoadTLSConfig("path/to/cert.pem", "path/to/key.pem", "", false) + if err != nil { + panic(fmt.Sprintf("Failed to load TLS configuration: %v", err)) + } + + // Create a new Okapi instance with default config + // Configured to listen on port 8080 for HTTP connections + o := okapi.Default(okapi.WithAddr(":8080")) + + // Configure a secondary HTTPS server listening on port 8443 + // This creates both HTTP (8080) and HTTPS (8443) endpoints + o.With(okapi.WithTLSServer(":8443", tls)) + + // Register application routes and handlers + + o.Get("/", func(c okapi.Context) error { + return c.JSON(http.StatusOK, okapi.M{ + "message": "Welcome to Okapi!", + "status": "operational", + }) + }) + + // Example parameterized route demonstrating path variables + o.Get("/greeting/:name", greetingHandler) + + // Start the server(s) + // This will launch both HTTP and HTTPS listeners in separate goroutines + log.Println("Starting server on :8080 (HTTP) and :8443 (HTTPS)") + if err := o.Start(); err != nil { + panic(fmt.Sprintf("Server failed to start: %v", err)) + } +} + +// greetingHandler handles personalized greeting requests +func greetingHandler(c okapi.Context) error { + name := c.Param("name") // Extract name from URL path + + // Return personalized greeting as JSON + return c.JSON(http.StatusOK, okapi.M{ + "message": fmt.Sprintf("Hello %s!", name), + "timestamp": time.Now().UTC().Format(time.RFC3339), + "user_agent": c.Request.UserAgent(), + }) +} diff --git a/okapi.go b/okapi.go index 26da3ae..c6e7242 100644 --- a/okapi.go +++ b/okapi.go @@ -27,11 +27,12 @@ package okapi import ( "bufio" "context" + "crypto/tls" + "errors" "fmt" "github.com/gorilla/mux" goutils "github.com/jkaninda/go-utils" "io" - "log" "log/slog" "net" "net/http" @@ -45,8 +46,8 @@ import ( var ( DefaultWriter io.Writer = os.Stdout DefaultErrorWriter io.Writer = os.Stderr - DefaultPort int = 8080 - DefaultAddr string = ":8080" + DefaultPort = 8080 + DefaultAddr = ":8080" ) type ( @@ -56,15 +57,23 @@ type ( middlewares []Middleware Server *http.Server TLSServer *http.Server + tlsConfig *tls.Config + tlsServerConfig *tls.Config + withTlsServer bool + tlsAddr string routes []*Route debug bool accessLog bool strictSlash bool logger *slog.Logger Renderer Renderer - enableCors bool + corsEnabled bool + disableKeepAlives bool cors Cors - optionsRegistered map[string]bool // NEW + writeTimeout int + readTimeout int + idleTimeout int + optionsRegistered map[string]bool } Router struct { mux *mux.Router @@ -124,17 +133,38 @@ func WithMux(mux *mux.Router) OptionFunc { } } -// WithTLSServer sets the TLS server for the Okapi instance -func WithTLSServer(server *http.Server) OptionFunc { +// WithServer sets the HTTP server for the Okapi instance +func WithServer(server *http.Server) OptionFunc { return func(o *Okapi) { - o.TLSServer = server + o.Server = server } } -// WithServer sets the HTTP server for the Okapi instance -func WithServer(server *http.Server) OptionFunc { +// WithTls sets tls config to HTTP Server for the Okapi instance +// +// Use okapi.LoadTLSConfig() to create a TLS configuration from certificate and key files +func WithTls(tlsConfig *tls.Config) OptionFunc { return func(o *Okapi) { - o.Server = server + if tlsConfig != nil { + o.tlsConfig = tlsConfig + } + } +} + +// WithTLSServer sets the TLS server for the Okapi instance +// +// Use okapi.LoadTLSConfig() to create a TLS configuration from certificate and key files +func WithTLSServer(addr string, tlsConfig *tls.Config) OptionFunc { + return func(o *Okapi) { + if len(addr) != 0 && tlsConfig != nil { + if !ValidateAddr(addr) { + panic("Invalid address for the TLS Server") + } + o.withTlsServer = true + o.tlsAddr = addr + o.tlsServerConfig = tlsConfig + } + } } @@ -144,13 +174,68 @@ func WithLogger(logger *slog.Logger) OptionFunc { o.logger = logger } } + +// WithCors returns an OptionFunc that configures CORS (Cross-Origin Resource Sharing) settings for the Okapi instance. +// The provided Cors configuration will be applied to all routes. +// Example: +// +// cors := okapi.Cors{AllowedOrigins: []string{"https://example.com"}} +// o := okapi.New(okapi.WithCors(cors)) func WithCors(cors Cors) OptionFunc { return func(o *Okapi) { - o.enableCors = true + o.corsEnabled = true o.cors = cors } } +// WithWriteTimeout returns an OptionFunc that sets the maximum duration before timing out writes +// of the response. The timeout includes processing time and writing the response body. +// The value should be specified in seconds. +// A value of 0 means no timeout. +// Default: 0 (no timeout) +func WithWriteTimeout(t int) OptionFunc { + return func(o *Okapi) { + o.writeTimeout = t + } +} + +// WithReadTimeout returns an OptionFunc that sets the maximum duration for reading the entire request, +// including the body. The value should be specified in seconds. +// A value of 0 means no timeout. +// Default: 0 (no timeout) +// Note: This helps protect against slow-client attacks. +func WithReadTimeout(t int) OptionFunc { + return func(o *Okapi) { + o.readTimeout = t + } +} + +// WithIdleTimeout returns an OptionFunc that sets the maximum amount of time to wait for the next request +// when keep-alives are enabled. The value should be specified in seconds. +// If IdleTimeout is 0, the value of ReadTimeout is used. +// Default: 0 (uses ReadTimeout value) +// Note: This helps free up server resources for inactive connections. +func WithIdleTimeout(t int) OptionFunc { + return func(o *Okapi) { + o.idleTimeout = t + } +} + +// WithDisabledKeepAlives returns an OptionFunc that disables HTTP keep-alive +// connections for the Okapi server. When enabled, the server will close +// connections after responding to each request. +// +// Example: +// +// o := okapi.New(okapi.WithDisabledKeepAlives()) +// +// Default: keep-alives enabled (true) +func WithDisabledKeepAlives() OptionFunc { + return func(o *Okapi) { + o.disableKeepAlives = true + } +} + // WithStrictSlash sets the strict slash mode for the Okapi instance func WithStrictSlash(strict bool) OptionFunc { return func(o *Okapi) { @@ -296,6 +381,7 @@ func Default(options ...OptionFunc) *Okapi { }, router: newRouter(), Server: server, + TLSServer: &http.Server{}, logger: slog.Default(), accessLog: true, middlewares: []Middleware{handleAccessLog}, @@ -310,11 +396,24 @@ func (o *Okapi) With(options ...OptionFunc) *Okapi { for _, option := range options { option(o) } + if o.debug { - o.logger = slog.New(slog.NewJSONHandler(DefaultWriter, - &slog.HandlerOptions{Level: slog.LevelDebug}, + o.logger = slog.New(slog.NewJSONHandler( + DefaultWriter, + &slog.HandlerOptions{ + Level: slog.LevelDebug, + AddSource: true, + }, )) } + o.applyServerConfig(o.Server) + + if o.tlsServerConfig != nil { + o.TLSServer.TLSConfig = o.tlsServerConfig + o.TLSServer.Addr = o.tlsAddr + o.applyServerConfig(o.TLSServer) + } + return o } @@ -344,29 +443,71 @@ func (o *Okapi) Use(middlewares ...Middleware) { // StartServer starts the Okapi server with the specified HTTP server func (o *Okapi) StartServer(server *http.Server) error { - // Validate the server address if !ValidateAddr(server.Addr) { o.logger.Error("Invalid server address", slog.String("addr", server.Addr)) panic("Invalid server address") } + o.Server = server server.Handler = o o.router.mux.StrictSlash(o.strictSlash) o.context.okapi = o - _, _ = fmt.Fprintf(DefaultWriter, "Starting Server on %s\n", o.Server.Addr) + + _, err := fmt.Fprintf(DefaultWriter, "Starting HTTP Server on %s\n", server.Addr) + if err != nil { + return err + } + // Serve with TLS if configured + if server.TLSConfig != nil { + return server.ListenAndServeTLS("", "") + } + + // Serve with separate TLS server if enabled + if o.withTlsServer && o.tlsServerConfig != nil { + go func() { + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + o.logger.Error("HTTP server error", slog.String("error", err.Error())) + panic(err) + } + }() + + o.TLSServer.Handler = o + _, err := fmt.Fprintf(DefaultWriter, "Starting HTTPS Server on %s\n", o.TLSServer.Addr) + if err != nil { + return err + } + return o.TLSServer.ListenAndServeTLS("", "") + } + + // Default HTTP only return server.ListenAndServe() } -// Stop stops the Okapi server +// Stop gracefully shuts down the Okapi server(s) func (o *Okapi) Stop() { - o.logger.Info("Stopping Server on...") - err := o.Server.Shutdown(context.Background()) - if err != nil { - log.Fatal(err) + o.logger.Info("Stopping HTTP Server...", slog.String("addr", o.Server.Addr)) + + if err := o.Shutdown(o.Server); err != nil { + o.logger.Error("Failed to shutdown HTTP server", slog.String("error", err.Error())) + panic(err) } o.Server = nil + + if o.withTlsServer && o.tlsServerConfig != nil && o.TLSServer != nil { + o.logger.Info("Stopping HTTPS Server...", slog.String("addr", o.TLSServer.Addr)) + if err := o.Shutdown(o.TLSServer); err != nil { + o.logger.Error("Failed to shutdown HTTPS server", slog.String("error", err.Error())) + panic(err) + } + o.TLSServer = nil + } } + +// Shutdown wraps graceful server shutdown with context func (o *Okapi) Shutdown(server *http.Server) error { + if server == nil { + return nil + } return server.Shutdown(context.Background()) } @@ -543,7 +684,7 @@ func (o *Okapi) HandleFunc(method, path string, h HandleFunc) { // registerOptionsHandler registers OPTIONS handler func (o *Okapi) registerOptionsHandler(path string) { // Register OPTIONS handler only once per path if CORS is enabled - if o.enableCors && !o.optionsRegistered[path] { + if o.corsEnabled && !o.optionsRegistered[path] { o.optionsRegistered[path] = true o.router.mux.StrictSlash(o.strictSlash).HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { @@ -653,6 +794,14 @@ func (o *Okapi) Group(path string, middlewares ...Middleware) *Group { return group } +// applyServerConfig sets common server timeout and keep-alive configurations +func (o *Okapi) applyServerConfig(s *http.Server) { + s.ReadTimeout = time.Duration(o.readTimeout) * time.Second + s.WriteTimeout = time.Duration(o.writeTimeout) * time.Second + s.IdleTimeout = time.Duration(o.idleTimeout) * time.Second + s.SetKeepAlivesEnabled(!o.disableKeepAlives) +} + // handleName returns the name of the handler function. func handleName(h HandleFunc) string { t := reflect.ValueOf(h).Type() diff --git a/util.go b/util.go index e91d8d3..3dc7947 100644 --- a/util.go +++ b/util.go @@ -25,8 +25,12 @@ package okapi import ( + "crypto/tls" + "crypto/x509" + "fmt" "net" "net/http" + "os" "regexp" "strconv" "strings" @@ -134,3 +138,48 @@ func allowedOrigin(allowed []string, origin string) bool { return false } + +// LoadTLSConfig creates a TLS configuration from certificate and key files +// Parameters: +// - certFile: Path to the certificate file (PEM format) +// - keyFile: Path to the private key file (PEM format) +// - caFile: Optional path to CA certificate file for client verification (set to "" to disable) +// - clientAuth: Whether to require client certificate verification +// +// Returns: +// - *tls.Config configured with the certificate and settings +// - error if any occurred during loading +func LoadTLSConfig(certFile, keyFile, caFile string, clientAuth bool) (*tls.Config, error) { + // Load server certificate and key + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, err + } + + config := &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, // Enforce minimum TLS version 1.2 + } + + // If caFile is provided, set up client certificate verification + if caFile != "" { + caCert, err := os.ReadFile(caFile) + if err != nil { + return nil, err + } + + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + _, _ = fmt.Fprintf(DefaultErrorWriter, "Warning: failed to append CA certs from PEM") + } + + config.ClientCAs = caCertPool + if clientAuth { + config.ClientAuth = tls.RequireAndVerifyClientCert + } else { + config.ClientAuth = tls.VerifyClientCertIfGiven + } + } + + return config, nil +}