diff --git a/docs/widgets.md b/docs/widgets.md index 0a7626c425..7bc0fc76a3 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -3297,9 +3297,66 @@ You can also use this widget to display an image, wither locally or from a remot ### API Response -Directly output plain-text response from any API-enabled service. +Display data from any HTTP/JSON API endpoint. Fetch a URL, then declaratively map response fields to labelled, formatted values. +This was very much inspired by, and is broadly compatible with [Homepage's customapi widget](https://gethomepage.dev/widgets/services/customapi/) -// Coming soon... +#### Options + +**Field** | **Type** | **Required** | **Description** +--- | --- | --- | --- +**`url`** | `string` | Required | The full URL of the API endpoint to fetch +**`refreshInterval`** | `number` | _Optional_ | How often to refetch, in milliseconds. Defaults to `10000` (10s). If omitted, the widget-level `updateInterval` (in seconds) is used instead +**`username`** | `string` | _Optional_ | Username for HTTP basic auth +**`password`** | `string` | _Optional_ | Password for HTTP basic auth +**`method`** | `string` | _Optional_ | HTTP method, e.g. `GET` (default) or `POST` +**`headers`** | `object` | _Optional_ | An object of custom request headers +**`requestBody`** | `string` or `object` | _Optional_ | Request body for non-GET methods. Prefer an object. A raw string is JSON-encoded again, so don't pre-stringify +**`display`** | `string` | _Optional_ | Layout for the fields: `block` (default, label and value on one row) or `list` (value stacked under the label) +**`mappings`** | `array` | _Optional_ | The fields to display from the response (see below). If omitted, the raw response root is shown + +Each item in `mappings` accepts: + +**Field** | **Type** | **Required** | **Description** +--- | --- | --- | --- +**`field`** | `string` | _Optional_ | Dot-path to the value, e.g. `path.to.key` or `items.0.name`. Omit to use the response root (useful with `size`) +**`label`** | `string` | _Optional_ | Label shown beside the value +**`format`** | `string` | _Optional_ | One of `text` (default), `number`, `percent`, `date`, `relativeDate` or `size` +**`locale`** | `string` | _Optional_ | Locale for `number`/`percent`/`date`/`relativeDate`, e.g. `nl`. Defaults to the browser locale +**`dateStyle`** | `string` | _Optional_ | For `date` format. One of `full`, `long` (default), `medium`, `short` +**`timeStyle`** | `string` | _Optional_ | For `date` format. One of `full`, `long`, `medium`, `short` +**`style`** | `string` | _Optional_ | For `relativeDate` format. One of `long` (default), `short`, `narrow` +**`numeric`** | `string` | _Optional_ | For `relativeDate` format. One of `always` (default) or `auto` +**`additionalField`** | `object` | _Optional_ | A secondary value shown next to the main one. Accepts `field`, `format` (and its options), plus `color` - one of `theme`, `adaptive` (action colour from the value's sign: positive green, negative red), `black` or `white` + +Notes: +- **`percent`** treats the value as an already-computed percentage, so `42` is shown as `42%` +- **`size`** returns the number of items in an array (or keys in an object) — pair it with an omitted `field` to count the response root +- For APIs that don't send CORS headers (most self-hosted services), set the widget-level `useProxy: true` to route the request through Dashy's server-side proxy + +#### Example + +```yaml +- type: customapi + options: + url: https://api.github.com/repos/lissy93/dashy + refreshInterval: 60000 + mappings: + - field: stargazers_count + label: Stars + format: number + - field: open_issues_count + label: Open Issues + format: number + - field: pushed_at + label: Last Push + format: relativeDate +``` + +#### Info + +- **CORS**: 🟠 Depends on target API (use `useProxy: true` if blocked) +- **Auth**: 🟠 Optional (basic auth or custom headers) +- **Price**: 🟢 Free --- diff --git a/src/components/Widgets/CustomApi.vue b/src/components/Widgets/CustomApi.vue new file mode 100644 index 0000000000..a8b08c7f5c --- /dev/null +++ b/src/components/Widgets/CustomApi.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/src/components/Widgets/WidgetBase.vue b/src/components/Widgets/WidgetBase.vue index e6e8326a5b..f715ff0c1a 100644 --- a/src/components/Widgets/WidgetBase.vue +++ b/src/components/Widgets/WidgetBase.vue @@ -66,6 +66,8 @@ const COMPAT = { 'crypto-watch-list': 'CryptoWatchList', 'custom-search': 'CustomSearch', 'custom-list': 'CustomList', + customapi: 'CustomApi', + 'custom-api': 'CustomApi', 'cve-vulnerabilities': 'CveVulnerabilities', 'domain-monitor': 'DomainMonitor', 'drone-ci': 'DroneCi', diff --git a/src/utils/CustomApiHelpers.js b/src/utils/CustomApiHelpers.js new file mode 100644 index 0000000000..a0d8ff4dcd --- /dev/null +++ b/src/utils/CustomApiHelpers.js @@ -0,0 +1,80 @@ +/* Field resolution and value formatting for the CustomApi widget */ + +/* Get a nested value from an object by dot-path, e.g. 'a.b.0.c'. Empty path returns the root */ +export const resolveField = (obj, path) => { + if (!path) return obj; + return String(path).split('.').reduce((acc, key) => (acc == null ? acc : acc[key]), obj); +}; + +/* Map a value's sign to an action colour, for `color: adaptive` fields. Non-numeric stays neutral */ +export const adaptiveColor = (raw) => { + const n = Number(raw); + if (Number.isNaN(n)) return ''; + if (n > 0) return 'success'; + if (n < 0) return 'error'; + return 'info'; +}; + +/* Largest-first periods, used to pick a unit for relative dates */ +const RELATIVE_UNITS = [ + { unit: 'year', secs: 31557600 }, + { unit: 'month', secs: 2628000 }, + { unit: 'week', secs: 604800 }, + { unit: 'day', secs: 86400 }, + { unit: 'hour', secs: 3600 }, + { unit: 'minute', secs: 60 }, + { unit: 'second', secs: 1 }, +]; + +/* Format a date as relative to now, e.g. '2 days ago' or 'in 3 hours' */ +const formatRelativeDate = (raw, mapping, locale) => { + const time = new Date(raw).getTime(); + if (Number.isNaN(time)) return String(raw); + const diffSecs = (time - Date.now()) / 1000; + const period = RELATIVE_UNITS.find((p) => Math.abs(diffSecs) >= p.secs) + || RELATIVE_UNITS[RELATIVE_UNITS.length - 1]; + const rtf = new Intl.RelativeTimeFormat(locale, { + style: mapping.style || 'long', + numeric: mapping.numeric || 'always', + }); + return rtf.format(Math.round(diffSecs / period.secs), period.unit); +}; + +/* Format a raw value per a mapping's `format`. `root` is the full response, used by `size` */ +export const formatValue = (raw, mapping = {}, root) => { + const format = mapping.format || 'text'; + const locale = mapping.locale || navigator.language; + + // `size` counts array elements / object keys (root when no field is given) + if (format === 'size') { + const target = mapping.field == null ? root : raw; + if (Array.isArray(target)) return String(target.length); + if (target && typeof target === 'object') return String(Object.keys(target).length); + return target == null ? '' : String(target); + } + + if (raw == null) return ''; + + switch (format) { + case 'number': { + const n = Number(raw); + return Number.isNaN(n) ? String(raw) : new Intl.NumberFormat(locale).format(n); + } + case 'percent': { + const n = Number(raw); + return Number.isNaN(n) ? String(raw) + : new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 2 }).format(n / 100); + } + case 'date': { + const date = new Date(raw); + if (Number.isNaN(date.getTime())) return String(raw); + const opts = { dateStyle: mapping.dateStyle || 'long' }; + if (mapping.timeStyle) opts.timeStyle = mapping.timeStyle; + return new Intl.DateTimeFormat(locale, opts).format(date); + } + case 'relativeDate': + return formatRelativeDate(raw, mapping, locale); + default: + return String(raw); + } +}; diff --git a/tests/components/customapi.test.js b/tests/components/customapi.test.js new file mode 100644 index 0000000000..17014693da --- /dev/null +++ b/tests/components/customapi.test.js @@ -0,0 +1,113 @@ +import { + describe, it, expect, beforeEach, afterEach, vi, +} from 'vitest'; +import { shallowMount, flushPromises } from '@vue/test-utils'; +import CustomApi from '@/components/Widgets/CustomApi.vue'; + +const response = { + name: 'dashy', + stars: 1234, + pushed_at: '2026-06-08T00:00:00Z', + items: [1, 2, 3], +}; + +vi.mock('@/utils/request', () => { + const fn = vi.fn(() => Promise.resolve({ data: response })); + fn.get = fn; fn.post = fn; fn.put = fn; + return { default: fn }; +}); +vi.mock('@/utils/logging/ErrorHandler', () => ({ default: vi.fn() })); + +/** Mount CustomApi with the given options object */ +function mountWidget(options) { + return shallowMount(CustomApi, { props: { options } }); +} + +describe('CustomApi widget', () => { + let wrapper; + afterEach(() => wrapper && wrapper.unmount()); + + it('renders a row per mapping with formatted values', async () => { + wrapper = mountWidget({ + url: 'https://example.com', + mappings: [ + { field: 'stars', label: 'Stars', format: 'number' }, + { label: 'Item count', format: 'size', field: 'items' }, + ], + }); + await flushPromises(); + const rows = wrapper.findAll('.row'); + expect(rows).toHaveLength(2); + expect(wrapper.text()).toContain('Stars'); + expect(wrapper.text()).toContain('1,234'); + expect(wrapper.text()).toContain('3'); + }); + + it('applies the list display class', () => { + wrapper = mountWidget({ url: 'https://example.com', display: 'list' }); + expect(wrapper.find('.customapi-wrapper').classes()).toContain('list'); + }); + + it('renders an additionalField with its colour class', async () => { + wrapper = mountWidget({ + url: 'https://example.com', + mappings: [ + { field: 'name', label: 'Repo', additionalField: { field: 'stars', color: 'theme' } }, + ], + }); + await flushPromises(); + expect(wrapper.find('.additional').classes()).toContain('color-theme'); + }); + + it('resolves an adaptive colour from the value sign', async () => { + wrapper = mountWidget({ + url: 'https://example.com', + mappings: [ + { field: 'name', label: 'Repo', additionalField: { field: 'stars', color: 'adaptive' } }, + ], + }); + await flushPromises(); + // stars (1234) is positive -> success + expect(wrapper.find('.additional').classes()).toContain('color-success'); + }); + + describe('mergedHeaders content-type', () => { + it('defaults to application/json for a body-bearing method', () => { + wrapper = mountWidget({ url: 'https://example.com', method: 'POST', requestBody: { a: 1 } }); + expect(wrapper.vm.mergedHeaders['Content-Type']).toBe('application/json'); + }); + it('does not add a content-type for GET', () => { + wrapper = mountWidget({ url: 'https://example.com', requestBody: { a: 1 } }); + const keys = Object.keys(wrapper.vm.mergedHeaders).map((k) => k.toLowerCase()); + expect(keys).not.toContain('content-type'); + }); + it('respects a user-provided content-type', () => { + wrapper = mountWidget({ + url: 'https://example.com', + method: 'POST', + requestBody: '', + headers: { 'Content-Type': 'application/xml' }, + }); + expect(wrapper.vm.mergedHeaders['Content-Type']).toBe('application/xml'); + }); + }); + + describe('updateInterval', () => { + it('defaults to 10s', () => { + wrapper = mountWidget({ url: 'https://example.com' }); + expect(wrapper.vm.updateInterval).toBe(10000); + }); + it('uses refreshInterval (ms) when given', () => { + wrapper = mountWidget({ url: 'https://example.com', refreshInterval: 30000 }); + expect(wrapper.vm.updateInterval).toBe(30000); + }); + it('falls back to native updateInterval (seconds)', () => { + wrapper = mountWidget({ url: 'https://example.com', updateInterval: 5 }); + expect(wrapper.vm.updateInterval).toBe(5000); + }); + it('returns 0 when refreshInterval is disabled', () => { + wrapper = mountWidget({ url: 'https://example.com', refreshInterval: 0 }); + expect(wrapper.vm.updateInterval).toBe(0); + }); + }); +}); diff --git a/tests/unit/customapi-helpers.test.js b/tests/unit/customapi-helpers.test.js new file mode 100644 index 0000000000..171c44fed9 --- /dev/null +++ b/tests/unit/customapi-helpers.test.js @@ -0,0 +1,100 @@ +import { + describe, it, expect, beforeEach, afterEach, vi, +} from 'vitest'; +import { resolveField, formatValue, adaptiveColor } from '@/utils/CustomApiHelpers'; + +describe('CustomApiHelpers - resolveField', () => { + it('resolves a nested dot-path', () => { + expect(resolveField({ a: { b: 1 } }, 'a.b')).toBe(1); + }); + + it('resolves array indices in the path', () => { + expect(resolveField({ a: [{ n: 'x' }] }, 'a.0.n')).toBe('x'); + }); + + it('returns undefined for a missing path without throwing', () => { + expect(resolveField({ a: 1 }, 'a.b.c')).toBeUndefined(); + }); + + it('returns the root when path is omitted', () => { + expect(resolveField('scalar')).toBe('scalar'); + }); + + it('returns null when the object is null', () => { + expect(resolveField(null, 'a')).toBeNull(); + }); +}); + +describe('CustomApiHelpers - formatValue', () => { + it('text: null becomes an empty string', () => { + expect(formatValue(null, { format: 'text' })).toBe(''); + }); + + it('text: coerces to string', () => { + expect(formatValue(5, { format: 'text' })).toBe('5'); + }); + + it('number: formats with grouping', () => { + expect(formatValue(1234, { format: 'number', locale: 'en-US' })).toBe('1,234'); + }); + + it('number: passes through non-numeric input', () => { + expect(formatValue('abc', { format: 'number', locale: 'en-US' })).toBe('abc'); + }); + + it('percent: treats the value as an already-computed percentage', () => { + expect(formatValue(42, { format: 'percent', locale: 'en-US' })).toBe('42%'); + }); + + it('date: formats a valid date', () => { + const out = formatValue('2026-06-10', { format: 'date', dateStyle: 'long', locale: 'en-US' }); + expect(out).toMatch(/2026/); + }); + + it('date: passes through an invalid date', () => { + expect(formatValue('notadate', { format: 'date', locale: 'en-US' })).toBe('notadate'); + }); + + it('size: counts array elements (root when field omitted)', () => { + expect(formatValue([1, 2, 3], { format: 'size' }, [1, 2, 3])).toBe('3'); + }); + + it('size: counts object keys', () => { + expect(formatValue({ a: 1, b: 2 }, { format: 'size' }, { a: 1, b: 2 })).toBe('2'); + }); +}); + +describe('CustomApiHelpers - adaptiveColor', () => { + it('returns success for a positive value', () => { + expect(adaptiveColor(5)).toBe('success'); + }); + it('returns error for a negative value', () => { + expect(adaptiveColor(-2.4)).toBe('error'); + }); + it('returns info for zero', () => { + expect(adaptiveColor(0)).toBe('info'); + }); + it('returns no colour for non-numeric input', () => { + expect(adaptiveColor('online')).toBe(''); + }); +}); + +describe('CustomApiHelpers - relativeDate', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-10T00:00:00Z')); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('formats a past date', () => { + const out = formatValue('2026-06-08T00:00:00Z', { format: 'relativeDate', locale: 'en-US' }); + expect(out).toBe('2 days ago'); + }); + + it('formats a future date', () => { + const out = formatValue('2026-06-13T00:00:00Z', { format: 'relativeDate', locale: 'en-US' }); + expect(out).toBe('in 3 days'); + }); +});