Add custom-api options and template requests

This commit is contained in:
Svilen Markov 2025-05-11 15:49:21 +01:00
parent 49c07f397e
commit 7bbf103e01
3 changed files with 248 additions and 13 deletions

View File

@ -1519,7 +1519,7 @@ Examples:
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| url | string | yes | |
| url | string | no | |
| headers | key (string) & value (string) | no | |
| method | string | no | GET |
| body-type | string | no | json |
@ -1528,6 +1528,7 @@ Examples:
| allow-insecure | boolean | no | false |
| skip-json-validation | boolean | no | false |
| template | string | yes | |
| options | map | no | |
| parameters | key (string) & value (string|array) | no | |
| subrequests | map of requests | no | |
@ -1580,6 +1581,95 @@ When set to `true`, skips the JSON validation step. This is useful when the API
##### `template`
The template that will be used to display the data. It relies on Go's `html/template` package so it's recommended to go through [its documentation](https://pkg.go.dev/text/template) to understand how to do basic things such as conditionals, loops, etc. In addition, it also uses [tidwall's gjson](https://github.com/tidwall/gjson) package to parse the JSON data so it's worth going through its documentation if you want to use more advanced JSON selectors. You can view additional examples with explanations and function definitions [here](custom-api.md).
##### `options`
A map of options that will be passed to the template and can be used to modify the behavior of the widget.
<details>
<summary>View examples</summary>
<br>
Instead of defining options within the template and having to modify the template itself like such:
```yaml
- type: custom-api
template: |
{{ /* User configurable options */ }}
{{ $collapseAfter := 5 }}
{{ $showThumbnails := true }}
{{ $showFlairs := false }}
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ $collapseAfter }}">
{{ if $showThumbnails }}
<li>
<img src="{{ .JSON.String "thumbnail" }}" alt="thumbnail" />
</li>
{{ end }}
{{ if $showFlairs }}
<li>
<span class="flair">{{ .JSON.String "flair" }}</span>
</li>
{{ end }}
</ul>
```
You can use the `options` property to retrieve and define default values for these variables:
```yaml
- type: custom-api
template: |
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .Options.IntOr "collapse-after" 5 }}">
{{ if (.Options.BoolOr "show-thumbnails" true) }}
<li>
<img src="{{ .JSON.String "thumbnail" }}" alt="thumbnail" />
</li>
{{ end }}
{{ if (.Options.BoolOr "show-flairs" false) }}
<li>
<span class="flair">{{ .JSON.String "flair" }}</span>
</li>
{{ end }}
</ul>
```
This way, you can optionally specify the `collapse-after`, `show-thumbnails` and `show-flairs` properties in the widget configuration:
```yaml
- type: custom-api
options:
collapse-after: 5
show-thumbnails: true
show-flairs: false
```
Which means you can reuse the same template for multiple widgets with different options:
```yaml
# Note that `custom-widgets` isn't a special property, it's just used to define the reusable "anchor", see https://support.atlassian.com/bitbucket-cloud/docs/yaml-anchors/
custom-widgets:
- &example-widget
type: custom-api
template: |
{{ .Options.StringOr "custom-option" "not defined" }}
pages:
- name: Home
columns:
- size: full
widgets:
- <<: *example-widget
options:
custom-option: "Value 1"
- <<: *example-widget
options:
custom-option: "Value 2"
```
Currently, the available methods on the `.Options` object are: `StringOr`, `IntOr`, `BoolOr` and `FloatOr`.
</details>
##### `parameters`
A list of keys and values that will be sent to the custom-api as query paramters.

View File

@ -358,6 +358,51 @@ Output:
<p>John</p>
```
<hr>
In some instances, you may need to make two consecutive API calls, where you use the result of the first call in the second call. To achieve this, you can make additional HTTP requests from within the template itself using the following syntax:
```yaml
- type: custom-api
url: https://api.example.com/get-id-of-something
template: |
{{
$theID := .JSON.String "id"
$something := newRequest (concat "https://api.example.com/something/" $theID)
| withParameter "key" "value"
| withHeader "Authorization" "Bearer token"
| getResponse
}}
{{ $something.String "title" }}
```
Here, `$theID` gets retrieved from the result of the first API call and used in the second API call. The `newRequest` function creates a new request, and the `getResponse` function executes it. You can also use `withParameter` and `withHeader` to optionally add parameters and headers to the request.
If you need to make a request to a URL that requires dynamic parameters, you can omit the `url` property in the YAML and run the request entirely from within the template itself:
```yaml
- type: custom-api
title: Events from the last 24h
template: |
{{
$events := newRequest "https://api.example.com/events"
| withParameter "after" (offsetNow "-24h" | formatTime "rfc3339")
| getResponse
}}
{{ if eq $events.Response.StatusCode 200 }}
{{ range $events.JSON.Array "events" }}
<div>{{ .String "title" }}</div>
<div {{ .String "date" | parseTime "rfc3339" | toRelativeTime }}></div>
{{ end }}
{{ else }}
<p>Failed to fetch data: {{ $events.Response.Status }}</p>
{{ end }}
```
*Note that you need to manually check for the correct status code.*
## Functions
The following functions are available on the `JSON` object:
@ -378,6 +423,7 @@ The following helper functions provided by Glance are available:
- `offsetNow(offset string) time.Time`: Returns the current time with an offset. The offset can be positive or negative and must be in the format "3h" "-1h" or "2h30m10s".
- `duration(str string) time.Duration`: Parses a string such as `1h`, `24h`, `5h30m`, etc into a `time.Duration`.
- `parseTime(layout string, s string) time.Time`: Parses a string into time.Time. The layout must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). You can alternatively use these values instead of the literal format: "unix", "RFC3339", "RFC3339Nano", "DateTime", "DateOnly".
- `formatTime(layout string, s string) time.Time`: Formats a `time.Time` into a string. The layout uses the same format as `parseTime`.
- `parseLocalTime(layout string, s string) time.Time`: Same as the above, except in the absence of a timezone, it will use the local timezone instead of UTC.
- `parseRelativeTime(layout string, s string) time.Time`: A shorthand for `{{ .String "date" | parseTime "rfc3339" | toRelativeTime }}`.
- `add(a, b float) float`: Adds two numbers.

View File

@ -41,6 +41,7 @@ type customAPIWidget struct {
widgetBase `yaml:",inline"`
*CustomAPIRequest `yaml:",inline"` // the primary request
Subrequests map[string]*CustomAPIRequest `yaml:"subrequests"`
Options customAPIOptions `yaml:"options"`
Template string `yaml:"template"`
Frameless bool `yaml:"frameless"`
compiledTemplate *template.Template `yaml:"-"`
@ -75,7 +76,9 @@ func (widget *customAPIWidget) initialize() error {
}
func (widget *customAPIWidget) update(ctx context.Context) {
compiledHTML, err := fetchAndParseCustomAPI(widget.CustomAPIRequest, widget.Subrequests, widget.compiledTemplate)
compiledHTML, err := fetchAndRenderCustomAPIRequest(
widget.CustomAPIRequest, widget.Subrequests, widget.Options, widget.compiledTemplate,
)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
@ -87,9 +90,36 @@ func (widget *customAPIWidget) Render() template.HTML {
return widget.renderTemplate(widget, customAPIWidgetTemplate)
}
type customAPIOptions map[string]any
func (o *customAPIOptions) StringOr(key, defaultValue string) string {
return customAPIGetOptionOrDefault(*o, key, defaultValue)
}
func (o *customAPIOptions) IntOr(key string, defaultValue int) int {
return customAPIGetOptionOrDefault(*o, key, defaultValue)
}
func (o *customAPIOptions) FloatOr(key string, defaultValue float64) float64 {
return customAPIGetOptionOrDefault(*o, key, defaultValue)
}
func (o *customAPIOptions) BoolOr(key string, defaultValue bool) bool {
return customAPIGetOptionOrDefault(*o, key, defaultValue)
}
func customAPIGetOptionOrDefault[T any](o customAPIOptions, key string, defaultValue T) T {
if value, exists := o[key]; exists {
if typedValue, ok := value.(T); ok {
return typedValue
}
}
return defaultValue
}
func (req *CustomAPIRequest) initialize() error {
if req.URL == "" {
return errors.New("URL is required")
if req == nil || req.URL == "" {
return nil
}
if req.Body != nil {
@ -156,6 +186,7 @@ type customAPIResponseData struct {
type customAPITemplateData struct {
*customAPIResponseData
subrequests map[string]*customAPIResponseData
Options customAPIOptions
}
func (data *customAPITemplateData) JSONLines() []decoratedGJSONResult {
@ -183,7 +214,14 @@ func (data *customAPITemplateData) Subrequest(key string) *customAPIResponseData
return req
}
func fetchCustomAPIRequest(ctx context.Context, req *CustomAPIRequest) (*customAPIResponseData, error) {
func fetchCustomAPIResponse(ctx context.Context, req *CustomAPIRequest) (*customAPIResponseData, error) {
if req == nil || req.URL == "" {
return &customAPIResponseData{
JSON: decoratedGJSONResult{gjson.Result{}},
Response: &http.Response{},
}, nil
}
if req.bodyReader != nil {
req.bodyReader.Seek(0, io.SeekStart)
}
@ -217,17 +255,16 @@ func fetchCustomAPIRequest(ctx context.Context, req *CustomAPIRequest) (*customA
}
data := &customAPIResponseData{
return &customAPIResponseData{
JSON: decoratedGJSONResult{gjson.Parse(body)},
Response: resp,
}
return data, nil
}, nil
}
func fetchAndParseCustomAPI(
func fetchAndRenderCustomAPIRequest(
primaryReq *CustomAPIRequest,
subReqs map[string]*CustomAPIRequest,
options customAPIOptions,
tmpl *template.Template,
) (template.HTML, error) {
var primaryData *customAPIResponseData
@ -236,7 +273,7 @@ func fetchAndParseCustomAPI(
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)
primaryData, err = fetchCustomAPIResponse(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
@ -251,7 +288,7 @@ func fetchAndParseCustomAPI(
go func() {
defer wg.Done()
var localErr error
primaryData, localErr = fetchCustomAPIRequest(ctx, primaryReq)
primaryData, localErr = fetchCustomAPIResponse(ctx, primaryReq)
mu.Lock()
if localErr != nil && err == nil {
err = localErr
@ -266,7 +303,7 @@ func fetchAndParseCustomAPI(
defer wg.Done()
var localErr error
var data *customAPIResponseData
data, localErr = fetchCustomAPIRequest(ctx, req)
data, localErr = fetchCustomAPIResponse(ctx, req)
mu.Lock()
if localErr == nil {
subData[key] = data
@ -290,6 +327,7 @@ func fetchAndParseCustomAPI(
data := customAPITemplateData{
customAPIResponseData: primaryData,
subrequests: subData,
Options: options,
}
var templateBuffer bytes.Buffer
@ -462,6 +500,7 @@ var customAPITemplateFuncs = func() template.FuncMap {
"parseTime": func(layout, value string) time.Time {
return customAPIFuncParseTimeInLocation(layout, value, time.UTC)
},
"formatTime": customAPIFuncFormatTime,
"parseLocalTime": func(layout, value string) time.Time {
return customAPIFuncParseTimeInLocation(layout, value, time.Local)
},
@ -569,6 +608,49 @@ var customAPITemplateFuncs = func() template.FuncMap {
}
return out
},
"newRequest": func(url string) *CustomAPIRequest {
return &CustomAPIRequest{
URL: url,
}
},
"withHeader": func(key, value string, req *CustomAPIRequest) *CustomAPIRequest {
if req.Headers == nil {
req.Headers = make(map[string]string)
}
req.Headers[key] = value
return req
},
"withParameter": func(key, value string, req *CustomAPIRequest) *CustomAPIRequest {
if req.Parameters == nil {
req.Parameters = make(queryParametersField)
}
req.Parameters[key] = append(req.Parameters[key], value)
return req
},
"withStringBody": func(body string, req *CustomAPIRequest) *CustomAPIRequest {
req.Body = body
req.BodyType = "string"
return req
},
"getResponse": func(req *CustomAPIRequest) *customAPIResponseData {
err := req.initialize()
if err != nil {
panic(fmt.Sprintf("initializing request: %v", err))
}
data, err := fetchCustomAPIResponse(context.Background(), req)
if err != nil {
slog.Error("Could not fetch response within custom API template", "error", err)
return &customAPIResponseData{
JSON: decoratedGJSONResult{gjson.Result{}},
Response: &http.Response{
Status: err.Error(),
},
}
}
return data
},
}
for key, value := range globalTemplateFunctions {
@ -580,6 +662,23 @@ var customAPITemplateFuncs = func() template.FuncMap {
return funcs
}()
func customAPIFuncFormatTime(layout string, t time.Time) string {
switch strings.ToLower(layout) {
case "unix":
return strconv.FormatInt(t.Unix(), 10)
case "rfc3339":
layout = time.RFC3339
case "rfc3339nano":
layout = time.RFC3339Nano
case "datetime":
layout = time.DateTime
case "dateonly":
layout = time.DateOnly
}
return t.Format(layout)
}
func customAPIFuncParseTimeInLocation(layout, value string, loc *time.Location) time.Time {
switch strings.ToLower(layout) {
case "unix":