Procházet zdrojové kódy

Update implementation & docs

Svilen Markov před 4 měsíci
rodič
revize
be02e2746f
3 změnil soubory, kde provedl 193 přidání a 146 odebrání
  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
 #### Properties
 | Name | Type | Required | Default |
 | Name | Type | Required | Default |
 | ---- | ---- | -------- | ------- |
 | ---- | ---- | -------- | ------- |
-| url | string | yes, unless `api-queries` is set | |
+| url | string | yes | |
 | 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 | |
-| 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`
 ##### `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.
@@ -1340,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 `.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]
 > [!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.

+ 0 - 22
docs/custom-api.md

@@ -240,28 +240,6 @@ Output:
 
 
 Other operations include `add`, `mul`, and `div`.
 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>
 <hr>
 
 
 In some instances, you may want to know the status code of the response. This can be done using the following:
 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 (
 import (
 	"bytes"
 	"bytes"
 	"context"
 	"context"
-	"encoding/json"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"html/template"
 	"html/template"
@@ -12,72 +11,43 @@ import (
 	"math"
 	"math"
 	"net/http"
 	"net/http"
 	"strings"
 	"strings"
+	"sync"
 	"time"
 	"time"
 
 
 	"github.com/tidwall/gjson"
 	"github.com/tidwall/gjson"
 )
 )
 
 
 var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.html")
 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 {
 func (widget *customAPIWidget) initialize() error {
 	widget.withTitle("Custom API").withCacheDuration(1 * time.Hour)
 	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 == "" {
 	if widget.Template == "" {
@@ -95,7 +65,7 @@ func (widget *customAPIWidget) initialize() error {
 }
 }
 
 
 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
 	}
 	}
@@ -107,61 +77,153 @@ func (widget *customAPIWidget) Render() template.HTML {
 	return widget.renderTemplate(widget, customAPIWidgetTemplate)
 	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 {
 	} 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{
 	data := customAPITemplateData{
-		JSON:     decoratedGJSONResult{gjson.Parse(mergedBody)},
-		Response: resp,
+		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
@@ -174,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))