Skip to content

Commit d57cdb8

Browse files
committed
feat(request): configurable base url and auth headers
1 parent f3a8afc commit d57cdb8

4 files changed

Lines changed: 89 additions & 0 deletions

File tree

src/utils/config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ export interface FrappeUIConfig {
2121

2222
// Error handling
2323
fallbackErrorHandler?: (error: any) => void
24+
25+
// Base URL prepended to relative request URLs. Set this for local UI dev
26+
// against a remote Frappe instance (cross-origin). When set, requests are
27+
// sent with credentials so cross-origin auth works.
28+
requestBaseUrl?: string
29+
30+
// Extra headers merged into every frappeRequest. Either a static object or a
31+
// function returning headers (useful for injecting an Authorization header,
32+
// e.g. `token <key>:<secret>`).
33+
requestHeaders?: Record<string, string> | (() => Record<string, string>)
2434
}
2535

2636
let config: FrappeUIConfig = {}

src/utils/frappeRequest.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,17 @@ export function frappeRequest(options) {
88
if (!options.url) {
99
throw new Error('[frappeRequest] options.url is required')
1010
}
11+
let configHeaders = getConfig('requestHeaders') || {}
12+
if (typeof configHeaders === 'function') {
13+
configHeaders = configHeaders()
14+
}
1115
let headers = Object.assign(
1216
{
1317
Accept: 'application/json',
1418
'Content-Type': 'application/json; charset=utf-8',
1519
'X-Frappe-Site-Name': window.location.hostname,
1620
},
21+
configHeaders,
1722
options.headers || {},
1823
)
1924
if (window.csrf_token && window.csrf_token !== '{{ csrf_token }}') {
@@ -22,10 +27,19 @@ export function frappeRequest(options) {
2227
if (!options.url.startsWith('/') && !options.url.startsWith('http')) {
2328
options.url = '/api/method/' + options.url
2429
}
30+
// Prepend a configured base URL to relative URLs for local dev against a
31+
// remote instance. Cross-origin requests need credentials included.
32+
let baseUrl = getConfig('requestBaseUrl')
33+
let credentials = options.credentials
34+
if (baseUrl && options.url.startsWith('/')) {
35+
options.url = baseUrl.replace(/\/$/, '') + options.url
36+
credentials = credentials || 'include'
37+
}
2538
return {
2639
...options,
2740
method: options.method || 'POST',
2841
headers,
42+
credentials,
2943
}
3044
},
3145
transformResponse: async (response, options) => {

src/utils/frappeRequest.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// @vitest-environment jsdom
2+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
3+
import { frappeRequest } from './frappeRequest'
4+
import { setConfig } from './config'
5+
6+
function mockFetch() {
7+
const fetchMock = vi.fn(async () => ({
8+
ok: true,
9+
json: async () => ({ message: 'ok' }),
10+
}))
11+
vi.stubGlobal('fetch', fetchMock)
12+
return fetchMock
13+
}
14+
15+
describe('frappeRequest configurable base url and auth headers', () => {
16+
beforeEach(() => {
17+
setConfig('requestBaseUrl', undefined)
18+
setConfig('requestHeaders', undefined)
19+
})
20+
21+
afterEach(() => {
22+
vi.unstubAllGlobals()
23+
setConfig('requestBaseUrl', undefined)
24+
setConfig('requestHeaders', undefined)
25+
})
26+
27+
it('keeps default behavior when nothing is configured', async () => {
28+
const fetchMock = mockFetch()
29+
await frappeRequest({ url: 'ping' })
30+
31+
const [url, opts] = fetchMock.mock.calls[0]
32+
expect(url).toBe('/api/method/ping')
33+
expect(opts.credentials).toBeUndefined()
34+
expect(opts.headers.Authorization).toBeUndefined()
35+
})
36+
37+
it('prepends the configured base url and includes credentials', async () => {
38+
setConfig('requestBaseUrl', 'https://remote.frappe.test/')
39+
const fetchMock = mockFetch()
40+
await frappeRequest({ url: 'ping' })
41+
42+
const [url, opts] = fetchMock.mock.calls[0]
43+
expect(url).toBe('https://remote.frappe.test/api/method/ping')
44+
expect(opts.credentials).toBe('include')
45+
})
46+
47+
it('injects static headers from config', async () => {
48+
setConfig('requestHeaders', { Authorization: 'token key:secret' })
49+
const fetchMock = mockFetch()
50+
await frappeRequest({ url: 'ping' })
51+
52+
const [, opts] = fetchMock.mock.calls[0]
53+
expect(opts.headers.Authorization).toBe('token key:secret')
54+
})
55+
56+
it('injects headers from a function returning headers', async () => {
57+
setConfig('requestHeaders', () => ({ Authorization: 'token dynamic:value' }))
58+
const fetchMock = mockFetch()
59+
await frappeRequest({ url: 'ping' })
60+
61+
const [, opts] = fetchMock.mock.calls[0]
62+
expect(opts.headers.Authorization).toBe('token dynamic:value')
63+
})
64+
})

src/utils/request.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export function request(_options) {
3232
headers: options.headers,
3333
body,
3434
signal: options.signal,
35+
...(options.credentials ? { credentials: options.credentials } : {}),
3536
})
3637
.then((response) => {
3738
if (options.transformResponse) {

0 commit comments

Comments
 (0)