Add registration and execution of request middlewares (#19421)

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
This commit is contained in:
Daniel James Smith 2026-03-09 11:36:06 +01:00 committed by GitHub
parent d066a06caf
commit fb01459bcb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 95 additions and 0 deletions

View File

@ -459,6 +459,12 @@ export abstract class ApiService {
abstract fetch(request: Request): Promise<Response>;
abstract nativeFetch(request: Request): Promise<Response>;
/**
* Adds a middleware function that will be called with the Request object before each API call. This allows for dynamic header manipulation, such as adding cookies for SSO authentication.
* @param middleware The middleware function to add
*/
abstract addMiddleware(middleware: (request: Request) => Promise<void>): void;
abstract preValidateSso(identifier: string): Promise<SsoPreValidateResponse>;
abstract postCreateSponsorship(

View File

@ -1245,4 +1245,77 @@ describe("ApiService", () => {
expect(logoutCallback).not.toHaveBeenCalled();
});
});
describe("fetch", () => {
it("does not execute any middlewares when none are registered", async () => {
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
nativeFetch.mockResolvedValue({
ok: true,
status: 200,
} satisfies Partial<Response> as Response);
sut.nativeFetch = nativeFetch;
const request = {
url: "https://example.com/api",
method: "POST",
headers: { set: jest.fn() },
} as unknown as Request;
await sut.fetch(request);
expect(nativeFetch).toHaveBeenCalledTimes(1);
});
it("executes a registered middleware before sending the request", async () => {
const middleware = jest.fn<Promise<void>, [Request]>().mockResolvedValue(undefined);
sut.addMiddleware(middleware);
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
nativeFetch.mockResolvedValue({
ok: true,
status: 200,
} satisfies Partial<Response> as Response);
sut.nativeFetch = nativeFetch;
const request = {
url: "https://example.com/api",
method: "POST",
headers: { set: jest.fn() },
} as unknown as Request;
await sut.fetch(request);
expect(middleware).toHaveBeenCalledTimes(1);
expect(middleware).toHaveBeenCalledWith(request);
expect(nativeFetch).toHaveBeenCalledTimes(1);
});
it("executes all registered middlewares before sending the request", async () => {
const callOrder: number[] = [];
const middleware1 = jest.fn<Promise<void>, [Request]>().mockImplementation(async () => {
callOrder.push(1);
});
const middleware2 = jest.fn<Promise<void>, [Request]>().mockImplementation(async () => {
callOrder.push(2);
});
sut.addMiddleware(middleware1);
sut.addMiddleware(middleware2);
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
nativeFetch.mockResolvedValue({
ok: true,
status: 200,
} satisfies Partial<Response> as Response);
sut.nativeFetch = nativeFetch;
const request = {
url: "https://example.com/api",
method: "POST",
headers: { set: jest.fn() },
} as unknown as Request;
await sut.fetch(request);
expect(middleware1).toHaveBeenCalledTimes(1);
expect(middleware2).toHaveBeenCalledTimes(1);
expect(nativeFetch).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -138,6 +138,11 @@ export class ApiService implements ApiServiceAbstraction {
private static readonly NEW_DEVICE_VERIFICATION_REQUIRED_MESSAGE =
"new device verification required";
/**
* Middlewares are functions that take a Request and return a Promise that resolves when the middleware is done processing the request. Middlewares are executed in the order they are added, and can modify the request before it is sent. This is used for things like adding cookies to requests for SSO authentication.
*/
private middlewares: Array<(request: Request) => Promise<void>> = [];
constructor(
private tokenService: TokenService,
private platformUtilsService: PlatformUtilsService,
@ -1319,6 +1324,10 @@ export class ApiService implements ApiServiceAbstraction {
return accessToken;
}
addMiddleware(middleware: (request: Request) => Promise<void>): void {
this.middlewares.push(middleware);
}
async fetch(request: Request): Promise<Response> {
if (!request.url.startsWith("https://") && !this.platformUtilsService.isDev()) {
throw new InsecureUrlNotAllowedError();
@ -1338,6 +1347,13 @@ export class ApiService implements ApiServiceAbstraction {
if (packageType != null) {
request.headers.set("Bitwarden-Package-Type", packageType);
}
await Promise.all(
this.middlewares.map((middleware) => {
return middleware(request);
}),
);
return this.nativeFetch(request);
}