From dd92f23e0bef645d86b527618d6b8bcb5aad467a Mon Sep 17 00:00:00 2001 From: Timo Riegebauer Date: Thu, 23 Apr 2026 20:25:13 +0200 Subject: [PATCH] feat: initial implementation of go-kite Core framework: - Kite router with full HTTP method support (GET, POST, PUT, DELETE, HEAD, OPTIONS, CONNECT, TRACE) - Nestable route groups with prefix and middleware inheritance - Global, group, and route-level middleware with onion ordering - Centralized error, not-found, and method-not-allowed handlers - Graceful shutdown on SIGTERM/SIGINT with configurable timeout - Configurable server read, write, and idle timeouts - Context with typed request/response helpers, value store, cookie, form, and body binding support - Response writer wrapper for status code tracking Middleware: - Logger with configurable output, format, and skip function - Recovery with configurable panic handler - RequestID with configurable header and generator, forwards incoming IDs - CORS with configurable origins, methods, headers, credentials, and max age - MaxBodySize with configurable byte limit Docs: - README with quickstart, routing, middleware, context API reference, and TLS guide --- LICENSE | 20 +- README.md | 390 ++++++++++++++++++++++++++++++++++ context.go | 165 ++++++++++++++ error_handler.go | 9 + go.mod | 5 + go.sum | 2 + group.go | 57 +++++ handler.go | 3 + kite.go | 202 ++++++++++++++++++ method_not_allowed_handler.go | 9 + middleware.go | 3 + middleware/cors.go | 67 ++++++ middleware/logger.go | 51 +++++ middleware/max_body_size.go | 28 +++ middleware/recovery.go | 37 ++++ middleware/request_id.go | 46 ++++ not_found_handler.go | 9 + response_writer.go | 33 +++ 18 files changed, 1126 insertions(+), 10 deletions(-) create mode 100644 context.go create mode 100644 error_handler.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 group.go create mode 100644 handler.go create mode 100644 kite.go create mode 100644 method_not_allowed_handler.go create mode 100644 middleware.go create mode 100644 middleware/cors.go create mode 100644 middleware/logger.go create mode 100644 middleware/max_body_size.go create mode 100644 middleware/recovery.go create mode 100644 middleware/request_id.go create mode 100644 not_found_handler.go create mode 100644 response_writer.go diff --git a/LICENSE b/LICENSE index ef81085..ffd650d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,18 +1,18 @@ MIT License -Copyright (c) 2026 trcreatives +Copyright (c) 2026 TR Creatives e.U. -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 +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 +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 +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. diff --git a/README.md b/README.md index b3f182c..8d060cc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,392 @@ # go-kite +A fast, lightweight, and expressive HTTP framework for Go, built on top of [httprouter](https://github.com/julienschmidt/httprouter). + +## Features + +- **Middleware** — global, group, and route-level middleware with correct onion ordering +- **Groups** — nestable route groups with prefix and middleware inheritance +- **Graceful shutdown** — in-flight requests finish cleanly on `SIGTERM` / `SIGINT` +- **Configurable** — server timeouts, error handlers, not-found handlers all configurable +- **Rich context** — typed request/response helpers, value store, cookie and form support +- **Built-in middleware** — logger, recovery, CORS, request ID, max body size + +## Installation + +```bash +go get git.trcreatives.at/trcreatives/go-kite +``` + +## Quickstart + +```go +package main + +import ( + "net/http" + "log" + + "git.trcreatives.at/trcreatives/go-kite" + "git.trcreatives.at/trcreatives/go-kite/middleware" +) + +func main() { + k := kite.New() + + k.Use( + middleware.Recovery(), + middleware.Logger(), + middleware.RequestID(), + ) + + k.GET("/", func(ctx *kite.Context) error { + return ctx.WriteJSON(http.StatusOK, map[string]string{ + "message": "Hello, World!", + }) + }) + + if err := k.Start("localhost:8080"); err != nil { + log.Fatal(err) + } +} +``` + +## Routing + +```go +k := kite.New() + +k.GET("/users", listUsers) +k.POST("/users", createUser) +k.GET("/users/:id", getUser) +k.PUT("/users/:id", updateUser) +k.DELETE("/users/:id", deleteUser) +``` + +All standard HTTP methods are supported: `GET`, `POST`, `PUT`, `DELETE`, `HEAD`, `OPTIONS`, `CONNECT`, `TRACE`, `PATCH`. + +## Route Groups + +Groups allow you to share a common prefix and middleware across multiple routes. + +```go +api := k.Group("/api") +api.GET("/users", listUsers) // GET /api/users +api.POST("/users", createUser) // POST /api/users +``` + +Groups can be nested: + +```go +api := k.Group("/api") +v1 := api.Group("/v1") +v1.GET("/users", listUsers) // GET /api/v1/users +``` + +Middleware can be attached to groups: + +```go +api := k.Group("/api", authMiddleware) +admin := api.Group("/admin", adminOnlyMiddleware) +admin.GET("/stats", getStats) // chain: global → auth → adminOnly → handler +``` + +## Middleware + +Middleware follows the standard onion model — each middleware wraps the next. + +```go +type Middleware func(h Handler) Handler +``` + +### Global Middleware + +Applied to every route: + +```go +k.Use(middleware.Recovery()) +k.Use(middleware.Logger()) +``` + +### Route Middleware + +Applied to a single route: + +```go +k.GET("/admin", adminHandler, authMiddleware, rateLimitMiddleware) +``` + +### Execution Order + +``` +→ Global 1 + → Global 2 + → Group 1 + → Route 1 + [Handler] + ← Route 1 + ← Group 1 + ← Global 2 +← Global 1 +``` + +### Writing Custom Middleware + +```go +func MyMiddleware(h kite.Handler) kite.Handler { + return func(ctx *kite.Context) error { + // before handler + err := h(ctx) + // after handler + return err + } +} +``` + +## Built-in Middleware + +All middleware lives in the `middleware` sub-package: + +```go +import "git.trcreatives.at/trcreatives/go-kite/middleware" +``` + +### Logger + +Logs method, path, status code and latency for every request. + +```go +// simple +k.Use(middleware.Logger()) + +// custom +k.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ + Output: logFile, + Format: func(method, path string, statusCode int, latency time.Duration) string { + return fmt.Sprintf("%s %s %d %dms", method, path, statusCode, latency.Milliseconds()) + }, + Skip: func(ctx *kite.Context) bool { + return ctx.GetPath() == "/health" + }, +})) +``` + +### Recovery + +Catches panics and returns a `500` instead of crashing the server. + +```go +// simple — always register this first +k.Use(middleware.Recovery()) + +// custom — integrate with Sentry, Datadog, etc. +k.Use(middleware.RecoveryWithConfig(middleware.RecoveryConfig{ + OnPanic: func(ctx *kite.Context, recovered any, stack []byte) error { + sentry.CaptureException(fmt.Errorf("%v", recovered)) + return ctx.WriteJSON(http.StatusInternalServerError, map[string]string{ + "error": "internal server error", + }) + }, +})) +``` + +### RequestID + +Attaches a unique ID to every request. Reuses an incoming `X-Request-ID` header if present, for distributed tracing. + +```go +// simple +k.Use(middleware.RequestID()) + +// custom header +k.Use(middleware.RequestIDWithConfig(middleware.RequestIDConfig{ + Header: "X-Trace-ID", +})) + +// retrieve in handler +id := ctx.GetValue("X-Request-ID") +``` + +### CORS + +Sets Cross-Origin Resource Sharing headers and handles preflight requests. + +```go +// allow all origins +k.Use(middleware.CORS()) + +// restrict origins +k.Use(middleware.CORS("https://myapp.com")) + +// full control +k.Use(middleware.CORSWithConfig(middleware.CORSConfig{ + AllowedOrigins: []string{"https://myapp.com"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowedHeaders: []string{"Content-Type", "Authorization"}, + ExposedHeaders: []string{"X-Request-ID"}, + AllowCredentials: true, + MaxAge: 86400, +})) +``` + +> **Note:** `AllowCredentials: true` cannot be combined with `AllowedOrigins: ["*"]` — browsers will reject it. + +### MaxBodySize + +Protects against oversized request bodies. Returns `413` if the limit is exceeded. + +```go +// global 4MB limit +k.Use(middleware.MaxBodySize(4 << 20)) + +// per-route override for file uploads +k.POST("/upload", uploadHandler, middleware.MaxBodySize(100 << 20)) +``` + +## Context + +Every handler receives a `*kite.Context` with the following methods: + +### Request + +| Method | Description | +| ---------------------------------------------- | ------------------------------------- | +| `GetRequest() *http.Request` | Underlying request | +| `GetResponse() http.ResponseWriter` | Underlying response writer | +| `GetContext() context.Context` | Underlying context | +| `GetMethod() string` | HTTP method | +| `GetPath() string` | Request path | +| `IsMethod(method string) bool` | Check HTTP method | +| `GetPathParam(key string) string` | Route parameter (e.g. `/users/:id`) | +| `GetQueryParam(key string) string` | Query string parameter | +| `GetHeader(key string) string` | Request header | +| `GetIP() string` | Client IP, respects `X-Forwarded-For` | +| `GetCookie(name string) (*http.Cookie, error)` | Request cookie | +| `GetStatusCode() int` | Current response status code | + +### Values + +| Method | Description | +| ----------------------------- | ---------------------------- | +| `SetValue(key string, v any)` | Store a value in the context | +| `GetValue(key string) any` | Retrieve a stored value | + +### Forms + +| Method | Description | +| --------------------------------------------------------------------------------- | ----------------- | +| `GetForm() (url.Values, error)` | All form values | +| `GetFormValue(key string) string` | Single form value | +| `GetMultipartForm(maxMemory int64) (*multipart.Form, error)` | Multipart form | +| `GetMultipartFormFile(key string) (multipart.File, *multipart.FileHeader, error)` | Uploaded file | + +### Body Binding + +| Method | Description | +| ----------------------- | ------------------------ | +| `BindJSON(v any) error` | Decode JSON request body | +| `BindXML(v any) error` | Decode XML request body | + +### Response + +| Method | Description | +| ----------------------------------------- | ------------------- | +| `WriteBytes(status int, v []byte) error` | Raw bytes response | +| `WriteString(status int, v string) error` | Plain text response | +| `WriteJSON(status int, v any) error` | JSON response | +| `WriteXML(status int, v any) error` | XML response | +| `WriteNoContent() error` | 204 No Content | +| `Redirect(status int, url string) error` | Redirect | +| `SetHeader(key, value string)` | Set response header | +| `SetCookie(cookie *http.Cookie)` | Set response cookie | + +## Error Handling + +Handlers return `error`. By default, errors are logged and a `500` is returned to the client. + +```go +k.GET("/users/:id", func(ctx *kite.Context) error { + user, err := db.GetUser(ctx.GetPathParam("id")) + if err != nil { + return err // caught by error handler + } + return ctx.WriteJSON(http.StatusOK, user) +}) +``` + +### Custom Error Handler + +```go +k.SetErrorHandler(func(ctx *kite.Context, err error) error { + return ctx.WriteJSON(http.StatusInternalServerError, map[string]string{ + "error": err.Error(), + }) +}) +``` + +### Custom Not Found Handler + +```go +k.SetNotFoundHandler(func(ctx *kite.Context) error { + return ctx.WriteJSON(http.StatusNotFound, map[string]string{ + "error": "route not found", + }) +}) +``` + +### Custom Method Not Allowed Handler + +```go +k.SetMethodNotAllowedHandler(func(ctx *kite.Context) error { + return ctx.WriteJSON(http.StatusMethodNotAllowed, map[string]string{ + "error": "method not allowed", + }) +}) +``` + +## Server Configuration + +```go +k := kite.New() + +k.SetServerReadTimeout(10 * time.Second) // default: 10s +k.SetServerWriteTimeout(30 * time.Second) // default: 30s +k.SetServerIdleTimeout(60 * time.Second) // default: 60s +k.SetServerShutdownTimeout(30 * time.Second) // default: 30s +``` + +## Graceful Shutdown + +`Start()` blocks until `SIGINT` or `SIGTERM` is received, then waits for all in-flight requests to finish before exiting. This maps directly to how Docker, Kubernetes, and systemd stop processes. + +```go +if err := k.Start("localhost:8080"); err != nil { + log.Fatal(err) +} +``` + +## TLS + +Use `StartTLS` instead of `Start` and provide your certificate and key files: + +```go +if err := k.StartTLS("localhost:443", "cert.pem", "key.pem"); err != nil { + log.Fatal(err) +} +``` + +For local development you can generate a self-signed certificate: + +```bash +openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes +``` + +For production, use a certificate from a trusted CA. [Caddy](https://caddyserver.com) and [Nginx](https://nginx.org) are both good options as a reverse proxy if you prefer to terminate TLS outside the application. + +## Performance + +> **Coming Soon** — In-depth benchmarks including requests/sec, avg latency, RAM usage, and CPU utilization across varying concurrency levels, with comparisons against other Go frameworks. + +## License + +MIT diff --git a/context.go b/context.go new file mode 100644 index 0000000..c022df8 --- /dev/null +++ b/context.go @@ -0,0 +1,165 @@ +package kite + +import ( + "context" + "encoding/json" + "encoding/xml" + "mime/multipart" + "net" + "net/http" + "net/url" + "strings" + + "github.com/julienschmidt/httprouter" +) + +type contextKey string + +type Context struct { + ctx context.Context + w *responseWriter + r *http.Request + p httprouter.Params +} + +func (c *Context) GetRequest() *http.Request { + return c.r +} + +func (c *Context) GetResponse() http.ResponseWriter { + return c.w +} + +func (c *Context) GetStatusCode() int { + return c.w.statusCode +} + +func (c *Context) GetContext() context.Context { + return c.ctx +} + +func (c *Context) GetPathParam(key string) string { + return c.p.ByName(key) +} + +func (c *Context) GetQueryParam(key string) string { + return c.r.URL.Query().Get(key) +} + +func (c *Context) SetValue(key string, v any) { + c.ctx = context.WithValue(c.ctx, contextKey(key), v) +} + +func (c *Context) GetValue(key string) any { + return c.ctx.Value(contextKey(key)) +} + +func (c *Context) SetHeader(key, v string) { + c.w.Header().Set(key, v) +} + +func (c *Context) GetHeader(key string) string { + return c.r.Header.Get(key) +} + +func (c *Context) SetCookie(cookie *http.Cookie) { + http.SetCookie(c.w, cookie) +} + +func (c *Context) GetCookie(key string) (*http.Cookie, error) { + return c.r.Cookie(key) +} + +func (c *Context) GetIP() string { + if ip := c.r.Header.Get("X-Forwarded-For"); ip != "" { + return strings.Split(ip, ",")[0] + } + + if ip := c.r.Header.Get("X-Real-IP"); ip != "" { + return ip + } + + ip, _, _ := net.SplitHostPort(c.r.RemoteAddr) + return ip +} + +func (c *Context) GetMethod() string { + return c.r.Method +} + +func (c *Context) GetPath() string { + return c.r.URL.Path +} + +func (c *Context) GetForm() (url.Values, error) { + if err := c.r.ParseForm(); err != nil { + return nil, err + } + + return c.r.Form, nil +} + +func (c *Context) GetFormValue(key string) string { + return c.r.FormValue(key) +} + +func (c *Context) GetMultipartForm(maxMemory int64) (*multipart.Form, error) { + if err := c.r.ParseMultipartForm(maxMemory); err != nil { + return nil, err + } + + return c.r.MultipartForm, nil +} + +func (c *Context) GetMultipartFormFile(key string) (multipart.File, *multipart.FileHeader, error) { + return c.r.FormFile(key) +} + +func (c *Context) IsMethod(method string) bool { + return c.r.Method == method +} + +func (c *Context) WriteBytes(statusCode int, v []byte) error { + c.w.WriteHeader(statusCode) + _, err := c.w.Write(v) + return err +} + +func (c *Context) WriteString(statusCode int, v string) error { + c.w.Header().Set("Content-Type", "text/plain") + c.w.WriteHeader(statusCode) + _, err := c.w.Write([]byte(v)) + return err +} + +func (c *Context) WriteJSON(statusCode int, v any) error { + c.w.Header().Set("Content-Type", "application/json") + c.w.WriteHeader(statusCode) + return json.NewEncoder(c.w).Encode(v) +} + +func (c *Context) WriteXML(statusCode int, v any) error { + c.w.Header().Set("Content-Type", "application/xml") + c.w.WriteHeader(statusCode) + return xml.NewEncoder(c.w).Encode(v) +} + +func (c *Context) WriteNoContent() error { + c.w.WriteHeader(http.StatusNoContent) + return nil +} + +func (c *Context) Redirect(statusCode int, url string) error { + http.Redirect(c.w, c.r, url, statusCode) + return nil +} + +func (c *Context) BindJSON(v any) error { + defer c.r.Body.Close() + return json.NewDecoder(c.r.Body).Decode(v) +} + +func (c *Context) BindXML(v any) error { + defer c.r.Body.Close() + return xml.NewDecoder(c.r.Body).Decode(v) +} diff --git a/error_handler.go b/error_handler.go new file mode 100644 index 0000000..6a5814e --- /dev/null +++ b/error_handler.go @@ -0,0 +1,9 @@ +package kite + +import "net/http" + +type ErrorHandler func(ctx *Context, err error) error + +func defaultErrorHandler(ctx *Context, err error) error { + return ctx.WriteString(http.StatusInternalServerError, err.Error()) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6ac0bf0 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.trcreatives.at/trcreatives/go-kite + +go 1.26.1 + +require github.com/julienschmidt/httprouter v1.3.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..096c54e --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= diff --git a/group.go b/group.go new file mode 100644 index 0000000..0eec3c4 --- /dev/null +++ b/group.go @@ -0,0 +1,57 @@ +package kite + +import "net/http" + +type Group struct { + k *Kite + prefix string + mws []Middleware +} + +func (g *Group) Group(prefix string, mws ...Middleware) *Group { + return &Group{ + k: g.k, + prefix: g.prefix + prefix, + mws: append(append([]Middleware{}, g.mws...), mws...), + } +} + +func (g *Group) Use(mws ...Middleware) { + g.mws = append(g.mws, mws...) +} + +func (g *Group) CONNECT(path string, h Handler, mws ...Middleware) { + g.handle(http.MethodConnect, path, h, mws...) +} + +func (g *Group) DELETE(path string, h Handler, mws ...Middleware) { + g.handle(http.MethodDelete, path, h, mws...) +} + +func (g *Group) GET(path string, h Handler, mws ...Middleware) { + g.handle(http.MethodGet, path, h, mws...) +} + +func (g *Group) HEAD(path string, h Handler, mws ...Middleware) { + g.handle(http.MethodHead, path, h, mws...) +} + +func (g *Group) OPTIONS(path string, h Handler, mws ...Middleware) { + g.handle(http.MethodOptions, path, h, mws...) +} + +func (g *Group) POST(path string, h Handler, mws ...Middleware) { + g.handle(http.MethodPost, path, h, mws...) +} + +func (g *Group) PUT(path string, h Handler, mws ...Middleware) { + g.handle(http.MethodPut, path, h, mws...) +} + +func (g *Group) TRACE(path string, h Handler, mws ...Middleware) { + g.handle(http.MethodTrace, path, h, mws...) +} + +func (g *Group) handle(method, path string, h Handler, mws ...Middleware) { + g.k.handle(method, g.prefix+path, h, append(g.mws, mws...)...) +} diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..cfe5079 --- /dev/null +++ b/handler.go @@ -0,0 +1,3 @@ +package kite + +type Handler func(ctx *Context) error diff --git a/kite.go b/kite.go new file mode 100644 index 0000000..4cd0ad4 --- /dev/null +++ b/kite.go @@ -0,0 +1,202 @@ +package kite + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/julienschmidt/httprouter" +) + +type Kite struct { + r *httprouter.Router + errorHandler ErrorHandler + mws []Middleware + + serverReadTimeout time.Duration + serverWriteTimeout time.Duration + serverIdleTimeout time.Duration + serverShutdownTimeout time.Duration +} + +func New() *Kite { + k := &Kite{ + r: httprouter.New(), + + serverReadTimeout: 10 * time.Second, + serverWriteTimeout: 30 * time.Second, + serverIdleTimeout: 60 * time.Second, + serverShutdownTimeout: 30 * time.Second, + } + + k.SetErrorHandler(defaultErrorHandler) + k.SetNotFoundHandler(defaultNotFoundHandler) + k.SetMethodNotAllowedHandler(defaultMethodNotAllowedHandler) + + return k +} + +func (k *Kite) SetErrorHandler(errorHandler ErrorHandler) { + k.errorHandler = errorHandler +} + +func (k *Kite) SetNotFoundHandler(notFoundHandler NotFoundHandler) { + k.r.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := &Context{ctx: r.Context(), w: newResponseWriter(w), r: r} + notFoundHandler(ctx) + }) +} + +func (k *Kite) SetMethodNotAllowedHandler(methodNotAllowedHandler MethodNotAllowedHandler) { + k.r.MethodNotAllowed = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := &Context{ctx: r.Context(), w: newResponseWriter(w), r: r} + methodNotAllowedHandler(ctx) + }) +} + +func (k *Kite) SetServerReadTimeout(d time.Duration) { + k.serverReadTimeout = d +} + +func (k *Kite) SetServerWriteTimeout(d time.Duration) { + k.serverWriteTimeout = d +} + +func (k *Kite) SetServerIdleTimeout(d time.Duration) { + k.serverIdleTimeout = d +} + +func (k *Kite) SetServerShutdownTimeout(d time.Duration) { + k.serverShutdownTimeout = d +} + +func (k *Kite) Start(listenAddr string) error { + srv := &http.Server{ + Addr: listenAddr, + Handler: k.r, + + ReadTimeout: k.serverReadTimeout, + WriteTimeout: k.serverWriteTimeout, + IdleTimeout: k.serverIdleTimeout, + } + + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("[Kite] Server error: %v\n", err) + } + }() + + log.Printf("[Kite] Server started and listening on http://%s\n", listenAddr) + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("[Kite] Shutting down...") + ctx, cancel := context.WithTimeout(context.Background(), k.serverShutdownTimeout) + defer cancel() + return srv.Shutdown(ctx) +} + +func (k *Kite) StartTLS(listenAddr, certFile, keyFile string) error { + srv := &http.Server{ + Addr: listenAddr, + Handler: k.r, + + ReadTimeout: k.serverReadTimeout, + WriteTimeout: k.serverWriteTimeout, + IdleTimeout: k.serverIdleTimeout, + } + + go func() { + if err := srv.ListenAndServeTLS(certFile, keyFile); err != nil && err != http.ErrServerClosed { + log.Printf("[Kite] Server error: %v\n", err) + } + }() + + log.Printf("[Kite] Server started and listening on https://%s\n", listenAddr) + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("[Kite] Shutting down...") + ctx, cancel := context.WithTimeout(context.Background(), k.serverShutdownTimeout) + defer cancel() + return srv.Shutdown(ctx) +} + +func (k *Kite) Group(prefix string, mws ...Middleware) *Group { + return &Group{ + k: k, + prefix: prefix, + mws: mws, + } +} + +func (k *Kite) Use(mws ...Middleware) { + k.mws = append(k.mws, mws...) +} + +func (k *Kite) CONNECT(path string, h Handler, mws ...Middleware) { + k.handle(http.MethodConnect, path, h, mws...) +} + +func (k *Kite) DELETE(path string, h Handler, mws ...Middleware) { + k.handle(http.MethodDelete, path, h, mws...) +} + +func (k *Kite) GET(path string, h Handler, mws ...Middleware) { + k.handle(http.MethodGet, path, h, mws...) +} + +func (k *Kite) HEAD(path string, h Handler, mws ...Middleware) { + k.handle(http.MethodHead, path, h, mws...) +} + +func (k *Kite) OPTIONS(path string, h Handler, mws ...Middleware) { + k.handle(http.MethodOptions, path, h, mws...) +} + +func (k *Kite) POST(path string, h Handler, mws ...Middleware) { + k.handle(http.MethodPost, path, h, mws...) +} + +func (k *Kite) PUT(path string, h Handler, mws ...Middleware) { + k.handle(http.MethodPut, path, h, mws...) +} + +func (k *Kite) TRACE(path string, h Handler, mws ...Middleware) { + k.handle(http.MethodTrace, path, h, mws...) +} + +func (k *Kite) handle(method, path string, h Handler, mws ...Middleware) { + k.r.Handle(method, path, func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + ctx := &Context{ + ctx: r.Context(), + w: newResponseWriter(w), + r: r, + p: p, + } + + wrappedHandler := h + + for i := len(mws) - 1; i >= 0; i-- { + wrappedHandler = mws[i](wrappedHandler) + } + + for i := len(k.mws) - 1; i >= 0; i-- { + wrappedHandler = k.mws[i](wrappedHandler) + } + + if err := wrappedHandler(ctx); err != nil { + if err := k.errorHandler(ctx, err); err != nil { + log.Printf("[Kite] Error handler failed: %v\n", err) + } + } + }) +} diff --git a/method_not_allowed_handler.go b/method_not_allowed_handler.go new file mode 100644 index 0000000..19e85b0 --- /dev/null +++ b/method_not_allowed_handler.go @@ -0,0 +1,9 @@ +package kite + +import "net/http" + +type MethodNotAllowedHandler Handler + +func defaultMethodNotAllowedHandler(ctx *Context) error { + return ctx.WriteString(http.StatusMethodNotAllowed, "405 method not allowed") +} diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..edb6439 --- /dev/null +++ b/middleware.go @@ -0,0 +1,3 @@ +package kite + +type Middleware func(h Handler) Handler diff --git a/middleware/cors.go b/middleware/cors.go new file mode 100644 index 0000000..45b2c85 --- /dev/null +++ b/middleware/cors.go @@ -0,0 +1,67 @@ +package middleware + +import ( + "fmt" + "net/http" + "strings" + + "git.trcreatives.at/trcreatives/go-kite" +) + +type CORSConfig struct { + AllowedOrigins []string + AllowedMethods []string + AllowedHeaders []string + ExposedHeaders []string + AllowCredentials bool + MaxAge int +} + +func CORS(allowedOrigins ...string) kite.Middleware { + return CORSWithConfig(CORSConfig{AllowedOrigins: allowedOrigins}) +} + +func CORSWithConfig(cfg CORSConfig) kite.Middleware { + if len(cfg.AllowedOrigins) == 0 { + cfg.AllowedOrigins = []string{"*"} + } + + if len(cfg.AllowedMethods) == 0 { + cfg.AllowedMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "CONNECT", "TRACE"} + } + + if len(cfg.AllowedHeaders) == 0 { + cfg.AllowedHeaders = []string{"Content-Type", "Authorization"} + } + + origins := strings.Join(cfg.AllowedOrigins, ", ") + methods := strings.Join(cfg.AllowedMethods, ", ") + headers := strings.Join(cfg.AllowedHeaders, ", ") + exposed := strings.Join(cfg.ExposedHeaders, ", ") + + return func(h kite.Handler) kite.Handler { + return func(ctx *kite.Context) error { + ctx.SetHeader("Access-Control-Allow-Origin", origins) + ctx.SetHeader("Access-Control-Allow-Methods", methods) + ctx.SetHeader("Access-Control-Allow-Headers", headers) + + if exposed != "" { + ctx.SetHeader("Access-Control-Expose-Headers", exposed) + } + + if cfg.AllowCredentials { + ctx.SetHeader("Access-Control-Allow-Credentials", "true") + } + + if cfg.MaxAge > 0 { + ctx.SetHeader("Access-Control-Max-Age", fmt.Sprintf("%d", cfg.MaxAge)) + } + + if ctx.IsMethod(http.MethodOptions) { + return ctx.WriteNoContent() + } + + return h(ctx) + } + } +} diff --git a/middleware/logger.go b/middleware/logger.go new file mode 100644 index 0000000..2aee1d1 --- /dev/null +++ b/middleware/logger.go @@ -0,0 +1,51 @@ +package middleware + +import ( + "fmt" + "io" + "log" + "os" + "time" + + "git.trcreatives.at/trcreatives/go-kite" +) + +type LoggerConfig struct { + Output io.Writer + Format func(method, path string, statusCode int, latency time.Duration) string + Skip func(ctx *kite.Context) bool +} + +func DefaultLoggerFormat(method, path string, statusCode int, latency time.Duration) string { + return fmt.Sprintf("[Kite] %s %s -> %d in %s", method, path, statusCode, latency) +} + +func Logger() kite.Middleware { + return LoggerWithConfig(LoggerConfig{}) +} + +func LoggerWithConfig(cfg LoggerConfig) kite.Middleware { + if cfg.Output == nil { + cfg.Output = os.Stdout + } + + if cfg.Format == nil { + cfg.Format = DefaultLoggerFormat + } + + logger := log.New(cfg.Output, "", log.LstdFlags) + + return func(h kite.Handler) kite.Handler { + return func(ctx *kite.Context) error { + if cfg.Skip != nil && cfg.Skip(ctx) { + return h(ctx) + } + + start := time.Now() + err := h(ctx) + logger.Println(cfg.Format(ctx.GetMethod(), ctx.GetPath(), ctx.GetStatusCode(), time.Since(start))) + + return err + } + } +} diff --git a/middleware/max_body_size.go b/middleware/max_body_size.go new file mode 100644 index 0000000..989ebf8 --- /dev/null +++ b/middleware/max_body_size.go @@ -0,0 +1,28 @@ +package middleware + +import ( + "net/http" + + "git.trcreatives.at/trcreatives/go-kite" +) + +type MaxBodySizeConfig struct { + MaxBytes int64 +} + +func MaxBodySize(maxBytes int64) kite.Middleware { + return MaxBodySizeWithConfig(MaxBodySizeConfig{MaxBytes: maxBytes}) +} + +func MaxBodySizeWithConfig(cfg MaxBodySizeConfig) kite.Middleware { + if cfg.MaxBytes == 0 { + cfg.MaxBytes = 4 << 20 + } + + return func(h kite.Handler) kite.Handler { + return func(ctx *kite.Context) error { + ctx.GetRequest().Body = http.MaxBytesReader(ctx.GetResponse(), ctx.GetRequest().Body, cfg.MaxBytes) + return h(ctx) + } + } +} diff --git a/middleware/recovery.go b/middleware/recovery.go new file mode 100644 index 0000000..7c92868 --- /dev/null +++ b/middleware/recovery.go @@ -0,0 +1,37 @@ +package middleware + +import ( + "log" + "net/http" + "runtime/debug" + + "git.trcreatives.at/trcreatives/go-kite" +) + +type RecoveryConfig struct { + OnPanic func(ctx *kite.Context, recovered any, stack []byte) error +} + +func Recovery() kite.Middleware { + return RecoveryWithConfig(RecoveryConfig{}) +} + +func RecoveryWithConfig(cfg RecoveryConfig) kite.Middleware { + if cfg.OnPanic == nil { + cfg.OnPanic = func(ctx *kite.Context, recovered any, stack []byte) error { + log.Printf("[Kite] Panic: %v\n%s", recovered, stack) + return ctx.WriteString(http.StatusInternalServerError, "500 internal server error") + } + } + + return func(h kite.Handler) kite.Handler { + return func(ctx *kite.Context) (err error) { + defer func() { + if r := recover(); r != nil { + err = cfg.OnPanic(ctx, r, debug.Stack()) + } + }() + return h(ctx) + } + } +} diff --git a/middleware/request_id.go b/middleware/request_id.go new file mode 100644 index 0000000..1a5d047 --- /dev/null +++ b/middleware/request_id.go @@ -0,0 +1,46 @@ +package middleware + +import ( + "crypto/rand" + "fmt" + + "git.trcreatives.at/trcreatives/go-kite" +) + +type RequestIDConfig struct { + Header string + Generator func() string +} + +func RequestID() kite.Middleware { + return RequestIDWithConfig(RequestIDConfig{}) +} + +func RequestIDWithConfig(cfg RequestIDConfig) kite.Middleware { + if cfg.Header == "" { + cfg.Header = "X-Request-ID" + } + + if cfg.Generator == nil { + cfg.Generator = func() string { + b := make([]byte, 16) + rand.Read(b) + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) + } + } + + return func(h kite.Handler) kite.Handler { + return func(ctx *kite.Context) error { + id := ctx.GetHeader(cfg.Header) + if id == "" { + id = cfg.Generator() + } + + ctx.SetHeader(cfg.Header, id) + ctx.SetValue(cfg.Header, id) + + return h(ctx) + } + } +} diff --git a/not_found_handler.go b/not_found_handler.go new file mode 100644 index 0000000..6b491e9 --- /dev/null +++ b/not_found_handler.go @@ -0,0 +1,9 @@ +package kite + +import "net/http" + +type NotFoundHandler Handler + +func defaultNotFoundHandler(ctx *Context) error { + return ctx.WriteString(http.StatusNotFound, "404 not found") +} diff --git a/response_writer.go b/response_writer.go new file mode 100644 index 0000000..21121a8 --- /dev/null +++ b/response_writer.go @@ -0,0 +1,33 @@ +package kite + +import "net/http" + +type responseWriter struct { + http.ResponseWriter + statusCode int + written bool +} + +func newResponseWriter(w http.ResponseWriter) *responseWriter { + return &responseWriter{ + ResponseWriter: w, + statusCode: http.StatusOK, + } +} + +func (rw *responseWriter) WriteHeader(statusCode int) { + if rw.written { + return + } + + rw.statusCode = statusCode + rw.written = true + rw.ResponseWriter.WriteHeader(statusCode) +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + if !rw.written { + rw.WriteHeader(http.StatusOK) + } + return rw.ResponseWriter.Write(b) +}