mirror of
https://github.com/glanceapp/glance.git
synced 2025-06-20 18:07:59 +02:00
Add custom-api options and template requests
This commit is contained in:
parent
49c07f397e
commit
7bbf103e01
@ -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.
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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":
|
||||
|
Loading…
x
Reference in New Issue
Block a user