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