From c265e422201741f99efeae05352f0a0566fb655a Mon Sep 17 00:00:00 2001 From: Ralph Ocdol Date: Wed, 12 Mar 2025 18:30:29 +0800 Subject: [PATCH] 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> --- docs/configuration.md | 37 +++++- internal/glance/widget-custom-api.go | 189 +++++++++++++++++++++------ 2 files changed, 185 insertions(+), 41 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 0d90c9d..e3781af 100644 --- a/docs/configuration.md +++ b/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: | +

{{ .JSON.String "text" }}

+

{{ (.Subrequest "another-one").JSON.String "text" }}

+``` + +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" }} +

{{ $anotherOne.JSON.String "text" }}

+``` + +You can also access the `.Response` property of a subrequest as you would with the main request: + +```yaml + template: | + {{ $anotherOne := .Subrequest "another-one" }} +

{{ $anotherOne.Response.StatusCode }}

+``` + > [!NOTE] > > Setting this property will override any query parameters that are already in the URL. diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go index 5ea7bf7..8def621 100644 --- a/internal/glance/widget-custom-api.go +++ b/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 += "... " } - 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))