initial version of http/rest API

This commit is contained in:
William Clark 2023-12-05 17:22:12 +00:00
parent 8f996ffee1
commit 6827006f94
15 changed files with 530 additions and 168 deletions

304
api/http/config.go Normal file
View File

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

85
api/http/data.go Normal file
View File

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

64
api/http/gin.go Normal file
View File

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

12
api/http/util.go Normal file
View File

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

View File

@ -1,13 +1,17 @@
[TH7] [TH7]
port = 8080
debug = true debug = true
nolog = true nolog = true
noweb = true noweb = false
[API]
HTTP.enabled = true
HTTP.port = 8080
HTTP.restricted = false
[DB] [DB]
type = "sqlite3" type = "sqlite3"
path = "test.db" path = "test.db"
freq = 60 freq = 300
[Channel_1] [Channel_1]
type = 'U' type = 'U'

View File

@ -28,7 +28,7 @@ var (
} }
) )
func getThermocoupleType(t string) (thermocouple.Type, error) { func GetThermocoupleType(t string) (thermocouple.Type, error) {
t = strings.ToUpper(t) t = strings.ToUpper(t)
if val, ok := thermocoupleTypes[t]; ok { if val, ok := thermocoupleTypes[t]; ok {
return val, nil return val, nil
@ -53,15 +53,20 @@ func Load() (config.Config, error) {
SetDefaultConfig(v) 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.Led = v.GetBool("TH7.LED")
cfg.Board.Logfreq = v.GetInt("DB.freq")
cfg.Board.Debug = v.GetBool("TH7.debug") 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.Board.NoWeb = v.GetBool("TH7.noweb")
cfg.Channels = make([]config.Channel, 0) 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++ { for i := 1; i < 8; i++ {
var c config.Channel var c config.Channel
@ -76,7 +81,7 @@ func Load() (config.Config, error) {
c.Gain = v.GetFloat64(head + ".gain") c.Gain = v.GetFloat64(head + ".gain")
c.Offset = v.GetFloat64(head + ".offset") c.Offset = v.GetFloat64(head + ".offset")
tc, err := getThermocoupleType(v.GetString(head + ".type")) tc, err := GetThermocoupleType(v.GetString(head + ".type"))
if err != nil { if err != nil {
fmt.Printf("%s.type=%s\n", head, v.GetString(head+".type")) fmt.Printf("%s.type=%s\n", head, v.GetString(head+".type"))
return cfg, err return cfg, err

View File

@ -7,13 +7,15 @@ import (
) )
func SetDefaultConfig(v *viper.Viper) { func SetDefaultConfig(v *viper.Viper) {
v.SetDefault("TH7.port", 8080)
v.SetDefault("TH7.cache", true)
v.SetDefault("TH7.LED", true) v.SetDefault("TH7.LED", true)
v.SetDefault("TH7.debug", false) v.SetDefault("TH7.debug", false)
v.SetDefault("TH7.nolog", false) v.SetDefault("TH7.nolog", false)
v.SetDefault("TH7.noweb", 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 // set defaults for channels
for i := 1; i < 8; i++ { for i := 1; i < 8; i++ {

View File

@ -4,6 +4,12 @@ import (
"th7/data/thermocouple" "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 { type Filter struct {
SampleSize int `json:"sample_size"` SampleSize int `json:"sample_size"`
Type int `json:"type"` Type int `json:"type"`
@ -19,12 +25,9 @@ type Channel struct {
} }
type TH7 struct { type TH7 struct {
Port int `json:"port"`
Cache bool `json:"cache"`
Led bool `json:"LED"` Led bool `json:"LED"`
Logfreq int `json:"logfreq"`
Debug bool `json:"debug"` Debug bool `json:"debug"`
NoLogging bool `json:"nologging"` NoLog bool `json:"nolog"`
NoWeb bool `json:"noweb"` NoWeb bool `json:"noweb"`
} }
@ -32,4 +35,5 @@ type Config struct {
Board TH7 `json:"TH7"` Board TH7 `json:"TH7"`
Channels []Channel `json:"channels"` Channels []Channel `json:"channels"`
DB map[string]interface{} `json:"db"` DB map[string]interface{} `json:"db"`
API []API `json:"API"`
} }

View File

@ -8,18 +8,25 @@ import (
"time" "time"
) )
const (
DB_LOGGER_DEFAULT_DUR = 300
)
func NewAdapter(corePort ports.CorePort, cfg config.Config) (ports.DBPort, error) { func NewAdapter(corePort ports.CorePort, cfg config.Config) (ports.DBPort, error) {
var duration time.Duration var duration time.Duration
var no_logging bool
var adapter ports.DBPort var adapter ports.DBPort
var err error var err error
no_logging = cfg.Board.NoLogging // if `freq' is not present in the DB map, use default of 5 minutes (300 sec)
duration = time.Duration(cfg.Board.Logfreq) * time.Second 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 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) adapter, _ = NewDummyAdapter(cfg)
go startLoggingProcess(adapter, corePort, duration) go startLoggingProcess(adapter, corePort, duration)
return adapter, nil return adapter, nil

View File

@ -8,6 +8,8 @@ import (
func startLoggingProcess(db ports.DBPort, core ports.CorePort, dur time.Duration) { 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 // wait 15 seconds to not log start up values that can be noisy
time.Sleep(15 * time.Second) time.Sleep(15 * time.Second)

16
main.go
View File

@ -6,12 +6,12 @@ import (
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"th7/api/http"
"th7/config" "th7/config"
"th7/core" "th7/core"
"th7/db" "th7/db"
"th7/pcb" "th7/pcb"
"th7/ports" "th7/ports"
"th7/web"
"time" "time"
"github.com/fatih/color" "github.com/fatih/color"
@ -22,7 +22,7 @@ func main() {
var pcbPort ports.PCBPort var pcbPort ports.PCBPort
var corePort ports.CorePort var corePort ports.CorePort
var dbPort ports.DBPort var dbPort ports.DBPort
var webPort ports.WebPort var apiPort ports.APIPort
var err error var err error
kill := make(chan os.Signal, 1) kill := make(chan os.Signal, 1)
@ -55,21 +55,23 @@ func main() {
defer dbPort.Close() defer dbPort.Close()
// if noweb is false, then start web service // if noweb is false, then start web service
if !cfg.Board.NoWeb { if !cfg.Board.NoWeb && cfg.API[0].Enabled {
webPort = web.NewGinAdapter(corePort, cfg) apiPort = http.NewGinAdapter(corePort, cfg)
go webPort.Run() go apiPort.Run()
} }
color.Set(color.FgHiRed) color.Set(color.FgHiRed)
fmt.Printf("Started on: %s\n", time.Now().Format(time.DateTime)) fmt.Printf("Started on: %s\n", time.Now().Format(time.DateTime))
color.Unset() color.Unset()
if !cfg.Board.NoWeb { if !cfg.Board.NoWeb && cfg.API[0].Enabled {
color.Set(color.FgHiGreen) 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() color.Unset()
} }
fmt.Println(cfg.API[0])
sig := <-kill sig := <-kill
log.Printf("Caught signal %v", sig) log.Printf("Caught signal %v", sig)
} }

5
ports/api.go Normal file
View File

@ -0,0 +1,5 @@
package ports
type APIPort interface {
Run()
}

View File

@ -1,5 +0,0 @@
package ports
type WebPort interface {
Run()
}

View File

@ -36,7 +36,7 @@ GET /data/ratio -> {
} }
# returns all data fields that a user may want to log/access # returns all data fields that a user may want to log/access
GET /data/all -> { GET /data -> {
return { return {
/data/channels without timestamp /data/channels without timestamp
@ -97,8 +97,8 @@ GET /config/db -> {
========== write configuration info ========== ========== write configuration info ==========
=== The POST request payload should be of the form: === The POST request payload should be of the form:
=== { key: value } and several options can be specified at once like such: === { key: value }
=== [{ key1: value }, { key2: value}, {key3: 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 # write device-specific key:value pair(s) to the device config
# returns success or error # returns success or error

View File

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