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>
This commit is contained in:
parent
c0bdf1551d
commit
c265e42220
2 changed files with 185 additions and 41 deletions
|
@ -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.
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue