@biffo/cli 0.244.10 → 0.244.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -61,3 +61,226 @@ describe('createApiClient error handling', () => {
|
|
|
61
61
|
)
|
|
62
62
|
})
|
|
63
63
|
})
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* An expired id token is not an error the user can act on — it is a renewal
|
|
67
|
+
* this app already knows how to perform, and did not (tabsii-lms#3).
|
|
68
|
+
*
|
|
69
|
+
* The observed failure: after ~an hour an authoring session's next action
|
|
70
|
+
* rendered `{"message":"Unauthorized"}` in red in the page. That body is API
|
|
71
|
+
* Gateway's, produced by the JWT authorizer before the request ever reached the
|
|
72
|
+
* app, so `extractErrorMessage` has no `detail` to unwrap and correctly falls
|
|
73
|
+
* back to the raw text — the message layer was working exactly as designed on an
|
|
74
|
+
* input it was never given a chance to improve. Meanwhile a usable refresh
|
|
75
|
+
* token sat in localStorage, and reloading the page renewed silently.
|
|
76
|
+
*
|
|
77
|
+
* Every assertion below is on observable behaviour rather than on an exported
|
|
78
|
+
* constant, so each one fails against the unfixed client for the reason the
|
|
79
|
+
* reporter saw rather than on a missing import.
|
|
80
|
+
*/
|
|
81
|
+
describe('createApiClient session renewal', () => {
|
|
82
|
+
const GATEWAY_401 = '{"message":"Unauthorized"}'
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
vi.unstubAllGlobals()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
function ok(body: unknown) {
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
status: 200,
|
|
92
|
+
statusText: 'OK',
|
|
93
|
+
json: () => Promise.resolve(body),
|
|
94
|
+
text: () => Promise.resolve(JSON.stringify(body)),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function unauthorized(body = GATEWAY_401) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
status: 401,
|
|
102
|
+
statusText: 'Unauthorized',
|
|
103
|
+
text: () => Promise.resolve(body),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Answers on the token it is given rather than on call count, so a retry that
|
|
109
|
+
* re-sends the stale token is a failed assertion rather than a passing one.
|
|
110
|
+
*/
|
|
111
|
+
function fetchAcceptingOnly(freshToken: string, body: unknown = { items: [] }) {
|
|
112
|
+
return vi.fn((_url: string, init: RequestInit) => {
|
|
113
|
+
const headers = (init.headers ?? {}) as Record<string, string>
|
|
114
|
+
return Promise.resolve(
|
|
115
|
+
headers['Authorization'] === `Bearer ${freshToken}` ? ok(body) : unauthorized(),
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
it('renews the session and retries once, so the call succeeds', async () => {
|
|
121
|
+
const fetchMock = fetchAcceptingOnly('fresh', { items: ['course'] })
|
|
122
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
123
|
+
const refresh = vi.fn(() => Promise.resolve<string | null>('fresh'))
|
|
124
|
+
|
|
125
|
+
const api = createApiClient(() => 'expired', refresh)
|
|
126
|
+
|
|
127
|
+
await expect(api.get('/api/v1/courses')).resolves.toEqual({ items: ['course'] })
|
|
128
|
+
expect(refresh).toHaveBeenCalledTimes(1)
|
|
129
|
+
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('retries with the refreshed token, never the one that was rejected', async () => {
|
|
133
|
+
const fetchMock = fetchAcceptingOnly('fresh')
|
|
134
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
135
|
+
|
|
136
|
+
// The shape every caller actually uses: `() => token` closes over React
|
|
137
|
+
// state, so it still returns the expired token after a renewal. A retry
|
|
138
|
+
// that re-reads it would send the same rejected credential.
|
|
139
|
+
const api = createApiClient(
|
|
140
|
+
() => 'expired',
|
|
141
|
+
() => Promise.resolve('fresh'),
|
|
142
|
+
)
|
|
143
|
+
await api.get('/api/v1/courses')
|
|
144
|
+
|
|
145
|
+
const sent = fetchMock.mock.calls.map(
|
|
146
|
+
([, init]) => ((init.headers ?? {}) as Record<string, string>)['Authorization'],
|
|
147
|
+
)
|
|
148
|
+
expect(sent).toEqual(['Bearer expired', 'Bearer fresh'])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('renews once for concurrent 401s rather than once per caller', async () => {
|
|
152
|
+
vi.stubGlobal('fetch', fetchAcceptingOnly('fresh'))
|
|
153
|
+
let resolveRefresh: (token: string) => void = () => {}
|
|
154
|
+
const refresh = vi.fn(
|
|
155
|
+
() =>
|
|
156
|
+
new Promise<string | null>((resolve) => {
|
|
157
|
+
resolveRefresh = resolve
|
|
158
|
+
}),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const api = createApiClient(() => 'expired', refresh)
|
|
162
|
+
const inFlight = Promise.all([
|
|
163
|
+
api.get('/api/v1/courses'),
|
|
164
|
+
api.get('/api/v1/modules'),
|
|
165
|
+
api.get('/api/v1/learners'),
|
|
166
|
+
])
|
|
167
|
+
// Let all three reach their 401 before the single refresh settles.
|
|
168
|
+
await Promise.resolve()
|
|
169
|
+
resolveRefresh('fresh')
|
|
170
|
+
await inFlight
|
|
171
|
+
|
|
172
|
+
expect(refresh).toHaveBeenCalledTimes(1)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('surfaces an actionable sentence, not API Gateway body, when renewal fails', async () => {
|
|
176
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(unauthorized()))
|
|
177
|
+
|
|
178
|
+
const api = createApiClient(
|
|
179
|
+
() => 'expired',
|
|
180
|
+
() => Promise.resolve(null),
|
|
181
|
+
)
|
|
182
|
+
const error = await api.get('/api/v1/courses').then(
|
|
183
|
+
() => {
|
|
184
|
+
throw new Error('expected the request to reject')
|
|
185
|
+
},
|
|
186
|
+
(e: unknown) => e as ApiError,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
expect(error).toBeInstanceOf(ApiError)
|
|
190
|
+
expect(error.status).toBe(401)
|
|
191
|
+
expect(error.message).not.toContain('Unauthorized"')
|
|
192
|
+
expect(error.message).toBe(
|
|
193
|
+
'Your session has expired. Reload the page to sign in again, then retry that action.',
|
|
194
|
+
)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('does not retry in a loop when the renewed token is also rejected', async () => {
|
|
198
|
+
const fetchMock = vi.fn().mockResolvedValue(unauthorized())
|
|
199
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
200
|
+
|
|
201
|
+
const api = createApiClient(
|
|
202
|
+
() => 'expired',
|
|
203
|
+
() => Promise.resolve('also-stale'),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
await expect(api.get('/api/v1/courses')).rejects.toThrow(/session has expired/i)
|
|
207
|
+
// The first attempt and exactly one retry. A revoked session must not spin.
|
|
208
|
+
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('treats a renewal that hands back the same token as no renewal at all', async () => {
|
|
212
|
+
const fetchMock = vi.fn().mockResolvedValue(unauthorized())
|
|
213
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
214
|
+
|
|
215
|
+
const api = createApiClient(
|
|
216
|
+
() => 'expired',
|
|
217
|
+
() => Promise.resolve('expired'),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
await expect(api.get('/api/v1/courses')).rejects.toThrow(/session has expired/i)
|
|
221
|
+
// Re-sending a credential the gateway just rejected cannot succeed, so the
|
|
222
|
+
// retry is skipped rather than spent.
|
|
223
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('reports the expiry sentence when the renewal itself throws', async () => {
|
|
227
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(unauthorized()))
|
|
228
|
+
|
|
229
|
+
const api = createApiClient(
|
|
230
|
+
() => 'expired',
|
|
231
|
+
() => Promise.reject(new Error('network down')),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
// The Cognito failure is plumbing the user cannot act on; what they can act
|
|
235
|
+
// on is signing in again.
|
|
236
|
+
await expect(api.get('/api/v1/courses')).rejects.toThrow(/session has expired/i)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('keeps a backend 401 that explains itself, rather than overwriting it', async () => {
|
|
240
|
+
vi.stubGlobal(
|
|
241
|
+
'fetch',
|
|
242
|
+
vi.fn().mockResolvedValue(unauthorized('{"detail":"Token issued for another tenant"}')),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
const api = createApiClient(
|
|
246
|
+
() => 'expired',
|
|
247
|
+
() => Promise.resolve(null),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
// Same reasoning as extractErrorMessage's: never hide information the
|
|
251
|
+
// caller already had. A `detail` is the product's own voice already.
|
|
252
|
+
await expect(api.get('/api/v1/courses')).rejects.toThrow('Token issued for another tenant')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('renews for a mutating verb too, and retries the body', async () => {
|
|
256
|
+
const fetchMock = fetchAcceptingOnly('fresh', { id: 'c1' })
|
|
257
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
258
|
+
|
|
259
|
+
const api = createApiClient(
|
|
260
|
+
() => 'expired',
|
|
261
|
+
() => Promise.resolve('fresh'),
|
|
262
|
+
)
|
|
263
|
+
await expect(api.post('/api/v1/courses', { title: 'New' })).resolves.toEqual({ id: 'c1' })
|
|
264
|
+
|
|
265
|
+
const [, retry] = fetchMock.mock.calls
|
|
266
|
+
expect(retry[1].method).toBe('POST')
|
|
267
|
+
expect(retry[1].body).toBe(JSON.stringify({ title: 'New' }))
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('leaves a non-401 failure entirely alone', async () => {
|
|
271
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
272
|
+
ok: false,
|
|
273
|
+
status: 403,
|
|
274
|
+
statusText: 'Forbidden',
|
|
275
|
+
text: () => Promise.resolve('{"detail":"Administrator access required"}'),
|
|
276
|
+
})
|
|
277
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
278
|
+
const refresh = vi.fn(() => Promise.resolve<string | null>('fresh'))
|
|
279
|
+
|
|
280
|
+
const api = createApiClient(() => 'token', refresh)
|
|
281
|
+
|
|
282
|
+
await expect(api.get('/api/v1/users')).rejects.toThrow('Administrator access required')
|
|
283
|
+
expect(refresh).not.toHaveBeenCalled()
|
|
284
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
285
|
+
})
|
|
286
|
+
})
|
|
@@ -38,67 +38,204 @@ export class ApiError extends Error {
|
|
|
38
38
|
* status text, because a browser rendering `''` shows the user nothing at all.
|
|
39
39
|
*/
|
|
40
40
|
export function extractErrorMessage(body: string, statusText: string): string {
|
|
41
|
+
const detail = parsedDetail(body)
|
|
42
|
+
if (detail !== null) return detail || statusText
|
|
43
|
+
return body || statusText
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The `detail` a Biffo backend put in its error body, or null if there isn't
|
|
48
|
+
* one to read.
|
|
49
|
+
*
|
|
50
|
+
* Split out of `extractErrorMessage` so the 401 path can ask the same question
|
|
51
|
+
* without re-implementing the answer — writing this parse a second time in the
|
|
52
|
+
* same file is precisely the mistake `_extract_detail` records (#1107/#1108),
|
|
53
|
+
* and a second copy would drift on the next FastAPI change.
|
|
54
|
+
*
|
|
55
|
+
* Returns null for "the backend did not speak", and a string — possibly `''` —
|
|
56
|
+
* for "it did". Callers need that distinction: an empty `detail` still means the
|
|
57
|
+
* body was the product's own shape, and collapsing the two would make an empty
|
|
58
|
+
* string indistinguishable from a CloudFront HTML page.
|
|
59
|
+
*/
|
|
60
|
+
function parsedDetail(body: string): string | null {
|
|
41
61
|
let parsed: unknown
|
|
42
62
|
try {
|
|
43
63
|
parsed = JSON.parse(body)
|
|
44
64
|
} catch {
|
|
45
|
-
return
|
|
65
|
+
return null
|
|
46
66
|
}
|
|
47
67
|
if (typeof parsed === 'object' && parsed !== null && 'detail' in parsed) {
|
|
48
68
|
const { detail } = parsed
|
|
49
|
-
|
|
69
|
+
// A non-string `detail` also falls back. FastAPI's own 422 makes it a list
|
|
70
|
+
// of field errors; picking something out of it would move the problem
|
|
71
|
+
// rather than fix it.
|
|
72
|
+
if (typeof detail === 'string') return detail
|
|
50
73
|
}
|
|
51
|
-
return
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A response body as text, or `''` if it could not be read. */
|
|
78
|
+
async function bodyOf(res: Response): Promise<string> {
|
|
79
|
+
return res.text().catch(() => '')
|
|
52
80
|
}
|
|
53
81
|
|
|
54
82
|
async function handleResponse<T>(res: Response): Promise<T> {
|
|
55
83
|
if (!res.ok) {
|
|
56
|
-
|
|
57
|
-
throw new ApiError(res.status, extractErrorMessage(body, res.statusText))
|
|
84
|
+
throw new ApiError(res.status, extractErrorMessage(await bodyOf(res), res.statusText))
|
|
58
85
|
}
|
|
59
86
|
return res.json() as Promise<T>
|
|
60
87
|
}
|
|
61
88
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
89
|
+
/**
|
|
90
|
+
* What a person is told when their session could not be renewed.
|
|
91
|
+
*
|
|
92
|
+
* "Unauthorized" is not an instruction, and it was not even the app's word: a
|
|
93
|
+
* 401 from the API Gateway JWT authorizer never reaches this app, so its body is
|
|
94
|
+
* `{"message":"Unauthorized"}` — a wire format with no `detail` for
|
|
95
|
+
* `extractErrorMessage` to unwrap (tabsii-lms#3). Reloading is named explicitly
|
|
96
|
+
* because it is what actually works: the mount path renews from the refresh
|
|
97
|
+
* token in localStorage, and if that token is dead too the reload lands on the
|
|
98
|
+
* portal's login, which is where the user needs to be either way.
|
|
99
|
+
*/
|
|
100
|
+
const SESSION_EXPIRED_MESSAGE =
|
|
101
|
+
'Your session has expired. Reload the page to sign in again, then retry that action.'
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The message for a 401 that survived a renewal attempt.
|
|
105
|
+
*
|
|
106
|
+
* This deliberately keeps `extractErrorMessage`'s first principle — never hide
|
|
107
|
+
* information the caller already had — rather than stamping the expiry sentence
|
|
108
|
+
* over every 401. A body carrying a `detail` is the product's own voice and may
|
|
109
|
+
* say something the expiry sentence would erase ("Token issued for another
|
|
110
|
+
* tenant"); a body without one is upstream plumbing the user cannot act on, and
|
|
111
|
+
* that is the only case worth replacing.
|
|
112
|
+
*
|
|
113
|
+
* It takes no `statusText`, unlike `extractErrorMessage`: the fallback here is a
|
|
114
|
+
* full sentence rather than a bare status, so there is nothing left for "401" or
|
|
115
|
+
* "Unauthorized" to add.
|
|
116
|
+
*/
|
|
117
|
+
function unauthorizedMessage(body: string): string {
|
|
118
|
+
const detail = parsedDetail(body)
|
|
119
|
+
return detail !== null && detail !== '' ? detail : SESSION_EXPIRED_MESSAGE
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* One renewal in flight at a time, across every client this module hands out.
|
|
124
|
+
*
|
|
125
|
+
* The Cognito session lives in localStorage, shared by the whole page, so a
|
|
126
|
+
* refresh is a property of the app rather than of one client instance — and a
|
|
127
|
+
* page load routinely fans out several calls at once, each of which would
|
|
128
|
+
* otherwise expire together and fire its own refresh. Cognito would then serve
|
|
129
|
+
* concurrent renewals for one session, and the losers could persist a token
|
|
130
|
+
* older than the winner's.
|
|
131
|
+
*
|
|
132
|
+
* Module scope rather than per-client is what makes that true even though
|
|
133
|
+
* `createApiClient` is called in several components independently.
|
|
134
|
+
*/
|
|
135
|
+
let inFlightRenewal: Promise<string | null> | null = null
|
|
136
|
+
let renewalCount = 0
|
|
137
|
+
|
|
138
|
+
function renewSessionOnce(refreshIdToken: () => Promise<string | null>): Promise<string | null> {
|
|
139
|
+
if (inFlightRenewal) return inFlightRenewal
|
|
140
|
+
|
|
141
|
+
// Compared in the `finally` so a settling renewal can only clear itself,
|
|
142
|
+
// never a newer one. (Identity on the promise would say the same thing, but
|
|
143
|
+
// it cannot reference itself before it is assigned.)
|
|
144
|
+
const id = ++renewalCount
|
|
145
|
+
inFlightRenewal = (async () => {
|
|
146
|
+
try {
|
|
147
|
+
return await refreshIdToken()
|
|
148
|
+
} catch {
|
|
149
|
+
// A refresh that throws is indistinguishable, to the user, from one that
|
|
150
|
+
// returns no session: either way they need to sign in again. Rejecting
|
|
151
|
+
// here instead would surface a Cognito internal at them.
|
|
152
|
+
return null
|
|
153
|
+
} finally {
|
|
154
|
+
if (renewalCount === id) inFlightRenewal = null
|
|
155
|
+
}
|
|
156
|
+
})()
|
|
157
|
+
|
|
158
|
+
return inFlightRenewal
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Renew through the portal session this sibling already reads (`./auth`).
|
|
163
|
+
*
|
|
164
|
+
* Imported dynamically, for two reasons. It keeps `api-client.ts` free of a
|
|
165
|
+
* load-time dependency on `auth.ts` — which is NOT distributed with this file
|
|
166
|
+
* (it carries a declared per-repo divergence, biffo-template#1117), so a static
|
|
167
|
+
* import would couple a synced file's module graph to one that may legitimately
|
|
168
|
+
* differ. And it keeps the Cognito SDK out of the bundle of any module that only
|
|
169
|
+
* ever calls the API and never hits a 401.
|
|
170
|
+
*
|
|
171
|
+
* `getCurrentSession()` is the renewal: `CognitoUser.getSession()` exchanges the
|
|
172
|
+
* stored refresh token for a fresh id token when the current one has expired.
|
|
173
|
+
* That is exactly what a page reload was already doing by accident, and nothing
|
|
174
|
+
* did on a live call.
|
|
175
|
+
*/
|
|
176
|
+
async function refreshViaPortalSession(): Promise<string | null> {
|
|
177
|
+
const { getCurrentSession } = await import('./auth')
|
|
178
|
+
const session = await getCurrentSession()
|
|
179
|
+
return session?.getIdToken().getJwtToken() ?? null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function createApiClient(
|
|
183
|
+
getIdToken: () => string | null,
|
|
184
|
+
refreshIdToken: () => Promise<string | null> = refreshViaPortalSession,
|
|
185
|
+
) {
|
|
186
|
+
function authHeaders(token: string | null): HeadersInit {
|
|
65
187
|
return {
|
|
66
188
|
'Content-Type': 'application/json',
|
|
67
189
|
...(token != null ? { Authorization: `Bearer ${token}` } : {}),
|
|
68
190
|
}
|
|
69
191
|
}
|
|
70
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Send the request; on a 401, renew the session once and send it again.
|
|
195
|
+
*
|
|
196
|
+
* The retry is a second statement rather than a recursive call, which is what
|
|
197
|
+
* makes "once" structural: there is no path back to the top, so a genuinely
|
|
198
|
+
* revoked session cannot spin however many 401s it produces.
|
|
199
|
+
*
|
|
200
|
+
* It also carries the token the renewal returned rather than re-reading
|
|
201
|
+
* `getIdToken()`. Every caller passes `() => token` closing over React state,
|
|
202
|
+
* which a renewal outside React has not updated — so re-reading it would
|
|
203
|
+
* re-send the credential the gateway just rejected and burn the one retry.
|
|
204
|
+
*/
|
|
205
|
+
async function send<T>(path: string, init: RequestInit): Promise<T> {
|
|
206
|
+
const sentToken = getIdToken()
|
|
207
|
+
const res = await fetch(`${API_URL}${path}`, { ...init, headers: authHeaders(sentToken) })
|
|
208
|
+
if (res.status !== 401) return handleResponse<T>(res)
|
|
209
|
+
|
|
210
|
+
const renewed = await renewSessionOnce(refreshIdToken)
|
|
211
|
+
// An unchanged token means the renewal had nothing newer to give, so the
|
|
212
|
+
// retry would re-send what was just rejected. Skip it rather than spend it.
|
|
213
|
+
if (renewed == null || renewed === sentToken) {
|
|
214
|
+
throw new ApiError(401, unauthorizedMessage(await bodyOf(res)))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const retried = await fetch(`${API_URL}${path}`, { ...init, headers: authHeaders(renewed) })
|
|
218
|
+
if (retried.status === 401) {
|
|
219
|
+
throw new ApiError(401, unauthorizedMessage(await bodyOf(retried)))
|
|
220
|
+
}
|
|
221
|
+
return handleResponse<T>(retried)
|
|
222
|
+
}
|
|
223
|
+
|
|
71
224
|
return {
|
|
72
|
-
get: <T>(path: string): Promise<T> =>
|
|
73
|
-
fetch(`${API_URL}${path}`, { headers: authHeaders() }).then((r) => handleResponse<T>(r)),
|
|
225
|
+
get: <T>(path: string): Promise<T> => send<T>(path, {}),
|
|
74
226
|
|
|
75
227
|
post: <T>(path: string, body: unknown): Promise<T> =>
|
|
76
|
-
|
|
77
|
-
method: 'POST',
|
|
78
|
-
headers: authHeaders(),
|
|
79
|
-
body: JSON.stringify(body),
|
|
80
|
-
}).then((r) => handleResponse<T>(r)),
|
|
228
|
+
send<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
|
81
229
|
|
|
82
230
|
put: <T>(path: string, body: unknown): Promise<T> =>
|
|
83
|
-
|
|
84
|
-
method: 'PUT',
|
|
85
|
-
headers: authHeaders(),
|
|
86
|
-
body: JSON.stringify(body),
|
|
87
|
-
}).then((r) => handleResponse<T>(r)),
|
|
231
|
+
send<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
|
88
232
|
|
|
89
233
|
// Folded in from tabsii-crm, which had added it locally. It costs a sibling
|
|
90
234
|
// that never calls it nothing, and leaving it out would mean this file
|
|
91
235
|
// could never be distributed without destroying crm's copy.
|
|
92
236
|
patch: <T>(path: string, body: unknown): Promise<T> =>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
body: JSON.stringify(body),
|
|
97
|
-
}).then((r) => handleResponse<T>(r)),
|
|
98
|
-
|
|
99
|
-
delete: <T>(path: string): Promise<T> =>
|
|
100
|
-
fetch(`${API_URL}${path}`, { method: 'DELETE', headers: authHeaders() }).then((r) =>
|
|
101
|
-
handleResponse<T>(r),
|
|
102
|
-
),
|
|
237
|
+
send<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
|
238
|
+
|
|
239
|
+
delete: <T>(path: string): Promise<T> => send<T>(path, { method: 'DELETE' }),
|
|
103
240
|
}
|
|
104
241
|
}
|