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
This commit is contained in:
2026-04-23 20:25:13 +02:00
parent 23ae03a92b
commit dd92f23e0b
18 changed files with 1126 additions and 10 deletions

20
LICENSE
View File

@@ -1,18 +1,18 @@
MIT License 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 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 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 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 copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions: 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. portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 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 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 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 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. USE OR OTHER DEALINGS IN THE SOFTWARE.

390
README.md
View File

@@ -1,2 +1,392 @@
# go-kite # 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

165
context.go Normal file
View File

@@ -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)
}

9
error_handler.go Normal file
View File

@@ -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())
}

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module git.trcreatives.at/trcreatives/go-kite
go 1.26.1
require github.com/julienschmidt/httprouter v1.3.0

2
go.sum Normal file
View File

@@ -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=

57
group.go Normal file
View File

@@ -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...)...)
}

3
handler.go Normal file
View File

@@ -0,0 +1,3 @@
package kite
type Handler func(ctx *Context) error

202
kite.go Normal file
View File

@@ -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)
}
}
})
}

View File

@@ -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")
}

3
middleware.go Normal file
View File

@@ -0,0 +1,3 @@
package kite
type Middleware func(h Handler) Handler

67
middleware/cors.go Normal file
View File

@@ -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)
}
}
}

51
middleware/logger.go Normal file
View File

@@ -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
}
}
}

View File

@@ -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)
}
}
}

37
middleware/recovery.go Normal file
View File

@@ -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)
}
}
}

46
middleware/request_id.go Normal file
View File

@@ -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)
}
}
}

9
not_found_handler.go Normal file
View File

@@ -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")
}

33
response_writer.go Normal file
View File

@@ -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)
}