Bläddra i källkod

Add subrequests to custom-api (#385)

* feat: custom-api multiple API queries

* fix template check

* refactor

* Update implementation & docs

* Swap statement

* Update docs

---------

Co-authored-by: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com>
Ralph Ocdol 4 månader sedan
förälder
incheckning
c265e42220
2 ändrade filer med 185 tillägg och 41 borttagningar
  1. 36 1
      docs/configuration.md
  2. 149 40
      internal/glance/widget-custom-api.go

+ 36 - 1
docs/configuration.md

@@ -1294,7 +1294,8 @@ Examples:
 | headers | key (string) & value (string) | no | |
 | headers | key (string) & value (string) | no | |
 | frameless | boolean | no | false |
 | frameless | boolean | no | false |
 | template | string | yes | |
 | template | string | yes | |
-| parameters | key & value | no | |
+| parameters | key (string) & value (string|array) | no | |
+| subrequests | map of requests | no | |
 
 
 ##### `url`
 ##### `url`
 The URL to fetch the data from. It must be accessible from the server that Glance is running on.
 The URL to fetch the data from. It must be accessible from the server that Glance is running on.
@@ -1317,6 +1318,40 @@ The template that will be used to display the data. It relies on Go's `html/temp
 ##### `parameters`
 ##### `parameters`
 A list of keys and values that will be sent to the custom-api as query paramters.
 A list of keys and values that will be sent to the custom-api as query paramters.
 
 
+##### `subrequests`
+A map of additional requests that will be executed concurrently and then made available in the template via the `.Subrequest` property. Example:
+
+```yaml
+- type: custom-api
+  cache: 2h
+  subrequests:
+    another-one:
+      url: https://uselessfacts.jsph.pl/api/v2/facts/random
+  title: Random Fact
+  url: https://uselessfacts.jsph.pl/api/v2/facts/random
+  template: |
+    <p class="size-h4 color-paragraph">{{ .JSON.String "text" }}</p>
+    <p class="size-h4 color-paragraph margin-top-15">{{ (.Subrequest "another-one").JSON.String "text" }}</p>
+```
+
+The subrequests support all the same properties as the main request, except for `subrequests` itself, so you can use `headers`, `parameters`, etc.
+
+`(.Subrequest "key")` can be a little cumbersome to write, so you can define a variable to make it easier:
+
+```yaml
+  template: |
+    {{ $anotherOne := .Subrequest "another-one" }}
+    <p>{{ $anotherOne.JSON.String "text" }}</p>
+```
+
+You can also access the `.Response` property of a subrequest as you would with the main request:
+
+```yaml
+  template: |
+    {{ $anotherOne := .Subrequest "another-one" }}
+    <p>{{ $anotherOne.Response.StatusCode }}</p>
+```
+
 > [!NOTE]
 > [!NOTE]
 >
 >
 > Setting this property will override any query parameters that are already in the URL.
 > Setting this property will override any query parameters that are already in the URL.

+ 149 - 40
internal/glance/widget-custom-api.go

@@ -11,6 +11,7 @@ import (
 	"math"
 	"math"
 	"net/http"
 	"net/http"
 	"strings"
 	"strings"
+	"sync"
 	"time"
 	"time"
 
 
 	"github.com/tidwall/gjson"
 	"github.com/tidwall/gjson"
@@ -18,23 +19,35 @@ import (
 
 
 var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.html")
 var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.html")
 
 
+// Needs to be exported for the YAML unmarshaler to work
+type CustomAPIRequest struct {
+	URL         string               `json:"url"`
+	Headers     map[string]string    `json:"headers"`
+	Parameters  queryParametersField `json:"parameters"`
+	httpRequest *http.Request        `yaml:"-"`
+}
+
 type customAPIWidget struct {
 type customAPIWidget struct {
-	widgetBase       `yaml:",inline"`
-	URL              string               `yaml:"url"`
-	Template         string               `yaml:"template"`
-	Frameless        bool                 `yaml:"frameless"`
-	Headers          map[string]string    `yaml:"headers"`
-	Parameters       queryParametersField `yaml:"parameters"`
-	APIRequest       *http.Request        `yaml:"-"`
-	compiledTemplate *template.Template   `yaml:"-"`
-	CompiledHTML     template.HTML        `yaml:"-"`
+	widgetBase        `yaml:",inline"`
+	*CustomAPIRequest `yaml:",inline"`             // the primary request
+	Subrequests       map[string]*CustomAPIRequest `yaml:"subrequests"`
+	Template          string                       `yaml:"template"`
+	Frameless         bool                         `yaml:"frameless"`
+	compiledTemplate  *template.Template           `yaml:"-"`
+	CompiledHTML      template.HTML                `yaml:"-"`
 }
 }
 
 
 func (widget *customAPIWidget) initialize() error {
 func (widget *customAPIWidget) initialize() error {
 	widget.withTitle("Custom API").withCacheDuration(1 * time.Hour)
 	widget.withTitle("Custom API").withCacheDuration(1 * time.Hour)
 
 
-	if widget.URL == "" {
-		return errors.New("URL is required")
+	if err := widget.CustomAPIRequest.initialize(); err != nil {
+		return fmt.Errorf("initializing primary request: %v", err)
+	}
+
+	for key := range widget.Subrequests {
+		if err := widget.Subrequests[key].initialize(); err != nil {
+			return fmt.Errorf("initializing subrequest %q: %v", key, err)
+		}
 	}
 	}
 
 
 	if widget.Template == "" {
 	if widget.Template == "" {
@@ -48,24 +61,11 @@ func (widget *customAPIWidget) initialize() error {
 
 
 	widget.compiledTemplate = compiledTemplate
 	widget.compiledTemplate = compiledTemplate
 
 
-	req, err := http.NewRequest(http.MethodGet, widget.URL, nil)
-	if err != nil {
-		return err
-	}
-
-	req.URL.RawQuery = widget.Parameters.toQueryString()
-
-	for key, value := range widget.Headers {
-		req.Header.Add(key, value)
-	}
-
-	widget.APIRequest = req
-
 	return nil
 	return nil
 }
 }
 
 
 func (widget *customAPIWidget) update(ctx context.Context) {
 func (widget *customAPIWidget) update(ctx context.Context) {
-	compiledHTML, err := fetchAndParseCustomAPI(widget.APIRequest, widget.compiledTemplate)
+	compiledHTML, err := fetchAndParseCustomAPI(widget.CustomAPIRequest, widget.Subrequests, widget.compiledTemplate)
 	if !widget.canContinueUpdateAfterHandlingErr(err) {
 	if !widget.canContinueUpdateAfterHandlingErr(err) {
 		return
 		return
 	}
 	}
@@ -77,18 +77,63 @@ func (widget *customAPIWidget) Render() template.HTML {
 	return widget.renderTemplate(widget, customAPIWidgetTemplate)
 	return widget.renderTemplate(widget, customAPIWidgetTemplate)
 }
 }
 
 
-func fetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) {
-	emptyBody := template.HTML("")
+func (req *CustomAPIRequest) initialize() error {
+	if req.URL == "" {
+		return errors.New("URL is required")
+	}
 
 
-	resp, err := defaultHTTPClient.Do(req)
+	httpReq, err := http.NewRequest(http.MethodGet, req.URL, nil)
 	if err != nil {
 	if err != nil {
-		return emptyBody, err
+		return err
+	}
+
+	if len(req.Parameters) > 0 {
+		httpReq.URL.RawQuery = req.Parameters.toQueryString()
+	}
+
+	for key, value := range req.Headers {
+		httpReq.Header.Add(key, value)
+	}
+
+	req.httpRequest = httpReq
+
+	return nil
+}
+
+type customAPIResponseData struct {
+	JSON     decoratedGJSONResult
+	Response *http.Response
+}
+
+type customAPITemplateData struct {
+	*customAPIResponseData
+	subrequests map[string]*customAPIResponseData
+}
+
+func (data *customAPITemplateData) Subrequest(key string) *customAPIResponseData {
+	req, exists := data.subrequests[key]
+	if !exists {
+		// We have to panic here since there's nothing sensible we can return and the
+		// lack of an error would cause requested data to return zero values which
+		// would be confusing from the user's perspective. Go's template module
+		// handles recovering from panics and will return the panic message as an
+		// error during template execution.
+		panic(fmt.Sprintf("subrequest with key %q has not been defined", key))
+	}
+
+	return req
+}
+
+func fetchCustomAPIRequest(ctx context.Context, req *CustomAPIRequest) (*customAPIResponseData, error) {
+	resp, err := defaultHTTPClient.Do(req.httpRequest.WithContext(ctx))
+	if err != nil {
+		return nil, err
 	}
 	}
 	defer resp.Body.Close()
 	defer resp.Body.Close()
 
 
 	bodyBytes, err := io.ReadAll(resp.Body)
 	bodyBytes, err := io.ReadAll(resp.Body)
 	if err != nil {
 	if err != nil {
-		return emptyBody, err
+		return nil, err
 	}
 	}
 
 
 	body := strings.TrimSpace(string(bodyBytes))
 	body := strings.TrimSpace(string(bodyBytes))
@@ -99,17 +144,86 @@ func fetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (templat
 			truncatedBody += "... <truncated>"
 			truncatedBody += "... <truncated>"
 		}
 		}
 
 
-		slog.Error("Invalid response JSON in custom API widget", "url", req.URL.String(), "body", truncatedBody)
-		return emptyBody, errors.New("invalid response JSON")
+		slog.Error("Invalid response JSON in custom API widget", "url", req.httpRequest.URL.String(), "body", truncatedBody)
+		return nil, errors.New("invalid response JSON")
 	}
 	}
 
 
-	var templateBuffer bytes.Buffer
-
-	data := customAPITemplateData{
+	data := &customAPIResponseData{
 		JSON:     decoratedGJSONResult{gjson.Parse(body)},
 		JSON:     decoratedGJSONResult{gjson.Parse(body)},
 		Response: resp,
 		Response: resp,
 	}
 	}
 
 
+	return data, nil
+}
+
+func fetchAndParseCustomAPI(
+	primaryReq *CustomAPIRequest,
+	subReqs map[string]*CustomAPIRequest,
+	tmpl *template.Template,
+) (template.HTML, error) {
+	var primaryData *customAPIResponseData
+	subData := make(map[string]*customAPIResponseData, len(subReqs))
+	var err error
+
+	if len(subReqs) == 0 {
+		// If there are no subrequests, we can fetch the primary request in a much simpler way
+		primaryData, err = fetchCustomAPIRequest(context.Background(), primaryReq)
+	} else {
+		// If there are subrequests, we need to fetch them concurrently
+		// and cancel all requests if any of them fail. There's probably
+		// a more elegant way to do this, but this works for now.
+		ctx, cancel := context.WithCancel(context.Background())
+		defer cancel()
+
+		var wg sync.WaitGroup
+		var mu sync.Mutex // protects subData and err
+
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			var localErr error
+			primaryData, localErr = fetchCustomAPIRequest(ctx, primaryReq)
+			mu.Lock()
+			if localErr != nil && err == nil {
+				err = localErr
+				cancel()
+			}
+			mu.Unlock()
+		}()
+
+		for key, req := range subReqs {
+			wg.Add(1)
+			go func() {
+				defer wg.Done()
+				var localErr error
+				var data *customAPIResponseData
+				data, localErr = fetchCustomAPIRequest(ctx, req)
+				mu.Lock()
+				if localErr == nil {
+					subData[key] = data
+				} else if err == nil {
+					err = localErr
+					cancel()
+				}
+				mu.Unlock()
+			}()
+		}
+
+		wg.Wait()
+	}
+
+	emptyBody := template.HTML("")
+
+	if err != nil {
+		return emptyBody, err
+	}
+
+	data := customAPITemplateData{
+		customAPIResponseData: primaryData,
+		subrequests:           subData,
+	}
+
+	var templateBuffer bytes.Buffer
 	err = tmpl.Execute(&templateBuffer, &data)
 	err = tmpl.Execute(&templateBuffer, &data)
 	if err != nil {
 	if err != nil {
 		return emptyBody, err
 		return emptyBody, err
@@ -122,11 +236,6 @@ type decoratedGJSONResult struct {
 	gjson.Result
 	gjson.Result
 }
 }
 
 
-type customAPITemplateData struct {
-	JSON     decoratedGJSONResult
-	Response *http.Response
-}
-
 func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedGJSONResult {
 func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedGJSONResult {
 	decoratedResults := make([]decoratedGJSONResult, len(results))
 	decoratedResults := make([]decoratedGJSONResult, len(results))