commit 2ad390c1c1df8681c324e30da373c495e755d2fe Author: Timo Riegebauer Date: Thu Apr 24 16:07:48 2025 +0000 go-drip: v1.0.0 - initial release diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..3495528 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,22 @@ +// 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:1-1.23-bookworm" + + // 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/.gitignore b/.gitignore new file mode 100644 index 0000000..6f6f5e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cf968d0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Timo Riegebauer + +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 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 USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/context.go b/context.go new file mode 100644 index 0000000..6c8817e --- /dev/null +++ b/context.go @@ -0,0 +1,153 @@ +package drip + +import ( + "context" + "encoding/json" + "encoding/xml" + "mime/multipart" + "net/http" + + "github.com/gorilla/schema" + "github.com/julienschmidt/httprouter" +) + +type Context struct { + response http.ResponseWriter + request *http.Request + params httprouter.Params + ctx context.Context + websocketManager *WebsocketManager +} + +type ContextKey string + +func (c *Context) Request() *http.Request { + return c.request +} + +func (c *Context) Response() http.ResponseWriter { + return c.response +} + +func (c *Context) Param(key string) string { + return c.params.ByName(key) +} + +func (c *Context) FormFile(field string) (multipart.File, error) { + file, _, err := c.request.FormFile(field) + if err != nil { + return nil, err + } + return file, nil +} + +func (c *Context) Query(key string) string { + return c.request.URL.Query().Get(key) +} + +func (c *Context) Header(key string) string { + return c.request.Header.Get(key) +} + +func (c *Context) Cookie(name string) (*http.Cookie, error) { + return c.request.Cookie(name) +} + +func (c *Context) Value(key ContextKey) any { + return c.ctx.Value(key) +} + +func (c *Context) ClientIP() string { + ip := c.request.Header.Get("X-Forwarded-For") + if ip == "" { + ip = c.request.RemoteAddr + } + return ip +} + +func (c *Context) UserAgent() string { + return c.request.UserAgent() +} + +func (c *Context) SetHeader(key, value string) { + c.response.Header().Set(key, value) +} + +func (c *Context) SetCookie(cookie *http.Cookie) { + http.SetCookie(c.response, cookie) +} + +func (c *Context) SetValue(key ContextKey, value any) { + c.ctx = context.WithValue(c.ctx, key, value) +} + +func (c *Context) Status(statusCode int) { + c.response.WriteHeader(statusCode) +} + +func (c *Context) Text(statusCode int, message string) error { + c.response.Header().Set("Content-Type", "text/plain") + c.response.WriteHeader(statusCode) + _, err := c.response.Write([]byte(message)) + return err +} + +func (c *Context) JSON(statusCode int, v any) error { + c.response.Header().Set("Content-Type", "application/json") + c.response.WriteHeader(statusCode) + return json.NewEncoder(c.response).Encode(v) +} + +func (c *Context) Bytes(statusCode int, data []byte, contentType string) error { + c.response.Header().Set("Content-Type", contentType) + c.response.WriteHeader(statusCode) + _, err := c.response.Write(data) + return err +} + +func (c *Context) HTML(statusCode int, html string) error { + c.response.Header().Set("Content-Type", "text/html") + c.response.WriteHeader(statusCode) + _, err := c.response.Write([]byte(html)) + return err +} + +func (c *Context) File(filePath string) error { + http.ServeFile(c.response, c.request, filePath) + return nil +} + +func (c *Context) ParseJSON(v any) error { + return json.NewDecoder(c.request.Body).Decode(v) +} + +func (c *Context) ParseXML(v any) error { + return xml.NewDecoder(c.request.Body).Decode(v) +} + +func (c *Context) ParseForm(v any) error { + if err := c.request.ParseForm(); err != nil { + return err + } + return schema.NewDecoder().Decode(v, c.request.Form) +} + +func (c *Context) ParseMultipartForm(v any, maxMemory int64) error { + if err := c.request.ParseMultipartForm(maxMemory); err != nil { + return err + } + return schema.NewDecoder().Decode(v, c.request.MultipartForm.Value) +} + +func (c *Context) ParseQuery(v any) error { + return schema.NewDecoder().Decode(v, c.request.URL.Query()) +} + +func (c *Context) Redirect(statusCode int, url string) error { + http.Redirect(c.response, c.request, url, statusCode) + return nil +} + +func (c *Context) Broadcast(path string, msg []byte) error { + return c.websocketManager.Broadcast(path, msg) +} diff --git a/drip.go b/drip.go new file mode 100644 index 0000000..5f668c7 --- /dev/null +++ b/drip.go @@ -0,0 +1,144 @@ +package drip + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "slices" + "strings" + + "github.com/gorilla/websocket" + "github.com/julienschmidt/httprouter" +) + +type Drip struct { + router *httprouter.Router + errorHandler ErrorHandler + middlewares []Middleware + logger *log.Logger + websocketManager *WebsocketManager +} + +func New() *Drip { + return &Drip{ + router: httprouter.New(), + errorHandler: defaultErrorHandler, + middlewares: []Middleware{}, + logger: log.New(os.Stdout, "[Drip] ", log.LstdFlags), + websocketManager: &WebsocketManager{ + conns: make(map[string]map[*websocket.Conn]bool), + }, + } +} + +func (d *Drip) Start(listenAddr string) error { + browsableUrl := listenAddr + if strings.HasPrefix(browsableUrl, ":") { + browsableUrl = fmt.Sprintf("http://localhost%s", browsableUrl) + } + + d.logger.Printf("Starting app at %s\n", browsableUrl) + return http.ListenAndServe(listenAddr, d.router) +} + +func (d *Drip) StartTLS(listenAddr, certFile, keyFile string) error { + browsableUrl := listenAddr + if strings.HasPrefix(browsableUrl, ":") { + browsableUrl = fmt.Sprintf("http://localhost%s", browsableUrl) + } + + d.logger.Printf("Starting app at %s\n", browsableUrl) + return http.ListenAndServeTLS(listenAddr, certFile, keyFile, d.router) +} + +func (d *Drip) SetErrorHandler(errorHandler ErrorHandler) { + d.errorHandler = errorHandler +} + +func (d *Drip) Group(prefix string) *Group { + return &Group{ + prefix: prefix, + drip: d, + } +} + +func (d *Drip) Websocket(path string, middlewares ...Middleware) { + d.register(http.MethodGet, path, d.websocketManager.createHandler(), middlewares...) +} + +func (d *Drip) Static(prefix, root string, middlewares ...Middleware) { + fileServer := http.FileServer(http.Dir(root)) + wrappedHandler := d.createHandlerChain(func(ctx *Context) error { + http.StripPrefix(prefix, fileServer).ServeHTTP(ctx.response, ctx.request) + return nil + }, middlewares) + + d.router.Handle(http.MethodGet, prefix+"/*filepath", wrappedHandler) +} + +func (d *Drip) Use(middlewares ...Middleware) { + d.middlewares = append(d.middlewares, middlewares...) +} + +func (d *Drip) GET(path string, h Handler, middlewares ...Middleware) { + d.register(http.MethodGet, path, h, middlewares...) +} + +func (d *Drip) POST(path string, h Handler, middlewares ...Middleware) { + d.register(http.MethodPost, path, h, middlewares...) +} + +func (d *Drip) PUT(path string, h Handler, middlewares ...Middleware) { + d.register(http.MethodPut, path, h, middlewares...) +} + +func (d *Drip) DELETE(path string, h Handler, middlewares ...Middleware) { + d.register(http.MethodDelete, path, h, middlewares...) +} + +func (d *Drip) PATCH(path string, h Handler, middlewares ...Middleware) { + d.register(http.MethodPatch, path, h, middlewares...) +} + +func (d *Drip) HEAD(path string, h Handler, middlewares ...Middleware) { + d.register(http.MethodHead, path, h, middlewares...) +} + +func (d *Drip) OPTIONS(path string, h Handler, middlewares ...Middleware) { + d.register(http.MethodOptions, path, h, middlewares...) +} + +func (d *Drip) register(method string, path string, h Handler, middlewares ...Middleware) { + handlers := slices.Clone(middlewares) + + finalHandler := d.createHandlerChain(h, handlers) + d.router.Handle(method, path, finalHandler) +} + +func (d *Drip) createHandlerChain(h Handler, middlewares []Middleware) httprouter.Handle { + return func(rw http.ResponseWriter, r *http.Request, params httprouter.Params) { + ctx := &Context{ + response: rw, + request: r, + params: params, + ctx: context.Background(), + } + + finalHandler := h + + for i := len(middlewares) - 1; i >= 0; i-- { + finalHandler = middlewares[i](finalHandler) + } + + for i := len(d.middlewares) - 1; i >= 0; i-- { + finalHandler = d.middlewares[i](finalHandler) + } + + if err := finalHandler(ctx); err != nil { + d.logger.Printf("Error in request %s %s: %v\n", r.Method, r.URL.Path, err) + d.errorHandler(err, ctx) + } + } +} diff --git a/error_handler.go b/error_handler.go new file mode 100644 index 0000000..bd0572a --- /dev/null +++ b/error_handler.go @@ -0,0 +1,9 @@ +package drip + +import "net/http" + +type ErrorHandler func(err error, ctx *Context) + +func defaultErrorHandler(err error, c *Context) { + c.JSON(http.StatusInternalServerError, Json{"status": "failed", "message": err.Error()}) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7994c2b --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module git.trcreatives.com/triegebauer/go-drip + +go 1.23.8 + +require ( + github.com/gorilla/schema v1.4.1 + github.com/gorilla/websocket v1.5.3 + github.com/julienschmidt/httprouter v1.3.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..830ddb4 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= +github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +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..ca76d86 --- /dev/null +++ b/group.go @@ -0,0 +1,101 @@ +package drip + +import ( + "net/http" + "strings" +) + +type Group struct { + prefix string + middlewares []Middleware + parent *Group + drip *Drip +} + +func (g *Group) Group(prefix string) *Group { + return &Group{ + prefix: prefix, + parent: g, + drip: g.drip, + } +} + +func (g *Group) Websocket(path string, middlewares ...Middleware) { + g.register(http.MethodGet, path, g.drip.websocketManager.createHandler(), middlewares...) +} + +func (g *Group) Static(prefix, root string, middlewares ...Middleware) { + fileServer := http.FileServer(http.Dir(root)) + fullPath := joinPaths(g.getFullPrefix(), prefix) + g.register(http.MethodGet, prefix+"/*filepath", func(ctx *Context) error { + http.StripPrefix(fullPath, fileServer).ServeHTTP(ctx.response, ctx.request) + return nil + }) +} + +func (g *Group) Use(middlewares ...Middleware) { + g.middlewares = append(g.middlewares, middlewares...) +} + +func (g *Group) GET(path string, h Handler, middlewares ...Middleware) { + g.register(http.MethodGet, path, h, middlewares...) +} + +func (g *Group) POST(path string, h Handler, middlewares ...Middleware) { + g.register(http.MethodPost, path, h, middlewares...) +} + +func (g *Group) PUT(path string, h Handler, middlewares ...Middleware) { + g.register(http.MethodPut, path, h, middlewares...) +} + +func (g *Group) DELETE(path string, h Handler, middlewares ...Middleware) { + g.register(http.MethodDelete, path, h, middlewares...) +} + +func (g *Group) PATCH(path string, h Handler, middlewares ...Middleware) { + g.register(http.MethodPatch, path, h, middlewares...) +} + +func (g *Group) HEAD(path string, h Handler, middlewares ...Middleware) { + g.register(http.MethodHead, path, h, middlewares...) +} + +func (g *Group) OPTIONS(path string, h Handler, middlewares ...Middleware) { + g.register(http.MethodOptions, path, h, middlewares...) +} + +func (g *Group) getFullPrefix() string { + if g.parent != nil { + return joinPaths(g.parent.getFullPrefix(), g.prefix) + } + return g.prefix +} + +func (g *Group) getAllMiddlewares() []Middleware { + middlewares := make([]Middleware, 0) + current := g + + for current != nil { + middlewares = append(current.middlewares, middlewares...) + current = current.parent + } + return middlewares +} + +func (g *Group) register(method string, path string, h Handler, middlewares ...Middleware) { + fullPath := joinPaths(g.getFullPrefix(), path) + allMiddlewares := append(g.getAllMiddlewares(), middlewares...) + g.drip.register(method, fullPath, h, allMiddlewares...) +} + +func joinPaths(a, b string) string { + a = strings.TrimSuffix(a, "/") + b = strings.TrimPrefix(b, "/") + + if b == "" { + return a + } + + return a + "/" + b +} diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..8b33210 --- /dev/null +++ b/handler.go @@ -0,0 +1,3 @@ +package drip + +type Handler func(ctx *Context) error diff --git a/json.go b/json.go new file mode 100644 index 0000000..04abed8 --- /dev/null +++ b/json.go @@ -0,0 +1,3 @@ +package drip + +type Json map[string]any diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..0a8f0b2 --- /dev/null +++ b/middleware.go @@ -0,0 +1,3 @@ +package drip + +type Middleware func(next Handler) Handler diff --git a/websocket_manager.go b/websocket_manager.go new file mode 100644 index 0000000..6d18608 --- /dev/null +++ b/websocket_manager.go @@ -0,0 +1,81 @@ +package drip + +import ( + "fmt" + "net/http" + "sync" + + "github.com/gorilla/websocket" +) + +type WebsocketManager struct { + conns map[string]map[*websocket.Conn]bool + mutex sync.RWMutex +} + +func (wm *WebsocketManager) createHandler() Handler { + return func(ctx *Context) error { + upgrader := websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + } + conn, err := upgrader.Upgrade(ctx.response, ctx.request, nil) + if err != nil { + return err + } + + wm.register(ctx.request.URL.Path, conn) + + for { + if _, _, err := conn.NextReader(); err != nil { + break + } + } + + wm.unregister(ctx.request.URL.Path, conn) + conn.Close() + return nil + } +} + +func (wm *WebsocketManager) register(path string, conn *websocket.Conn) { + wm.mutex.Lock() + defer wm.mutex.Unlock() + + if _, exists := wm.conns[path]; !exists { + wm.conns[path] = make(map[*websocket.Conn]bool) + } + wm.conns[path][conn] = true +} + +func (wm *WebsocketManager) unregister(path string, conn *websocket.Conn) { + wm.mutex.Lock() + defer wm.mutex.Unlock() + + if conns, exists := wm.conns[path]; exists { + delete(conns, conn) + if len(conns) == 0 { + delete(wm.conns, path) + } + } +} + +func (wm *WebsocketManager) Broadcast(path string, msg []byte) error { + wm.mutex.RLock() + conns, exists := wm.conns[path] + wm.mutex.RUnlock() + + if !exists { + return fmt.Errorf("there is no websocket on path '%s'", path) + } + + for conn := range conns { + go func(c *websocket.Conn) { + if err := c.WriteMessage(websocket.TextMessage, msg); err != nil { + wm.unregister(path, conn) + conn.Close() + } + }(conn) + } + + return nil +}