Skip to content

Commit c7dbd6e

Browse files
authored
fetch api: add support for downloading raw response (#4917)
* Factor out `BaseRequestOpts` ... to make it easier to find the docs from methods that use it. * fetch api: add support for downloading raw response I need to make an authenticated request to the media repo, and expect to get a binary file back. AFAICT there is no easy way to do that right now. * Clarify doc strings * Various fixes
1 parent 556494b commit c7dbd6e

File tree

3 files changed

+109
-26
lines changed

3 files changed

+109
-26
lines changed

spec/unit/http-api/fetch.spec.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,15 @@ describe("FetchHttpApi", () => {
120120
await expect(api.requestOtherUrl(Method.Get, "http://url")).resolves.toBe(res);
121121
});
122122

123-
it("should return text if json=false", async () => {
123+
it("should set an Accept header, and parse the response as JSON, by default", async () => {
124+
const result = { a: 1 };
125+
const fetchFn = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue(result) });
126+
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
127+
await expect(api.requestOtherUrl(Method.Get, "http://url")).resolves.toBe(result);
128+
expect(fetchFn.mock.calls[0][1].headers.Accept).toBe("application/json");
129+
});
130+
131+
it("should not set an Accept header, and should return text if json=false", async () => {
124132
const text = "418 I'm a teapot";
125133
const fetchFn = jest.fn().mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue(text) });
126134
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
@@ -129,6 +137,31 @@ describe("FetchHttpApi", () => {
129137
json: false,
130138
}),
131139
).resolves.toBe(text);
140+
expect(fetchFn.mock.calls[0][1].headers.Accept).not.toBeDefined();
141+
});
142+
143+
it("should not set an Accept header, and should return a blob, if rawResponseBody is true", async () => {
144+
const blob = new Blob(["blobby"]);
145+
const fetchFn = jest.fn().mockResolvedValue({ ok: true, blob: jest.fn().mockResolvedValue(blob) });
146+
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
147+
await expect(
148+
api.requestOtherUrl(Method.Get, "http://url", undefined, {
149+
rawResponseBody: true,
150+
}),
151+
).resolves.toBe(blob);
152+
expect(fetchFn.mock.calls[0][1].headers.Accept).not.toBeDefined();
153+
});
154+
155+
it("should throw an error if both `json` and `rawResponseBody` are defined", async () => {
156+
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
157+
baseUrl,
158+
prefix,
159+
fetchFn: jest.fn(),
160+
onlyData: true,
161+
});
162+
await expect(
163+
api.requestOtherUrl(Method.Get, "http://url", undefined, { rawResponseBody: false, json: true }),
164+
).rejects.toThrow("Invalid call to `FetchHttpApi`");
132165
});
133166

134167
it("should send token via query params if useAuthorizationHeader=false", async () => {

src/http-api/fetch.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
2323
import { Method } from "./method.ts";
2424
import { ConnectionError, MatrixError, TokenRefreshError } from "./errors.ts";
2525
import {
26+
type BaseRequestOpts,
2627
HttpApiEvent,
2728
type HttpApiEventHandlerMap,
2829
type IHttpOpts,
@@ -269,21 +270,20 @@ export class FetchHttpApi<O extends IHttpOpts> {
269270
method: Method,
270271
url: URL | string,
271272
body?: Body,
272-
opts: Pick<IRequestOpts, "headers" | "json" | "localTimeoutMs" | "keepAlive" | "abortSignal" | "priority"> = {},
273+
opts: BaseRequestOpts = {},
273274
): Promise<ResponseType<T, O>> {
275+
if (opts.json !== undefined && opts.rawResponseBody !== undefined) {
276+
throw new Error("Invalid call to `FetchHttpApi` sets both `opts.json` and `opts.rawResponseBody`");
277+
}
278+
274279
const urlForLogs = this.sanitizeUrlForLogs(url);
280+
275281
this.opts.logger?.debug(`FetchHttpApi: --> ${method} ${urlForLogs}`);
276282

277283
const headers = Object.assign({}, opts.headers || {});
278-
const json = opts.json ?? true;
279-
// We can't use getPrototypeOf here as objects made in other contexts e.g. over postMessage won't have same ref
280-
const jsonBody = json && body?.constructor?.name === Object.name;
281-
282-
if (json) {
283-
if (jsonBody && !headers["Content-Type"]) {
284-
headers["Content-Type"] = "application/json";
285-
}
286284

285+
const jsonResponse = !opts.rawResponseBody && opts.json !== false;
286+
if (jsonResponse) {
287287
if (!headers["Accept"]) {
288288
headers["Accept"] = "application/json";
289289
}
@@ -299,9 +299,15 @@ export class FetchHttpApi<O extends IHttpOpts> {
299299
signals.push(opts.abortSignal);
300300
}
301301

302+
// If the body is an object, encode it as JSON and set the `Content-Type` header,
303+
// unless that has been explicitly inhibited by setting `opts.json: false`.
304+
// We can't use getPrototypeOf here as objects made in other contexts e.g. over postMessage won't have same ref
302305
let data: BodyInit;
303-
if (jsonBody) {
306+
if (opts.json !== false && body?.constructor?.name === Object.name) {
304307
data = JSON.stringify(body);
308+
if (!headers["Content-Type"]) {
309+
headers["Content-Type"] = "application/json";
310+
}
305311
} else {
306312
data = body as BodyInit;
307313
}
@@ -343,10 +349,15 @@ export class FetchHttpApi<O extends IHttpOpts> {
343349
throw parseErrorResponse(res, await res.text());
344350
}
345351

346-
if (this.opts.onlyData) {
347-
return (json ? res.json() : res.text()) as ResponseType<T, O>;
352+
if (!this.opts.onlyData) {
353+
return res as ResponseType<T, O>;
354+
} else if (opts.rawResponseBody) {
355+
return (await res.blob()) as ResponseType<T, O>;
356+
} else if (jsonResponse) {
357+
return await res.json();
358+
} else {
359+
return (await res.text()) as ResponseType<T, O>;
348360
}
349-
return res as ResponseType<T, O>;
350361
}
351362

352363
private sanitizeUrlForLogs(url: URL | string): string {

src/http-api/interface.ts

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export type AccessTokens = {
4747
* Can be passed to HttpApi instance as {@link IHttpOpts.tokenRefreshFunction} during client creation {@link ICreateClientOpts}
4848
*/
4949
export type TokenRefreshFunction = (refreshToken: string) => Promise<AccessTokens>;
50+
51+
/** Options object for `FetchHttpApi` and {@link MatrixHttpApi}. */
5052
export interface IHttpOpts {
5153
fetchFn?: typeof globalThis.fetch;
5254

@@ -67,24 +69,20 @@ export interface IHttpOpts {
6769
tokenRefreshFunction?: TokenRefreshFunction;
6870
useAuthorizationHeader?: boolean; // defaults to true
6971

72+
/**
73+
* Normally, methods in `FetchHttpApi` will return a {@link https://developer.mozilla.org/en-US/docs/Web/API/Response Response} object.
74+
* If this is set to `true`, they instead return the response body.
75+
*/
7076
onlyData?: boolean;
77+
7178
localTimeoutMs?: number;
7279

7380
/** Optional logger instance. If provided, requests and responses will be logged. */
7481
logger?: Logger;
7582
}
7683

77-
export interface IRequestOpts extends Pick<RequestInit, "priority"> {
78-
/**
79-
* The alternative base url to use.
80-
* If not specified, uses this.opts.baseUrl
81-
*/
82-
baseUrl?: string;
83-
/**
84-
* The full prefix to use e.g.
85-
* "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix.
86-
*/
87-
prefix?: string;
84+
/** Options object for `FetchHttpApi.requestOtherUrl`. */
85+
export interface BaseRequestOpts extends Pick<RequestInit, "priority"> {
8886
/**
8987
* map of additional request headers
9088
*/
@@ -96,7 +94,48 @@ export interface IRequestOpts extends Pick<RequestInit, "priority"> {
9694
*/
9795
localTimeoutMs?: number;
9896
keepAlive?: boolean; // defaults to false
99-
json?: boolean; // defaults to true
97+
98+
/**
99+
* By default, we will:
100+
*
101+
* * If the `body` is an object, JSON-encode it and set `Content-Type: application/json` in the
102+
* request headers (unless overridden by {@link headers}).
103+
*
104+
* * Set `Accept: application/json` in the request headers (again, unless overridden by {@link headers}).
105+
*
106+
* * If `IHTTPOpts.onlyData` is set to `true` on the `FetchHttpApi` instance, parse the response as
107+
* JSON and return the parsed response.
108+
*
109+
* Setting this to `false` inhibits all three behaviors, and (if `IHTTPOpts.onlyData` is set to `true`) the response
110+
* is instead parsed as a UTF-8 string. It defaults to `true`, unless {@link rawResponseBody} is set.
111+
*
112+
* @deprecated Instead of setting this to `false`, set {@link rawResponseBody} to `true`.
113+
*/
114+
json?: boolean;
115+
116+
/**
117+
* Setting this to `true` does two things:
118+
*
119+
* * Inhibits the automatic addition of `Accept: application/json` in the request headers.
120+
*
121+
* * Assuming `IHTTPOpts.onlyData` is set to `true` on the `FetchHttpApi` instance, causes the
122+
* raw response to be returned as a {@link https://developer.mozilla.org/en-US/docs/Web/API/Blob|Blob}
123+
* instead of parsing it as `json`.
124+
*/
125+
rawResponseBody?: boolean;
126+
}
127+
128+
export interface IRequestOpts extends BaseRequestOpts {
129+
/**
130+
* The alternative base url to use.
131+
* If not specified, uses this.opts.baseUrl
132+
*/
133+
baseUrl?: string;
134+
/**
135+
* The full prefix to use e.g.
136+
* "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix.
137+
*/
138+
prefix?: string;
100139

101140
// Set to true to prevent the request function from emitting a Session.logged_out event.
102141
// This is intended for use on endpoints where M_UNKNOWN_TOKEN is a valid/notable error response,

0 commit comments

Comments
 (0)