From 3576d420d2d9c71a930f6e719b6ce9f352d8b3e0 Mon Sep 17 00:00:00 2001 From: Timo Riegebauer Date: Sat, 14 Feb 2026 14:02:21 +0000 Subject: [PATCH] go-tower: first release v0.1.0 --- .devcontainer/devcontainer.json | 29 +++ .github/dependabot.yml | 12 ++ README.md | 328 +++++++++++++++++++++++++++++++- context.go | 117 ++++++++++++ go.mod | 5 + go.sum | 2 + group.go | 82 ++++++++ handler.go | 9 + map.go | 3 + middleware.go | 3 + path.go | 14 ++ response_writer.go | 33 ++++ tower.go | 117 ++++++++++++ 13 files changed, 753 insertions(+), 1 deletion(-) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .github/dependabot.yml create mode 100644 context.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 group.go create mode 100644 handler.go create mode 100644 map.go create mode 100644 middleware.go create mode 100644 path.go create mode 100644 response_writer.go create mode 100644 tower.go diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..e9bedfd --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,29 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/go . +{ + "name": "Go", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie", + "customizations": { + "vscode": { + "extensions": [ + "golang.go" + ] + } + } + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "go version", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f33a02c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for more information: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://containers.dev/guide/dependabot + +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly diff --git a/README.md b/README.md index ac49339..7241516 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,328 @@ -# go-tower +# Tower +Tower is a minimal, composable HTTP web framework for Go built on top of `net/http` and `httprouter`. +It provides a small, explicit API for routing, middleware composition, grouping, and structured request/response handling via a custom context. + +The framework favors: +- Small surface area +- Explicit middleware chaining +- Typed handler signatures +- Zero magic abstractions +- Compatibility with the standard library + +--- + +## Installation + +```bash +go get git.trcreatives.at/TR_Creatives/go-tower +```` + +--- + +## Quick Start + +```go +package main + +import ( + "log" + + "git.trcreatives.at/TR_Creatives/go-tower" +) + +func main() { + app := tower.New() + + app.GET("/hello/:name", func(c *tower.Context) error { + name := c.Param("name") + return c.WriteJSON(200, tower.Map{ + "message": "Hello " + name, + }) + }) + + log.Fatal(app.Start(":8080")) +} +``` + +--- + +## Core Concepts + +### Handler + +All route handlers use a unified signature: + +```go +type Handler func(c *Context) error +``` + +Returning an error forwards it to the configured error handler. + +```go +app.GET("/ping", func(c *tower.Context) error { + return c.WriteString(200, "pong") +}) +``` + +--- + +### Middleware + +Middleware wraps handlers and composes into a chain: + +```go +type Middleware func(h Handler) Handler +``` + +Example: + +```go +func Logger() tower.Middleware { + return func(next tower.Handler) tower.Handler { + return func(c *tower.Context) error { + err := next(c) + log.Printf("%s %s -> %d", + c.Request().Method, + c.Request().URL.Path, + c.Status(), + ) + return err + } + } +} +``` + +Register globally: + +```go +app.Use(Logger()) +``` + +Or per route: + +```go +app.GET("/secure", handler, AuthMiddleware()) +``` + +Middleware execution order (outer → inner): + +1. Global middleware +2. Group middleware +3. Route middleware +4. Handler + +--- + +## Routing + +Tower uses `httprouter`, so parameters are declared with `:name`. + +```go +app.GET("/users/:id", func(c *tower.Context) error { + id := c.Param("id") + return c.WriteString(200, "User: "+id) +}) +``` + +Available helpers: + +```go +app.GET(path, handler, mws...) +app.POST(path, handler, mws...) +app.PUT(path, handler, mws...) +app.PATCH(path, handler, mws...) +app.DELETE(path, handler, mws...) +``` + +Or manually: + +```go +app.Handle("GET", "/custom", handler) +``` + +--- + +## Groups + +Groups allow route prefixing and shared middleware. + +```go +api := app.Group("/api") + +api.GET("/health", func(c *tower.Context) error { + return c.WriteString(200, "ok") +}) +``` + +Nested groups: + +```go +v1 := api.Group("/v1") +v1.GET("/users", listUsers) +``` + +Group-level middleware: + +```go +auth := app.Group("/auth", AuthMiddleware()) +auth.GET("/profile", profileHandler) +``` + +--- + +## Static Files + +Serve a directory under a URL prefix: + +```go +app.Static("/assets", "./public") +``` + +Group variant: + +```go +api.Static("/docs", "./docs") +``` + +--- + +## Context API + +`Context` wraps `http.Request` and `http.ResponseWriter` and provides helpers. + +### Request Access + +```go +c.Request() // *http.Request +c.Param("id") // path parameter +c.Query("q") // query string value +c.Header("X-Token") // request header +c.Cookie("session") // cookie +c.ClientIP() // best-effort client IP +``` + +### Response Helpers + +```go +c.SetStatus(201) +c.SetHeader("X-App", "tower") + +c.WriteString(200, "ok") +c.WriteJSON(200, data) +c.WriteXML(200, data) +c.WriteBytes(200, []byte("raw"), "application/octet-stream") +c.Redirect(302, "/login") +``` + +### Binding Request Bodies + +```go +var payload CreateUserRequest +if err := c.BindJSON(&payload); err != nil { + return err +} +``` + +Also supported: + +```go +c.BindXML(&payload) +``` + +### Context Values + +```go +type userKey tower.ContextKey // define your own alias if desired + +c.SetValue("userID", "123") +id := c.Value("userID") +``` + +(Values are stored in the underlying `request.Context()`.) + +--- + +## Error Handling + +Handlers return errors which are passed to the global error handler. + +Set a custom handler: + +```go +app.SetErrorHandler(func(c *tower.Context, err error) error { + return c.WriteJSON(500, tower.Map{ + "error": err.Error(), + }) +}) +``` + +If the error handler itself returns an error, it is logged. + +--- + +## Server Lifecycle + +### Start HTTP + +```go +if err := app.Start(":8080"); err != nil { + log.Fatal(err) +} +``` + +### Start HTTPS + +```go +log.Fatal(app.StartTLS(":8443", "cert.pem", "key.pem")) +``` + +### Graceful Shutdown + +```go +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +defer cancel() + +_ = app.Shutdown(ctx) +``` + +--- + +## Execution Flow + +For each incoming request: + +1. Route matched via `httprouter` +2. `Context` constructed +3. Route middleware applied +4. Global middleware applied +5. Handler executed +6. Returned error passed to `ErrorHandler` + +--- + +## Map Helper + +Tower provides a small convenience type: + +```go +type Map map[string]any +``` + +Useful for JSON responses: + +```go +return c.WriteJSON(200, tower.Map{ + "status": "ok", +}) +``` + +--- + +## Design Goals + +* Minimal abstraction over `net/http` +* Deterministic middleware composition +* No reflection or hidden state +* Explicit error propagation +* Fully standard-library compatible \ No newline at end of file diff --git a/context.go b/context.go new file mode 100644 index 0000000..a7f4583 --- /dev/null +++ b/context.go @@ -0,0 +1,117 @@ +package tower + +import ( + "context" + "encoding/json" + "encoding/xml" + "net/http" + + "github.com/julienschmidt/httprouter" +) + +type Context struct { + request *http.Request + response *responseWriter + params httprouter.Params +} + +type ContextKey string + +func (c *Context) Request() *http.Request { + return c.request +} + +func (c *Context) Response() http.ResponseWriter { + return c.response +} + +func (c *Context) Query(key string) string { + return c.request.URL.Query().Get(key) +} + +func (c *Context) Param(key string) string { + return c.params.ByName(key) +} + +func (c *Context) Value(key ContextKey) any { + return c.request.Context().Value(key) +} + +func (c *Context) SetValue(key ContextKey, value any) { + ctx := context.WithValue(c.request.Context(), key, value) + c.request = c.request.WithContext(ctx) +} + +func (c *Context) Cookie(key string) (*http.Cookie, error) { + return c.request.Cookie(key) +} + +func (c *Context) SetCookie(cookie *http.Cookie) { + http.SetCookie(c.response, cookie) +} + +func (c *Context) Header(key string) string { + return c.request.Header.Get(key) +} + +func (c *Context) SetHeader(key, value string) { + c.response.Header().Set(key, value) +} + +func (c *Context) Status() int { + return c.response.Status() +} + +func (c *Context) SetStatus(code int) { + c.response.WriteHeader(code) +} + +func (c *Context) ClientIP() string { + if ip := c.Header("X-Forwarded-For"); ip != "" { + return ip + } + + if ip := c.Header("X-Real-IP"); ip != "" { + return ip + } + + return c.request.RemoteAddr +} + +func (c *Context) Redirect(code int, url string) { + http.Redirect(c.response, c.request, url, code) +} + +func (c *Context) WriteBytes(code int, b []byte, contentType string) error { + c.SetStatus(code) + c.SetHeader("Content-Type", contentType) + _, err := c.response.Write(b) + return err +} + +func (c *Context) WriteString(code int, v string) error { + c.SetStatus(code) + c.SetHeader("Content-Type", "text/plain; charset=utf-8") + _, err := c.response.Write([]byte(v)) + return err +} + +func (c *Context) WriteJSON(code int, v any) error { + c.SetStatus(code) + c.SetHeader("Content-Type", "application/json; charset=utf-8") + return json.NewEncoder(c.response).Encode(v) +} + +func (c *Context) WriteXML(code int, v any) error { + c.SetStatus(code) + c.SetHeader("Content-Type", "application/xml; charset=utf-8") + return xml.NewEncoder(c.response).Encode(v) +} + +func (c *Context) BindJSON(v any) error { + return json.NewDecoder(c.request.Body).Decode(v) +} + +func (c *Context) BindXML(v any) error { + return xml.NewDecoder(c.request.Body).Decode(v) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e28c55c --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.trcreatives.at/trcreatives/go-tower + +go 1.25.6 + +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..6a89901 --- /dev/null +++ b/group.go @@ -0,0 +1,82 @@ +package tower + +import ( + "net/http" +) + +type Group struct { + prefix string + parent *Tower + groupMiddlewares []Middleware +} + +func (g *Group) Use(groupMiddlewares ...Middleware) { + g.groupMiddlewares = append(g.groupMiddlewares, groupMiddlewares...) +} + +func (g *Group) Group(prefix string, mws ...Middleware) *Group { + child := &Group{ + parent: g.parent, + prefix: g.buildFullPath(prefix), + } + + child.groupMiddlewares = make([]Middleware, 0, len(g.groupMiddlewares)+len(mws)) + child.groupMiddlewares = append(child.groupMiddlewares, g.groupMiddlewares...) + child.groupMiddlewares = append(child.groupMiddlewares, mws...) + + return child +} + +func (g *Group) Static(prefix, dir string) { + if prefix == "" || prefix[0] != '/' { + prefix = "/" + prefix + } + + if prefix[len(prefix)-1] == '/' { + prefix = prefix[:len(prefix)-1] + } + prefix += "/*filepath" + + g.parent.router.ServeFiles(g.buildFullPath(prefix), http.Dir(dir)) +} + +func (g *Group) GET(path string, h Handler, routeMiddlewares ...Middleware) { + g.Handle(http.MethodGet, path, h, routeMiddlewares...) +} + +func (g *Group) POST(path string, h Handler, routeMiddlewares ...Middleware) { + g.Handle(http.MethodPost, path, h, routeMiddlewares...) +} + +func (g *Group) PUT(path string, h Handler, routeMiddlewares ...Middleware) { + g.Handle(http.MethodPut, path, h, routeMiddlewares...) +} + +func (g *Group) PATCH(path string, h Handler, routeMiddlewares ...Middleware) { + g.Handle(http.MethodPatch, path, h, routeMiddlewares...) +} + +func (g *Group) DELETE(path string, h Handler, routeMiddlewares ...Middleware) { + g.Handle(http.MethodDelete, path, h, routeMiddlewares...) +} + +func (g *Group) Handle(method string, path string, h Handler, routeMiddlewares ...Middleware) { + fullPath := g.buildFullPath(path) + + mws := make([]Middleware, 0, len(g.groupMiddlewares)+len(routeMiddlewares)) + mws = append(mws, g.groupMiddlewares...) + mws = append(mws, routeMiddlewares...) + + g.parent.Handle(method, fullPath, h, mws...) +} + +func (g *Group) buildFullPath(path string) string { + normalizedPrefix := normalizePath(g.prefix) + normalizedPath := normalizePath(path) + + if normalizedPrefix == "/" && normalizedPath == "/" { + return "/" + } + + return normalizePath(g.prefix) + normalizePath(path) +} diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..57b6dc9 --- /dev/null +++ b/handler.go @@ -0,0 +1,9 @@ +package tower + +type Handler func(c *Context) error + +type ErrorHandler func(c *Context, err error) error + +func defaultErrorHandler(c *Context, err error) error { + return nil +} diff --git a/map.go b/map.go new file mode 100644 index 0000000..852b654 --- /dev/null +++ b/map.go @@ -0,0 +1,3 @@ +package tower + +type Map map[string]any diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..8cede18 --- /dev/null +++ b/middleware.go @@ -0,0 +1,3 @@ +package tower + +type Middleware func(h Handler) Handler diff --git a/path.go b/path.go new file mode 100644 index 0000000..1327665 --- /dev/null +++ b/path.go @@ -0,0 +1,14 @@ +package tower + +import "strings" + +func normalizePath(path string) string { + if path == "" { + return "/" + } + + path = strings.TrimLeft(path, "/") + path = strings.TrimRight(path, "/") + + return "/" + path +} diff --git a/response_writer.go b/response_writer.go new file mode 100644 index 0000000..6adc6cd --- /dev/null +++ b/response_writer.go @@ -0,0 +1,33 @@ +package tower + +import "net/http" + +type responseWriter struct { + http.ResponseWriter + status int + wroteHeader bool +} + +func (w *responseWriter) WriteHeader(code int) { + if !w.wroteHeader { + w.status = code + w.wroteHeader = true + } + + w.ResponseWriter.WriteHeader(code) +} + +func (w *responseWriter) Write(b []byte) (int, error) { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + + return w.ResponseWriter.Write(b) +} + +func (w *responseWriter) Status() int { + if w.status == 0 { + return http.StatusOK + } + return w.status +} diff --git a/tower.go b/tower.go new file mode 100644 index 0000000..2358f10 --- /dev/null +++ b/tower.go @@ -0,0 +1,117 @@ +package tower + +import ( + "context" + "log" + "net/http" + + "github.com/julienschmidt/httprouter" +) + +type Tower struct { + router *httprouter.Router + errorHandler ErrorHandler + globalMiddlewares []Middleware + server *http.Server +} + +func New() *Tower { + t := &Tower{ + router: httprouter.New(), + errorHandler: defaultErrorHandler, + globalMiddlewares: []Middleware{}, + } + + t.server = &http.Server{ + Handler: t.router, + } + + return t +} + +func (t *Tower) Start(listenAddr string) error { + t.server.Addr = listenAddr + return t.server.ListenAndServe() +} + +func (t *Tower) StartTLS(listenAddr, certFile, keyFile string) error { + t.server.Addr = listenAddr + return t.server.ListenAndServeTLS(certFile, keyFile) +} + +func (t *Tower) Shutdown(ctx context.Context) error { + return t.server.Shutdown(ctx) +} + +func (t *Tower) SetErrorHandler(errorHandler ErrorHandler) { + t.errorHandler = errorHandler +} + +func (t *Tower) Use(globalMiddlewares ...Middleware) { + t.globalMiddlewares = append(t.globalMiddlewares, globalMiddlewares...) +} + +func (t *Tower) Group(prefix string, groupMiddlewares ...Middleware) *Group { + return &Group{ + prefix: normalizePath(prefix), + parent: t, + groupMiddlewares: groupMiddlewares, + } +} + +func (t *Tower) Static(prefix, dir string) { + prefix = normalizePath(prefix) + prefix += "/*filepath" + + t.router.ServeFiles(prefix, http.Dir(dir)) +} + +func (t *Tower) GET(path string, h Handler, routeMiddlewares ...Middleware) { + t.Handle(http.MethodGet, path, h, routeMiddlewares...) +} + +func (t *Tower) POST(path string, h Handler, routeMiddlewares ...Middleware) { + t.Handle(http.MethodPost, path, h, routeMiddlewares...) +} + +func (t *Tower) PUT(path string, h Handler, routeMiddlewares ...Middleware) { + t.Handle(http.MethodPut, path, h, routeMiddlewares...) +} + +func (t *Tower) PATCH(path string, h Handler, routeMiddlewares ...Middleware) { + t.Handle(http.MethodPatch, path, h, routeMiddlewares...) +} + +func (t *Tower) DELETE(path string, h Handler, routeMiddlewares ...Middleware) { + t.Handle(http.MethodDelete, path, h, routeMiddlewares...) +} + +func (t *Tower) Handle(method string, path string, h Handler, routeMiddlewares ...Middleware) { + t.router.Handle(method, path, t.buildHTTPRouterHandle(h, routeMiddlewares...)) +} + +func (t *Tower) buildHTTPRouterHandle(h Handler, routeMiddlewares ...Middleware) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + ctx := Context{ + request: r, + response: &responseWriter{ResponseWriter: w}, + params: p, + } + + wrappedHandler := h + + for i := len(routeMiddlewares) - 1; i >= 0; i-- { + wrappedHandler = routeMiddlewares[i](wrappedHandler) + } + + for i := len(t.globalMiddlewares) - 1; i >= 0; i-- { + wrappedHandler = t.globalMiddlewares[i](wrappedHandler) + } + + if err := wrappedHandler(&ctx); err != nil { + if err := t.errorHandler(&ctx, err); err != nil { + log.Printf("[Error] %v", err) + } + } + } +}