Skip to content

fetch api: add support for downloading raw response #4917

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions spec/unit/http-api/fetch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,29 @@ describe("FetchHttpApi", () => {
).resolves.toBe(text);
});

it("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);
});

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 () => {
const fetchFn = jest.fn().mockResolvedValue({ ok: true });
const api = new FetchHttpApi(new TypedEventEmitter<any, any>(), {
Expand Down
37 changes: 24 additions & 13 deletions src/http-api/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import { Method } from "./method.ts";
import { ConnectionError, MatrixError, TokenRefreshError } from "./errors.ts";
import {
type BaseRequestOpts,
HttpApiEvent,
type HttpApiEventHandlerMap,
type IHttpOpts,
Expand Down Expand Up @@ -263,21 +264,20 @@
method: Method,
url: URL | string,
body?: Body,
opts: Pick<IRequestOpts, "headers" | "json" | "localTimeoutMs" | "keepAlive" | "abortSignal" | "priority"> = {},
opts: BaseRequestOpts = {},
): 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);

this.opts.logger?.debug(`FetchHttpApi: --> ${method} ${urlForLogs}`);

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.json !== false;
if (jsonResponse) {
if (!headers["Accept"]) {
headers["Accept"] = "application/json";
}
Expand All @@ -293,9 +293,15 @@
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;
if (jsonBody) {
if (opts.json !== false && body?.constructor?.name === Object.name) {
data = JSON.stringify(body);
if (!headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
} else {
data = body as BodyInit;
}
Expand Down Expand Up @@ -337,10 +343,15 @@
throw parseErrorResponse(res, await res.text());
}

if (this.opts.onlyData) {
return (json ? res.json() : res.text()) as ResponseType<T, O>;
if (!this.opts.onlyData) {
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>;
}
return res as ResponseType<T, O>;
}

private sanitizeUrlForLogs(url: URL | string): string {
Expand Down
64 changes: 52 additions & 12 deletions src/http-api/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export type AccessTokens = {
* Can be passed to HttpApi instance as {@link IHttpOpts.tokenRefreshFunction} during client creation {@link ICreateClientOpts}
*/
export type TokenRefreshFunction = (refreshToken: string) => Promise<AccessTokens>;

/** Options object for `FetchHttpApi` and {@link MatrixHttpApi}. */
export interface IHttpOpts {
fetchFn?: typeof globalThis.fetch;

Expand All @@ -67,24 +69,20 @@ export interface IHttpOpts {
tokenRefreshFunction?: TokenRefreshFunction;
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;

localTimeoutMs?: number;

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

export interface IRequestOpts 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;
/** Options object for `FetchHttpApi.requestOtherUrl`. */
export interface BaseRequestOpts extends Pick<RequestInit, "priority"> {
/**
* map of additional request headers
*/
Expand All @@ -96,7 +94,49 @@ export interface IRequestOpts extends Pick<RequestInit, "priority"> {
*/
localTimeoutMs?: number;
keepAlive?: boolean; // defaults to false
json?: boolean; // defaults to true

/**
* By default, we will:
*
* * If the `body` is an object, we will JSON-encode it and set `Content-Type: application/json` in the
* request headers (again, unless overridden by {@link headers}).
*
* * Set `Accept: application/json` in the request headers (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` overrides inhibits all three behaviors, and 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;

/**
* By default, we will:
*
* * Set `Accept: application/json` in the request headers (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 `true` overrides inhibits this behavior, and the raw response is returned as a {@link https://developer.mozilla.org/en-US/docs/Web/API/Blob|Blob}.
*/
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.
// This is intended for use on endpoints where M_UNKNOWN_TOKEN is a valid/notable error response,
Expand Down