1
0
Fork 0
hats/internal/api/api.go

340 lines
8.7 KiB
Go
Raw Normal View History

2023-10-12 17:23:35 +00:00
package api
import (
"fmt"
2023-11-13 21:54:07 +00:00
"io"
2023-10-12 17:23:35 +00:00
"net/http"
2023-11-17 18:24:12 +00:00
"strings"
2023-10-12 17:23:35 +00:00
"time"
"log/slog"
"code.jhot.me/jhot/hats/internal/nats"
2023-11-08 23:12:04 +00:00
"code.jhot.me/jhot/hats/internal/ntfy"
2023-10-12 17:23:35 +00:00
"code.jhot.me/jhot/hats/pkg/config"
"code.jhot.me/jhot/hats/pkg/homeassistant"
2023-11-08 23:12:04 +00:00
ntfyPkg "code.jhot.me/jhot/hats/pkg/ntfy"
2023-10-12 17:23:35 +00:00
"github.com/go-chi/chi/middleware"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
2023-11-17 18:43:50 +00:00
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
2023-10-12 17:23:35 +00:00
)
var (
cfg *config.HatsConfig
logger *slog.Logger
server http.Server
haClient *homeassistant.RestClient
2023-11-17 18:43:50 +00:00
homepage string
2023-10-12 17:23:35 +00:00
)
const (
HA_STATE_PREFIX = "homeassistant.states"
)
2023-11-17 18:43:50 +00:00
func Listen(parentLogger *slog.Logger, readme []byte) {
2023-10-12 17:23:35 +00:00
logger = parentLogger
2023-11-17 18:43:50 +00:00
// render readme to HTML
p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock)
doc := p.Parse(readme)
homepage = string(markdown.Render(doc, html.NewRenderer(html.RendererOptions{Flags: html.CommonFlags | html.HrefTargetBlank})))
2023-10-12 17:23:35 +00:00
cfg = config.FromEnvironment()
haClient = homeassistant.NewRestClient(cfg.GetHomeAssistantBaseUrl(), cfg.HomeAssistantToken)
2023-10-12 17:23:35 +00:00
router := chi.NewRouter()
router.Use(middleware.RequestID)
router.Use(middleware.RealIP)
2023-11-17 18:24:12 +00:00
router.Use(loggerMiddleware)
2023-10-12 17:23:35 +00:00
router.Use(middleware.Recoverer)
router.Use(middleware.Timeout(60 * time.Second))
2023-11-17 18:43:50 +00:00
router.Get("/", func(w http.ResponseWriter, r *http.Request) {
render.HTML(w, r, homepage)
})
router.Get("/status", func(w http.ResponseWriter, r *http.Request) {
render.PlainText(w, r, "OK")
})
router.Route("/api", func(r chi.Router) {
r.Use(tokenAuthMiddleware)
r.Get(`/api/state/{entityId}`, getEntityStateHandler)
r.Post("/api/state/{entityId}/{service}", setEntityStateHandler)
2023-10-13 20:31:23 +00:00
2023-11-17 18:43:50 +00:00
r.Get("/api/timer/{timerName}", getTimerHandler)
r.Post("/api/timer/{timerName}", startTimerHandler)
r.Delete("/api/timer/{timerName}", deleteTimerHandler)
2023-10-13 20:31:23 +00:00
2023-11-17 18:43:50 +00:00
r.Get("/api/schedule/{scheduleName}", getScheduleHandler)
r.Post("/api/schedule/{scheduleName}", createScheduleHandler)
r.Delete("/api/schedule/{scheduleName}", deleteScheduleHandler)
2023-10-12 17:23:35 +00:00
2023-11-17 18:43:50 +00:00
r.Post("/api/ntfy", postNtfyHandler)
2023-11-08 23:12:04 +00:00
2023-11-17 18:43:50 +00:00
r.Post("/api/command/{commandName}", postCommandHandler)
})
2023-11-13 21:54:07 +00:00
2023-10-12 17:23:35 +00:00
server = http.Server{
Addr: ":8888",
Handler: router,
}
go server.ListenAndServe()
}
func Close() {
if server.Addr != "" {
server.Close()
}
}
2023-10-13 20:31:23 +00:00
2023-11-17 18:24:12 +00:00
func tokenAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if cfg.HatsToken == "" { // No token required
2023-11-17 18:52:09 +00:00
logger.Debug("Skipping token auth")
2023-11-17 18:24:12 +00:00
next.ServeHTTP(w, r)
return
}
2023-11-17 18:56:22 +00:00
logger.Debug("Checking bearer token")
authHeaderParts := strings.Split(r.Header.Get("Authorization"), " ")
if len(authHeaderParts) != 2 || authHeaderParts[0] != "Bearer" || authHeaderParts[1] != cfg.HatsToken {
2023-11-17 18:52:09 +00:00
logger.Warn("Unauthorized request", "method", r.Method, "path", r.URL.Path, "address", r.RemoteAddr)
2023-11-17 18:24:12 +00:00
http.Error(w, "Bearer authorization header doesn't match configured token", http.StatusUnauthorized)
return
}
2023-11-17 18:56:22 +00:00
logger.Debug("Token valid")
next.ServeHTTP(w, r)
2023-11-17 18:24:12 +00:00
})
}
func loggerMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger.Debug(fmt.Sprintf("%s %s", r.Method, r.URL.Path), "method", r.Method, "path", r.URL.Path, "address", r.RemoteAddr)
next.ServeHTTP(w, r)
})
2023-10-13 20:31:23 +00:00
}
// HOME ASSISTANT ENTITIES
func getEntityStateHandler(w http.ResponseWriter, r *http.Request) {
entityId := chi.URLParam(r, "entityId")
2023-10-16 16:54:09 +00:00
full := r.URL.Query().Get("full") == "true"
if !full {
kvVal, err := nats.GetKeyValue(fmt.Sprintf("%s.%s", HA_STATE_PREFIX, entityId))
if err == nil && len(kvVal) > 0 {
w.Write(kvVal)
return
}
2023-10-13 20:31:23 +00:00
}
data, err := haClient.GetState(entityId)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
nats.SetKeyValueString(fmt.Sprintf("%s.%s", HA_STATE_PREFIX, entityId), data.State)
2023-10-16 16:54:09 +00:00
if full {
render.JSON(w, r, data)
} else {
render.PlainText(w, r, data.State)
}
2023-10-13 20:31:23 +00:00
}
func setEntityStateHandler(w http.ResponseWriter, r *http.Request) {
entityId := chi.URLParam(r, "entityId")
service := chi.URLParam(r, "service")
2023-11-07 23:09:10 +00:00
domain := r.URL.Query().Get("domain")
2023-10-13 20:31:23 +00:00
2023-10-18 18:35:46 +00:00
var extras map[string]any
2023-10-13 20:31:23 +00:00
err := render.DecodeJSON(r.Body, &extras)
var haErr error
if err == nil && len(extras) > 0 {
2023-11-07 23:09:10 +00:00
if domain != "" {
haErr = haClient.CallServiceManual(domain, entityId, service, extras)
} else {
haErr = haClient.CallService(entityId, service, extras)
}
2023-10-13 20:31:23 +00:00
} else {
2023-11-07 23:09:10 +00:00
if domain != "" {
haErr = haClient.CallServiceManual(domain, entityId, service)
} else {
haErr = haClient.CallService(entityId, service)
}
2023-10-13 20:31:23 +00:00
}
if haErr != nil {
logger.Error("Error setting state", "error", haErr)
http.Error(w, fmt.Sprintf("error proxying request: %s", haErr.Error()), http.StatusInternalServerError)
return
}
render.Status(r, http.StatusOK)
render.PlainText(w, r, "OK")
}
// TIMERS
func getTimerHandler(w http.ResponseWriter, r *http.Request) {
timerName := chi.URLParam(r, "timerName")
timer, err := nats.GetTimer(timerName)
if err != nil {
http.Error(w, "Unable to get timer: "+err.Error(), http.StatusInternalServerError)
return
}
2023-10-18 18:35:46 +00:00
render.PlainText(w, r, timer.ToString())
2023-10-13 20:31:23 +00:00
}
2023-10-18 18:35:46 +00:00
type StartTimerData struct {
2023-10-13 20:31:23 +00:00
Duration string `json:"duration"`
Force bool `json:"force"`
}
2023-10-18 18:35:46 +00:00
func startTimerHandler(w http.ResponseWriter, r *http.Request) {
2023-10-13 20:31:23 +00:00
timerName := chi.URLParam(r, "timerName")
2023-10-18 18:35:46 +00:00
data := &StartTimerData{}
2023-10-13 20:31:23 +00:00
if err := render.DecodeJSON(r.Body, data); err != nil {
http.Error(w, "Unable to parse timer data", http.StatusNotAcceptable)
return
}
2023-10-18 18:35:46 +00:00
timer, err := nats.GetTimer(timerName)
if err != nil {
http.Error(w, "Unable to get timer: "+err.Error(), http.StatusInternalServerError)
2023-10-13 20:31:23 +00:00
return
}
2023-10-18 18:35:46 +00:00
if data.Duration == "" {
data.Duration = timer.Duration.String()
}
2023-10-13 20:31:23 +00:00
if data.Force {
2023-10-18 18:35:46 +00:00
timer.Activate(data.Duration)
2023-10-13 20:31:23 +00:00
} else {
2023-10-18 18:35:46 +00:00
timer.ActivateIfNotAlready(data.Duration)
2023-10-13 20:31:23 +00:00
}
getTimerHandler(w, r)
}
func deleteTimerHandler(w http.ResponseWriter, r *http.Request) {
timerName := chi.URLParam(r, "timerName")
timer, err := nats.GetTimer(timerName)
if err != nil {
http.Error(w, "Unable to get timer: "+err.Error(), http.StatusInternalServerError)
return
}
timer.Cancel()
render.PlainText(w, r, "OK")
}
// SCHEDULES
func getScheduleHandler(w http.ResponseWriter, r *http.Request) {
scheduleName := chi.URLParam(r, "scheduleName")
schedule, err := nats.GetSchedule(scheduleName)
if err != nil {
http.Error(w, "Unable to get schedule: "+err.Error(), http.StatusInternalServerError)
return
}
render.PlainText(w, r, string(schedule.GetNext()))
}
type CreateScheduleData struct {
Cron string `json:"cron"`
}
func createScheduleHandler(w http.ResponseWriter, r *http.Request) {
scheduleName := chi.URLParam(r, "scheduleName")
data := &CreateScheduleData{}
if err := render.DecodeJSON(r.Body, data); err != nil {
http.Error(w, "Unable to parse schedule data", http.StatusNotAcceptable)
return
}
if data.Cron == "" {
http.Error(w, "cron required", http.StatusNotAcceptable)
return
}
schedule := nats.NewSchedule(scheduleName, data.Cron)
schedule.Activate()
getScheduleHandler(w, r)
}
func deleteScheduleHandler(w http.ResponseWriter, r *http.Request) {
scheduleName := chi.URLParam(r, "scheduleName")
schedule, err := nats.GetSchedule(scheduleName)
if err != nil {
http.Error(w, "Unable to get schedule: "+err.Error(), http.StatusInternalServerError)
return
}
schedule.Cancel()
render.PlainText(w, r, "OK")
}
2023-11-08 23:12:04 +00:00
// NTFY
func postNtfyHandler(w http.ResponseWriter, r *http.Request) {
data := &ntfyPkg.Message{}
if err := render.DecodeJSON(r.Body, data); err != nil {
http.Error(w, "Unable to parse message data", http.StatusNotAcceptable)
return
}
if err := ntfy.Send(*data); err != nil {
http.Error(w, "Unable to send message", http.StatusBadRequest)
return
}
render.PlainText(w, r, "OK")
}
2023-11-13 21:54:07 +00:00
// Command
func postCommandHandler(w http.ResponseWriter, r *http.Request) {
commandName := chi.URLParam(r, "commandName")
switch commandName {
// Commands without payloads
case "bedtime":
nats.PublishString(fmt.Sprintf("command.%s", commandName), "called")
render.PlainText(w, r, "OK")
return
// Commands with payloads
case "sonarr":
case "radarr":
case "paupdate":
body, err := io.ReadAll(r.Body)
if err != nil {
logger.Error("Error reading request body", "error", err, "url", r.URL.String())
http.Error(w, "Unable to read body", http.StatusBadRequest)
return
}
nats.Publish(fmt.Sprintf("command.%s", commandName), body)
render.PlainText(w, r, "OK")
return
// Otherwise
default:
logger.Error("Command not implemented", "commandName", commandName)
http.Error(w, "Command not implemented", http.StatusNotFound)
return
}
}