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 += "...