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]
port = 8080
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'

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

View File

@ -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++ {

View File

@ -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,12 +25,9 @@ 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"`
NoLog bool `json:"nolog"`
NoWeb bool `json:"noweb"`
}
@ -32,4 +35,5 @@ type Config struct {
Board TH7 `json:"TH7"`
Channels []Channel `json:"channels"`
DB map[string]interface{} `json:"db"`
API []API `json:"API"`
}

View File

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

View File

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

16
main.go
View File

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

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
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

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