From 2ee6db04e5e233981dc7519e90dc527ff775a3f3 Mon Sep 17 00:00:00 2001 From: Gibheer Date: Wed, 21 Apr 2021 21:40:55 +0200 Subject: [PATCH] initial release --- .gitignore | 1 + dim.toml.example | 16 +++++ main.go | 76 +++++++++++++++++++++++ server.go | 154 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 247 insertions(+) create mode 100644 .gitignore create mode 100644 dim.toml.example create mode 100644 main.go create mode 100644 server.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e24253 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dim.toml diff --git a/dim.toml.example b/dim.toml.example new file mode 100644 index 0000000..bcca08b --- /dev/null +++ b/dim.toml.example @@ -0,0 +1,16 @@ +# Set the address and port the server should listen for incoming requests. +listen = "localhost:8080" + +# This section contains the database configuration. +[db] +# Type must be set to either 'mysql' or 'postgresql', so that the correct +# database driver is used. +type = "postgresql" + +# Conn should contain the correct connection string to the database. +# For mysql, use the following as a template: +# conn = "user:password@tcp(localhost:5555)/dbname" +# +# For postgresql separate fields can be used, see +# https://pkg.go.dev/github.com/lib/pq#hdr-Connection_String_Parameters +# conn = "host=localhost user=dim password=foobar" diff --git a/main.go b/main.go new file mode 100644 index 0000000..501bb71 --- /dev/null +++ b/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "database/sql" + "flag" + "log" + "net/http" + "strings" + + _ "github.com/go-sql-driver/mysql" + _ "github.com/lib/pq" + "github.com/pelletier/go-toml" +) + +var ( + configPath = flag.String("config", "dim.toml", "path to the config file") +) + +type ( + Config struct { + Listen string `toml:"listen"` + DB struct { + Type string `toml:"type"` + Connection string `toml:"conn"` + } `toml:"db"` + } +) + +func main() { + flag.Parse() + + cfg := Config{} + raw, err := toml.LoadFile(*configPath) + if err != nil { + log.Fatalf("could not load config file '%s': %s", *configPath, err) + return + } + if err := raw.Unmarshal(&cfg); err != nil { + log.Fatalf("could not parse config file '%s': %s", *configPath, err) + return + } + + if cfg.DB.Type != "mysql" && cfg.DB.Type != "postgres" { + log.Fatalf( + "unknown database type '%s' in config '%s'. Allowed is 'mysql' and 'postgresql'", + cfg.DB.Type, + *configPath, + ) + return + } + if strings.Trim(cfg.DB.Connection, " \t\n\r") == "" { + log.Fatalf("no database connection string in config '%s' set", *configPath) + return + } + + db, err := sql.Open(cfg.DB.Type, cfg.DB.Connection) + if err != nil { + log.Fatalf("could not open database connection: %s", err) + return + } + + s, err := NewServer(db) + if err != nil { + log.Fatalf("could not create server instance: %s", err) + return + } + s.Register("foobar", func(c *Context, req Request, res *Response) error { + c.Logf(LevelInfo, "I received a request!") + res.AddMessage(LevelInfo, "Is this working?") + res.Result["foo"] = "seems so" + return nil + }) + + http.HandleFunc("/dim", s.Handle) + log.Fatal(http.ListenAndServe(cfg.Listen, nil)) +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..037cc0c --- /dev/null +++ b/server.go @@ -0,0 +1,154 @@ +package main + +import ( + "crypto/rand" + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" +) + +const ( + LevelError = `ERROR` + LevelInfo = `INFO` +) + +type ( + // Server is the central handler of all incoming requests. It generates + // the context to call handlers, which then process the requests. + Server struct { + db *sql.DB + routes map[string]Handler + } + + // Handler is a function receiving a Context to process a request. + // It is expected to investigate the Request and fill the Result. + Handler func(c *Context, req Request, resp *Response) error + + // Context is the pre filled global context every handler receives. + // It contains a prepared transaction for usage and important details like + // the user account. + Context struct { + id string + req *http.Request + w http.ResponseWriter + + username string + tx *sql.DB + } + + // Request contains the method name and parameters requested by the client. + Request struct { + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + + // Response can have messages and/or a result to return the to client. + Response struct { + Messages map[string][]string `json:"messages,omitempty"` + Result map[string]interface{} `json:"result,omitempty"` + } + + ident []byte +) + +// NewServer creates a new server handler. +func NewServer(db *sql.DB) (*Server, error) { + if db == nil { + return nil, fmt.Errorf("database connection is not set") + } + return &Server{ + db: db, + routes: map[string]Handler{}, + }, nil +} + +// Register takes a new handler which will be called when the name is called. +func (s *Server) Register(name string, handler Handler) { + if _, found := s.routes[name]; found { + log.Fatalf("route with name %s already exists", name) + } + s.routes[name] = handler +} + +// Handle implements http.HandleFunc to serve content for the standard http +// interface. +func (s *Server) Handle(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write([]byte("only POST requests allowed")) + return + } + id, err := newIdent() + if err != nil { + log.Printf("could not generate request id: %s", err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("sorry, can't currently process your request")) + return + } + c := &Context{ + id: id, + req: r, + w: w, + } + + req := Request{} + res := &Response{ + Messages: map[string][]string{}, + Result: map[string]interface{}{}, + } + + dec := json.NewDecoder(r.Body) + defer r.Body.Close() + if err := dec.Decode(&req); err != nil { + res.AddMessage(LevelError, fmt.Sprintf("could not parse payload: %s", err)) + c.w.WriteHeader(http.StatusBadRequest) + c.render(res) + return + } + + handler, found := s.routes[req.Method] + if !found { + res.AddMessage(LevelError, "method %s does not exist", req.Method) + c.w.WriteHeader(http.StatusNotFound) + c.render(res) + return + } + c.Logf(LevelInfo, "method '%s' called with '%s'", req.Method, req.Params) + if err := handler(c, req, res); err != nil { + c.Logf(LevelError, "method '%s' returned an error: %s", req.Method, err) + } + c.render(res) +} + +// Render converts the Result to json and sends it back to the client. +func (c *Context) render(res *Response) { + enc := json.NewEncoder(c.w) + if err := enc.Encode(res); err != nil { + c.Logf(LevelError, "%s - could not encode result: %s\n%s", c.id, err, res) + return + } +} + +// Logf logs a message to stdout, outfitted with the request ID. +func (c *Context) Logf(level, msg string, args ...interface{}) { + log.Printf("%s - %s - %s", c.id, level, fmt.Sprintf(msg, args...)) +} + +// Generate a useable request ID, so that it can be found in the logs. +func newIdent() (string, error) { + b := make([]byte, 16) + _, err := rand.Read(b) + if err != nil { + return "", err + } + + return fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil +} + +// AddMessage adds a new message to the result. +// When Render is called, these messages will be sent to the client. +func (r *Response) AddMessage(level string, msg string, args ...interface{}) { + r.Messages[level] = append(r.Messages[level], fmt.Sprintf(msg, args...)) +}