go-tower: first release v0.1.0

This commit is contained in:
2026-02-14 14:02:21 +00:00
parent 93a16c3665
commit 3576d420d2
13 changed files with 753 additions and 1 deletions

View File

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

12
.github/dependabot.yml vendored Normal file
View File

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

328
README.md
View File

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

117
context.go Normal file
View File

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

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module git.trcreatives.at/trcreatives/go-tower
go 1.25.6
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=

82
group.go Normal file
View File

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

9
handler.go Normal file
View File

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

3
map.go Normal file
View File

@@ -0,0 +1,3 @@
package tower
type Map map[string]any

3
middleware.go Normal file
View File

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

14
path.go Normal file
View File

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

33
response_writer.go Normal file
View File

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

117
tower.go Normal file
View File

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