Browse Source

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 months ago
parent
commit
c265e42220
2 changed files with 185 additions and 41 deletions
  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 | |
 | frameless | boolean | no | false |
 | template | string | yes | |
-| parameters | key & value | no | |
+| parameters | key (string) & value (string|array) | no | |
+| subrequests | map of requests | no | |
 
 ##### `url`
 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`
 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]
 >
 > 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"
 	"net/http"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/tidwall/gjson"
@@ -18,23 +19,35 @@ import (
 
 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 {
-	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 {
 	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 == "" {
@@ -48,24 +61,11 @@ func (widget *customAPIWidget) initialize() error {
 
 	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
 }
 
 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) {
 		return
 	}
@@ -77,18 +77,63 @@ func (widget *customAPIWidget) Render() template.HTML {
 	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 {
-		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()
 
 	bodyBytes, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return emptyBody, err
+		return nil, err
 	}
 
 	body := strings.TrimSpace(string(bodyBytes))
@@ -99,17 +144,86 @@ func fetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (templat
 			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)},
 		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)
 	if err != nil {
 		return emptyBody, err
@@ -122,11 +236,6 @@ type decoratedGJSONResult struct {
 	gjson.Result
 }
 
-type customAPITemplateData struct {
-	JSON     decoratedGJSONResult
-	Response *http.Response
-}
-
 func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedGJSONResult {
 	decoratedResults := make([]decoratedGJSONResult, len(results))