From 6827006f94a4eaeb7fe40cdd4b6e543b3cb90574 Mon Sep 17 00:00:00 2001 From: William Clark Date: Tue, 5 Dec 2023 17:22:12 +0000 Subject: [PATCH] initial version of http/rest API --- api/http/config.go | 304 ++++++++++++++++++++++++++++++++++++++++++++ api/http/data.go | 85 +++++++++++++ api/http/gin.go | 64 ++++++++++ api/http/util.go | 12 ++ config.toml | 12 +- config/config.go | 17 ++- config/defaults.go | 8 +- data/config/data.go | 18 ++- db/adapter.go | 15 ++- db/logging.go | 2 + main.go | 16 ++- ports/api.go | 5 + ports/web.go | 5 - rest-api-idea.txt | 6 +- web/gin.go | 129 ------------------- 15 files changed, 530 insertions(+), 168 deletions(-) create mode 100644 api/http/config.go create mode 100644 api/http/data.go create mode 100644 api/http/gin.go create mode 100644 api/http/util.go create mode 100644 ports/api.go delete mode 100644 ports/web.go delete mode 100644 web/gin.go diff --git a/api/http/config.go b/api/http/config.go new file mode 100644 index 0000000..23d391f --- /dev/null +++ b/api/http/config.go @@ -0,0 +1,304 @@ +package http + +import ( + "errors" + "net/http" + "strconv" + "strings" + ccc "th7/config" + "th7/data/config" + + "github.com/gin-gonic/gin" +) + +var ( + ErrConfigChannelNotFound = errors.New("a configured channel with this ID was not found") + ErrConfigRestrictedDB = errors.New("access to the DB map is restricted") +) + +func (g *GinAdapter) getConfigByID(id int) (config.Channel, error) { + for channel := range g.cfg.Channels { + if g.cfg.Channels[channel].Id == id { + return g.cfg.Channels[channel], nil + } + } + + return config.Channel{}, ErrConfigChannelNotFound +} + +// check if a supplied key string is in the device struct (TH7) +func configDeviceIsValidKey(key string) bool { + deviceKeys := []string{ + "debug", "nolog", "noweb", "led", + } + + key = strings.ToLower(key) + + for k := range deviceKeys { + if key == deviceKeys[k] { + return true + } + } + + return false +} + +func configDeviceIsValidValue(key string, value string) bool { + // all the values are bools for now + value = strings.ToLower(value) + return (value == "true" || value == "false") +} + +// this doesn't actually do anything to program state... yet +func (g *GinAdapter) updateConfigDevice(key string, value string) error { + bValue, err := strconv.ParseBool(value) + if err != nil { + return err + } + + key = strings.ToLower(key) + + // might have to sync these writes ? + // go doesn't have switch case fall-thru + switch key { + case "led": + g.cfg.Board.Led = bValue + + case "nolog": + g.cfg.Board.NoLog = bValue + + case "noweb": + g.cfg.Board.NoWeb = bValue + + case "debug": + g.cfg.Board.Debug = bValue + + } + + return nil +} + +func (g *GinAdapter) validConfigChannel(id int) bool { + + for c := range g.cfg.Channels { + if g.cfg.Channels[c].Id == id { + return true + } + } + + return false +} + +func configChannelIsValidKey(key string) bool { + // will add more later + channelKeys := []string{ + "thermocouple", "gain", "offset", + } + + key = strings.ToLower(key) + + for k := range channelKeys { + if key == channelKeys[k] { + return true + } + } + + return false +} + +func (g *GinAdapter) configChannelUpdateValue(id int, key string, value string) error { + + key = strings.ToLower(key) + value = strings.ToLower(value) + var cfgChannelPtr *config.Channel + foundChannel := false + + // find the channel, given and checked by id + for k := range g.cfg.Channels { + if g.cfg.Channels[k].Id == id { + cfgChannelPtr = &(g.cfg.Channels[k]) + foundChannel = true + } + } + + if !foundChannel { + return ErrConfigChannelNotFound + } + + switch key { + case "thermocouple": + tc, err := ccc.GetThermocoupleType(value) + if err != nil { + return err + } + cfgChannelPtr.Thermo = tc + case "gain": + fValue, err := strconv.ParseFloat(value, 64) + if err != nil { + return err + } + cfgChannelPtr.Gain = fValue + case "offset": + fValue, err := strconv.ParseFloat(value, 64) + if err != nil { + return err + } + cfgChannelPtr.Offset = fValue + + } + + return nil +} + +/*************************************************************************/ +/*************************************************************************/ +/*************************************************************************/ + +func (g *GinAdapter) GetConfigChannelByIdHandler(c *gin.Context) { + var timestamp string + id, err := strconv.Atoi(c.Param("id")) + + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err, + }) + return + } + + channel, err := g.getConfigByID(id) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err, + }) + return + } + + timestamp = g.getTimestamp() + c.JSON(http.StatusOK, gin.H{ + "channel": channel, + "time": timestamp, + }) +} + +func (g *GinAdapter) GetConfigChannelsHandler(c *gin.Context) { + timestamp := g.getTimestamp() + + c.JSON(http.StatusOK, gin.H{ + "channels": g.cfg.Channels, + "time": timestamp, + }) +} + +func (g *GinAdapter) GetConfigDeviceHandler(c *gin.Context) { + timestamp := g.getTimestamp() + + c.JSON(http.StatusOK, gin.H{ + "channels": g.cfg.Board, + "time": timestamp, + }) +} + +func (g *GinAdapter) GetConfigDBHandler(c *gin.Context) { + var timestamp string + + if g.cfg.API[0].Restricted { + c.JSON(http.StatusForbidden, gin.H{ + "error": ErrConfigRestrictedDB, + }) + return + } + + timestamp = g.getTimestamp() + + c.JSON(http.StatusOK, gin.H{ + // "db": g.getConfigDB(), + "db": g.cfg.DB, + "time": timestamp, + }) +} + +func (g *GinAdapter) SetConfigDeviceHandler(c *gin.Context) { + var req PostRequest + + if err := c.ShouldBind(&req); err != nil { + c.String(http.StatusBadRequest, err.Error()) + return + } + + validKey := configDeviceIsValidKey(req.Key) + if !validKey { + c.String(http.StatusBadRequest, "invalid device config key") + return + } + + validValue := configDeviceIsValidValue(req.Key, req.Value) + if !validValue { + c.String(http.StatusBadRequest, "invalid device config value") + return + } + + err := g.updateConfigDevice(req.Key, req.Value) + if err != nil { + c.String(http.StatusBadRequest, err.Error()) + return + } + + c.String(http.StatusOK, "OK") +} + +// this function can only modify already set up channels, for now. +func (g *GinAdapter) SetConfigChannelByIdHandler(c *gin.Context) { + + var req PostRequest + id, err := strconv.Atoi(c.Param("id")) + + if err != nil { + c.String(http.StatusBadRequest, err.Error()) + return + } + + validChannel := g.validConfigChannel(id) + if !validChannel { + c.String(http.StatusBadRequest, "channel not initially configured. TODO fix") + return + } + + if err := c.ShouldBind(&req); err != nil { + c.String(http.StatusBadRequest, err.Error()) + return + } + + validKey := configChannelIsValidKey(req.Key) + if !validKey { + c.String(http.StatusBadRequest, "invalid channel config key") + return + } + + err = g.configChannelUpdateValue(id, req.Key, req.Value) + if err != nil { + c.String(http.StatusBadRequest, err.Error()) + return + } + + c.String(http.StatusOK, "OK") +} + +func (g *GinAdapter) SetConfigDBHandler(c *gin.Context) { + + var req PostRequest + + if g.cfg.API[0].Restricted { + c.String(http.StatusForbidden, ErrConfigRestrictedDB.Error()) + return + } + + if err := c.ShouldBind(&req); err != nil { + c.String(http.StatusBadRequest, err.Error()) + return + } + + // what could go wrong? + g.cfg.DB[req.Key] = req.Value + + c.String(http.StatusOK, "OK") +} diff --git a/api/http/data.go b/api/http/data.go new file mode 100644 index 0000000..c641b02 --- /dev/null +++ b/api/http/data.go @@ -0,0 +1,85 @@ +package http + +import ( + "net/http" + "strconv" + "th7/data/core" + + "github.com/gin-gonic/gin" +) + +// helper functions + +func (g *GinAdapter) getRatio() core.Ratio { + return g.corePort.GetRatio() +} + +func (g *GinAdapter) getChannels() []core.Channel { + return g.corePort.GetChannels() +} + +func (g *GinAdapter) getChannelByID(id int) (core.Channel, error) { + return g.corePort.GetChannel(id) +} + +/*************************************************************************/ +/*************************************************************************/ +/*************************************************************************/ + +func (g *GinAdapter) GetChannelByIDHandler(c *gin.Context) { + var timestamp string + id, err := strconv.Atoi(c.Param("id")) + + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err, + }) + return + } + + channel, err := g.getChannelByID(id) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err, + }) + return + } + + timestamp = g.getTimestamp() + c.JSON(http.StatusOK, gin.H{ + "channel": channel, + "time": timestamp, + }) +} + +func (g *GinAdapter) GetChannelsHandler(c *gin.Context) { + channels := g.getChannels() + timestamp := g.getTimestamp() + + c.JSON(http.StatusOK, gin.H{ + "channels": channels, + "time": timestamp, + }) +} + +func (g *GinAdapter) GetRatioHandler(c *gin.Context) { + ratio := g.getRatio() + timestamp := g.getTimestamp() + + c.JSON(http.StatusOK, gin.H{ + "ratio": ratio, + "time": timestamp, + }) +} + +func (g *GinAdapter) GetDataHandler(c *gin.Context) { + timestamp := g.getTimestamp() + ratio := g.getRatio() + channels := g.getChannels() + + c.JSON(http.StatusOK, gin.H{ + "channels": channels, + "ratio": ratio, + "time": timestamp, + }) +} diff --git a/api/http/gin.go b/api/http/gin.go new file mode 100644 index 0000000..5d098f8 --- /dev/null +++ b/api/http/gin.go @@ -0,0 +1,64 @@ +package http + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "th7/data/config" + "th7/ports" +) + +type GinAdapter struct { + router *gin.Engine + corePort ports.CorePort + cfg config.Config + port int +} + +func (g *GinAdapter) IndexHandler(c *gin.Context) { + c.HTML(http.StatusOK, "page_index", gin.H{ + "title": "TH7", + }) +} + +func NewGinAdapter(corePort ports.CorePort, cfg config.Config) *GinAdapter { + var adapter GinAdapter + + adapter.corePort = corePort + adapter.port = cfg.API[0].Port + adapter.cfg = cfg + + //gin.SetMode(gin.ReleaseMode) + + adapter.router = gin.New() + + // API + adapter.router.GET("/data/channel/:id", adapter.GetChannelByIDHandler) + adapter.router.GET("/data/channels", adapter.GetChannelsHandler) + adapter.router.GET("/data/ratio", adapter.GetRatioHandler) + adapter.router.GET("/data", adapter.GetDataHandler) + + adapter.router.GET("/config/channel/:id", adapter.GetConfigChannelByIdHandler) + adapter.router.GET("/config/channels", adapter.GetConfigChannelsHandler) + adapter.router.GET("/config/device", adapter.GetConfigDeviceHandler) + adapter.router.GET("/config/db", adapter.GetConfigDBHandler) + + adapter.router.POST("/config/device", adapter.SetConfigDeviceHandler) + adapter.router.POST("/config/channel/:id", adapter.SetConfigChannelByIdHandler) + adapter.router.POST("/config/db", adapter.SetConfigDBHandler) + + //adapter.router.GET("/time", adapter.TimestampHandler) + + adapter.router.LoadHTMLGlob("./templates/*.tmpl") + adapter.router.Static("/assets", "./static") + adapter.router.GET("/", adapter.IndexHandler) + + return &adapter +} + +func (g *GinAdapter) Run() { + portStr := fmt.Sprintf(":%d", g.port) + g.router.Run(portStr) +} diff --git a/api/http/util.go b/api/http/util.go new file mode 100644 index 0000000..7e6eca1 --- /dev/null +++ b/api/http/util.go @@ -0,0 +1,12 @@ +package http + +import "time" + +type PostRequest struct { + Key string `form:"key" binding:"required"` + Value string `form:"value" binding:"required"` +} + +func (g *GinAdapter) getTimestamp() string { + return time.Now().Format(time.RFC1123) +} diff --git a/config.toml b/config.toml index 87ab9cd..1d5b13e 100644 --- a/config.toml +++ b/config.toml @@ -1,13 +1,17 @@ -[TH7] -port = 8080 +[TH7] debug = true nolog = true -noweb = true +noweb = false + +[API] +HTTP.enabled = true +HTTP.port = 8080 +HTTP.restricted = false [DB] type = "sqlite3" path = "test.db" -freq = 60 +freq = 300 [Channel_1] type = 'U' diff --git a/config/config.go b/config/config.go index 89c6fec..02d5657 100644 --- a/config/config.go +++ b/config/config.go @@ -28,7 +28,7 @@ var ( } ) -func getThermocoupleType(t string) (thermocouple.Type, error) { +func GetThermocoupleType(t string) (thermocouple.Type, error) { t = strings.ToUpper(t) if val, ok := thermocoupleTypes[t]; ok { return val, nil @@ -53,15 +53,20 @@ func Load() (config.Config, error) { SetDefaultConfig(v) - cfg.Board.Port = v.GetInt("TH7.port") - cfg.Board.Cache = v.GetBool("TH7.cache") cfg.Board.Led = v.GetBool("TH7.LED") - cfg.Board.Logfreq = v.GetInt("DB.freq") cfg.Board.Debug = v.GetBool("TH7.debug") - cfg.Board.NoLogging = v.GetBool("TH7.nolog") + cfg.Board.NoLog = v.GetBool("TH7.nolog") cfg.Board.NoWeb = v.GetBool("TH7.noweb") cfg.Channels = make([]config.Channel, 0) + cfg.API = make([]config.API, 0) + + // just REST API for now .. + cfg.API = append(cfg.API, config.API{ + Port: v.GetInt("API.HTTP.port"), + Enabled: v.GetBool("API.HTTP.enabled"), + Restricted: v.GetBool("API.HTTP.restricted"), + }) for i := 1; i < 8; i++ { var c config.Channel @@ -76,7 +81,7 @@ func Load() (config.Config, error) { c.Gain = v.GetFloat64(head + ".gain") c.Offset = v.GetFloat64(head + ".offset") - tc, err := getThermocoupleType(v.GetString(head + ".type")) + tc, err := GetThermocoupleType(v.GetString(head + ".type")) if err != nil { fmt.Printf("%s.type=%s\n", head, v.GetString(head+".type")) return cfg, err diff --git a/config/defaults.go b/config/defaults.go index 4eb2b69..a7795e9 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -7,13 +7,15 @@ import ( ) func SetDefaultConfig(v *viper.Viper) { - v.SetDefault("TH7.port", 8080) - v.SetDefault("TH7.cache", true) v.SetDefault("TH7.LED", true) v.SetDefault("TH7.debug", false) v.SetDefault("TH7.nolog", false) v.SetDefault("TH7.noweb", false) - v.SetDefault("DB.freq", 600) + + // set defaults for REST API + v.SetDefault("API.HTTP.enabled", false) + v.SetDefault("API.HTTP.restricted", true) + v.SetDefault("API.HTTP.port", 8080) // set defaults for channels for i := 1; i < 8; i++ { diff --git a/data/config/data.go b/data/config/data.go index 0e003f6..e246b8a 100644 --- a/data/config/data.go +++ b/data/config/data.go @@ -4,6 +4,12 @@ import ( "th7/data/thermocouple" ) +type API struct { + Port int `json:"port"` + Enabled bool `json:"enabled"` + Restricted bool `json:"restricted"` // can read/modify DB settings? +} + type Filter struct { SampleSize int `json:"sample_size"` Type int `json:"type"` @@ -19,17 +25,15 @@ type Channel struct { } type TH7 struct { - Port int `json:"port"` - Cache bool `json:"cache"` - Led bool `json:"LED"` - Logfreq int `json:"logfreq"` - Debug bool `json:"debug"` - NoLogging bool `json:"nologging"` - NoWeb bool `json:"noweb"` + Led bool `json:"LED"` + Debug bool `json:"debug"` + NoLog bool `json:"nolog"` + NoWeb bool `json:"noweb"` } type Config struct { Board TH7 `json:"TH7"` Channels []Channel `json:"channels"` DB map[string]interface{} `json:"db"` + API []API `json:"API"` } diff --git a/db/adapter.go b/db/adapter.go index a322b8c..0f3ccd6 100644 --- a/db/adapter.go +++ b/db/adapter.go @@ -8,18 +8,25 @@ import ( "time" ) +const ( + DB_LOGGER_DEFAULT_DUR = 300 +) + func NewAdapter(corePort ports.CorePort, cfg config.Config) (ports.DBPort, error) { var duration time.Duration - var no_logging bool var adapter ports.DBPort var err error - no_logging = cfg.Board.NoLogging - duration = time.Duration(cfg.Board.Logfreq) * time.Second + // if `freq' is not present in the DB map, use default of 5 minutes (300 sec) + if _, ok := cfg.DB["freq"]; !ok { + duration = time.Duration(DB_LOGGER_DEFAULT_DUR) * time.Second + } else { + duration = time.Duration(cfg.DB["freq"].(int64)) * time.Second + } // if no DB is specified, or nolog=true then use a dummy db adapter - if _, ok := cfg.DB["type"]; !ok || no_logging { + if _, ok := cfg.DB["type"]; !ok || cfg.Board.NoLog { adapter, _ = NewDummyAdapter(cfg) go startLoggingProcess(adapter, corePort, duration) return adapter, nil diff --git a/db/logging.go b/db/logging.go index c833373..9e7f570 100644 --- a/db/logging.go +++ b/db/logging.go @@ -8,6 +8,8 @@ import ( func startLoggingProcess(db ports.DBPort, core ports.CorePort, dur time.Duration) { + log.Printf("[DB] logging frequency=%v\n", dur) + // wait 15 seconds to not log start up values that can be noisy time.Sleep(15 * time.Second) diff --git a/main.go b/main.go index 41a71c9..f24e506 100644 --- a/main.go +++ b/main.go @@ -6,12 +6,12 @@ import ( "os" "os/signal" "syscall" + "th7/api/http" "th7/config" "th7/core" "th7/db" "th7/pcb" "th7/ports" - "th7/web" "time" "github.com/fatih/color" @@ -22,7 +22,7 @@ func main() { var pcbPort ports.PCBPort var corePort ports.CorePort var dbPort ports.DBPort - var webPort ports.WebPort + var apiPort ports.APIPort var err error kill := make(chan os.Signal, 1) @@ -55,21 +55,23 @@ func main() { defer dbPort.Close() // if noweb is false, then start web service - if !cfg.Board.NoWeb { - webPort = web.NewGinAdapter(corePort, cfg) - go webPort.Run() + if !cfg.Board.NoWeb && cfg.API[0].Enabled { + apiPort = http.NewGinAdapter(corePort, cfg) + go apiPort.Run() } color.Set(color.FgHiRed) fmt.Printf("Started on: %s\n", time.Now().Format(time.DateTime)) color.Unset() - if !cfg.Board.NoWeb { + if !cfg.Board.NoWeb && cfg.API[0].Enabled { color.Set(color.FgHiGreen) - fmt.Printf("TH7 web view is live on http://localhost:%d/\n", cfg.Board.Port) + fmt.Printf("TH7 API is live on http://localhost:%d/\n", cfg.API[0].Port) color.Unset() } + fmt.Println(cfg.API[0]) + sig := <-kill log.Printf("Caught signal %v", sig) } diff --git a/ports/api.go b/ports/api.go new file mode 100644 index 0000000..595915a --- /dev/null +++ b/ports/api.go @@ -0,0 +1,5 @@ +package ports + +type APIPort interface { + Run() +} diff --git a/ports/web.go b/ports/web.go deleted file mode 100644 index c2bea64..0000000 --- a/ports/web.go +++ /dev/null @@ -1,5 +0,0 @@ -package ports - -type WebPort interface { - Run() -} diff --git a/rest-api-idea.txt b/rest-api-idea.txt index e3da106..feaac3a 100644 --- a/rest-api-idea.txt +++ b/rest-api-idea.txt @@ -36,7 +36,7 @@ GET /data/ratio -> { } # returns all data fields that a user may want to log/access -GET /data/all -> { +GET /data -> { return { /data/channels without timestamp @@ -97,8 +97,8 @@ GET /config/db -> { ========== write configuration info ========== === The POST request payload should be of the form: -=== { key: value } and several options can be specified at once like such: -=== [{ key1: value }, { key2: value}, {key3: value }] +=== { key: value } +=== $ curl -d "key=testkey&value=testvalue" -X POST "http://localhost:8080/config/device" # write device-specific key:value pair(s) to the device config # returns success or error diff --git a/web/gin.go b/web/gin.go deleted file mode 100644 index b810706..0000000 --- a/web/gin.go +++ /dev/null @@ -1,129 +0,0 @@ -package web - -import ( - "fmt" - "net/http" - "strconv" - "time" - - "github.com/gin-gonic/gin" - - "th7/data/config" - "th7/data/core" - "th7/ports" -) - -type GinAdapter struct { - router *gin.Engine - corePort ports.CorePort - cfg config.Config - port int -} - -func (g *GinAdapter) getRatio() core.Ratio { - return g.corePort.GetRatio() -} - -func (g *GinAdapter) RatioHandler(c *gin.Context) { - ratio := g.getRatio() - c.JSON(http.StatusOK, ratio) -} - -func (g *GinAdapter) getChannels() []core.Channel { - return g.corePort.GetChannels() -} - -func (g *GinAdapter) ChannelsHandler(c *gin.Context) { - channels := g.getChannels() - c.JSON(http.StatusOK, channels) -} - -func (g *GinAdapter) getChannelByID(id int) (core.Channel, error) { - return g.corePort.GetChannel(id) -} - -func (g *GinAdapter) ChannelByIDHandler(c *gin.Context) { - id, err := strconv.Atoi(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": err, - }) - return - } - - channel, err := g.getChannelByID(id) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": err, - }) - return - } - - c.JSON(http.StatusOK, channel) -} - -func (g *GinAdapter) getTimestamp() string { - return time.Now().Format(time.RFC1123) -} - -func (g *GinAdapter) TimestampHandler(c *gin.Context) { - ts := g.getTimestamp() - c.JSON(http.StatusOK, gin.H{ - "time": ts, - }) -} - -func (g *GinAdapter) DataHandler(c *gin.Context) { - timestamp := g.getTimestamp() - ratio := g.getRatio() - channels := g.getChannels() - - c.JSON(http.StatusOK, gin.H{ - "time": timestamp, - "ratio": ratio, - "channels": channels, - }) -} - -func (g *GinAdapter) IndexHandler(c *gin.Context) { - c.HTML(http.StatusOK, "page_index", gin.H{ - "title": "TH7", - }) -} - -func (g *GinAdapter) ConfigHandler(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "config": g.cfg, - }) -} - -func NewGinAdapter(corePort ports.CorePort, cfg config.Config) *GinAdapter { - var adapter GinAdapter - - adapter.corePort = corePort - adapter.port = cfg.Board.Port - adapter.cfg = cfg - - gin.SetMode(gin.ReleaseMode) - - adapter.router = gin.New() - - // API - adapter.router.GET("/ratio", adapter.RatioHandler) - adapter.router.GET("/channels", adapter.ChannelsHandler) - adapter.router.GET("/channel/:id", adapter.ChannelByIDHandler) - adapter.router.GET("/time", adapter.TimestampHandler) - adapter.router.GET("/data", adapter.DataHandler) - adapter.router.GET("/config", adapter.ConfigHandler) - - adapter.router.LoadHTMLGlob("./templates/*.tmpl") - adapter.router.Static("/assets", "./static") - adapter.router.GET("/", adapter.IndexHandler) - - return &adapter -} - -func (g *GinAdapter) Run() { - portStr := fmt.Sprintf(":%d", g.port) - g.router.Run(portStr) -}