Browse Source

Update implementation & docs

Svilen Markov 4 months ago
parent
commit
be02e2746f
3 changed files with 193 additions and 146 deletions
  1. 37 25
      docs/configuration.md
  2. 0 22
      docs/custom-api.md
  3. 156 99
      internal/glance/widget-custom-api.go

+ 37 - 25
docs/configuration.md

@@ -1290,34 +1290,12 @@ Examples:
 #### Properties
 | Name | Type | Required | Default |
 | ---- | ---- | -------- | ------- |
-| url | string | yes, unless `api-queries` is set | |
+| url | string | yes | |
 | headers | key (string) & value (string) | no | |
 | frameless | boolean | no | false |
 | template | string | yes | |
-| parameters | key & value | no | |
-| api-queries | list of urls, parameters & headers | no | |
-
-> [!NOTE]
-> 
-> `api-queries` will override `url`, `headers` and `parameters`
-> since it also provides its own options
-
-##### `api-queries`
-A list of API queries, the name set will be the name of the json returned
-```yaml
-api-queries:
-  sample-data1:
-    url: https://domain.com/api
-    parameters:
-      foo: bar
-    headers:
-      x-api-key: your-api-key
-      Accept: application/json
-  sample-data2:
-    url: https://another-domain.com/api
-```
-see [custom-api docs](./custom-api.md#api-queries)
-
+| 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.
@@ -1340,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 `.Subrequests` 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.

+ 0 - 22
docs/custom-api.md

@@ -240,28 +240,6 @@ Output:
 
 Other operations include `add`, `mul`, and `div`.
 
-<hr>
-
-#### API-Queries
-JSON response
-```json
-  {
-    "sample-data1": {
-      "title": "My Title",
-      "content": "My Content"
-    },
-    "sample-data2": [
-      {
-          "name": "John Doe"
-      },
-      {
-          "name": "Jane Doe"
-      }
-    ]
-  }
-```
-
-
 <hr>
 
 In some instances, you may want to know the status code of the response. This can be done using the following:

+ 156 - 99
internal/glance/widget-custom-api.go

@@ -3,7 +3,6 @@ package glance
 import (
 	"bytes"
 	"context"
-	"encoding/json"
 	"errors"
 	"fmt"
 	"html/template"
@@ -12,72 +11,43 @@ import (
 	"math"
 	"net/http"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/tidwall/gjson"
 )
 
 var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.html")
-var customRandomKeyForSingleRequest = fmt.Sprintf("%x", time.Now().UnixNano())
 
-type customAPIWidget struct {
-	widgetBase       `yaml:",inline"`
-	ApiQueries		 map[string]apiQueries		`yaml:"api-queries"`
-	URL              string               		`yaml:"url"`
-	Template         string               		`yaml:"template"`
-	Frameless        bool                 		`yaml:"frameless"`
-	Headers          map[string]string    		`yaml:"headers"`
-	Parameters       queryParametersField 		`yaml:"parameters"`
-	APIRequest       map[string]*http.Request	`yaml:"-"`
-	compiledTemplate *template.Template   		`yaml:"-"`
-	CompiledHTML     template.HTML        		`yaml:"-"`
+// 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 apiQueries struct {
-	URL              string               `yaml:"url"`
-	Headers          map[string]string    `yaml:"headers"`
-	Parameters       queryParametersField `yaml:"parameters"`
+type customAPIWidget struct {
+	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)
 
-	widget.APIRequest = make(map[string]*http.Request)
-	if len(widget.ApiQueries) != 0 {
-		for object, query := range widget.ApiQueries {
-			if query.URL == "" {
-				return errors.New("URL for each query is required")
-			}
-			req, err := http.NewRequest(http.MethodGet, query.URL, nil)
-			if err != nil {
-				return err
-			}
-
-			req.URL.RawQuery = query.Parameters.toQueryString()
-
-			for key, value := range query.Headers {
-				req.Header.Add(key, value)
-			}
+	if err := widget.CustomAPIRequest.initialize(); err != nil {
+		return fmt.Errorf("initializing primary request: %v", err)
+	}
 
-			widget.APIRequest[object] = req
-		}
-	} else {
-		if widget.URL == "" {
-			return errors.New("URL is required")
-		}
-		
-		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)
+	for key := range widget.Subrequests {
+		if err := widget.Subrequests[key].initialize(); err != nil {
+			return fmt.Errorf("initializing subrequest %q: %v", key, err)
 		}
-		
-		widget.APIRequest[customRandomKeyForSingleRequest] = req
 	}
 
 	if widget.Template == "" {
@@ -95,7 +65,7 @@ func (widget *customAPIWidget) initialize() error {
 }
 
 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
 	}
@@ -107,61 +77,153 @@ func (widget *customAPIWidget) Render() template.HTML {
 	return widget.renderTemplate(widget, customAPIWidgetTemplate)
 }
 
-func fetchAndParseCustomAPI(requests map[string]*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")
+	}
 
-	var resp *http.Response
-	var err error
-	body := make(map[string]string)
-	for key, req := range requests {
-		resp, err = defaultHTTPClient.Do(req)
-		if err != nil {
-			return emptyBody, err
-		}
-		defer resp.Body.Close()
+	httpReq, err := http.NewRequest(http.MethodGet, req.URL, nil)
+	if err != nil {
+		return err
+	}
 
-		bodyBytes, err := io.ReadAll(resp.Body)
-		if err != nil {
-			return emptyBody, err
-		}
-	
-		body[key] = strings.TrimSpace(string(bodyBytes))
-	
-		if body[key] != "" && !gjson.Valid(body[key]) {
-			truncatedBody, isTruncated := limitStringLength(body[key], 100)
-			if isTruncated {
-				truncatedBody += "... <truncated>"
-			}
-	
-			slog.Error("Invalid response JSON in custom API widget", "url", req.URL.String(), key, truncatedBody)
-			return emptyBody, errors.New("invalid response JSON")
+	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 nil, err
+	}
+
+	body := strings.TrimSpace(string(bodyBytes))
+
+	if body != "" && !gjson.Valid(body) {
+		truncatedBody, isTruncated := limitStringLength(body, 100)
+		if isTruncated {
+			truncatedBody += "... <truncated>"
 		}
+
+		slog.Error("Invalid response JSON in custom API widget", "url", req.httpRequest.URL.String(), "body", truncatedBody)
+		return nil, errors.New("invalid response JSON")
 	}
-	
-	mergedBody := "{}"
-	if jsonBody, exists := body[customRandomKeyForSingleRequest]; exists {
-		mergedBody = jsonBody
+
+	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 {
-		mergedMap := make(map[string]json.RawMessage)
-		for key, jsonBody := range body {
-			if !gjson.Valid(jsonBody) {
-				continue
+		// 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()
 			}
-			mergedMap[key] = json.RawMessage(jsonBody)
-		}
-		if len(mergedMap) > 0 {
-			bytes, _ := json.Marshal(mergedMap)
-			mergedBody = string(bytes)
+			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 && err == nil {
+					err = localErr
+					cancel()
+				} else {
+					subData[key] = data
+				}
+				mu.Unlock()
+			}()
 		}
+
+		wg.Wait()
 	}
 
-	var templateBuffer bytes.Buffer
+	emptyBody := template.HTML("")
+
+	if err != nil {
+		return emptyBody, err
+	}
 
 	data := customAPITemplateData{
-		JSON:     decoratedGJSONResult{gjson.Parse(mergedBody)},
-		Response: resp,
+		customAPIResponseData: primaryData,
+		subrequests:           subData,
 	}
 
+	var templateBuffer bytes.Buffer
 	err = tmpl.Execute(&templateBuffer, &data)
 	if err != nil {
 		return emptyBody, err
@@ -174,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))