1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-07 23:02:56 +03:00

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
This commit is contained in:
Richard van der Hoff
2025-07-24 12:06:52 +01:00
committed by GitHub
parent 556494b8f0
commit c7dbd6e33b
3 changed files with 109 additions and 26 deletions

View File

@@ -120,7 +120,15 @@ describe("FetchHttpApi", () => {
await expect(api.requestOtherUrl(Method.Get, "http://url")).resolves.toBe(res); await expect(api.requestOtherUrl(Method.Get, "http://url")).resolves.toBe(res);
}); });
it("should return text if json=false", async () => { it("should set an Accept header, and parse the response as JSON, by default", async () => {
const result = { a: 1 };
const fetchFn = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue(result) });
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
await expect(api.requestOtherUrl(Method.Get, "http://url")).resolves.toBe(result);
expect(fetchFn.mock.calls[0][1].headers.Accept).toBe("application/json");
});
it("should not set an Accept header, and should return text if json=false", async () => {
const text = "418 I'm a teapot"; const text = "418 I'm a teapot";
const fetchFn = jest.fn().mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue(text) }); const fetchFn = jest.fn().mockResolvedValue({ ok: true, text: jest.fn().mockResolvedValue(text) });
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true }); const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
@@ -129,6 +137,31 @@ describe("FetchHttpApi", () => {
json: false, json: false,
}), }),
).resolves.toBe(text); ).resolves.toBe(text);
expect(fetchFn.mock.calls[0][1].headers.Accept).not.toBeDefined();
});
it("should not set an Accept header, and should return a blob, if rawResponseBody is true", async () => {
const blob = new Blob(["blobby"]);
const fetchFn = jest.fn().mockResolvedValue({ ok: true, blob: jest.fn().mockResolvedValue(blob) });
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), { baseUrl, prefix, fetchFn, onlyData: true });
await expect(
api.requestOtherUrl(Method.Get, "http://url", undefined, {
rawResponseBody: true,
}),
).resolves.toBe(blob);
expect(fetchFn.mock.calls[0][1].headers.Accept).not.toBeDefined();
});
it("should throw an error if both `json` and `rawResponseBody` are defined", async () => {
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
baseUrl,
prefix,
fetchFn: jest.fn(),
onlyData: true,
});
await expect(
api.requestOtherUrl(Method.Get, "http://url", undefined, { rawResponseBody: false, json: true }),
).rejects.toThrow("Invalid call to `FetchHttpApi`");
}); });
it("should send token via query params if useAuthorizationHeader=false", async () => { it("should send token via query params if useAuthorizationHeader=false", async () => {

View File

@@ -23,6 +23,7 @@ import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { Method } from "./method.ts"; import { Method } from "./method.ts";
import { ConnectionError, MatrixError, TokenRefreshError } from "./errors.ts"; import { ConnectionError, MatrixError, TokenRefreshError } from "./errors.ts";
import { import {
type BaseRequestOpts,
HttpApiEvent, HttpApiEvent,
type HttpApiEventHandlerMap, type HttpApiEventHandlerMap,
type IHttpOpts, type IHttpOpts,
@@ -269,21 +270,20 @@ export class FetchHttpApi<O extends IHttpOpts> {
method: Method, method: Method,
url: URL | string, url: URL | string,
body?: Body, body?: Body,
opts: Pick<IRequestOpts, "headers" | "json" | "localTimeoutMs" | "keepAlive" | "abortSignal" | "priority"> = {}, opts: BaseRequestOpts = {},
): Promise<ResponseType<T, O>> { ): Promise<ResponseType<T, O>> {
if (opts.json !== undefined && opts.rawResponseBody !== undefined) {
throw new Error("Invalid call to `FetchHttpApi` sets both `opts.json` and `opts.rawResponseBody`");
}
const urlForLogs = this.sanitizeUrlForLogs(url); const urlForLogs = this.sanitizeUrlForLogs(url);
this.opts.logger?.debug(`FetchHttpApi: --> ${method} ${urlForLogs}`); this.opts.logger?.debug(`FetchHttpApi: --> ${method} ${urlForLogs}`);
const headers = Object.assign({}, opts.headers || {}); const headers = Object.assign({}, opts.headers || {});
const json = opts.json ?? true;
// We can't use getPrototypeOf here as objects made in other contexts e.g. over postMessage won't have same ref
const jsonBody = json && body?.constructor?.name === Object.name;
if (json) {
if (jsonBody && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
const jsonResponse = !opts.rawResponseBody && opts.json !== false;
if (jsonResponse) {
if (!headers["Accept"]) { if (!headers["Accept"]) {
headers["Accept"] = "application/json"; headers["Accept"] = "application/json";
} }
@@ -299,9 +299,15 @@ export class FetchHttpApi<O extends IHttpOpts> {
signals.push(opts.abortSignal); signals.push(opts.abortSignal);
} }
// If the body is an object, encode it as JSON and set the `Content-Type` header,
// unless that has been explicitly inhibited by setting `opts.json: false`.
// We can't use getPrototypeOf here as objects made in other contexts e.g. over postMessage won't have same ref
let data: BodyInit; let data: BodyInit;
if (jsonBody) { if (opts.json !== false && body?.constructor?.name === Object.name) {
data = JSON.stringify(body); data = JSON.stringify(body);
if (!headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
} else { } else {
data = body as BodyInit; data = body as BodyInit;
} }
@@ -343,10 +349,15 @@ export class FetchHttpApi<O extends IHttpOpts> {
throw parseErrorResponse(res, await res.text()); throw parseErrorResponse(res, await res.text());
} }
if (this.opts.onlyData) { if (!this.opts.onlyData) {
return (json ? res.json() : res.text()) as ResponseType<T, O>;
}
return res as ResponseType<T, O>; return res as ResponseType<T, O>;
} else if (opts.rawResponseBody) {
return (await res.blob()) as ResponseType<T, O>;
} else if (jsonResponse) {
return await res.json();
} else {
return (await res.text()) as ResponseType<T, O>;
}
} }
private sanitizeUrlForLogs(url: URL | string): string { private sanitizeUrlForLogs(url: URL | string): string {

View File

@@ -47,6 +47,8 @@ export type AccessTokens = {
* Can be passed to HttpApi instance as {@link IHttpOpts.tokenRefreshFunction} during client creation {@link ICreateClientOpts} * Can be passed to HttpApi instance as {@link IHttpOpts.tokenRefreshFunction} during client creation {@link ICreateClientOpts}
*/ */
export type TokenRefreshFunction = (refreshToken: string) => Promise<AccessTokens>; export type TokenRefreshFunction = (refreshToken: string) => Promise<AccessTokens>;
/** Options object for `FetchHttpApi` and {@link MatrixHttpApi}. */
export interface IHttpOpts { export interface IHttpOpts {
fetchFn?: typeof globalThis.fetch; fetchFn?: typeof globalThis.fetch;
@@ -67,24 +69,20 @@ export interface IHttpOpts {
tokenRefreshFunction?: TokenRefreshFunction; tokenRefreshFunction?: TokenRefreshFunction;
useAuthorizationHeader?: boolean; // defaults to true useAuthorizationHeader?: boolean; // defaults to true
/**
* Normally, methods in `FetchHttpApi` will return a {@link https://developer.mozilla.org/en-US/docs/Web/API/Response Response} object.
* If this is set to `true`, they instead return the response body.
*/
onlyData?: boolean; onlyData?: boolean;
localTimeoutMs?: number; localTimeoutMs?: number;
/** Optional logger instance. If provided, requests and responses will be logged. */ /** Optional logger instance. If provided, requests and responses will be logged. */
logger?: Logger; logger?: Logger;
} }
export interface IRequestOpts extends Pick<RequestInit, "priority"> { /** Options object for `FetchHttpApi.requestOtherUrl`. */
/** export interface BaseRequestOpts extends Pick<RequestInit, "priority"> {
* The alternative base url to use.
* If not specified, uses this.opts.baseUrl
*/
baseUrl?: string;
/**
* The full prefix to use e.g.
* "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix.
*/
prefix?: string;
/** /**
* map of additional request headers * map of additional request headers
*/ */
@@ -96,7 +94,48 @@ export interface IRequestOpts extends Pick<RequestInit, "priority"> {
*/ */
localTimeoutMs?: number; localTimeoutMs?: number;
keepAlive?: boolean; // defaults to false keepAlive?: boolean; // defaults to false
json?: boolean; // defaults to true
/**
* By default, we will:
*
* * If the `body` is an object, JSON-encode it and set `Content-Type: application/json` in the
* request headers (unless overridden by {@link headers}).
*
* * Set `Accept: application/json` in the request headers (again, unless overridden by {@link headers}).
*
* * If `IHTTPOpts.onlyData` is set to `true` on the `FetchHttpApi` instance, parse the response as
* JSON and return the parsed response.
*
* Setting this to `false` inhibits all three behaviors, and (if `IHTTPOpts.onlyData` is set to `true`) the response
* is instead parsed as a UTF-8 string. It defaults to `true`, unless {@link rawResponseBody} is set.
*
* @deprecated Instead of setting this to `false`, set {@link rawResponseBody} to `true`.
*/
json?: boolean;
/**
* Setting this to `true` does two things:
*
* * Inhibits the automatic addition of `Accept: application/json` in the request headers.
*
* * Assuming `IHTTPOpts.onlyData` is set to `true` on the `FetchHttpApi` instance, causes the
* raw response to be returned as a {@link https://developer.mozilla.org/en-US/docs/Web/API/Blob|Blob}
* instead of parsing it as `json`.
*/
rawResponseBody?: boolean;
}
export interface IRequestOpts extends BaseRequestOpts {
/**
* The alternative base url to use.
* If not specified, uses this.opts.baseUrl
*/
baseUrl?: string;
/**
* The full prefix to use e.g.
* "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix.
*/
prefix?: string;
// Set to true to prevent the request function from emitting a Session.logged_out event. // Set to true to prevent the request function from emitting a Session.logged_out event.
// This is intended for use on endpoints where M_UNKNOWN_TOKEN is a valid/notable error response, // This is intended for use on endpoints where M_UNKNOWN_TOKEN is a valid/notable error response,