Add custom API widget

This commit is contained in:
Svilen Markov
2024-10-21 23:27:25 +01:00
parent 2dd5b29303
commit 84a7f90129
8 changed files with 244 additions and 8 deletions

View File

@@ -40,9 +40,10 @@ var (
GroupTemplate = compileTemplate("group.html", "widget-base.html")
DNSStatsTemplate = compileTemplate("dns-stats.html", "widget-base.html")
SplitColumnTemplate = compileTemplate("split-column.html", "widget-base.html")
CustomAPITemplate = compileTemplate("custom-api.html", "widget-base.html")
)
var globalTemplateFunctions = template.FuncMap{
var GlobalTemplateFunctions = template.FuncMap{
"relativeTime": relativeTimeSince,
"formatViewerCount": formatViewerCount,
"formatNumber": intl.Sprint,
@@ -59,7 +60,7 @@ var globalTemplateFunctions = template.FuncMap{
func compileTemplate(primary string, dependencies ...string) *template.Template {
t, err := template.New(primary).
Funcs(globalTemplateFunctions).
Funcs(GlobalTemplateFunctions).
ParseFS(TemplateFS, append([]string{primary}, dependencies...)...)
if err != nil {

View File

@@ -0,0 +1,7 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}{{ if .Frameless }}widget-content-frameless{{ end }}{{ end }}
{{ define "widget-content" }}
{{ .CompiledHTML }}
{{ end }}

148
internal/feed/custom-api.go Normal file
View File

@@ -0,0 +1,148 @@
package feed
import (
"bytes"
"errors"
"html/template"
"io"
"log/slog"
"net/http"
"github.com/glanceapp/glance/internal/assets"
"github.com/tidwall/gjson"
)
func FetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) {
emptyBody := template.HTML("")
resp, err := defaultClient.Do(req)
if err != nil {
return emptyBody, err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return emptyBody, err
}
body := string(bodyBytes)
if !gjson.Valid(body) {
truncatedBody, isTruncated := limitStringLength(body, 100)
if isTruncated {
truncatedBody += "... <truncated>"
}
slog.Error("invalid response JSON in custom API widget", "URL", req.URL.String(), "body", truncatedBody)
return emptyBody, errors.New("invalid response JSON")
}
var templateBuffer bytes.Buffer
data := CustomAPITemplateData{
JSON: DecoratedGJSONResult{gjson.Parse(body)},
Response: resp,
}
err = tmpl.Execute(&templateBuffer, &data)
if err != nil {
return emptyBody, err
}
return template.HTML(templateBuffer.String()), nil
}
type DecoratedGJSONResult struct {
gjson.Result
}
type CustomAPITemplateData struct {
JSON DecoratedGJSONResult
Response *http.Response
}
func GJsonResultArrayToDecoratedResultArray(results []gjson.Result) []DecoratedGJSONResult {
decoratedResults := make([]DecoratedGJSONResult, len(results))
for i, result := range results {
decoratedResults[i] = DecoratedGJSONResult{result}
}
return decoratedResults
}
func (r *DecoratedGJSONResult) Array(key string) []DecoratedGJSONResult {
if key == "" {
return GJsonResultArrayToDecoratedResultArray(r.Result.Array())
}
return GJsonResultArrayToDecoratedResultArray(r.Get(key).Array())
}
func (r *DecoratedGJSONResult) String(key string) string {
if key == "" {
return r.Result.String()
}
return r.Get(key).String()
}
func (r *DecoratedGJSONResult) Int(key string) int64 {
if key == "" {
return r.Result.Int()
}
return r.Get(key).Int()
}
func (r *DecoratedGJSONResult) Float(key string) float64 {
if key == "" {
return r.Result.Float()
}
return r.Get(key).Float()
}
func (r *DecoratedGJSONResult) Bool(key string) bool {
if key == "" {
return r.Result.Bool()
}
return r.Get(key).Bool()
}
var CustomAPITemplateFuncs = func() template.FuncMap {
funcs := template.FuncMap{
"toFloat": func(a int64) float64 {
return float64(a)
},
"toInt": func(a float64) int64 {
return int64(a)
},
"mathexpr": func(left float64, op string, right float64) float64 {
if right == 0 {
return 0
}
switch op {
case "+":
return left + right
case "-":
return left - right
case "*":
return left * right
case "/":
return left / right
default:
return 0
}
},
}
for key, value := range assets.GlobalTemplateFunctions {
funcs[key] = value
}
return funcs
}()

View File

@@ -0,0 +1,70 @@
package widget
import (
"context"
"errors"
"fmt"
"html/template"
"net/http"
"time"
"github.com/glanceapp/glance/internal/assets"
"github.com/glanceapp/glance/internal/feed"
)
type CustomApi struct {
widgetBase `yaml:",inline"`
URL string `yaml:"url"`
Template string `yaml:"template"`
Frameless bool `yaml:"frameless"`
Headers map[string]OptionalEnvString `yaml:"headers"`
APIRequest *http.Request `yaml:"-"`
compiledTemplate *template.Template `yaml:"-"`
CompiledHTML template.HTML `yaml:"-"`
}
func (widget *CustomApi) Initialize() error {
widget.withTitle("Custom API").withCacheDuration(1 * time.Hour)
if widget.URL == "" {
return errors.New("URL is required for the custom API widget")
}
if widget.Template == "" {
return errors.New("template is required for the custom API widget")
}
compiledTemplate, err := template.New("").Funcs(feed.CustomAPITemplateFuncs).Parse(widget.Template)
if err != nil {
return fmt.Errorf("failed parsing custom API widget template: %w", err)
}
widget.compiledTemplate = compiledTemplate
req, err := http.NewRequest(http.MethodGet, widget.URL, nil)
if err != nil {
return err
}
for key, value := range widget.Headers {
req.Header.Add(key, value.String())
}
widget.APIRequest = req
return nil
}
func (widget *CustomApi) Update(ctx context.Context) {
compiledHTML, err := feed.FetchAndParseCustomAPI(widget.APIRequest, widget.compiledTemplate)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
widget.CompiledHTML = compiledHTML
}
func (widget *CustomApi) Render() template.HTML {
return widget.render(widget, assets.CustomAPITemplate)
}

View File

@@ -69,6 +69,8 @@ func New(widgetType string) (Widget, error) {
widget = &DNSStats{}
case "split-column":
widget = &SplitColumn{}
case "custom-api":
widget = &CustomApi{}
default:
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
}