From fc95e8ef2d23b01a40b3e5b35fd9ac8fb6fd50ed Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Thu, 11 Jun 2026 13:51:15 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Completes=20body/header=20parsing?= =?UTF-8?q?=20of=20custom=20api=20widget=20(#2191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/widgets.md | 4 ++-- src/components/Widgets/CustomApi.vue | 7 ++++++- tests/components/customapi.test.js | 21 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/widgets.md b/docs/widgets.md index ad2c560b..7bc0fc76 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -3310,7 +3310,7 @@ This was very much inspired by, and is broadly compatible with [Homepage's custo **`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 (sent as JSON) — a raw string is JSON-encoded again, so don't pre-stringify +**`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 @@ -3326,7 +3326,7 @@ Each item in `mappings` accepts: **`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` +**`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%` diff --git a/src/components/Widgets/CustomApi.vue b/src/components/Widgets/CustomApi.vue index ed2715ba..a8b08c7f 100644 --- a/src/components/Widgets/CustomApi.vue +++ b/src/components/Widgets/CustomApi.vue @@ -45,7 +45,12 @@ export default { Object.keys(userHeaders).forEach((key) => { resolved[key] = this.parseAsEnvVar(userHeaders[key]); }); - return { ...this.authHeaders, ...resolved }; + const headers = { ...this.authHeaders, ...resolved }; + // Default a JSON content-type for body-bearing methods, unless the user set one + const hasBody = this.options.requestBody != null && this.method !== 'GET' && this.method !== 'HEAD'; + const hasContentType = Object.keys(headers).some((k) => k.toLowerCase() === 'content-type'); + if (hasBody && !hasContentType) headers['Content-Type'] = 'application/json'; + return headers; }, method() { return (this.options.method || 'GET').toUpperCase(); diff --git a/tests/components/customapi.test.js b/tests/components/customapi.test.js index 61204068..17014693 100644 --- a/tests/components/customapi.test.js +++ b/tests/components/customapi.test.js @@ -71,6 +71,27 @@ describe('CustomApi widget', () => { 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' });