From 8886b71c69f3c74a00950f4adfd4eeb1e20127aa Mon Sep 17 00:00:00 2001 From: Timo Riegebauer Date: Tue, 22 Apr 2025 06:59:36 +0000 Subject: [PATCH] go-dart: v1.0.0 - initial release --- .devcontainer/devcontainer.json | 22 +++ .github/dependabot.yml | 12 ++ .gitignore | 22 +++ LICENSE | 21 +++ cloud_context.go | 51 +++++++ cloud_function.go | 9 ++ cloud_handler.go | 3 + cmd/main.go | 21 +++ context.go | 168 +++++++++++++++++++++++ dart.go | 228 ++++++++++++++++++++++++++++++++ database.go | 70 ++++++++++ error_handler.go | 9 ++ go.mod | 28 ++++ go.sum | 53 ++++++++ group.go | 97 ++++++++++++++ handler.go | 3 + json.go | 3 + middleware.go | 3 + websocket_manager.go | 81 ++++++++++++ 19 files changed, 904 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .github/dependabot.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 cloud_context.go create mode 100644 cloud_function.go create mode 100644 cloud_handler.go create mode 100644 cmd/main.go create mode 100644 context.go create mode 100644 dart.go create mode 100644 database.go create mode 100644 error_handler.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 group.go create mode 100644 handler.go create mode 100644 json.go create mode 100644 middleware.go create mode 100644 websocket_manager.go 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/cloud_context.go b/cloud_context.go new file mode 100644 index 0000000..7f92d17 --- /dev/null +++ b/cloud_context.go @@ -0,0 +1,51 @@ +package dart + +import ( + "errors" + "sync" +) + +type CloudContext struct { + database *Database + websocketManager *WebsocketManager + state map[string]any + mutex sync.Mutex +} + +func (c *CloudContext) Database() (*Database, error) { + if c.database == nil { + return nil, errors.New("database is not enabled") + } + + return c.database, nil +} + +func (c *CloudContext) WebsocketManager() (*WebsocketManager, error) { + if c.websocketManager == nil { + return nil, errors.New("websocket manager is not enabled") + } + + return c.websocketManager, nil +} + +func (c *CloudContext) Set(key string, value any) { + c.mutex.Lock() + defer c.mutex.Unlock() + + c.state[key] = value +} + +func (c *CloudContext) Get(key string) (any, bool) { + c.mutex.Lock() + defer c.mutex.Unlock() + + val, exists := c.state[key] + return val, exists +} + +func (c *CloudContext) Delete(key string) { + c.mutex.Lock() + defer c.mutex.Unlock() + + delete(c.state, key) +} diff --git a/cloud_function.go b/cloud_function.go new file mode 100644 index 0000000..e721ba8 --- /dev/null +++ b/cloud_function.go @@ -0,0 +1,9 @@ +package dart + +import "github.com/robfig/cron/v3" + +type CloudFunction struct { + Name string + EntryID cron.EntryID + Handler CloudHandler +} diff --git a/cloud_handler.go b/cloud_handler.go new file mode 100644 index 0000000..bfff2df --- /dev/null +++ b/cloud_handler.go @@ -0,0 +1,3 @@ +package dart + +type CloudHandler func(ctx *CloudContext) error diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..d29e121 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "git.trcreatives.com/triegebauer/go-dart" +) + +func main() { + app := dart.New() + app.WithCloudFunctions() + + cloudFunction := dart.CloudFunction{ + Name: "Loading Osu Data", + Handler: func(ctx *dart.CloudContext) error { + return nil + }, + } + + app.ScheduleCloudFunction("* * * * * *", &cloudFunction) + + app.Start(":8123") +} diff --git a/context.go b/context.go new file mode 100644 index 0000000..1a22812 --- /dev/null +++ b/context.go @@ -0,0 +1,168 @@ +package dart + +import ( + "context" + "encoding/json" + "encoding/xml" + "errors" + "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 + + database *Database + 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) DB() (*Database, error) { + if c.database == nil { + return nil, errors.New("database is not enabled") + } + + return c.database, nil +} + +func (c *Context) WebsocketManager() (*WebsocketManager, error) { + if c.websocketManager == nil { + return nil, errors.New("websocket manager is not enabled") + } + + return c.websocketManager, nil +} diff --git a/dart.go b/dart.go new file mode 100644 index 0000000..3bbfb64 --- /dev/null +++ b/dart.go @@ -0,0 +1,228 @@ +package dart + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "os" + "slices" + "strings" + + "github.com/gorilla/websocket" + "github.com/julienschmidt/httprouter" + "github.com/robfig/cron/v3" +) + +type Dart struct { + router *httprouter.Router + errorHandler ErrorHandler + middlewares []Middleware + logger *log.Logger + + database *Database + enableDatabase bool + + websocketManager *WebsocketManager + enableWebsocketManager bool + + cron *cron.Cron + enableCloudFunctions bool +} + +func New() *Dart { + return &Dart{ + router: httprouter.New(), + errorHandler: defaultErrorHandler, + middlewares: []Middleware{}, + logger: log.New(os.Stdout, "[Wasp] ", log.LstdFlags), + } +} + +func (d *Dart) WithMySQLDatabase(cfg MySQLConfig) { + d.database = NewMySQLDatabase(cfg) + d.enableDatabase = true +} + +func (d *Dart) WithPostgreSQLDatabase(cfg PostgreSQLConfig) { + d.database = NewPostgreSQLDatabase(cfg) + d.enableDatabase = true +} + +func (d *Dart) WithSQLiteDatabase(cfg SQLiteConfig) { + d.database = NewSQLiteDatabase(cfg) + d.enableDatabase = true +} + +func (d *Dart) DB() (*Database, error) { + if d.database == nil { + return nil, errors.New("database is not enabled") + } + + return d.database, nil +} + +func (d *Dart) WithWebsocketManager() { + d.websocketManager = &WebsocketManager{ + conns: make(map[string]map[*websocket.Conn]bool), + } + d.enableWebsocketManager = true +} + +func (d *Dart) WithCloudFunctions() { + d.cron = cron.New(cron.WithSeconds()) + d.enableCloudFunctions = true +} + +func (d *Dart) ScheduleCloudFunction(spec string, cf *CloudFunction) { + if !d.enableCloudFunctions { + d.logger.Println("Cloud function scheduling attempted but cloud functions are disabled") + return + } + + ctx := &CloudContext{ + database: d.database, + websocketManager: d.websocketManager, + state: make(map[string]any), + } + + entryId, err := d.cron.AddFunc(spec, func() { + d.logger.Printf("Executing cloud function: %s", cf.Name) + if err := cf.Handler(ctx); err != nil { + d.logger.Printf("Error executing cloud function '%s': %v\n", cf.Name, err) + } + }) + if err != nil { + d.logger.Printf("Error scheduling cloud function '%s': '%v\n", cf.Name, entryId) + return + } + cf.EntryID = entryId +} + +func (d *Dart) Start(listenAddr string) error { + browsableUrl := listenAddr + if strings.HasPrefix(browsableUrl, ":") { + browsableUrl = fmt.Sprintf("http://localhost%s", browsableUrl) + } + + if d.enableCloudFunctions { + d.cron.Start() + defer d.cron.Stop() + } + + d.logger.Printf("Starting Dart app at %s\n", browsableUrl) + return http.ListenAndServe(listenAddr, d.router) +} + +func (d *Dart) StartTLS(listenAddr, certFile, keyFile string) error { + browsableUrl := listenAddr + if strings.HasPrefix(browsableUrl, ":") { + browsableUrl = fmt.Sprintf("http://localhost%s", browsableUrl) + } + + if d.enableCloudFunctions { + d.cron.Start() + defer d.cron.Stop() + } + + d.logger.Printf("Starting Dart app at %s\n", browsableUrl) + return http.ListenAndServeTLS(listenAddr, certFile, keyFile, d.router) +} + +func (d *Dart) SetErrorHandler(errorHandler ErrorHandler) { + d.errorHandler = errorHandler +} + +func (d *Dart) Group(prefix string) *Group { + return &Group{ + prefix: prefix, + dart: d, + } +} + +func (d *Dart) Websocket(path string, middlewares ...Middleware) { + if !d.enableWebsocketManager { + d.logger.Println("Websocket registration attempted but websockets are disabled") + return + } + + d.register(http.MethodGet, path, d.websocketManager.createHandler(), middlewares...) +} + +func (d *Dart) 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 *Dart) Use(middlewares ...Middleware) { + d.middlewares = append(d.middlewares, middlewares...) +} + +func (d *Dart) GET(path string, h Handler, middlewares ...Middleware) { + d.register(http.MethodGet, path, h, middlewares...) +} + +func (d *Dart) POST(path string, h Handler, middlewares ...Middleware) { + d.register(http.MethodPost, path, h, middlewares...) +} + +func (d *Dart) PUT(path string, h Handler, middlewares ...Middleware) { + d.register(http.MethodPut, path, h, middlewares...) +} + +func (d *Dart) DELETE(path string, h Handler, middlewares ...Middleware) { + d.register(http.MethodDelete, path, h, middlewares...) +} + +func (d *Dart) PATCH(path string, h Handler, middlewares ...Middleware) { + d.register(http.MethodPatch, path, h, middlewares...) +} + +func (d *Dart) HEAD(path string, h Handler, middlewares ...Middleware) { + d.register(http.MethodHead, path, h, middlewares...) +} + +func (d *Dart) OPTIONS(path string, h Handler, middlewares ...Middleware) { + d.register(http.MethodOptions, path, h, middlewares...) +} + +func (d *Dart) 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 *Dart) 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(), + database: d.database, + websocketManager: d.websocketManager, + } + + 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/database.go b/database.go new file mode 100644 index 0000000..0d37fc3 --- /dev/null +++ b/database.go @@ -0,0 +1,70 @@ +package dart + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type Database struct { + Conn *gorm.DB +} + +type MySQLConfig struct { + Username string + Password string + Host string + Port string + Database string +} + +type PostgreSQLConfig struct { + Username string + Password string + Host string + Port string + Database string +} + +type SQLiteConfig struct { + Filename string +} + +func NewMySQLDatabase(cfg MySQLConfig) *Database { + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", cfg.Username, cfg.Password, cfg.Host, cfg.Port, cfg.Database) + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + log.Fatal(err) + } + + return &Database{ + Conn: db, + } +} + +func NewPostgreSQLDatabase(cfg PostgreSQLConfig) *Database { + dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disabled TimeZone=Europe/Vienna", cfg.Host, cfg.Username, cfg.Password, cfg.Database, cfg.Port) + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + log.Fatal(err) + } + + return &Database{ + Conn: db, + } +} + +func NewSQLiteDatabase(cfg SQLiteConfig) *Database { + db, err := gorm.Open(sqlite.Open(cfg.Filename), &gorm.Config{}) + if err != nil { + log.Fatal(err) + } + + return &Database{ + Conn: db, + } +} diff --git a/error_handler.go b/error_handler.go new file mode 100644 index 0000000..d3278b9 --- /dev/null +++ b/error_handler.go @@ -0,0 +1,9 @@ +package dart + +import "net/http" + +type ErrorHandler func(err error, ctx *Context) + +func defaultErrorHandler(err error, c *Context) { + c.JSON(http.StatusInternalServerError, Json{"error": err.Error()}) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fc657fc --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module git.trcreatives.com/triegebauer/go-dart + +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 + github.com/robfig/cron/v3 v3.0.1 + gorm.io/driver/mysql v1.5.7 + gorm.io/driver/postgres v1.5.11 + gorm.io/driver/sqlite v1.5.7 + gorm.io/gorm v1.25.12 +) + +require ( + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..064c1c6 --- /dev/null +++ b/go.sum @@ -0,0 +1,53 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= +gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I= +gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= diff --git a/group.go b/group.go new file mode 100644 index 0000000..50732f1 --- /dev/null +++ b/group.go @@ -0,0 +1,97 @@ +package dart + +import ( + "net/http" + "strings" +) + +type Group struct { + prefix string + middlewares []Middleware + parent *Group + dart *Dart +} + +func (g *Group) Group(prefix string) *Group { + return &Group{ + prefix: prefix, + parent: g, + dart: g.dart, + } +} + +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.dart.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..8456284 --- /dev/null +++ b/handler.go @@ -0,0 +1,3 @@ +package dart + +type Handler func(ctx *Context) error diff --git a/json.go b/json.go new file mode 100644 index 0000000..8109931 --- /dev/null +++ b/json.go @@ -0,0 +1,3 @@ +package dart + +type Json map[string]any diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..f74a65b --- /dev/null +++ b/middleware.go @@ -0,0 +1,3 @@ +package dart + +type Middleware func(next Handler) Handler diff --git a/websocket_manager.go b/websocket_manager.go new file mode 100644 index 0000000..f405106 --- /dev/null +++ b/websocket_manager.go @@ -0,0 +1,81 @@ +package dart + +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 +}